on master help sign in
Fit the host to the real fm on macOS 27

Checked on Montelot's Mac (macOS 27.0, 26A428): - The nested organize schema needs `--object groups --schema <json>`; the host now builds the Group schema first ("existing" optional). Schemas carry a version, so older two-call schemas get rewritten. - The prompt goes on stdin instead of argv, so tab titles stay out of the process list. - The context is ~8k tokens: budget 24,000 chars; name/organize caps doubled to 48/36. - A guardrail refusal is retried once with --guardrails permissive-content-transformations. - fm only has --model system: the Private Cloud Compute setting is gone and old values read as system. - NO_COLOR for plain stderr; the fake fm follows the real one (stdin, --object needs --schema, model). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YTy2MovVuA1R9w6f9gRDU2

commit92b9721c60bd586561afa6606949acd3aa0823e2 authorClaude <noreply@anthropic.com> dateThu 24 Sep 2026, 07:15 (2 weeks ago) parentd0ca350
20 files changed+187−67
PRIVACY.md+1−2 README.md+11−8 docs/store/screenshot-settings.png host/diagonal-host.py+67−14 host/prompts.py+1 host/tests/fake_fm.py+10−1 host/tests/golden/organize-3.txt+1 host/tests/support.py+9 host/tests/test_host.py+59−15 host/validate.py+2 scripts/store-assets.mjs+1−1 src/background/host.ts+1−1 src/background/naming.ts+1−1 src/background/organize.ts+2−2 src/background/settings.ts+3−3 src/options/options.ts+1−10 test/e2e/smoke-fm.py+1−1 test/naming.test.ts+5−5 test/organize.test.ts+3−3 test/settings.test.ts+8
PRIVACY.md+1−2
@@ -8,8 +8,7 @@ For each normal (not incognito) tab: its title, its address and the page's `<met
88
99## Where it goes
1010
11−- **To Apple's on-device model on your Mac.** Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). By default the model runs on your Mac and nothing leaves it.
12−- **To Apple's Private Cloud Compute, only if you choose it.** Settings → Privacy → Model offers Apple's larger server model. With that picked, the same text goes to Apple under Apple's Private Cloud Compute terms. It is off by default.
11+Only to Apple's on-device model on your Mac. Diagonal passes that text to Apple's `fm` tool through a small helper program on your Mac (the native messaging host installed with `brew install iko-soy/tap/diagonal`). The model runs on the Mac, and nothing leaves it.
1312
1413Diagonal has no server of its own. It sends nothing to its developers and has no analytics. The only other request is the browser loading each archived tab's site icon from that site when you open the popup.
1514
README.md+11−8
@@ -130,13 +130,16 @@ Every push to `master` runs `.github/workflows/release.yml`: it typechecks, runs
130130- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
131131- **Restarts.** Tab and group IDs change when the browser restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
132132
133−## Not verified on a real Mac yet
133+## How fm behaves on macOS 27
134134
135−Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
135+Checked on a Mac running macOS 27.0 (26A428) on 2026-09-24; the tests' fake `fm` follows it.
136136
137−- **Confirmed on a Mac:** `fm` is at `/usr/bin/fm`, and until an admin runs `sudo fm license` every `fm` command exits 69 with a terms notice. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
138−- The `fm schema` flags. `diagonal-host --install-schemas` tries the nested organize schema and falls back to the two flat schemas plus two calls if that fails. The flags are in `SCHEMA_COMMANDS` at the top of `host/diagonal-host.py`.
139−- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
140−- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
141−- The context window. `CHAR_BUDGET` is 10,000 characters, assuming 4,096 tokens. Double it (and `NAME_ITEM_CAP` / `ORGANIZE_ITEM_CAP`) once 8,192 is confirmed.
142−- The Nix flake builds both packages on x86_64-linux, and `.github/workflows/nix.yml` builds them on Linux and Apple silicon on every push. The home-manager module has not been activated on a real Mac.
137+- `fm` is at `/usr/bin/fm`. Until an admin runs `sudo fm license`, every command exits 69 with a terms notice; `fm license --status` reports the state. The installer offers the terms in the terminal; if they are declined, Diagonal reports `LICENSE_REQUIRED` with the fix. The host writes its schemas on first use, so install order does not matter.
138+- `fm schema object` builds the schemas. A nested object needs its own schema: `--object groups --schema "<json from another fm schema object>" --array`. The host does that for `organize.json` (see `SUB_SCHEMAS` in `host/diagonal-host.py`) and keeps the two flat schemas plus two calls as a fallback.
139+- `fm respond` reads the prompt from stdin when no prompt argument is given, so the host never puts tab titles in the process list. There is no timeout flag; the host enforces its own.
140+- The only model is `system` (`--model pcc` is rejected), so Diagonal always uses the on-device model.
141+- The context is about 8,000 tokens for prompt and reply together (38,000 characters worked, 40,000 overflowed). `CHAR_BUDGET` is 24,000 characters.
142+- The safety layer sometimes refuses harmless text ("The model's safety guardrails were triggered."). The host retries such a call once with `--guardrails permissive-content-transformations`.
143+- Reply JSON key order is not stable, and the model can list a tab both in a group and in leftovers; the validator handles both.
144+
145+Still open: the Nix flake builds both packages on Linux and Apple silicon in CI, but the home-manager module has not been activated on a real Mac.
docs/store/screenshot-settings.png

