on master help sign in
Rename Grove to Diagonal

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdKDAYoqmGehyHsV42LhJW

commit0a83dd894fb150ed8b19e20f2509813baccf416c authorClaude <noreply@anthropic.com> dateWed 23 Sep 2026, 16:19 (2 weeks ago) parentf77975a
18 files changed+105−105
.gitignore+1−1 README.md+15−15 flake.nix+22−22 host/grove-host.py → host/diagonal-host.py+11−11 host/emoji.txt+1−1 host/tests/support.py+5−5 host/tests/test_host.py+6−6 manifest.json+2−2 package-lock.json+2−2 package.json+2−2 scripts/gen-key.sh+2−2 scripts/install-manifest.sh+9−9 src/background/host.ts+4−4 src/background/index.ts+3−3 src/options/options.html+2−2 src/options/options.ts+6−6 src/popup/popup.html+2−2 test/e2e/smoke.mjs+10−10
.gitignore+1−1
@@ -2,7 +2,7 @@ node_modules/
22dist/
33result
44key.pem
5−grove-extension.zip
5+diagonal-extension.zip
66host/tests/__pycache__/
77host/__pycache__/
88__pycache__/
README.md+15−15
@@ -1,11 +1,11 @@
1−# Grove
1+# Diagonal
22
33Dia-style tab groups for Brave on macOS, named by Apple's on-device model through the `fm` command-line tool over Chromium native messaging. No HTTP server, no cloud call, no model download.
44
55What it does (v1):
66
771. **Opener grouping.** Cmd-click a link from an ungrouped, unpinned tab and both tabs become a group, titled with the site's hostname until the model names it. An opener group that shrinks to one tab dissolves.
8−2. **Naming loop.** Groups Grove made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
8+2. **Naming loop.** Groups Diagonal made get an `emoji + 2–4 word` title from the model. The title is recomputed after 4 s of quiet when membership changes, stays put when the model only paraphrases, and is never touched again once you type a title yourself.
993. **Organize this window** (toolbar or Alt+Shift+G). Clusters ungrouped tabs into topic groups in batches of 18. Undo is available for an hour.
10104. **Tidy sweep.** Tabs idle for 24 h move to a collapsed grey "Parked" group (Ask mode by default). After another 48 h they are archived and closed, with restore from the popup.
1111
@@ -21,7 +21,7 @@ The model sees a tab's title, address and `<meta name="description">`, and nothi
2121
2222### 1. The extension
2323
24−The built extension is in `dist/` (or `grove-extension/` next to this folder in the delivery).
24+The built extension is in `dist/` (or `diagonal-extension/` next to this folder in the delivery).
2525
26261. Open `brave://extensions` and turn on **Developer mode**.
27272. Click **Load unpacked** and pick the `dist/` folder.
@@ -37,25 +37,25 @@ Without Nix:
3737scripts/install-manifest.sh # add --beta / --nightly / --all-channels for other Brave channels
3838```
3939
40−This copies `host/` to `~/.local/share/grove-host`, links `~/.local/bin/grove-host`, pins its shebang to your `python3`, writes `~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.grove.host.json`, runs `grove-host --install-schemas`, then `grove-host --selftest`.
40+This copies `host/` to `~/.local/share/diagonal-host`, links `~/.local/bin/diagonal-host`, pins its shebang to your `python3`, writes `~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.diagonal.host.json`, runs `diagonal-host --install-schemas`, then `diagonal-host --selftest`.
4141
4242With Nix (nix-darwin + home-manager):
4343
4444```nix
4545# flake inputs
46−grove.url = "path:/path/to/grove"; # or a git URL once it lives in a repo
46+diagonal.url = "path:/path/to/diagonal"; # or a git URL once it lives in a repo
4747
4848# home-manager config
49−imports = [ inputs.grove.homeManagerModules.default ];
50−programs.grove.enable = true;
51−# programs.grove.channels = [ "Brave-Browser" "Brave-Browser-Beta" ];
49+imports = [ inputs.diagonal.homeManagerModules.default ];
50+programs.diagonal.enable = true;
51+# programs.diagonal.channels = [ "Brave-Browser" "Brave-Browser-Beta" ];
5252```
5353
54−`nix build .#extension` builds `dist/` into `./result`; `nix build .#grove-host` builds the host.
54+`nix build .#extension` builds `dist/` into `./result`; `nix build .#diagonal-host` builds the host.
5555
5656### 3. Check it
5757
58−Open Grove's settings page and click **Run self-test**. Expect `fmAvailable: true`, `schemasOk: true` and a title for the three fixture tabs. From a terminal, `grove-host --selftest` runs the same checks.
58+Open Diagonal's settings page and click **Run self-test**. Expect `fmAvailable: true`, `schemasOk: true` and a title for the three fixture tabs. From a terminal, `diagonal-host --selftest` runs the same checks.
5959
6060## Keyboard shortcuts
6161
@@ -81,20 +81,20 @@ npm run smoke # Linux: loads dist/ in Chromium with the real host and a
8181npm run check # typecheck + both unit suites
8282```
8383
84−Layout follows the spec: `src/background/` (engine, naming, organize, tidy, host client, state, settings), `src/content/meta.ts`, `src/popup/`, `src/options/`, `src/shared/`, and `host/` (`grove-host.py`, `prompts.py`, `validate.py`, `emoji.txt`, `tests/`).
84+Layout follows the spec: `src/background/` (engine, naming, organize, tidy, host client, state, settings), `src/content/meta.ts`, `src/popup/`, `src/options/`, `src/shared/`, and `host/` (`diagonal-host.py`, `prompts.py`, `validate.py`, `emoji.txt`, `tests/`).
8585
8686`host/emoji.txt` is the single emoji list. `npm run gen-emoji` regenerates `src/shared/emoji.gen.ts` from it.
8787
8888### Pinning a different extension ID
8989
90−`scripts/gen-key.sh` creates `key.pem` if it is missing, writes the public key into `manifest.json`, the ID into `extension-id` and into `ALLOWED_ORIGIN` in `host/grove-host.py`, and prints the ID. The shipped ID comes from a key that is not included; you only need the private key to pack a `.crx`. Running the script without a `key.pem` makes a new key and a new ID, so after that rebuild and rerun the host installer.
90+`scripts/gen-key.sh` creates `key.pem` if it is missing, writes the public key into `manifest.json`, the ID into `extension-id` and into `ALLOWED_ORIGIN` in `host/diagonal-host.py`, and prints the ID. The shipped ID comes from a key that is not included; you only need the private key to pack a `.crx`. Running the script without a `key.pem` makes a new key and a new ID, so after that rebuild and rerun the host installer.
9191
9292## Where this differs from the spec, and why
9393
9494- **New tab in group is Alt+Shift+T, not Alt+Cmd+T.** Chromium refuses to load an extension whose manifest suggests Alt+Command+T (it is treated as Ctrl+Alt, which is reserved for AltGr). You can still bind Alt+Cmd+T by hand at `brave://extensions/shortcuts` if Brave accepts it there.
95−- **Query strings.** The spec keeps the query only when the path is empty, but also wants `youtube.com/watch?v=…` kept. Grove keeps the whole query when the path is empty and otherwise keeps only content keys (`v`, `q`, `query`, `search_query`, `search`, `s`, `k`, `id`, `p`, `list`, `page`), dropping `utm_*` and similar.
95+- **Query strings.** The spec keeps the query only when the path is empty, but also wants `youtube.com/watch?v=…` kept. Diagonal keeps the whole query when the path is empty and otherwise keeps only content keys (`v`, `q`, `query`, `search_query`, `search`, `s`, `k`, `id`, `p`, `list`, `page`), dropping `utm_*` and similar.
9696- **Grey is reserved for the Parked group.** Site colours hash over the other eight colours.
97−- **Organize only adds tabs to Grove's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
97+- **Organize only adds tabs to Diagonal's own groups,** not to groups you made by hand, unless "Name my own groups" is on.
9898- **An Organize group whose title fails validation is still created,** with the hostname as a placeholder, and the naming loop names it.
9999- **Restarts.** Tab and group IDs change when Brave restarts, so records are matched to live groups by title and colour, which keeps managed groups managed.
100100
@@ -102,7 +102,7 @@ Layout follows the spec: `src/background/` (engine, naming, organize, tidy, host
102102
103103Everything was tested against a fake `fm`. These are open until someone runs it on macOS 27 (section 16 of the spec):
104104
105−- The `fm schema` flags. `grove-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/grove-host.py`.
105+- 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`.
106106- `fm` stderr wording. `classify()` matches keywords, so an unexpected message shows as `FM_ERROR` with the raw text on the settings page.
107107- Whether `fm respond` can read the prompt from stdin. Until then, the prompt is an argument and is briefly visible in `ps`.
108108- 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.
flake.nix+22−22
@@ -1,5 +1,5 @@
11{
2− description = "Grove: Dia-style tab groups for Brave, named by Apple's on-device model";
2+ description = "Diagonal: Dia-style tab groups for Brave, named by Apple's on-device model";
33
44 inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
55
@@ -13,7 +13,7 @@
1313 packages = forAll (pkgs: rec {
1414 # The unpacked extension (dist/). Load it from brave://extensions → Load unpacked.
1515 extension = pkgs.buildNpmPackage {
16− pname = "grove-extension";
16+ pname = "diagonal-extension";
1717 version = "0.1.0";
1818 src = ./.;
1919 npmDeps = pkgs.importNpmLock { npmRoot = ./.; };
@@ -23,24 +23,24 @@
2323 };
2424
2525 # The native host: host/ copied whole, the shebang pinned to Nix's python3.
26− grove-host = pkgs.stdenvNoCC.mkDerivation {
27− pname = "grove-host";
26+ diagonal-host = pkgs.stdenvNoCC.mkDerivation {
27+ pname = "diagonal-host";
2828 version = "0.1.0";
2929 src = ./host;
3030 dontBuild = true;
3131 installPhase = ''
32− mkdir -p $out/libexec/grove-host $out/bin
33− cp grove-host.py prompts.py validate.py emoji.txt $out/libexec/grove-host/
34− substituteInPlace $out/libexec/grove-host/grove-host.py \
32+ mkdir -p $out/libexec/diagonal-host $out/bin
33+ cp diagonal-host.py prompts.py validate.py emoji.txt $out/libexec/diagonal-host/
34+ substituteInPlace $out/libexec/diagonal-host/diagonal-host.py \
3535 --replace-fail "#!/usr/bin/env python3" "#!${pkgs.python3}/bin/python3"
36− chmod +x $out/libexec/grove-host/grove-host.py
37− ln -s $out/libexec/grove-host/grove-host.py $out/bin/grove-host
36+ chmod +x $out/libexec/diagonal-host/diagonal-host.py
37+ ln -s $out/libexec/diagonal-host/diagonal-host.py $out/bin/diagonal-host
3838 '';
3939 doInstallCheck = true;
40− installCheckPhase = "$out/bin/grove-host --version";
40+ installCheckPhase = "$out/bin/diagonal-host --version";
4141 };
4242
43− default = grove-host;
43+ default = diagonal-host;
4444 });
4545
4646 devShells = forAll (pkgs: {
@@ -51,23 +51,23 @@
5151
5252 homeManagerModules.default = { config, pkgs, lib, ... }:
5353 let
54− cfg = config.programs.grove;
55− host = self.packages.${pkgs.stdenv.hostPlatform.system}.grove-host;
54+ cfg = config.programs.diagonal;
55+ host = self.packages.${pkgs.stdenv.hostPlatform.system}.diagonal-host;
5656 manifest = {
57− name = "io.grove.host";
58− description = "Grove: names tab groups with Apple's on-device model";
59− path = "${host}/bin/grove-host";
57+ name = "io.diagonal.host";
58+ description = "Diagonal: names tab groups with Apple's on-device model";
59+ path = "${host}/bin/diagonal-host";
6060 type = "stdio";
6161 allowed_origins = [ "chrome-extension://${cfg.extensionId}/" ];
6262 };
6363 in
6464 {
65− options.programs.grove = {
66− enable = lib.mkEnableOption "the Grove native messaging host for Brave";
65+ options.programs.diagonal = {
66+ enable = lib.mkEnableOption "the Diagonal native messaging host for Brave";
6767 extensionId = lib.mkOption {
6868 type = lib.types.str;
6969 default = extensionId;
70− description = "Pinned extension ID (from scripts/gen-key.sh). The host itself checks the ID baked into host/grove-host.py, so change both together by re-running gen-key.sh.";
70+ description = "Pinned extension ID (from scripts/gen-key.sh). The host itself checks the ID baked into host/diagonal-host.py, so change both together by re-running gen-key.sh.";
7171 };
7272 channels = lib.mkOption {
7373 type = lib.types.listOf (lib.types.enum [ "Brave-Browser" "Brave-Browser-Beta" "Brave-Browser-Nightly" ]);
@@ -80,12 +80,12 @@
8080 home.packages = [ host ];
8181 home.file = lib.listToAttrs (map
8282 (ch: lib.nameValuePair
83− "Library/Application Support/BraveSoftware/${ch}/NativeMessagingHosts/io.grove.host.json"
83+ "Library/Application Support/BraveSoftware/${ch}/NativeMessagingHosts/io.diagonal.host.json"
8484 { text = builtins.toJSON manifest; })
8585 cfg.channels);
8686 # `|| true`: a Mac without Apple Intelligence still activates.
87− home.activation.groveSchemas = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
88− ${host}/bin/grove-host --install-schemas || true
87+ home.activation.diagonalSchemas = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
88+ ${host}/bin/diagonal-host --install-schemas || true
8989 '';
9090 };
9191 };
host/grove-host.py → host/diagonal-host.pyrenamed+11−11
@@ -1,15 +1,15 @@
11#!/usr/bin/env python3
2−"""grove-host: Brave native-messaging host that calls /usr/bin/fm once and exits.
2+"""diagonal-host: Brave native-messaging host that calls /usr/bin/fm once and exits.
33
44Protocol (section 8): Brave starts this process with the caller's origin as argv[1], writes one
55length-prefixed JSON request to stdin, and reads one length-prefixed JSON reply from stdout.
66Standard library only; no network imports.
77
88Other entry points:
9− grove-host --selftest check fm, the model, the schemas, and one fixture naming call
10− grove-host --install-schemas write the fm schema files to ~/Library/Application Support/Grove/schemas
11− grove-host --print-manifest P print the host manifest JSON for executable path P
12− grove-host --version
9+ diagonal-host --selftest check fm, the model, the schemas, and one fixture naming call
10+ diagonal-host --install-schemas write the fm schema files to ~/Library/Application Support/Diagonal/schemas
11+ diagonal-host --print-manifest P print the host manifest JSON for executable path P
12+ diagonal-host --version
1313"""
1414import json
1515import os
@@ -24,12 +24,12 @@ import prompts # noqa: E402
2424import validate # noqa: E402
2525
2626HOST_VERSION = "0.1.0"
27−HOST_NAME = "io.grove.host"
27+HOST_NAME = "io.diagonal.host"
2828ALLOWED_ORIGIN = "chrome-extension://mpnodlalikgeehnlnofdkpgapkmbkjdf/"
29−FM = os.environ.get("GROVE_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
30−SUPPORT = os.environ.get("GROVE_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Grove"))
29+FM = os.environ.get("DIAGONAL_FM", "/usr/bin/fm") # tests point this at host/tests/fake_fm.py
30+SUPPORT = os.environ.get("DIAGONAL_SUPPORT_DIR", os.path.expanduser("~/Library/Application Support/Diagonal"))
3131SCHEMAS = os.path.join(SUPPORT, "schemas")
32−LOG_DIR = os.environ.get("GROVE_LOG_DIR", os.path.expanduser("~/Library/Logs/Grove"))
32+LOG_DIR = os.environ.get("DIAGONAL_LOG_DIR", os.path.expanduser("~/Library/Logs/Diagonal"))
3333LOG_MAX_BYTES = 5 * 1024 * 1024
3434CHAR_BUDGET = 10_000 # ≈ 2,850 tokens of prompt at 3.5 chars/token (section 9); double once 8,192 is confirmed
3535MAX_REQUEST = 64 * 1024 * 1024
@@ -378,7 +378,7 @@ def selftest():
378378 for name in ["name.json", *(["organize.json"] if organize_mode() == "nested" else list(FALLBACK_SCHEMA_COMMANDS))]:
379379 path = os.path.join(SCHEMAS, name)
380380 readable = os.path.isfile(path) and os.path.getsize(path) > 0
381− check(f"schema {name}", readable, path if readable else "missing: run grove-host --install-schemas")
381+ check(f"schema {name}", readable, path if readable else "missing: run diagonal-host --install-schemas")
382382 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
383383 if ping["fmAvailable"] and schema_path("name.json"):
384384 reply = handle({"v": 1, "id": "selftest", "op": "name", "payload": json.loads(json.dumps(FIXTURE)), "opts": {"timeoutMs": 45000}})
@@ -390,7 +390,7 @@ def selftest():
390390
391391
392392def manifest_for(path):
393− return {"name": HOST_NAME, "description": "Grove: names tab groups with Apple's on-device model",
393+ return {"name": HOST_NAME, "description": "Diagonal: names tab groups with Apple's on-device model",
394394 "path": os.path.abspath(path), "type": "stdio", "allowed_origins": [ALLOWED_ORIGIN]}
395395
396396
host/emoji.txt+1−1
@@ -1,4 +1,4 @@
1−# Grove curated emoji list. One category per line: "<category>: <emoji> <emoji> ...".
1+# Diagonal curated emoji list. One category per line: "<category>: <emoji> <emoji> ...".
22# The first emoji of each line is that category's default. This file is the single
33# source of truth: host/validate.py reads it at runtime and scripts/gen-emoji.mjs
44# generates src/shared/emoji.ts from it (npm run gen-emoji).
host/tests/support.py+5−5
@@ -10,12 +10,12 @@ import tempfile
1010import unittest
1111
1212HOST_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
13−HOST_SCRIPT = os.path.join(HOST_DIR, "grove-host.py")
13+HOST_SCRIPT = os.path.join(HOST_DIR, "diagonal-host.py")
1414sys.path.insert(0, HOST_DIR)
1515
1616
1717def load_host():
18− spec = importlib.util.spec_from_file_location("grove_host", HOST_SCRIPT)
18+ spec = importlib.util.spec_from_file_location("diagonal_host", HOST_SCRIPT)
1919 mod = importlib.util.module_from_spec(spec)
2020 spec.loader.exec_module(mod)
2121 return mod
@@ -51,7 +51,7 @@ class HostCase(unittest.TestCase):
5151 """Each test gets a fresh temp dir with a fake fm and installed schemas."""
5252
5353 def setUp(self):
54− self.tmp = tempfile.mkdtemp(prefix="grove-host-test-")
54+ self.tmp = tempfile.mkdtemp(prefix="diagonal-host-test-")
5555 self.fm = os.path.join(self.tmp, "fm")
5656 with open(os.path.join(HOST_DIR, "tests", "fake_fm.py"), encoding="utf-8") as f:
5757 body = f.read()
@@ -64,8 +64,8 @@ class HostCase(unittest.TestCase):
6464 with open(os.path.join(self.support, "schemas", name), "w") as f:
6565 f.write("{}")
6666 self.control({})
67− self.env = {**os.environ, "GROVE_FM": self.fm, "GROVE_SUPPORT_DIR": self.support,
68− "GROVE_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
67+ self.env = {**os.environ, "DIAGONAL_FM": self.fm, "DIAGONAL_SUPPORT_DIR": self.support,
68+ "DIAGONAL_LOG_DIR": os.path.join(self.tmp, "logs"), "HOME": self.tmp}
6969
7070 def tearDown(self):
7171 shutil.rmtree(self.tmp, ignore_errors=True)
host/tests/test_host.py+6−6
@@ -72,7 +72,7 @@ class Ping(HostCase):
7272 self.assertIn("Apple Intelligence", r["result"]["fmMessage"])
7373
7474 def test_ping_without_fm(self):
75− self.env["GROVE_FM"] = os.path.join(self.tmp, "missing-fm")
75+ self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
7676 r = self.call("ping")
7777 self.assertFalse(r["result"]["fmAvailable"])
7878 self.assertIn("requires macOS 27", r["result"]["fmMessage"])
@@ -127,7 +127,7 @@ class ErrorCodes(HostCase):
127127 self.expect("MODEL_UNAVAILABLE", stderr="Error: The model is not available. Enable Apple Intelligence.", exit=1)
128128
129129 def test_fm_missing(self):
130− self.env["GROVE_FM"] = os.path.join(self.tmp, "nope")
130+ self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "nope")
131131 r = self.call("name", {"items": ITEMS3})
132132 self.assertEqual(r["error"]["code"], "MODEL_UNAVAILABLE")
133133 self.assertIn("requires macOS 27", r["error"]["message"])
@@ -251,10 +251,10 @@ class SelfTest(HostCase):
251251 self.assertIn("FAIL model available", p.stdout.decode())
252252
253253 def test_print_manifest(self):
254− code, _, p = self.run_host(args=["--print-manifest", "/Users/me/.local/bin/grove-host"])
254+ code, _, p = self.run_host(args=["--print-manifest", "/Users/me/.local/bin/diagonal-host"])
255255 m = json.loads(p.stdout)
256− self.assertEqual(m["name"], "io.grove.host")
257− self.assertEqual(m["path"], "/Users/me/.local/bin/grove-host")
256+ self.assertEqual(m["name"], "io.diagonal.host")
257+ self.assertEqual(m["path"], "/Users/me/.local/bin/diagonal-host")
258258 self.assertEqual(m["allowed_origins"], [host.ALLOWED_ORIGIN])
259259
260260
@@ -273,7 +273,7 @@ class Logging(HostCase):
273273class Privacy(unittest.TestCase):
274274 def test_no_network_imports(self):
275275 banned = re.compile(r"^\s*(import|from)\s+(socket|ssl|http|urllib\.request|urllib3|requests|ftplib|smtplib|asyncio|xmlrpc)\b", re.M)
276− for name in ("grove-host.py", "prompts.py", "validate.py"):
276+ for name in ("diagonal-host.py", "prompts.py", "validate.py"):
277277 src = open(os.path.join(HOST_DIR, name), encoding="utf-8").read()
278278 self.assertIsNone(banned.search(src), name)
279279
manifest.json+2−2
@@ -1,6 +1,6 @@
11{
22 "manifest_version": 3,
3− "name": "Grove",
3+ "name": "Diagonal",
44 "version": "0.1.0",
55 "minimum_chrome_version": "121",
66 "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjQfra9g/ZwPFNa1EGvqo6GORMuLrj9Gk6/yonBCrF78qMk6Kb8S7inM5I0O+V+aaZ1DwI3IPAJTyTbvzcPR5xuaGzaDCBuwqb2uo3zyY8dKqK//s6SXbDVue/czTRXvEBrKx0G4Mo19VFmft2XcHqDdqRZH3eSagHLw2r+CNLAeyNJOQNN8/Dfdx56axtB++HwK+/D9fafFQUMsTOGIQ3atjkM9VCK7050CFTeoL2IpJPJuo5Mh4Rd+zOzMI6Q4mfb2mFgraRwMNevbYBRgWWn19UMkKIc1IkP17f6BDo/0NP7ehGxL/rUmB+pqNCAmWDxJyYLyER35y5Ap+5BLpcQIDAQAB",
@@ -35,7 +35,7 @@
3535 ],
3636 "action": {
3737 "default_popup": "popup.html",
38− "default_title": "Grove",
38+ "default_title": "Diagonal",
3939 "default_icon": {
4040 "16": "icons/16.png",
4141 "32": "icons/32.png",
package-lock.json+2−2
@@ -1,11 +1,11 @@
11{
2− "name": "grove-extension",
2+ "name": "diagonal-extension",
33 "version": "0.1.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
8− "name": "grove-extension",
8+ "name": "diagonal-extension",
99 "version": "0.1.0",
1010 "devDependencies": {
1111 "@types/chrome": "^0.3.0",
package.json+2−2
@@ -1,5 +1,5 @@
11{
2− "name": "grove-extension",
2+ "name": "diagonal-extension",
33 "version": "0.1.0",
44 "private": true,
55 "type": "module",
@@ -11,7 +11,7 @@
1111 "test": "vitest run",
1212 "test:host": "cd host && python3 -m unittest discover -s tests -v",
1313 "gen-emoji": "node scripts/gen-emoji.mjs",
14− "zip": "node scripts/build.mjs && cd dist && zip -qr ../grove-extension.zip .",
14+ "zip": "node scripts/build.mjs && cd dist && zip -qr ../diagonal-extension.zip .",
1515 "smoke": "node scripts/build.mjs && node test/e2e/smoke.mjs",
1616 "icons": "node scripts/render-icons.mjs",
1717 "check": "tsc --noEmit && vitest run && cd host && python3 -m unittest discover -s tests"
scripts/gen-key.sh+2−2
@@ -1,6 +1,6 @@
11#!/usr/bin/env bash
22# Pins the extension ID (section 14). Creates key.pem once (keep it out of the repo), writes the
3−# public key into manifest.json, the ID into ./extension-id and into host/grove-host.py's
3+# public key into manifest.json, the ID into ./extension-id and into host/diagonal-host.py's
44# ALLOWED_ORIGIN, and prints the ID. Re-running with an existing key.pem changes nothing.
55set -euo pipefail
66cd "$(dirname "$0")/.."
@@ -23,7 +23,7 @@ m["key"] = key
2323with open("manifest.json", "w", encoding="utf-8") as f:
2424 json.dump(m, f, indent=2, ensure_ascii=False)
2525 f.write("\n")
26−p = "host/grove-host.py"
26+p = "host/diagonal-host.py"
2727s = open(p, encoding="utf-8").read()
2828s = re.sub(r'^ALLOWED_ORIGIN = ".*"$', f'ALLOWED_ORIGIN = "chrome-extension://{ext_id}/"', s, count=1, flags=re.M)
2929open(p, "w", encoding="utf-8").write(s)
scripts/install-manifest.sh+9−9
@@ -1,7 +1,7 @@
11#!/usr/bin/env bash
22# Non-Nix installer for the native host (section 14).
33# scripts/install-manifest.sh [--beta] [--nightly] [--all-channels]
4−# Copies host/ to ~/.local/share/grove-host, links ~/.local/bin/grove-host to it, writes the Brave
4+# Copies host/ to ~/.local/share/diagonal-host, links ~/.local/bin/diagonal-host to it, writes the Brave
55# host manifest with that absolute path and the pinned extension ID, then installs the fm schemas
66# and runs the self-test. Safe to re-run.
77set -euo pipefail
@@ -31,20 +31,20 @@ fi
3131# Brave starts the host with a minimal PATH, so the shebang names this python3 explicitly.
3232PYTHON=$("$PYTHON" -c 'import sys; print(sys.executable)')
3333
34−SHARE="$HOME/.local/share/grove-host"
35−BIN="$HOME/.local/bin/grove-host"
34+SHARE="$HOME/.local/share/diagonal-host"
35+BIN="$HOME/.local/bin/diagonal-host"
3636mkdir -p "$SHARE" "$(dirname "$BIN")"
3737cp host/prompts.py host/validate.py host/emoji.txt "$SHARE/"
38−sed -e "1s|.*|#!$PYTHON|" -e "s|^ALLOWED_ORIGIN = \".*\"|ALLOWED_ORIGIN = \"chrome-extension://$ID/\"|" host/grove-host.py > "$SHARE/grove-host.py"
39−chmod 755 "$SHARE/grove-host.py"
40−ln -sf "$SHARE/grove-host.py" "$BIN"
41−echo "host: $BIN -> $SHARE/grove-host.py"
38+sed -e "1s|.*|#!$PYTHON|" -e "s|^ALLOWED_ORIGIN = \".*\"|ALLOWED_ORIGIN = \"chrome-extension://$ID/\"|" host/diagonal-host.py > "$SHARE/diagonal-host.py"
39+chmod 755 "$SHARE/diagonal-host.py"
40+ln -sf "$SHARE/diagonal-host.py" "$BIN"
41+echo "host: $BIN -> $SHARE/diagonal-host.py"
4242
4343for ch in "${CHANNELS[@]}"; do
4444 DIR="$HOME/Library/Application Support/BraveSoftware/$ch/NativeMessagingHosts"
4545 mkdir -p "$DIR"
46− "$BIN" --print-manifest "$BIN" > "$DIR/io.grove.host.json"
47− echo "manifest: $DIR/io.grove.host.json"
46+ "$BIN" --print-manifest "$BIN" > "$DIR/io.diagonal.host.json"
47+ echo "manifest: $DIR/io.diagonal.host.json"
4848done
4949
5050echo "schemas:"
src/background/host.ts+4−4
@@ -1,7 +1,7 @@
11import type { HostError, HostErrorCode } from "./state";
22
33/** Section 8's wire protocol, from the extension's side. One `sendNativeMessage` per request. */
4−export const HOST_NAME = "io.grove.host";
4+export const HOST_NAME = "io.diagonal.host";
55export const HOST_MANIFEST_DIR = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/";
66export const HOST_MANIFEST_PATH = `${HOST_MANIFEST_DIR}${HOST_NAME}.json`;
77
@@ -65,13 +65,13 @@ export async function callHost<T>(
6565 const reply = await Promise.race([
6666 send(HOST_NAME, request),
6767 new Promise((_, reject) => {
68− timer = setTimeout(() => reject(new Error("__grove_timeout__")), guardMs);
68+ timer = setTimeout(() => reject(new Error("__diagonal_timeout__")), guardMs);
6969 }),
7070 ]);
7171 return normalizeReply<T>(reply, now());
7272 } catch (e) {
7373 const message = e instanceof Error ? e.message : String(e);
74− if (message === "__grove_timeout__") {
74+ if (message === "__diagonal_timeout__") {
7575 return { ok: false, error: { code: "TIMEOUT", message: `no reply from host in ${guardMs} ms`, retryable: true, at: now() } };
7676 }
7777 return { ok: false, error: { ...mapLastError(message), at: now() } };
@@ -116,7 +116,7 @@ export function explain(error: HostError | undefined, extensionId = ""): string
116116 case "FORBIDDEN_ORIGIN":
117117 return `The host does not allow this extension. Its manifest needs "allowed_origins": ["chrome-extension://${extensionId}/"]`;
118118 case "SCHEMA_MISSING":
119− return "Schemas missing. Run: grove-host --install-schemas";
119+ return "Schemas missing. Run: diagonal-host --install-schemas";
120120 case "MODEL_UNAVAILABLE":
121121 return `On-device model unavailable: ${error.message}. Check System Settings → Apple Intelligence & Siri.`;
122122 case "RATE_LIMITED":
src/background/index.ts+3−3
@@ -35,7 +35,7 @@ function commit(): void {
3535 if (saveTimer) return;
3636 saveTimer = setTimeout(() => {
3737 saveTimer = undefined;
38− chrome.storage.local.set({ state }).catch((e) => console.error("[grove] save failed", e));
38+ chrome.storage.local.set({ state }).catch((e) => console.error("[diagonal] save failed", e));
3939 }, 0);
4040}
4141
@@ -54,12 +54,12 @@ async function pushRing(key: string, entry: unknown, cap: number): Promise<void>
5454
5555function log(...args: unknown[]): void {
5656 if (!settings?.debugLog) return;
57− console.log("[grove]", ...args);
57+ console.log("[diagonal]", ...args);
5858 void pushRing("debugLog", { at: Date.now(), msg: args.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : JSON.stringify(a))).join(" ") }, 200);
5959}
6060
6161function logError(where: string, e: unknown): void {
62− console.error(`[grove] ${where}`, e);
62+ console.error(`[diagonal] ${where}`, e);
6363 if (settings?.debugLog) void pushRing("debugLog", { at: Date.now(), msg: `${where}: ${e instanceof Error ? e.message : String(e)}` }, 200);
6464}
6565
src/options/options.html+2−2
@@ -2,7 +2,7 @@
22<html lang="en">
33<head>
44 <meta charset="utf-8">
5− <title>Grove settings</title>
5+ <title>Diagonal settings</title>
66 <link rel="stylesheet" href="ui.css">
77 <style>
88 body { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }
@@ -19,7 +19,7 @@
1919 </style>
2020</head>
2121<body>
22− <div class="row"><h1 class="grow">Grove settings</h1><span id="saved" class="muted" style="opacity:0">Saved</span></div>
22+ <div class="row"><h1 class="grow">Diagonal settings</h1><span id="saved" class="muted" style="opacity:0">Saved</span></div>
2323 <p class="muted">Tab groups named by Apple's on-device model through the <code>fm</code> command-line tool. Nothing is sent over the network.</p>
2424
2525 <section>
src/options/options.ts+6−6
@@ -36,7 +36,7 @@ const SECTIONS: [string, Field[]][] = [
3636 { key: "archiveCap", label: "Keep at most", kind: "number", min: 50, max: 5000, unit: "archived tabs" },
3737 ]],
3838 ["Diagnostics", [
39− { key: "debugLog", label: "Debug logging", help: "Worker logs to the console; the host logs to ~/Library/Logs/Grove/host.log when ~/Library/Application Support/Grove/debug exists.", kind: "bool" },
39+ { key: "debugLog", label: "Debug logging", help: "Worker logs to the console; the host logs to ~/Library/Logs/Diagonal/host.log when ~/Library/Application Support/Diagonal/debug exists.", kind: "bool" },
4040 ]],
4141];
4242
@@ -123,11 +123,11 @@ async function main(): Promise<void> {
123123 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
124124 renderSettings();
125125 const id = chrome.runtime.id;
126− const path = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.grove.host.json";
126+ const path = "~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/io.diagonal.host.json";
127127 const manifest = JSON.stringify({
128− name: "io.grove.host",
129− description: "Grove: names tab groups with Apple's on-device model",
130− path: "/Users/<you>/.local/bin/grove-host",
128+ name: "io.diagonal.host",
129+ description: "Diagonal: names tab groups with Apple's on-device model",
130+ path: "/Users/<you>/.local/bin/diagonal-host",
131131 type: "stdio",
132132 allowed_origins: [`chrome-extension://${id}/`],
133133 }, null, 2);
@@ -151,7 +151,7 @@ async function main(): Promise<void> {
151151 $("export").onclick = async () => {
152152 const archive = await send("exportArchive");
153153 const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
154− const a = el("a", { href: URL.createObjectURL(blob), download: `grove-archive-${new Date().toISOString().slice(0, 10)}.json` });
154+ const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
155155 a.click();
156156 setTimeout(() => URL.revokeObjectURL(a.href), 1000);
157157 };
src/popup/popup.html+2−2
@@ -2,7 +2,7 @@
22<html lang="en">
33<head>
44 <meta charset="utf-8">
5− <title>Grove</title>
5+ <title>Diagonal</title>
66 <link rel="stylesheet" href="ui.css">
77 <style>
88 body { width: 360px; padding: 12px 14px 14px; }
@@ -15,7 +15,7 @@
1515</head>
1616<body>
1717 <div class="row">
18− <h1 class="grow">Grove</h1>
18+ <h1 class="grow">Diagonal</h1>
1919 <span class="pill" id="status"><span class="dot" id="status-dot"></span><span id="status-text">Checking…</span></span>
2020 </div>
2121 <div id="notice" class="notice" hidden></div>
test/e2e/smoke.mjs+10−10
@@ -1,4 +1,4 @@
1−// End-to-end smoke test: loads dist/ into Chromium with the real native host (host/grove-host.py)
1+// End-to-end smoke test: loads dist/ into Chromium with the real native host (host/diagonal-host.py)
22// wired to a keyword-driven fake fm, then drives opener grouping, naming, a user rename, dissolve,
33// Organize and the popup. Linux only (native host manifests live under the profile directory).
44// node test/e2e/smoke.mjs (after npm run build)
@@ -13,7 +13,7 @@ import { execFileSync } from "node:child_process";
1313const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
1414const dist = join(root, "dist");
1515const extId = readFileSync(join(root, "extension-id"), "utf8").trim();
16−const tmp = mkdtempSync(join(tmpdir(), "grove-smoke-"));
16+const tmp = mkdtempSync(join(tmpdir(), "diagonal-smoke-"));
1717const python = execFileSync("python3", ["-c", "import sys; print(sys.executable)"]).toString().trim();
1818const shotDir = process.env.SMOKE_SHOTS || join(tmp, "shots");
1919mkdirSync(shotDir, { recursive: true });
@@ -22,20 +22,20 @@ mkdirSync(shotDir, { recursive: true });
2222const hostDir = join(tmp, "host");
2323mkdirSync(hostDir);
2424for (const f of ["prompts.py", "validate.py", "emoji.txt"]) cpSync(join(root, "host", f), join(hostDir, f));
25−writeFileSync(join(hostDir, "grove-host.py"), readFileSync(join(root, "host/grove-host.py"), "utf8").replace(/^#!.*$/m, `#!${python}`));
26−chmodSync(join(hostDir, "grove-host.py"), 0o755);
25+writeFileSync(join(hostDir, "diagonal-host.py"), readFileSync(join(root, "host/diagonal-host.py"), "utf8").replace(/^#!.*$/m, `#!${python}`));
26+chmodSync(join(hostDir, "diagonal-host.py"), 0o755);
2727const fm = join(tmp, "fm");
2828writeFileSync(fm, `#!${python}\n` + readFileSync(join(root, "test/e2e/smoke-fm.py"), "utf8"));
2929chmodSync(fm, 0o755);
3030const support = join(tmp, "support");
3131const profile = join(tmp, "profile");
32−const manifest = JSON.stringify({ name: "io.grove.host", description: "Grove smoke", path: join(hostDir, "grove-host.py"), type: "stdio", allowed_origins: [`chrome-extension://${extId}/`] });
32+const manifest = JSON.stringify({ name: "io.diagonal.host", description: "Diagonal smoke", path: join(hostDir, "diagonal-host.py"), type: "stdio", allowed_origins: [`chrome-extension://${extId}/`] });
3333for (const dir of [join(profile, "NativeMessagingHosts"), join(tmp, "home/.config/chromium/NativeMessagingHosts")]) {
3434 mkdirSync(dir, { recursive: true });
35− writeFileSync(join(dir, "io.grove.host.json"), manifest);
35+ writeFileSync(join(dir, "io.diagonal.host.json"), manifest);
3636}
37−const env = { ...process.env, GROVE_FM: fm, GROVE_SUPPORT_DIR: support, HOME: join(tmp, "home") };
38−execFileSync(join(hostDir, "grove-host.py"), ["--install-schemas"], { env, stdio: "inherit" });
37+const env = { ...process.env, DIAGONAL_FM: fm, DIAGONAL_SUPPORT_DIR: support, HOME: join(tmp, "home") };
38+execFileSync(join(hostDir, "diagonal-host.py"), ["--install-schemas"], { env, stdio: "inherit" });
3939
4040// A tiny site with meta descriptions.
4141const pages = {
@@ -175,8 +175,8 @@ try {
175175 await options.screenshot({ path: join(shotDir, "options.png"), fullPage: true });
176176
177177 // 7. Host missing → HOST_NOT_FOUND surfaced.
178− writeFileSync(join(profile, "NativeMessagingHosts/io.grove.host.json"), "{}");
179− writeFileSync(join(tmp, "home/.config/chromium/NativeMessagingHosts/io.grove.host.json"), "{}");
178+ writeFileSync(join(profile, "NativeMessagingHosts/io.diagonal.host.json"), "{}");
179+ writeFileSync(join(tmp, "home/.config/chromium/NativeMessagingHosts/io.diagonal.host.json"), "{}");
180180 const bad = await popup.evaluate(async () => chrome.runtime.sendMessage({ cmd: "ping" }));
181181 check("a broken host manifest is reported, not swallowed", bad?.result?.ok === false && /HOST_/.test(bad.result.error.code), JSON.stringify(bad?.result?.error));
182182} finally {