@@ -206,7 +206,7 @@ def count_tokens(prompt):
206206 """fm's exact count (under 0.1 s), or None when it can't give one."""
207207 try:
208208 p = subprocess.run([FM, "count-tokens", "-q", "-i", prompt.instructions], input=prompt.text,
209− capture_output=True, text=True, timeout=10, env=fm_env())
209+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, env=fm_env())
210210 except (OSError, subprocess.TimeoutExpired):
211211 return None
212212 out = p.stdout.strip()
@@ -255,28 +255,41 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255255USAGE_EXIT = 64
256256
257257
258−def run_fm(prompt, schema, model, timeout_s):
259− """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
258+# Below this much time left in a request, a second fm call (the permissive retry, naming one more new
259+# group) is not started: it would only be cut off.
260+MIN_CALL_S = 5.0
261+
262+
263+def run_fm(prompt, schema, opts):
264+ """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin. Every call in one
265+ request shares its deadline, so the extension's guard (its timeout plus 10 s) is a real upper bound."""
260266 try:
261− return _run_fm(prompt, schema, model, timeout_s, [])
267+ return _run_fm(prompt, schema, opts, [])
262268 except Fail as f:
263− if f.code != "GUARDRAIL":
269+ if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
264270 raise
265− return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
271+ return _run_fm(prompt, schema, opts, PERMISSIVE)
272+
273+
274+def time_left(opts):
275+ return opts["deadline"] - time.time()
266276
267277
268−def _run_fm(prompt, schema, model, timeout_s, extra):
278+def _run_fm(prompt, schema, opts, extra):
269279 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
270280 # in the process list. fm has no timeout of its own; subprocess enforces ours.
271281 # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
272− # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them.
273− args = [FM, "respond", "--model", model, "--no-stream", "--greedy", "--schema", schema, "-i", prompt.instructions, *extra]
282+ # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them. The strict
283+ # retry after an unusable answer samples instead: greedy would give the same answer again.
284+ greedy = [] if opts.get("strict") else ["--greedy"]
285+ args = [FM, "respond", "--model", opts["model"], "--no-stream", *greedy, "--schema", schema, "-i", prompt.instructions, *extra]
286+ timeout_s = max(1.0, time_left(opts))
274287 try:
275− p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
288+ p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, env=fm_env())
276289 except FileNotFoundError:
277290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
278291 except subprocess.TimeoutExpired:
279− raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
292+ raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
280293 if p.returncode != 0:
281294 code = classify(p.stderr, p.returncode)
282295 if code == "LICENSE_REQUIRED":
@@ -295,11 +308,11 @@ def organize_mode():
295308
296309def op_ping(_payload, _opts):
297310 if not os.path.exists(FM):
298− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
311+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
299312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
300313 license_required = False
301314 try:
302− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
315+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
303316 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
304317 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
305318 license_required, msg = True, LICENSE_MESSAGE
@@ -367,7 +380,7 @@ def op_name(payload, opts):
367380 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
368381 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
369382 try:
370− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
383+ out = run_fm(prompt, schema, opts)
371384 except Fail as f:
372385 raise _fit_overflow(f, len(payload["items"]))
373386 try:
@@ -383,7 +396,7 @@ def op_organize(payload, opts):
383396 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
384397 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
385398 try:
386− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
399+ out = run_fm(prompt, schema, opts)
387400 except Fail as f:
388401 raise _fit_overflow(f, len(payload["items"]))
389402 _, owner = prompts.topic_items(payload)
@@ -393,29 +406,31 @@ def op_organize(payload, opts):
393406 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
394407 chars = prompts.size(prompt)
395408 if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
396− chars += name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
409+ chars += name_new_groups(result, payload, opts)
397410 return result, chars, "topics"
398411
399412
400−def name_new_groups(result, payload, opts, deadline):
413+def name_new_groups(result, payload, opts):
401414 """Title each new group with the name op (well under a second each on fm), so groups appear named.
402− A group that fails, or runs past the deadline, keeps "" and the extension's naming loop names it."""
403− taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
415+ A group that fails, runs past the request's deadline, or gets a title another group in the window
416+ already has keeps "" and the extension's naming loop, which checks every sibling, names it."""
417+ taken = [t for t in [g.get("title") for g in payload.get("existingGroups") or []] + list(payload.get("siblingTitles") or []) if t]
404418 chars = 0
405419 for g in result["groups"]:
406420 if "existing" in g or g.get("title"):
407421 continue
408− left = deadline - time.time()
409− if left < 5:
422+ if time_left(opts) < MIN_CALL_S:
410423 break
411424 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
412425 try:
413− named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
426+ named, n, _ = op_name(sub, {**opts, "strict": False})
414427 except Fail:
415428 continue
429+ chars += n
430+ if any(validate.same_label(named["title"], t) for t in taken):
431+ continue
416432 g["title"], g["emoji"] = named["title"], named["emoji"]
417433 taken.append(named["title"])
418− chars += n
419434 return chars
420435
421436
@@ -429,8 +444,8 @@ def handle(req):
429444 raw_opts = req.get("opts") or {}
430445 timeout_ms = raw_opts.get("timeoutMs", 45000)
431446 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
432− opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
433− "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
447+ timeout_s = max(5.0, min(120.0, timeout_ms / 1000))
448+ opts = {"model": "system", "strict": bool(raw_opts.get("strict")), "timeout_s": timeout_s, "deadline": t0 + timeout_s}
434449 op = req["op"]
435450 if op == "ping":
436451 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -484,7 +499,7 @@ def install_schemas():
484499 if "{%s}" % key not in args:
485500 continue
486501 try:
487− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
502+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
488503 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
489504 print(f" {name}: fm failed ({e})", file=sys.stderr)
490505 return "error"
@@ -496,7 +511,7 @@ def install_schemas():
496511 subs["{%s}" % key] = p.stdout.strip()
497512 args = [subs.get(a, a) for a in args]
498513 try:
499− p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
514+ p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
500515 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
501516 print(f" {name}: fm failed ({e})", file=sys.stderr)
502517 return "error"
@@ -553,14 +568,16 @@ def selftest():
553568
554569 ping = op_ping({}, {})
555570 check("fm found", os.path.exists(FM), FM)
556− if ping["licenseRequired"]:
571+ if not os.path.exists(FM):
572+ return 1 # nothing else can work without it
573+ if ping.get("licenseRequired"):
557574 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
558575 else:
559576 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
560577 for name in SCHEMA_COMMANDS:
561578 path = os.path.join(SCHEMAS, name)
562579 readable = os.path.isfile(path) and os.path.getsize(path) > 0
563− hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
580+ hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
564581 check(f"schema {name}", readable, path if readable else hint)
565582 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
566583 if ping["fmAvailable"] and schema_path("name.json"):
@@ -670,7 +687,9 @@ def register(host_path):
670687 f.write("\n".join(written) + "\n")
671688 except OSError:
672689 pass
673− return 1 if failures else 0
690+ # One browser that could not be written (a locked folder, a stale profile) does not undo the others:
691+ # the install goes on and the ones that failed were named above.
692+ return 0 if len(dirs) - failures > 0 else 1
674693
675694
676695def unregister():