@@ -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, encoding="utf-8", errors="replace", timeout=10, env=fm_env())
209+ capture_output=True, text=True, timeout=10, env=fm_env())
210210 except (OSError, subprocess.TimeoutExpired):
211211 return None
212212 out = p.stdout.strip()
@@ -255,41 +255,28 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255255USAGE_EXIT = 64
256256
257257
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."""
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."""
266260 try:
267− return _run_fm(prompt, schema, opts, [])
261+ return _run_fm(prompt, schema, model, timeout_s, [])
268262 except Fail as f:
269− if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
263+ if f.code != "GUARDRAIL":
270264 raise
271− return _run_fm(prompt, schema, opts, PERMISSIVE)
272−
273−
274−def time_left(opts):
275− return opts["deadline"] - time.time()
265+ return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
276266
277267
278−def _run_fm(prompt, schema, opts, extra):
268+def _run_fm(prompt, schema, model, timeout_s, extra):
279269 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
280270 # in the process list. fm has no timeout of its own; subprocess enforces ours.
281271 # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
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))
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]
287274 try:
288− p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_s, env=fm_env())
275+ p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
289276 except FileNotFoundError:
290277 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
291278 except subprocess.TimeoutExpired:
292− raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
279+ raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
293280 if p.returncode != 0:
294281 code = classify(p.stderr, p.returncode)
295282 if code == "LICENSE_REQUIRED":
@@ -308,11 +295,11 @@ def organize_mode():
308295
309296def op_ping(_payload, _opts):
310297 if not os.path.exists(FM):
311− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
298+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
312299 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
313300 license_required = False
314301 try:
315− avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=fm_env())
302+ avail = subprocess.run([FM, "available", "--model", "system"], capture_output=True, text=True, timeout=15, env=fm_env())
316303 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
317304 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
318305 license_required, msg = True, LICENSE_MESSAGE
@@ -380,7 +367,7 @@ def op_name(payload, opts):
380367 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
381368 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
382369 try:
383− out = run_fm(prompt, schema, opts)
370+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
384371 except Fail as f:
385372 raise _fit_overflow(f, len(payload["items"]))
386373 try:
@@ -396,7 +383,7 @@ def op_organize(payload, opts):
396383 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
397384 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
398385 try:
399− out = run_fm(prompt, schema, opts)
386+ out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
400387 except Fail as f:
401388 raise _fit_overflow(f, len(payload["items"]))
402389 _, owner = prompts.topic_items(payload)
@@ -406,31 +393,29 @@ def op_organize(payload, opts):
406393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
407394 chars = prompts.size(prompt)
408395 if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
409− chars += name_new_groups(result, payload, opts)
396+ chars += name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
410397 return result, chars, "topics"
411398
412399
413−def name_new_groups(result, payload, opts):
400+def name_new_groups(result, payload, opts, deadline):
414401 """Title each new group with the name op (well under a second each on fm), so groups appear named.
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]
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")]
418404 chars = 0
419405 for g in result["groups"]:
420406 if "existing" in g or g.get("title"):
421407 continue
422− if time_left(opts) < MIN_CALL_S:
408+ left = deadline - time.time()
409+ if left < 5:
423410 break
424411 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
425412 try:
426− named, n, _ = op_name(sub, {**opts, "strict": False})
413+ named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
427414 except Fail:
428415 continue
429− chars += n
430− if any(validate.same_label(named["title"], t) for t in taken):
431− continue
432416 g["title"], g["emoji"] = named["title"], named["emoji"]
433417 taken.append(named["title"])
418+ chars += n
434419 return chars
435420
436421
@@ -444,8 +429,8 @@ def handle(req):
444429 raw_opts = req.get("opts") or {}
445430 timeout_ms = raw_opts.get("timeoutMs", 45000)
446431 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
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}
432+ opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
433+ "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
449434 op = req["op"]
450435 if op == "ping":
451436 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -499,7 +484,7 @@ def install_schemas():
499484 if "{%s}" % key not in args:
500485 continue
501486 try:
502− p = subprocess.run([FM, *sub_args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
487+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
503488 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
504489 print(f" {name}: fm failed ({e})", file=sys.stderr)
505490 return "error"
@@ -511,7 +496,7 @@ def install_schemas():
511496 subs["{%s}" % key] = p.stdout.strip()
512497 args = [subs.get(a, a) for a in args]
513498 try:
514− p = subprocess.run([FM, *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, env=fm_env())
499+ p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
515500 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
516501 print(f" {name}: fm failed ({e})", file=sys.stderr)
517502 return "error"
@@ -568,16 +553,14 @@ def selftest():
568553
569554 ping = op_ping({}, {})
570555 check("fm found", os.path.exists(FM), FM)
571− if not os.path.exists(FM):
572− return 1 # nothing else can work without it
573− if ping.get("licenseRequired"):
556+ if ping["licenseRequired"]:
574557 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
575558 else:
576559 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
577560 for name in SCHEMA_COMMANDS:
578561 path = os.path.join(SCHEMAS, name)
579562 readable = os.path.isfile(path) and os.path.getsize(path) > 0
580− hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
563+ hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
581564 check(f"schema {name}", readable, path if readable else hint)
582565 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
583566 if ping["fmAvailable"] and schema_path("name.json"):
@@ -687,9 +670,7 @@ def register(host_path):
687670 f.write("\n".join(written) + "\n")
688671 except OSError:
689672 pass
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
673+ return 1 if failures else 0
693674
694675
695676def unregister():