Binary file not shown.

host/diagonal-host.py+67−14
@@ -36,19 +36,25 @@ SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/A
3636SCHEMAS = os.path.join(SUPPORT, "schemas")
3737LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
3838LOG_MAX_BYTES = 5 * 1024 * 1024
39−CHAR_BUDGET = 10_000 # ≈ 2,850 tokens of prompt at 3.5 chars/token (section 9); double once 8,192 is confirmed
39+# fm's context is about 8,000 tokens for prompt and reply together (measured on macOS 27: 38,000 chars of
40+# prompt worked, 40,000 overflowed, ~4.7 chars/token). 24,000 chars leaves room for the reply and dense text.
41+CHAR_BUDGET = 24_000
4042MAX_REQUEST = 64 * 1024 * 1024
4143MAX_REPLY = 1024 * 1024
4244OPS = {"ping", "name", "organize"}
4345
44−# `fm schema` argument lists (section 8). The nested form is unverified on a real macOS 27 machine;
45−# when it fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
46+# `fm schema` argument lists (section 8), checked against fm on macOS 27. `--object NAME` takes the
47+# nested object's schema as JSON from `--schema`; "{Group}" is replaced by the output of SUB_SCHEMAS["Group"].
48+# If the nested form fails, --install-schemas writes the two flat schemas and `organize` takes two calls.
49+SUB_SCHEMAS = {
50+ "Group": ["schema", "object", "--name", "Group",
51+ "--string", "title", "--string", "emoji", "--string", "color",
52+ "--integer", "existing", "--optional", "--integer", "members", "--array"],
53+}
4654SCHEMA_COMMANDS = {
4755 "name.json": ["schema", "object", "--name", "GroupName", "--string", "title", "--string", "emoji"],
4856 "organize.json": ["schema", "object", "--name", "Organized",
49− "--object", "groups", "--array",
50− "--string", "title", "--string", "emoji", "--string", "color",
51− "--integer", "existing", "--integer", "members", "--array",
57+ "--object", "groups", "--schema", "{Group}", "--array",
5258 "--integer", "leftovers", "--array"],
5359}
5460FALLBACK_SCHEMA_COMMANDS = {
@@ -146,9 +152,8 @@ def validate_request(req):
146152 {"g": n, "title": _trim(g.get("title"), 60), "samples": [_trim(s, 60) for s in (g.get("samples") or [])[:2]]}
147153 for n, g in enumerate(groups[:12]) if isinstance(g, dict)
148154 ]
149− model = opts.get("model", "system")
150− if model not in ("system", "pcc"):
151− raise Fail("BAD_REQUEST", "opts.model must be system or pcc")
155+ # fm on macOS 27 only has the on-device model; older extensions may still ask for "pcc".
156+ opts["model"] = "system"
152157 return req
153158
154159
@@ -181,7 +186,7 @@ def classify(stderr, returncode=None):
181186
182187
183188def fm_env():
184− return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8"}
189+ return {"PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", ""), "LANG": "en_US.UTF-8", "NO_COLOR": "1"}
185190
186191
187192def schema_path(name):
@@ -212,10 +217,26 @@ def extract_json(stdout):
212217 raise Fail("BAD_MODEL_OUTPUT", "no JSON object in fm output", raw=text[:2000])
213218
214219
220+# fm's safety layer sometimes refuses harmless tab lists. Naming tabs only restates their own text, so a
221+# refused call is retried once in the mode Apple provides for transforming given content.
222+PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
223+
224+
215225def run_fm(prompt, schema, model, timeout_s):
216− args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, "--", prompt]
217226 try:
218− p = subprocess.run(args, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
227+ return _run_fm(prompt, schema, model, timeout_s, [])
228+ except Fail as f:
229+ if f.code != "GUARDRAIL":
230+ raise
231+ return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
232+
233+
234+def _run_fm(prompt, schema, model, timeout_s, extra):
235+ # The prompt goes on stdin (fm reads it there when no prompt argument is given), so tab titles never
236+ # show up in the process list. fm has no timeout of its own; subprocess enforces ours.
237+ args = [FM, "respond", "--model", model, "--no-stream", "--schema", schema, *extra]
238+ try:
239+ p = subprocess.run(args, input=prompt, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
219240 except FileNotFoundError:
220241 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
221242 except subprocess.TimeoutExpired:
@@ -264,6 +285,19 @@ def op_ping(_payload, _opts):
264285 return reply
265286
266287
288+# Bumped when SCHEMA_COMMANDS change, so schemas written by an older host are rewritten. Version 2: the
289+# nested organize schema in the form fm actually accepts (older hosts fell back to two calls).
290+SCHEMA_VERSION = "2"
291+
292+
293+def schemas_current():
294+ try:
295+ with open(os.path.join(SCHEMAS, "version"), encoding="utf-8") as f:
296+ return f.read().strip() == SCHEMA_VERSION
297+ except OSError:
298+ return False
299+
300+
267301def schemas_ok():
268302 return bool(schema_path("name.json")) and organize_mode() != "missing"
269303
@@ -271,7 +305,7 @@ def schemas_ok():
271305def ensure_schemas():
272306 """Write the schema files on first use, so install order does not matter (e.g. the fm terms were
273307 accepted after the installer ran). stdout is the native-messaging channel: progress lines are swallowed."""
274− if schemas_ok():
308+ if schemas_ok() and schemas_current():
275309 return
276310 out = io.StringIO()
277311 with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
@@ -332,7 +366,7 @@ def handle(req):
332366 raw_opts = req.get("opts") or {}
333367 timeout_ms = raw_opts.get("timeoutMs", 45000)
334368 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
335− opts = {"model": raw_opts.get("model", "system"), "strict": bool(raw_opts.get("strict")),
369+ opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
336370 "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
337371 op = req["op"]
338372 if op == "ping":
@@ -382,6 +416,22 @@ def install_schemas():
382416
383417 def write(name, args):
384418 """True when written; "license" or "rejected" (fm ran and refused) or "error" otherwise."""
419+ subs = {}
420+ for key, sub_args in SUB_SCHEMAS.items():
421+ if "{%s}" % key not in args:
422+ continue
423+ try:
424+ p = subprocess.run([FM, *sub_args], capture_output=True, text=True, timeout=30, env=fm_env())
425+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
426+ print(f" {name}: fm failed ({e})", file=sys.stderr)
427+ return "error"
428+ if license_needed(p.returncode, p.stdout + p.stderr):
429+ return "license"
430+ if p.returncode != 0 or not p.stdout.strip():
431+ print(f" {name}: fm schema ({key}) exited {p.returncode}: {p.stderr.strip()[:200]}", file=sys.stderr)
432+ return "rejected"
433+ subs["{%s}" % key] = p.stdout.strip()
434+ args = [subs.get(a, a) for a in args]
385435 try:
386436 p = subprocess.run([FM, *args], capture_output=True, text=True, timeout=30, env=fm_env())
387437 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
@@ -421,6 +471,9 @@ def install_schemas():
421471 pass
422472 for name, args in FALLBACK_SCHEMA_COMMANDS.items():
423473 ok &= write(name, args) is True
474+ if ok:
475+ with open(os.path.join(SCHEMAS, "version"), "w", encoding="utf-8") as f:
476+ f.write(SCHEMA_VERSION + "\n")
424477 return 0 if ok else 1
425478
426479
host/prompts.py+1
@@ -69,6 +69,7 @@ def _organize_rules(max_groups, strict):
6969 "- Put each tab index into exactly one group, or into leftovers if it fits nowhere.",
7070 f"- A group needs at least 2 tabs. Prefer fewer, broader groups; at most {max_groups} new groups.",
7171 '- Reuse an existing group when a tab fits it: set "existing" to that group\'s number and leave title, emoji and color empty.',
72+ '- For a new group, leave "existing" out.',
7273 f"- New groups: title of 2 to 4 words in sentence case, one emoji from: {EMOJI_LIST}, one color from: {', '.join(COLORS)}.",
7374 "- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.",
7475 "- Write titles in the language most tab titles use.",
host/tests/fake_fm.py+10−1
@@ -7,7 +7,9 @@ control.json keys:
77 schema_nested: false → `fm schema` with --object fails (simulates no nested-array support)
88 schema_fail: true → every `fm schema` fails
99 license: true → every command exits 69 with the terms notice, as fm does before `sudo fm license`
10−Every invocation's argv is appended to argv.log as one JSON line.
10+Every invocation's argv is appended to argv.log as one JSON line; `fm respond` also appends its stdin
11+(the prompt) to stdin.log. Like the real fm: `--object` must be followed by `--schema <json>`, and the only
12+--model is "system".
1113"""
1214import json
1315import os
@@ -41,14 +43,21 @@ def main():
4143 spec = {"stderr": LICENSE_NOTICE, "exit": 69}
4244 elif cmd == "available":
4345 spec = control.get("available", {"stdout": "available", "exit": 0})
46+ elif "--model" in argv and argv[argv.index("--model") + 1] != "system":
47+ spec = {"stderr": f"Error: The value '{argv[argv.index('--model') + 1]}' is invalid for '--model <model>'. Please provide one of 'system'.\n", "exit": 64}
4448 elif cmd == "schema":
4549 if control.get("schema_fail"):
4650 spec = {"stderr": "error: schema generation failed", "exit": 1}
4751 elif "--object" in argv and control.get("schema_nested", True) is False:
4852 spec = {"stderr": "error: unknown option --object", "exit": 64}
53+ elif "--object" in argv and argv[argv.index("--object") + 2:argv.index("--object") + 3] != ["--schema"]:
54+ name = argv[argv.index("--object") + 1]
55+ spec = {"stderr": f"Error: SchemaError(errorDescription: Optional(\"--object '{name}' must be followed by --schema\"))\n", "exit": 1}
4956 else:
5057 spec = {"stdout": json.dumps({"schema": argv[3] if len(argv) > 3 else "x", "args": argv[1:]}), "exit": 0}
5158 elif cmd == "respond":
59+ with open(os.path.join(HERE, "stdin.log"), "a", encoding="utf-8") as f:
60+ f.write(json.dumps(sys.stdin.read()) + "\n")
5261 queue = control.get("respond_queue") or []
5362 if queue:
5463 spec = queue.pop(0)
host/tests/golden/organize-3.txt+1
@@ -4,6 +4,7 @@ Rules:
44- Put each tab index into exactly one group, or into leftovers if it fits nowhere.
55- A group needs at least 2 tabs. Prefer fewer, broader groups; at most 1 new groups.
66- Reuse an existing group when a tab fits it: set "existing" to that group's number and leave title, emoji and color empty.
7+- For a new group, leave "existing" out.
78- New groups: title of 2 to 4 words in sentence case, one emoji from: 💻 🖥️ ⌨️ 🔧 🛠️ ⚙️ 🐛 🦀 🐍 🐧 📦 🧩 🔌 🤖 🧠 🗄️ ☁️ 🔒 📚 📖 📰 📝 🔖 ✍️ 📄 🗞️ 📜 🛒 🛍️ 🏷️ 👟 👕 🎁 💳 ✈️ 🗺️ 🏨 🚆 🚗 🏖️ ⛰️ 🧳 🌍 🚲 🎬 🎵 🎮 📺 🎧 📷 🎨 🎙️ 🍿 🎸 💼 📊 📈 📅 🗂️ 📋 📌 ✅ 📎 🏢 👥 💰 🏦 💵 📉 🪙 🧾 💹 🔬 🧪 🧬 🔭 ⚗️ 🧮 📐 🌌 🍳 🍕 🍜 🥗 ☕ 🍷 🍰 🥖 🍣 🏠 🛋️ 🪴 🔨 🧹 🐶 🐱 👶 🩺 💊 🏃 🧘 🏋️ 🦷 🍎 💬 ✉️ 📞 📣 🗣️ 🧭 ⭐ 🔥 💡 🎯 🏆 ⚽ 🎓 🌱 🎉 🔍 ⚖️ 🏛️ 🗳️ 💤 🔄, one color from: grey, blue, red, yellow, green, pink, purple, cyan, orange.
89- Group by topic or task, not by website, unless the tabs are the same site and nothing else links them.
910- Write titles in the language most tab titles use.
host/tests/support.py+9
@@ -63,6 +63,8 @@ class HostCase(unittest.TestCase):
6363 for name in ("name.json", "organize.json"):
6464 with open(os.path.join(self.support, "schemas", name), "w") as f:
6565 f.write("{}")
66+ with open(os.path.join(self.support, "schemas", "version"), "w") as f:
67+ f.write(host.SCHEMA_VERSION + "\n")
6668 self.control({})
6769 self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
6870 "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
@@ -83,6 +85,13 @@ class HostCase(unittest.TestCase):
8385 return []
8486 return [json.loads(line) for line in open(path, encoding="utf-8")]
8587
88+ def stdin_log(self):
89+ """The prompts `fm respond` read on stdin, in call order."""
90+ path = os.path.join(self.tmp, "stdin.log")
91+ if not os.path.exists(path):
92+ return []
93+ return [json.loads(line) for line in open(path, encoding="utf-8")]
94+
8695 def run_host(self, request=None, origin=ORIGIN, raw=None, args=None, timeout=30):
8796 stdin = raw if raw is not None else (frame(request) if request is not None else b"")
8897 argv = args if args is not None else [origin]
host/tests/test_host.py+59−15
@@ -107,7 +107,7 @@ class Ping(HostCase):
107107 r = self.call("ping")
108108 self.assertTrue(r["ok"], r)
109109 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
110+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
111111
112112 def test_ping_without_fm(self):
113113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,10 +124,9 @@ class Name(HostCase):
124124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125125 self.assertGreater(r["meta"]["promptChars"], 0)
126126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv[:6], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128− self.assertEqual(argv[6], "--")
129− self.assertIn("tokio.rs", argv[7])
130− self.assertEqual(len(argv), 8)
127+ self.assertEqual(argv, ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128+ # The prompt goes on stdin, never into argv where `ps` would show tab titles.
129+ self.assertIn("tokio.rs", self.stdin_log()[0])
131130
132131 def test_json_wrapped_in_prose_and_fence(self):
133132 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -140,16 +139,34 @@ class Name(HostCase):
140139 r = self.call("name", {"items": items})
141140 self.assertEqual(r["result"]["emoji"], "💻")
142141
143− def test_pcc_only_when_asked(self):
142+ def test_pcc_falls_back_to_the_on_device_model(self):
143+ # fm on macOS 27 rejects --model pcc; an older setting must not break naming.
144144 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
145− self.call("name", {"items": ITEMS3}, model="pcc")
145+ r = self.call("name", {"items": ITEMS3}, model="pcc")
146+ self.assertTrue(r["ok"], r)
146147 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
147− self.assertEqual(argv[1:3], ["--model", "pcc"])
148+ self.assertEqual(argv[1:3], ["--model", "system"])
149+
150+ def test_guardrail_refusal_is_retried_permissively_once(self):
151+ self.control({"respond_queue": [
152+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
153+ {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
154+ ]})
155+ r = self.call("name", {"items": ITEMS3})
156+ self.assertTrue(r["ok"], r)
157+ calls = [a for a in self.argv_log() if a[0] == "respond"]
158+ self.assertEqual(len(calls), 2)
159+ self.assertNotIn("--guardrails", calls[0])
160+ self.assertEqual(calls[1][-2:], ["--guardrails", "permissive-content-transformations"])
161+
162+ def test_context_overflow_is_over_budget(self):
163+ self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
164+ self.assertEqual(self.call("name", {"items": ITEMS3})["error"]["code"], "OVER_BUDGET")
148165
149166 def test_prompt_never_contains_tab_ids(self):
150167 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
151168 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
152− prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
169+ prompt = self.stdin_log()[0]
153170 self.assertNotIn("987654", prompt)
154171
155172
@@ -184,11 +201,11 @@ class ErrorCodes(HostCase):
184201 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
185202
186203 def test_over_budget_precheck_reports_allowed_items(self):
187− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(30)]
204+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(60)]
188205 r = self.call("name", {"items": big})
189206 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
190207 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
191− self.assertLess(r["error"]["allowedItems"], 30)
208+ self.assertLess(r["error"]["allowedItems"], 60)
192209 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
193210
194211 def test_guardrail(self):
@@ -223,7 +240,7 @@ class ErrorCodes(HostCase):
223240 def test_strict_retry_appends_line(self):
224241 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
225242 self.call("name", {"items": ITEMS3}, strict=True)
226− prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
243+ prompt = self.stdin_log()[0]
227244 self.assertIn("Reply with only the object", prompt)
228245
229246
@@ -262,7 +279,7 @@ class Organize(HostCase):
262279 self.assertEqual(groups[-1]["title"], "Rust async")
263280 self.assertEqual(groups[-1]["members"], [0, 1, 2])
264281 self.assertEqual(r["result"]["leftovers"], [5])
265− prompts_sent = [a[-1] for a in self.argv_log() if a[0] == "respond"]
282+ prompts_sent = self.stdin_log()
266283 self.assertEqual(len(prompts_sent), 2)
267284 self.assertIn("1 | Rust async", prompts_sent[1])
268285
@@ -273,13 +290,40 @@ class InstallSchemas(HostCase):
273290 os.remove(os.path.join(self.support, "schemas", f))
274291 code, _, p = self.run_host(args=["--install-schemas"])
275292 self.assertEqual(code, 0, p.stderr)
276− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
293+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
294+
295+ def test_nested_object_gets_its_schema_from_fm_first(self):
296+ # Real fm: `--object groups` must be followed by `--schema <json>` for the group's own schema.
297+ for f in os.listdir(os.path.join(self.support, "schemas")):
298+ os.remove(os.path.join(self.support, "schemas", f))
299+ code, _, p = self.run_host(args=["--install-schemas"])
300+ self.assertEqual(code, 0, p.stderr)
301+ calls = [a for a in self.argv_log() if a[0] == "schema"]
302+ group = next(a for a in calls if "Group" in a)
303+ self.assertIn("--optional", group) # "existing" is left out for new groups
304+ organize = next(a for a in calls if "Organized" in a)
305+ i = organize.index("--object")
306+ self.assertEqual(organize[i + 1:i + 3], ["groups", "--schema"])
307+ self.assertIn('"args"', organize[i + 3]) # the fake fm's output for the Group schema
308+ self.assertEqual(organize[i + 4], "--array")
309+
310+ def test_schemas_from_an_older_host_are_rewritten(self):
311+ # Hosts before schema version 2 could not build the nested schema on real fm and fell back to two calls.
312+ d = os.path.join(self.support, "schemas")
313+ for f in os.listdir(d):
314+ os.remove(os.path.join(d, f))
315+ for name in ("name.json", "organize-labels.json", "organize-assign.json"):
316+ open(os.path.join(d, name), "w").write("{}")
317+ r = self.call("ping")
318+ self.assertTrue(r["result"]["schemasOk"], r)
319+ self.assertEqual(r["result"]["organizeMode"], "nested")
320+ self.assertEqual(open(os.path.join(d, "version")).read().strip(), host.SCHEMA_VERSION)
277321
278322 def test_falls_back_to_two_flat_schemas(self):
279323 self.control({"schema_nested": False})
280324 code, _, p = self.run_host(args=["--install-schemas"])
281325 self.assertEqual(code, 0, p.stderr)
282− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
326+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize-assign.json", "organize-labels.json"])
283327
284328
285329 def test_first_use_writes_missing_schemas(self):
host/validate.py+2
@@ -161,6 +161,8 @@ def validate_organize(out, payload):
161161 if not members:
162162 continue
163163 ex = g.get("existing")
164+ if isinstance(ex, list): # fm has returned the optional integer as [] or [n]
165+ ex = ex[0] if len(ex) == 1 else None
164166 if isinstance(ex, int) and not isinstance(ex, bool) and 0 <= ex < n_existing:
165167 joins.append({"title": "", "emoji": "", "color": "", "existing": ex, "members": members})
166168 continue
scripts/store-assets.mjs+1−1
@@ -15,7 +15,7 @@ h1{font-size:40px;margin:0 0 16px;line-height:1.15} p{font-size:20px;line-height
1515
1616const pages = [
1717 { file: "screenshot-popup.png", w: 1280, h: 800, html: `<div><h1>Tabs group themselves</h1><p>Open and close tabs as usual. Diagonal groups related tabs and names each group with Apple's on-device model.</p></div><img class="shot" src="${img(join(shots, "popup.png"))}" style="height:560px">` },
18− { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac. It sees a tab's title, address and description, and nothing leaves the Mac unless you pick Apple's Private Cloud Compute.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
18+ { file: "screenshot-settings.png", w: 1280, h: 800, html: `<div><h1>Private by design</h1><p>The model runs on your Mac and sees only a tab's title, address and description. Nothing leaves the Mac.</p></div><div class="shot" style="width:560px;height:680px;overflow:hidden"><img src="${img(join(shots, "options.png"))}" style="width:560px;display:block"></div>` },
1919 { file: "promo-small.png", w: 440, h: 280, html: `<img src="${icon}" style="width:120px"><div style="font-size:34px;font-weight:700">Diagonal</div>`, gap: 24 },
2020];
2121
src/background/host.ts+1−1
@@ -9,7 +9,7 @@ export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
99export type Op = "ping" | "name" | "organize";
1010
1111export interface HostOpts {
12− model: "system" | "pcc";
12+ model: "system";
1313 timeoutMs: number;
1414 emoji: boolean;
1515 strict?: boolean; // append "reply with only the object" (BAD_MODEL_OUTPUT retry)
src/background/naming.ts+1−1
@@ -8,7 +8,7 @@ import type { GroupRecord, HostError, State } from "./state";
88
99/** Section 6: debounced, sticky, sibling-aware naming of managed groups. */
1010
11−export const NAME_ITEM_CAP = 24;
11+export const NAME_ITEM_CAP = 48;
1212export const SAMPLE_EACH = 8;
1313export const QUEUE_GAP_MS = 250;
1414export const FALLBACK_ALARM_MS = 30_000;
src/background/organize.ts+2−2
@@ -8,9 +8,9 @@ import { markDirty, type State } from "./state";
88
99/** Section 3/9: "Organize all tabs" — cluster ungrouped tabs in batches sized to the model's window. */
1010
11−export const ORGANIZE_ITEM_CAP = 18;
11+export const ORGANIZE_ITEM_CAP = 36;
1212export const EXISTING_GROUPS_CAP = 12;
13−export const CHAR_BUDGET = 10_000;
13+export const CHAR_BUDGET = 24_000; // matches the host's budget for fm's ~8k-token context
1414export const INSTRUCTION_CHARS = 1_400; // ~320 tokens of rules, emoji and colour lists
1515export const EXISTING_GROUP_CHARS = 90;
1616export const UNDO_WINDOW_MS = 3_600_000;
src/background/settings.ts+3−3
@@ -1,6 +1,7 @@
11/** Section 10: every setting, its default and its range. */
22export type TidyMode = "ask" | "auto" | "off";
3−export type ModelChoice = "system" | "pcc";
3+/** fm on macOS 27 has only the on-device model; a stored "pcc" from older versions reads as "system". */
4+export type ModelChoice = "system";
45
56export interface Settings {
67 openerGrouping: boolean;
@@ -78,8 +79,7 @@ export function withDefaults(raw: unknown): Settings {
7879 (s as any)[key] = range ? Math.min(range[1], Math.max(range[0], Math.round(v))) : v;
7980 } else if (Array.isArray(def) && Array.isArray(v)) {
8081 (s as any)[key] = v.filter((x) => typeof x === "string" && x.trim()).map((x: string) => x.trim());
81− } else if (key === "model" && (v === "system" || v === "pcc")) s.model = v;
82− else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
82+ } else if (key === "tidyMode" && (v === "ask" || v === "auto" || v === "off")) s.tidyMode = v;
8383 }
8484 if (s.archiveAfterHours !== 0 && s.archiveAfterHours < 1) s.archiveAfterHours = 1;
8585 return s;
src/options/options.ts+1−10
@@ -38,9 +38,8 @@ const SECTIONS: Section[] = [
3838 },
3939 {
4040 title: "Privacy",
41− intro: "What the model sees when it names a group.",
41+ intro: "What Apple's on-device model sees when it names a group. It runs on this Mac; nothing leaves it.",
4242 fields: [
43− { key: "model", label: "Model", kind: "select", options: [["system", "On this Mac"], ["pcc", "Private Cloud Compute"]] },
4443 { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
4544 { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
4645 ],
@@ -97,8 +96,6 @@ function applyDependencies(): void {
9796 row.classList.toggle("off", !on);
9897 row.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((c) => (c.disabled = !on));
9998 }
100− const w = document.getElementById("pcc-warning");
101− if (w) w.hidden = settings.model !== "pcc";
10299}
103100
104101function fieldRow(f: Field): HTMLElement {
@@ -136,12 +133,6 @@ function fieldRow(f: Field): HTMLElement {
136133 onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
137134 }));
138135 }
139− if (f.key === "model") {
140− row.append(el("div", {
141− className: "warn-text", id: "pcc-warning",
142− textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. On this Mac, nothing leaves the computer.",
143− }));
144− }
145136 rows.push({ field: f, row });
146137 return row;
147138}
test/e2e/smoke-fm.py+1−1
@@ -39,7 +39,7 @@ def main():
3939 print(json.dumps({"schema": argv[1:]}))
4040 return
4141 if argv[0] == "respond":
42− prompt = argv[-1]
42+ prompt = sys.stdin.read()
4343 if prompt.startswith("You name"):
4444 found = topic_of(prompt.split("Tabs (", 1)[-1]) or ("Reading list", "📚", "grey")
4545 print(json.dumps({"title": found[0], "emoji": found[1]}))
test/naming.test.ts+5−5
@@ -20,14 +20,14 @@ describe("membership hash", () => {
2020});
2121
2222describe("large group sampling", () => {
23− it("sends the 8 most recent, the first 8 and the last 8, in strip order, capped at 24", () => {
24− const ms = Array.from({ length: 40 }, (_, i) => member(i, { lastActive: i === 20 || i === 21 ? 1000 + i : i }));
23+ it("sends the 16 most recent, the first 16 and the last 16, in strip order, capped at 48", () => {
24+ const ms = Array.from({ length: 80 }, (_, i) => member(i, { lastActive: i === 40 || i === 41 ? 1000 + i : i }));
2525 const picked = sampleMembers(ms);
26− expect(picked.length).toBeLessThanOrEqual(24);
26+ expect(picked.length).toBeLessThanOrEqual(48);
2727 const ids = picked.map((m) => m.id);
2828 expect(ids).toEqual([...ids].sort((a, b) => a - b));
29− for (const id of [0, 7, 32, 39, 20, 21]) expect(ids).toContain(id);
30− expect(ids).not.toContain(10);
29+ for (const id of [0, 15, 64, 79, 40, 41]) expect(ids).toContain(id);
30+ expect(ids).not.toContain(30);
3131 });
3232
3333 it("leaves small groups whole", () => {
test/organize.test.ts+3−3
@@ -8,9 +8,9 @@ const t = (n: number, over: { title?: string; description?: string } = {}) => ({
88});
99
1010describe("batching", () => {
11− it("caps batches at 18 items", () => {
12− const b = makeBatches(Array.from({ length: 60 }, (_, i) => t(i)));
13− expect(b.map((x) => x.length)).toEqual([18, 18, 18, 6]);
11+ it("caps batches at 36 items", () => {
12+ const b = makeBatches(Array.from({ length: 80 }, (_, i) => t(i)));
13+ expect(b.map((x) => x.length)).toEqual([36, 36, 8]);
1414 });
1515
1616 it("also respects the character budget", () => {
test/settings.test.tsnew file+8
@@ -0,0 +1,8 @@
1+import { describe, expect, it } from "vitest";
2+import { withDefaults } from "../src/background/settings";
3+
4+describe("withDefaults", () => {
5+ it("reads a stored Private Cloud Compute choice as the on-device model, the only one fm has", () => {
6+ expect(withDefaults({ model: "pcc" }).model).toBe("system");
7+ });
8+});