on master help sign in
Lift a pause on any model answer, keep tabs' flags when Chromium swaps them, never re-cut a release

- A successful rename or Organize during a pause lifts it right away, instead of the popup saying "Paused" for up to ten more minutes. - A tab Chromium replaces (a prerendered page, a discarded tab coming back) keeps what Diagonal knew: that you took it out of a group, placed it by hand, or that it was already sorted. - Organize now remembers the tabs it looked at, so auto-organize does not send the same tabs again right after. - The "Finish setting up" notice gives zip installs their own command. - The helper reads fm's output as UTF-8 whatever locale the browser gives it. - A release re-run for a commit that already released publishes nothing, and a second commit in the same minute takes the next free minute, instead of replacing the first one's zip under the checksum the tap already has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SsrrFmPFG4NfNt7omzSis

commite663988b9a92c5c731948bd41421833ef5cfc911 authorClaude <noreply@anthropic.com> dateSat 26 Sep 2026, 12:01 (2 weeks ago) parent6c3796d
5 files changed+47−21
unifiedsplit
.github/workflows/release.yml+19−7 host/diagonal-host.py+5−5 src/background/index.ts+19−7 src/background/organize.ts+1−1 src/shared/health.ts+3−1
.github/workflows/release.yml+19−7
@@ -35,8 +35,23 @@ jobs:
3535
3636 - name: Version from commit time
3737 id: version
38+ env:
39+ GH_TOKEN: ${{ github.token }}
3840 run: |
39− 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
4055 echo "version=$version" >> "$GITHUB_OUTPUT"
4156 echo "Version $version"
4257
@@ -57,22 +72,19 @@ jobs:
5772 npm run zip:store
5873
5974 - name: Publish release
75+ if: steps.version.outputs.published != 'true'
6076 env:
6177 GH_TOKEN: ${{ github.token }}
6278 VERSION: ${{ steps.version.outputs.version }}
6379 run: |
6480 asset="diagonal-extension-${VERSION}.zip"
6581 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"
66− if gh release view "$VERSION" >/dev/null 2>&1; then
67− # Two commits in the same minute share a version: refresh the asset on the existing release.
68− gh release upload "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --clobber
69− else
70− gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
71− fi
82+ gh release create "$VERSION" "$asset" diagonal-extension.zip diagonal-store.zip --target "$GITHUB_SHA" --title "Diagonal $VERSION" --notes "$notes" --latest
7283
7384 # Uploads the release to the Chrome Web Store and submits it for review, once the listing exists and
7485 # its API credentials are set as repository secrets (docs/store/listing.md). Skipped until then.
7586 - name: Upload to the Chrome Web Store
87+ if: steps.version.outputs.published != 'true'
7688 env:
7789 CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
7890 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
host/diagonal-host.py+5−5
@@ -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()
@@ -285,7 +285,7 @@ def _run_fm(prompt, schema, opts, extra):
285285 args = [FM, "respond", "--model", opts["model"], "--no-stream", *greedy, "--schema", schema, "-i", prompt.instructions, *extra]
286286 timeout_s = max(1.0, time_left(opts))
287287 try:
288− 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())
289289 except FileNotFoundError:
290290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
291291 except subprocess.TimeoutExpired:
@@ -312,7 +312,7 @@ def op_ping(_payload, _opts):
312312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
313313 license_required = False
314314 try:
315− 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())
316316 ok, msg = avail.returncode == 0, (avail.stdout + avail.stderr).strip()[:300]
317317 if not ok and license_needed(avail.returncode, avail.stdout + avail.stderr):
318318 license_required, msg = True, LICENSE_MESSAGE
@@ -499,7 +499,7 @@ def install_schemas():
499499 if "{%s}" % key not in args:
500500 continue
501501 try:
502− 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())
503503 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
504504 print(f" {name}: fm failed ({e})", file=sys.stderr)
505505 return "error"
@@ -511,7 +511,7 @@ def install_schemas():
511511 subs["{%s}" % key] = p.stdout.strip()
512512 args = [subs.get(a, a) for a in args]
513513 try:
514− 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())
515515 except (FileNotFoundError, subprocess.TimeoutExpired) as e:
516516 print(f" {name}: fm failed ({e})", file=sys.stderr)
517517 return "error"
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/organize.ts+1−1
@@ -313,7 +313,7 @@ export async function organizeWindow(rt: Runtime, windowId: number): Promise<Org
313313 report.left = tabs.length;
314314 return report;
315315 }
316− const run = newRun(windowId, false);
316+ const run = newRun(windowId, true); // auto-organize need not send the same tabs again right after
317317 rt.state().inFlight = { groupId: -1, startedAt: rt.now() };
318318 rt.refreshBadge();
319319 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 });