@@ -255,28 +255,41 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255USAGE_EXIT = 64
255USAGE_EXIT = 64
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.
258−def run_fm(prompt, schema, model, timeout_s):
263+def run_fm(prompt, schema, opts):
259− """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
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."""
261− return _run_fm(prompt, schema, model, timeout_s, [])
267+ return _run_fm(prompt, schema, opts, [])
262 except Fail as f:
268 except Fail as f:
263− if f.code != "GUARDRAIL":
269+ if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
265− return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
271+ return _run_fm(prompt, schema, opts, PERMISSIVE)
275+ return opts["deadline"] - time.time()
268−def _run_fm(prompt, schema, model, timeout_s, extra):
278+def _run_fm(prompt, schema, opts, extra):
269 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
279 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
270 # in the process list. fm has no timeout of its own; subprocess enforces ours.
280 # in the process list. fm has no timeout of its own; subprocess enforces ours.
271 # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
281 # --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.
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"]
273− args = [FM, "respond", "--model", model, "--no-stream", "--greedy", "--schema", schema, "-i", prompt.instructions, *extra]
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))
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, timeout=timeout_s, env=fm_env())
276 except FileNotFoundError:
289 except FileNotFoundError:
277 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
278 except subprocess.TimeoutExpired:
291 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)
280 if p.returncode != 0:
293 if p.returncode != 0:
281 code = classify(p.stderr, p.returncode)
294 code = classify(p.stderr, p.returncode)
282 if code == "LICENSE_REQUIRED":
295 if code == "LICENSE_REQUIRED":
@@ -295,7 +308,7 @@ def organize_mode():
296def op_ping(_payload, _opts):
309def op_ping(_payload, _opts):
297 if not os.path.exists(FM):
310 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,
299 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
300 license_required = False
313 license_required = False
@@ -367,7 +380,7 @@ def op_name(payload, opts):
367 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
380 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
368 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
381 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
370− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
383+ out = run_fm(prompt, schema, opts)
371 except Fail as f:
384 except Fail as f:
372 raise _fit_overflow(f, len(payload["items"]))
385 raise _fit_overflow(f, len(payload["items"]))
@@ -383,7 +396,7 @@ def op_organize(payload, opts):
383 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
396 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
384 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
397 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
386− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
399+ out = run_fm(prompt, schema, opts)
387 except Fail as f:
400 except Fail as f:
388 raise _fit_overflow(f, len(payload["items"]))
401 raise _fit_overflow(f, len(payload["items"]))
389 _, owner = prompts.topic_items(payload)
402 _, owner = prompts.topic_items(payload)
@@ -393,29 +406,31 @@ def op_organize(payload, opts):
393 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
406 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
394 chars = prompts.size(prompt)
407 chars = prompts.size(prompt)
395 if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
408 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)
397 return result, chars, "topics"
410 return result, chars, "topics"
400−def name_new_groups(result, payload, opts, deadline):
413+def name_new_groups(result, payload, opts):
401 """Title each new group with the name op (well under a second each on fm), so groups appear named.
414 """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
402− A group that fails, or runs past the deadline, keeps "" and the extension's naming loop names it."""
416+ already has keeps "" and the extension's naming loop, which checks every sibling, names it."""
403− taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
417+ taken = [t for t in [g.get("title") for g in payload.get("existingGroups") or []] + list(payload.get("siblingTitles") or []) if t]
404 chars = 0
418 chars = 0
405 for g in result["groups"]:
419 for g in result["groups"]:
406 if "existing" in g or g.get("title"):
420 if "existing" in g or g.get("title"):
408− left = deadline - time.time()
422+ if time_left(opts) < MIN_CALL_S:
411 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
424 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
413− named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
426+ named, n, _ = op_name(sub, {**opts, "strict": False})
414 except Fail:
427 except Fail:
430+ if any(validate.same_label(named["title"], t) for t in taken):
416 g["title"], g["emoji"] = named["title"], named["emoji"]
432 g["title"], g["emoji"] = named["title"], named["emoji"]
417 taken.append(named["title"])
433 taken.append(named["title"])
419 return chars
434 return chars
@@ -429,8 +444,8 @@ def handle(req):
429 raw_opts = req.get("opts") or {}
444 raw_opts = req.get("opts") or {}
430 timeout_ms = raw_opts.get("timeoutMs", 45000)
445 timeout_ms = raw_opts.get("timeoutMs", 45000)
431 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
446 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}
434 op = req["op"]
449 op = req["op"]
435 if op == "ping":
450 if op == "ping":
436 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
451 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -553,14 +568,16 @@ def selftest():
554 ping = op_ping({}, {})
569 ping = op_ping({}, {})
555 check("fm found", os.path.exists(FM), FM)
570 check("fm found", os.path.exists(FM), FM)
571+ if not os.path.exists(FM):
572+ return 1 # nothing else can work without it
556− if ping["licenseRequired"]:
573+ if ping.get("licenseRequired"):
557 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
574 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
559 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
576 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
560 for name in SCHEMA_COMMANDS:
577 for name in SCHEMA_COMMANDS:
561 path = os.path.join(SCHEMAS, name)
578 path = os.path.join(SCHEMAS, name)
562 readable = os.path.isfile(path) and os.path.getsize(path) > 0
579 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"
564 check(f"schema {name}", readable, path if readable else hint)
581 check(f"schema {name}", readable, path if readable else hint)
565 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
582 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
566 if ping["fmAvailable"] and schema_path("name.json"):
583 if ping["fmAvailable"] and schema_path("name.json"):
@@ -670,7 +687,9 @@ def register(host_path):
670 f.write("\n".join(written) + "\n")
687 f.write("\n".join(written) + "\n")
671 except OSError:
688 except OSError:
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.
673− return 1 if failures else 0
692+ return 0 if len(dirs) - failures > 0 else 1
676def unregister():
695def unregister():