on master help sign in
11 files changed+238−55
since splitsnapshots
unifiedsplit
.github/workflows/release.yml+23−8 host/diagonal-host.py+49−30 host/tests/test_host.py+56 host/validate.py+44−4 scripts/install-manifest.sh+11−2 src/background/index.ts+19−7 src/background/naming.ts+13 src/background/organize.ts+11−3 src/shared/health.ts+3−1 test/auto.test.ts+1 test/naming.test.ts+8
.github/workflows/release.yml+23−8
@@ -9,8 +9,11 @@ on:
99permissions:
1010 contents: write
1111
12+# One release at a time, in push order: an older run finishing last must not become the latest release,
13+# which is what the Homebrew cask downloads.
1214concurrency:
13− group: release-${{ github.sha }}
15+ group: release
16+ cancel-in-progress: false
1417
1518jobs:
1619 release:
@@ -32,8 +35,23 @@ jobs:
3235
3336 - name: Version from commit time
3437 id: version
38+ env:
39+ GH_TOKEN: ${{ github.token }}
3540 run: |
36− version=$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M "$GITHUB_SHA")
41+ # The commit's minute, or the next free minute when another commit already released in it. A re-run
42+ # for a commit that already released publishes nothing: a rebuilt zip has new bytes, which would
43+ # break the sha256 the tap already has, and users on that version would never get the new one.
44+ t=$(git log -1 --format=%ct "$GITHUB_SHA")
45+ for _ in $(seq 1 30); do
46+ version=$(date -u -d "@$t" +%Y.%m.%d.%H%M)
47+ target=$(gh release view "$version" --json targetCommitish -q .targetCommitish 2>/dev/null || true)
48+ [ -z "$target" ] && break
49+ if [ "$target" = "$GITHUB_SHA" ]; then
50+ echo "published=true" >> "$GITHUB_OUTPUT"
51+ break
52+ fi
53+ t=$((t + 60))
54+ done
3755 echo "version=$version" >> "$GITHUB_OUTPUT"
3856 echo "Version $version"
3957
@@ -54,22 +72,19 @@ jobs:
5472 npm run zip:store
5573
5674 - name: Publish release
75+ if: steps.version.outputs.published != 'true'
5776 env:
5877 GH_TOKEN: ${{ github.token }}
5978 VERSION: ${{ steps.version.outputs.version }}
6079 run: |
6180 asset="diagonal-extension-${VERSION}.zip"
6281 notes="Built from ${GITHUB_SHA::7}. Install with: brew install iko-soy/tap/diagonal. Or by hand: unzip, load the folder at brave://extensions (Developer mode → Load unpacked), then install the native host once from Terminal: bash <that folder>/install-host.command"
63− if gh release view "$VERSION" >/dev/null 2>&1; then
64− # Two commits in the same minute share a version: refresh the asset on the existing release.
65− gh release upload "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --clobber
66− else
67− gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
68− fi
82+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
6983
7084 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
7185 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
7286 - name: Upload to the Chrome Web Store
87+ if: steps.version.outputs.published != 'true'
7388 env:
7489 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
7590 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
host/diagonal-host.py+49−30
@@ -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():
host/tests/test_host.py+56
@@ -287,6 +287,19 @@ class ErrorCodes(HostCase):
287287 self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
288288
289289
290+ def test_strict_retry_samples_instead_of_repeating_the_greedy_answer(self):
291+ self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
292+ self.call("name", {"items": ITEMS3}, strict=True)
293+ self.assertNotIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
294+
295+ def test_permissive_retry_shares_the_request_deadline(self):
296+ # The first call uses up the request's time: no second call runs past what the extension waits for.
297+ self.control({"respond_queue": [{"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1, "sleep": 2.5}]})
298+ r = self.call("name", {"items": ITEMS3}, timeoutMs=5000)
299+ self.assertEqual(r["error"]["code"], "GUARDRAIL")
300+ self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
301+
302+
290303class Organize(HostCase):
291304 ITEMS = ITEMS3 + [
292305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
@@ -331,6 +344,33 @@ class Organize(HostCase):
331344 self.assertIn("Tokio", name_calls[0])
332345 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
333346
347+ def test_a_new_group_named_like_another_group_is_left_to_the_naming_loop(self):
348+ self.control({"respond_queue": [
349+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": "Programming"} for i in (0, 1, 2)]})},
350+ {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
351+ ]})
352+ r = self.call("organize", {"items": self.ITEMS, "siblingTitles": ["rust async"]})
353+ self.assertEqual(r["result"]["groups"][0]["title"], "")
354+ self.assertIn('"rust async"', self.stdin_log()[1]) # the name call was told, too
355+
356+ def test_a_topic_two_groups_share_equally_joins_neither_unless_the_words_say_which(self):
357+ existing = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon"]},
358+ {"g": 1, "title": "Porto trip", "samples": ["Porto restaurants"]}]
359+ # Examples 8 (group 0) and 9 (group 1) both got Travel: a tie.
360+ self.topics([(3, "Travel"), (4, "Travel"), (8, "Travel"), (9, "Travel")])
361+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": existing})
362+ self.assertEqual([g.get("existing") for g in r["result"]["groups"]], [0]) # "Lisbon" is in both tabs' titles
363+ items = [{"title": "Beach day", "url": "https://a.example/"}, {"title": "City walk", "url": "https://b.example/"}]
364+ self.topics([(0, "Travel"), (1, "Travel"), (2, "Travel"), (3, "Travel")])
365+ r = self.call("organize", {"items": items, "existingGroups": existing})
366+ self.assertNotIn("existing", r["result"]["groups"][0])
367+
368+ def test_only_vague_topics_leave_every_tab_loose(self):
369+ self.topics([(0, "Other"), (1, "Misc"), (2, "General")])
370+ r = self.call("organize", {"items": ITEMS3})
371+ self.assertTrue(r["ok"], r)
372+ self.assertEqual(r["result"], {"groups": [], "leftovers": [0, 1, 2]})
373+
334374 def test_two_topics_joining_one_group_make_one_join(self):
335375 # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
336376 self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
@@ -487,6 +527,15 @@ class Register(HostCase):
487527 self.assertEqual(p.returncode, 1)
488528 self.assertIn("missing or not executable", p.stderr)
489529
530+ def test_one_browser_that_cannot_be_written_does_not_fail_the_rest(self):
531+ chrome = self.make_profile("Google/Chrome")
532+ brave = self.make_profile("BraveSoftware/Brave-Browser")
533+ open(os.path.join(brave, "NativeMessagingHosts"), "w").close() # a file where the folder should be
534+ p = self.run_copy("--register", self.host_copy)
535+ self.assertEqual(p.returncode, 0, p.stderr)
536+ self.assertIn("BraveSoftware/Brave-Browser: could not write", p.stderr)
537+ self.assertTrue(os.path.exists(os.path.join(chrome, "NativeMessagingHosts", "io.diagonal.host.json")))
538+
490539 def test_no_browser_found(self):
491540 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
492541 p = self.run_copy("--register", self.host_copy)
@@ -508,6 +557,13 @@ class SelfTest(HostCase):
508557 self.assertEqual(code, 1)
509558 self.assertIn("FAIL model available", p.stdout.decode())
510559
560+ def test_selftest_without_fm_says_so_and_stops(self):
561+ self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "no-such-fm")
562+ code, _, p = self.run_host(args=["--selftest"])
563+ self.assertEqual(code, 1)
564+ self.assertIn("FAIL fm found", p.stdout.decode())
565+ self.assertNotIn("Traceback", p.stderr.decode())
566+
511567 def test_selftest_names_the_license_fix(self):
512568 self.control({"license": True})
513569 code, _, p = self.run_host(args=["--selftest"])
host/validate.py+44−4
@@ -160,6 +160,10 @@ def is_valid_label(label):
160160 )
161161
162162
163+def same_label(a, b):
164+ return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165+
166+
163167def validate_name(out, payload):
164168 if not isinstance(out, dict):
165169 raise ValidationError("reply is not an object")
@@ -265,16 +269,52 @@ def organize_from_topics(out, payload, owner):
265269 votes = group_votes.setdefault(topic, {})
266270 votes[owner[i]] = votes.get(owner[i], 0) + 1
267271 if not tab_topic:
268− raise ValidationError("no tab got a topic")
272+ if not any(isinstance(t, dict) and _in_range(t.get("index"), n_items) for t in out["tabs"]):
273+ raise ValidationError("no tab got a topic")
274+ # Every tab got only a vague topic ("Other", "Misc"): the model found nothing in common.
275+ return validate_organize({"groups": [], "leftovers": []}, payload)
269276 clusters = {}
270277 for i in sorted(tab_topic):
271278 clusters.setdefault(tab_topic[i], []).append(i)
272279 groups = []
273280 for topic, members in clusters.items():
274− votes = group_votes.get(topic)
275− if votes:
276− g = max(sorted(votes), key=lambda k: votes[k])
281+ g = _winner(group_votes.get(topic) or {}, members, payload)
282+ if g is not None:
277283 groups.append({"existing": g, "members": members})
278284 else:
279285 groups.append({"title": "", "emoji": "", "color": "", "members": members})
280286 return validate_organize({"groups": groups, "leftovers": []}, payload)
287+
288+
289+def _in_range(i, n):
290+ return isinstance(i, int) and not isinstance(i, bool) and 0 <= i < n
291+
292+
293+_WORD = re.compile(r"\w{3,}")
294+
295+
296+def _words(text):
297+ return {w.casefold() for w in _WORD.findall(text or "")}
298+
299+
300+def _winner(votes, members, payload):
301+ """The existing group a topic's tabs join: the one whose example tabs share it most. When two groups tie,
302+ the one whose title and examples share more words with these tabs; when that ties too, none (the tabs
303+ form a group of their own, or stay loose), rather than whichever group happens to be listed first."""
304+ if not votes:
305+ return None
306+ top = max(votes.values())
307+ tied = sorted(k for k, v in votes.items() if v == top)
308+ if len(tied) == 1:
309+ return tied[0]
310+ tab_words = set().union(*(_words(payload["items"][i].get("title")) for i in members))
311+ groups = {g["g"]: g for g in payload.get("existingGroups") or []}
312+
313+ def overlap(k):
314+ g = groups.get(k) or {}
315+ return len(tab_words & _words(" ".join([g.get("title") or "", *(g.get("samples") or [])])))
316+
317+ scores = {k: overlap(k) for k in tied}
318+ best = max(scores.values())
319+ leaders = [k for k in tied if scores[k] == best]
320+ return leaders[0] if len(leaders) == 1 and best > 0 else None
scripts/install-manifest.sh+11−2
@@ -53,7 +53,16 @@ echo "host: $BIN -> $SHARE/diagonal-host.py"
5353"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
5454# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
5555# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
56−"$BIN" --register "$BIN"
56+# A browser that was never opened has no folder yet: the rest of the install still runs, and the end
57+# says how to finish.
58+register_rc=0
59+"$BIN" --register "$BIN" || register_rc=$?
60+register_note() {
61+ if [[ $register_rc -ne 0 ]]; then
62+ echo "No browser could be set up for Diagonal yet. Open your browser once, then run: bash '$HERE/$(basename "$0")'"
63+ fi
64+}
65+trap register_note EXIT
5766
5867FM="${DIAGONAL_FM:-/usr/bin/fm}"
5968
@@ -89,7 +98,7 @@ fi
8998# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
9099schemas_out=$("$BIN" --install-schemas 2>&1) || true
91100if selftest_out=$("$BIN" --selftest 2>&1); then
92− echo "Diagonal's host is ready."
101+ [[ $register_rc -ne 0 ]] || echo "Diagonal's host is ready."
93102else
94103 echo "Diagonal's host is installed, but its self-test found a problem:"
95104 echo "$schemas_out"
src/background/index.ts+19−7
@@ -93,7 +93,11 @@ async function host<T>(op: Op, payload: object, extra: Partial<HostOpts> = {}):
9393 h.lastOkAt = Date.now();
9494 h.consecutiveFailures = 0;
9595 h.consecutiveTimeouts = 0;
96− if (op !== "ping") h.rateLimitStep = 0; // a ping doesn't use the model, so it says nothing about its limit
96+ if (op !== "ping") {
97+ h.rateLimitStep = 0; // a ping doesn't use the model, so it says nothing about its limit
98+ // A model answer (a manual Organize or Name now during a pause) proves the helper works again.
99+ if (h.pausedUntil) recovered();
100+ }
97101 if (reply.meta?.ms !== undefined) void pushRing("latency", reply.meta.ms, 100);
98102 } else {
99103 recordHostError(reply.error);
@@ -140,11 +144,7 @@ async function ping(): Promise<HostReply<PingResult>> {
140144 } else if (!reply.result.schemasOk) {
141145 recordHostError({ code: "SCHEMA_MISSING", message: reply.result.schemaMessage || "fm could not write Diagonal's schema files" });
142146 } else {
143− h.pausedUntil = undefined;
144− h.lastError = undefined;
145− chrome.alarms.clear(ALARM_HOST);
146− void naming.sweepDirty();
147− void autoOrganizer.sweep();
147+ recovered();
148148 }
149149 }
150150 commit();
@@ -152,6 +152,16 @@ async function ping(): Promise<HostReply<PingResult>> {
152152 return reply;
153153}
154154
155+/** The helper answers again: lift the pause and pick up what waited for it. */
156+function recovered(): void {
157+ const h = state.host;
158+ h.pausedUntil = undefined;
159+ h.lastError = undefined;
160+ chrome.alarms.clear(ALARM_HOST);
161+ void naming.sweepDirty();
162+ void autoOrganizer.sweep();
163+}
164+
155165// ----- badge -----------------------------------------------------------------------------------
156166
157167function busy(on: boolean): void {
@@ -502,12 +512,14 @@ if (GROUPS_SUPPORTED) {
502512
503513 chrome.tabs.onReplaced.addListener((added, removed) =>
504514 void serial("tabs.onReplaced", async () => {
515+ // Chromium swapped in a new tab (a prerendered page, a discarded tab coming back): it is the same
516+ // tab to the user, so it keeps what Diagonal knew about it, including choices the user made.
505517 const rec = state.tabs[removed];
506518 delete state.tabs[removed];
507519 const t = await tabById(added);
508520 if (t) {
521+ if (rec && !state.tabs[added]) state.tabs[added] = { ...rec, id: added };
509522 await feed({ type: "tabUpdated", tab: snapTab(t) });
510− if (rec?.description && state.tabs[added]) state.tabs[added].description = rec.description;
511523 }
512524 }),
513525 );
src/background/naming.ts+13
@@ -312,6 +312,7 @@ export class Naming {
312312 }
313313 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
314314 retried.output = true;
315+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
315316 continue;
316317 }
317318 this.fail(g, e, hash);
@@ -323,6 +324,7 @@ export class Naming {
323324 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
324325 if (!retried.output) {
325326 retried.output = true;
327+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
326328 continue;
327329 }
328330 this.fail(g, e, hash);
@@ -412,6 +414,17 @@ export class Naming {
412414 }
413415}
414416
417+/** The retry after an unusable title lists that title as not allowed, so the model doesn't give it again. */
418+function withRejected(list: string[] | undefined, raw: string | undefined): string[] | undefined {
419+ let title: unknown;
420+ try {
421+ title = raw ? JSON.parse(raw)?.title : undefined;
422+ } catch {
423+ return list;
424+ }
425+ return typeof title === "string" && title.trim() ? [...(list ?? []), title.trim().slice(0, 60)] : list;
426+}
427+
415428/** The retry bookkeeping of earlier failures, which no longer applies. */
416429function clearFailures(g: GroupRecord): void {
417430 g.nameAttempts = 0;
src/background/organize.ts+11−3
@@ -27,6 +27,8 @@ export interface ExistingGroup {
2727export interface OrganizePayload {
2828 items: NameItem[];
2929 existingGroups: ExistingGroup[];
30+ /** Every group label in the window: a new group the host names must not repeat one. */
31+ siblingTitles?: string[];
3032 allowNew: boolean;
3133 maxGroups: number;
3234 minGroupSize: number;
@@ -177,11 +179,17 @@ const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_
177179const MAX_TIMEOUT_SPLITS = 1;
178180
179181/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
180−async function askOrganize(rt: Runtime, part: Member[], existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
182+async function askOrganize(rt: Runtime, part: Member[], windowId: number, existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
181183 const settings = rt.settings();
184+ const s = rt.state();
182185 const payload: OrganizePayload = {
183186 items: toItems(part, settings, withDescriptions),
184187 existingGroups,
188+ siblingTitles: Object.values(s.groups)
189+ .filter((g) => g.windowId === windowId && g.origin !== "tidy")
190+ .map((g) => labelOf(g.stripTitle || g.title))
191+ .filter(Boolean)
192+ .slice(0, 20),
185193 allowNew: part.length >= 2,
186194 maxGroups: maxGroupsFor(part.length),
187195 minGroupSize: settings.organizeMinGroupSize,
@@ -224,7 +232,7 @@ export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe
224232 run.left += part.length;
225233 return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
226234 }
227− const reply = await askOrganize(rt, part, existingGroups, true);
235+ const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
228236 if (!reply.ok) {
229237 const e = reply.error;
230238 const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
@@ -305,7 +313,7 @@ export async function organizeWindow(rt: Runtime, windowId: number): Promise<Org
305313 report.left = tabs.length;
306314 return report;
307315 }
308− const run = newRun(windowId, false);
316+ const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
309317 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
310318 rt.refreshBadge();
311319 try {
src/shared/health.ts+3−1
@@ -46,7 +46,9 @@ export function notices(s: Health): Notice[] {
4646 case "HOST_NOT_FOUND":
4747 out.push({
4848 tone: "bad", title: "Finish setting up Diagonal",
49− body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
49+ body: chrome.runtime.getManifest().update_url
50+ ? "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal."
51+ : "Diagonal's helper isn't registered with this browser yet. Installed with Homebrew? Run this in Terminal, then reopen Diagonal. Installed from a zip? Run: bash <the Diagonal folder>/install-host.command",
5052 // A Web Store install has an update_url and may never have had the cask; an unpacked one came from it.
5153 command: chrome.runtime.getManifest().update_url ? "brew install iko-soy/tap/diagonal" : "brew reinstall diagonal",
5254 });
test/auto.test.ts+1
@@ -94,6 +94,7 @@ describe("auto-organize", () => {
9494 await auto.run(1);
9595 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
9696 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97+ expect(calls[0].siblingTitles).toEqual(["Rust"]);
9798 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
9899 });
99100
test/naming.test.ts+8
@@ -299,6 +299,14 @@ describe("naming queue", () => {
299299 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
300300 });
301301
302+ it("the retry after a title the host rejected lists that title as not allowed", async () => {
303+ replies.push({ ok: false, error: { code: "BAD_MODEL_OUTPUT", message: "rules", raw: JSON.stringify({ title: "Python", emoji: "🐍" }) } });
304+ naming.touch(7);
305+ await settle();
306+ expect(calls).toHaveLength(2);
307+ expect(calls[1].mustDifferFrom).toEqual(["Python"]);
308+ });
309+
302310 it("a group renamed by hand while the model runs keeps the user's title", async () => {
303311 const n = new Naming({
304312 ...(naming as any).d,