From 60e6bbbacf08a005293ba2f9e8b31f5c9ea98eeb Mon Sep 17 00:00:00 2001 From: Hamza Zafar Date: Fri, 23 Oct 2026 23:29:28 +0200 Subject: [PATCH 1/3] reels: spawn stub returns { value } so the test passes on 2.1.309 --- mods/reels/tests/reels.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/reels/tests/reels.test.tsx b/mods/reels/tests/reels.test.tsx index 1a464ca..88a1efa 100644 --- a/mods/reels/tests/reels.test.tsx +++ b/mods/reels/tests/reels.test.tsx @@ -30,11 +30,11 @@ function engine(on: any, opts: { installed: boolean }) { }) on('process.spawn', async function* (_$: any, e: any) { spawned.push(e.argv) - if (e.argv.at(-1) !== 'pane') return { code: 0 } + if (e.argv.at(-1) !== 'pane') return { value: { code: 0, signal: null } } running = true yield { stream: 'stdout', text: 'R ready\nF 1 /Users/me/.claude-mods/reels/frames/f-1.png\n' } while (running) await new Promise(done => (globalThis as any).setTimeout(done, 5)) - return { code: 0 } + return { value: { code: 0, signal: null } } }) return { sent, spawned } } From 1ee92b3d58ec2fdab82287f4f542c8861de66dda Mon Sep 17 00:00:00 2001 From: Hamza Zafar Date: Fri, 23 Oct 2026 23:32:38 +0200 Subject: [PATCH 2/3] glance, prayer-times, switchboard: one timer per session, even if session.start fires again --- .claude-plugin/marketplace.json | 6 +++--- mods/glance/.claude-plugin/plugin.json | 2 +- mods/glance/hooks/register.tsx | 7 +++++-- mods/glance/tests/glance.test.tsx | 11 +++++++++++ mods/prayer-times/.claude-plugin/plugin.json | 2 +- mods/prayer-times/hooks/register.tsx | 6 ++++-- mods/prayer-times/tests/prayer-times.test.tsx | 14 ++++++++++++++ mods/switchboard/.claude-plugin/plugin.json | 2 +- mods/switchboard/hooks/register.tsx | 6 ++++-- mods/switchboard/tests/switchboard.test.tsx | 18 +++++++++++++++++- site/mods/glance/index.html | 4 ++-- site/mods/prayer-times/index.html | 4 ++-- site/mods/switchboard/index.html | 4 ++-- 13 files changed, 67 insertions(+), 19 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7375cba..1e0d7b4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -73,7 +73,7 @@ { "name": "glance", "description": "One line above the prompt with what needs you: next meeting, PRs, Linear issues and Slack DMs. /glance lists them all.", - "version": "0.1.1", + "version": "0.1.2", "source": "./mods/glance", "category": "My setup (fork and adapt)", "tags": [ @@ -155,7 +155,7 @@ { "name": "prayer-times", "description": "The current prayer and how long is left, the next prayer, and zawal, above the prompt. Computed on your computer from your location; nothing is sent anywhere.", - "version": "0.1.4", + "version": "0.1.5", "source": "./mods/prayer-times", "category": "See what's happening", "tags": [ @@ -287,7 +287,7 @@ { "name": "switchboard", "description": "Picks the model for each subagent that names none, with OpenAI's Decisions API or Jev, from its short label only. One line per spawn above the prompt; /route shows each pick and what each subagent cost.", - "version": "0.3.2", + "version": "0.3.3", "source": "./mods/switchboard", "category": "Spend less", "tags": [ diff --git a/mods/glance/.claude-plugin/plugin.json b/mods/glance/.claude-plugin/plugin.json index 7e565ed..60067a1 100644 --- a/mods/glance/.claude-plugin/plugin.json +++ b/mods/glance/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glance", - "version": "0.1.1", + "version": "0.1.2", "description": "One line above the prompt with what needs you: next meeting, PRs, Linear issues and Slack DMs. /glance lists them all.", "author": { "name": "Hamza Zafar", diff --git a/mods/glance/hooks/register.tsx b/mods/glance/hooks/register.tsx index 45b3995..5eb6a11 100644 --- a/mods/glance/hooks/register.tsx +++ b/mods/glance/hooks/register.tsx @@ -1,6 +1,6 @@ // glance: one line above the prompt with what needs you: next meeting, PRs, Linear, Slack. import { atom, read, update } from 'claude-code' -import type { EngineInterface, McpToolResult, Register } from 'claude-code' +import type { EngineInterface, McpToolResult, Register, Timer } from 'claude-code' import type { Dm, Issue, Meeting, Pr, Source } from '../types' import { bandsFile, parseOrder, stackBands } from './bands' @@ -24,11 +24,14 @@ const minute = atom({ plugin: 'glance', key: 'minute' } as const, 0) let bandOrder: Promise | undefined // read once per session: restart to apply export const register: Register = on => { + let timer: Timer | undefined // one minute timer, even if session.start fires again + on('session.start', async ($, e, next) => { const result = await next(e) await $.command.register({ name: 'glance', description: 'Refresh glance and list everything that needs you' }).catch(() => {}) // a name Claude Code already has is refused: start anyway void refresh($, false).catch(() => {}) // in the background: a slow source never holds up the session - $.clock.every(TICK_MS, () => void tick($).catch(() => {})) + timer?.cancel() + timer = $.clock.every(TICK_MS, () => void tick($).catch(() => {})) return result }) diff --git a/mods/glance/tests/glance.test.tsx b/mods/glance/tests/glance.test.tsx index f3d28d9..35c4ee7 100644 --- a/mods/glance/tests/glance.test.tsx +++ b/mods/glance/tests/glance.test.tsx @@ -161,6 +161,17 @@ describe('glance', () => { expect(h.ghCalls()).toBe(2) }) + test('a second session.start keeps one minute timer, not two', async ($, on) => { + const h = engine(on) + let ticks = 0 // each tick writes the minute once + on('state.set', (_$: any, e: any, next: any) => (e.key === 'minute' && ticks++, next(e))) + await start($) + await start($) + await h.clock.advance(MIN) + await settle() + expect(ticks).toBe(1) + }) + test('a failing source is left out, and the others still show', async ($, on) => { engine(on, ['claude.ai Slack', 'gh']) await start($) diff --git a/mods/prayer-times/.claude-plugin/plugin.json b/mods/prayer-times/.claude-plugin/plugin.json index 447c3c7..a6b9735 100644 --- a/mods/prayer-times/.claude-plugin/plugin.json +++ b/mods/prayer-times/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "prayer-times", - "version": "0.1.4", + "version": "0.1.5", "description": "The current prayer and how long is left, the next prayer, and zawal, above the prompt. Computed on your computer from your location; nothing is sent anywhere.", "author": { "name": "Hamza Zafar", diff --git a/mods/prayer-times/hooks/register.tsx b/mods/prayer-times/hooks/register.tsx index d722cdc..527516a 100644 --- a/mods/prayer-times/hooks/register.tsx +++ b/mods/prayer-times/hooks/register.tsx @@ -6,7 +6,7 @@ // /prayers lists today's times. A toast says when a prayer begins, and again when its time // is nearly over. import { atom, read, update } from 'claude-code' -import type { EngineInterface, Register } from 'claude-code' +import type { EngineInterface, Register, Timer } from 'claude-code' import { bandsFile, parseOrder, stackBands } from './bands' @@ -50,6 +50,7 @@ export const register: Register = (on, options) => { const warnMinutes = Math.min(120, num(options.warnMinutes, 15)) let toasted = '' let warned = '' + let timer: Timer | undefined // one timer, even if session.start fires again on('session.start', async ($, e, next) => { const r = await next(e) @@ -57,7 +58,8 @@ export const register: Register = (on, options) => { const first = await $.clock.now() await update($, now, () => first) // The line moves each minute; a toast marks the start of each prayer, and another its last minutes. - $.clock.every(30_000, () => { + timer?.cancel() + timer = $.clock.every(30_000, () => { void (async () => { const t = await $.clock.now() await update($, now, () => t) diff --git a/mods/prayer-times/tests/prayer-times.test.tsx b/mods/prayer-times/tests/prayer-times.test.tsx index 9814759..170d0d4 100644 --- a/mods/prayer-times/tests/prayer-times.test.tsx +++ b/mods/prayer-times/tests/prayer-times.test.tsx @@ -190,6 +190,20 @@ describe('prayer-times', () => { expect(toasts.filter(x => / ends in /.test(x))).toEqual([expect.stringMatching(/^⏳ Dhuhr ends in 1[45] min \(\d\d:\d\d\)$/)]) }) + test('a second session.start keeps one timer, not two', { options: { latitude: 51.5074, longitude: -0.1278 } }, async ($, on) => { + const clock = mock.clock(on, { now: Date.now() }) + let ticks = 0 // each tick writes the time once + on('session.start', (_$: any, e: any) => ({ sessionId: 's', cwd: e.cwd })) + on('command.register', () => ({ value: undefined })) + on('ui.toast', () => ({ value: undefined })) + on('state.set', (_$: any, e: any, next: any) => (e.key === 'now' && ticks++, next(e))) + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any) + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any) + ticks = 0 + await clock.advance(30_000) + expect(ticks).toBe(1) + }) + test('without a location it asks for one and computes nothing', async ($, on) => { mock.clock(on, { now: Date.now() }) resetBandOrder() diff --git a/mods/switchboard/.claude-plugin/plugin.json b/mods/switchboard/.claude-plugin/plugin.json index 8a48a02..b0c2f39 100644 --- a/mods/switchboard/.claude-plugin/plugin.json +++ b/mods/switchboard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "switchboard", - "version": "0.3.2", + "version": "0.3.3", "description": "Picks the model for each subagent that names none, with OpenAI's Decisions API or Jev, from its short label only. One line per spawn above the prompt; /route shows each pick and what each subagent cost.", "author": { "name": "Hamza Zafar", diff --git a/mods/switchboard/hooks/register.tsx b/mods/switchboard/hooks/register.tsx index e9b732d..705525b 100644 --- a/mods/switchboard/hooks/register.tsx +++ b/mods/switchboard/hooks/register.tsx @@ -6,7 +6,7 @@ // in time, nothing changes. In auto mode the pick replaces the model the spawn would have run // on; in suggest mode it is only shown. /route lists every subagent, its pick and its cost. import { atom, read, update } from 'claude-code' -import type { EngineInterface, Register } from 'claude-code' +import type { EngineInterface, Register, Timer } from 'claude-code' import type { Route, Tier } from '../types' import { bandsFile, parseOrder, stackBands } from './bands' @@ -50,12 +50,14 @@ export const register: Register = (on, options) => { openai: text(options.openaiApiKey), zeroRetention: options.gatewayZeroRetention === true, } + let timer: Timer | undefined // one redraw timer, even if session.start fires again on('session.start', async ($, e, next) => { const r = await next(e) await $.command.register({ name: 'route', description: 'Show which model Switchboard picked for each subagent, and what it cost' }).catch(() => {}) // a name Claude Code already has is refused: start anyway // Redraws the band so a finished line leaves after 30 s; quiet when nothing is shown. - $.clock.every(5_000, () => { + timer?.cancel() + timer = $.clock.every(5_000, () => { void (async () => { if ((await read($, routes)).some(r => isShown(r, Date.now() - 5_000))) await update($, now, () => Date.now()) })().catch(() => {}) diff --git a/mods/switchboard/tests/switchboard.test.tsx b/mods/switchboard/tests/switchboard.test.tsx index b3acb9e..2b32af0 100644 --- a/mods/switchboard/tests/switchboard.test.tsx +++ b/mods/switchboard/tests/switchboard.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'claude-code/testing' +import { describe, expect, mock, test } from 'claude-code/testing' import { baseline, move, resetBandOrder, totals, trim } from '../hooks/register' import { LADDERS, costOf, parsePick, pickRequest, tierOf } from '../hooks/route' @@ -224,6 +224,22 @@ describe('switchboard', () => { expect(spawned).toEqual([undefined]) }) + test('a second session.start keeps one redraw timer, not two', async ($, on) => { + const clock = mock.clock(on, { now: Date.now() }) + let ticks = 0 // each tick writes the time once while a line shows + on('session.start', (_$: any, e: any) => ({ sessionId: 's', cwd: e.cwd })) + on('command.register', () => ({ value: undefined })) + on('env.get', () => ({ value: undefined })) + on('agent.spawn', (_$: any, e: any) => ({ model: e.parentModel, agentId: 'a1' })) + on('state.set', (_$: any, e: any, next: any) => (e.key === 'now' && ticks++, next(e))) + await $.session.start(START) + await $.session.start(START) + await $.agent.spawn(SPAWN as any) // a running subagent: its line shows + await clock.advance(5_000) + await new Promise(done => (globalThis as any).setTimeout(done, 20)) // the tick runs in the background + expect(ticks).toBe(1) + }) + test('keys its band and follows the band order file', { options: { picker: 'openai' } }, async ($, on) => { // another band mod drew its column beneath const beneath = { diff --git a/site/mods/glance/index.html b/site/mods/glance/index.html index 2f9a850..4838f08 100644 --- a/site/mods/glance/index.html +++ b/site/mods/glance/index.html @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@

glance

One line above the prompt with what needs you: next meeting, PRs, Linear issues and Slack DMs.

-

Fork and adapt Version 0.1.1

+

Fork and adapt Version 0.1.2

diff --git a/site/mods/prayer-times/index.html b/site/mods/prayer-times/index.html index ff7c3e2..efc9fe4 100644 --- a/site/mods/prayer-times/index.html +++ b/site/mods/prayer-times/index.html @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@

prayer-times

The current prayer and how long is left, the next prayer, and zawal, above the prompt. Computed on your computer from your location. Nothing is sent anywhere.

-

See what's happening Version 0.1.4

+

See what's happening Version 0.1.5

prayer-times line: Dhuhr, 1h 26m left, next Asr 16:32
The current prayer, the time left, and the next one.
diff --git a/site/mods/switchboard/index.html b/site/mods/switchboard/index.html index 2adcf02..d48de0c 100644 --- a/site/mods/switchboard/index.html +++ b/site/mods/switchboard/index.html @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@

switchboard

Picks the model for each subagent that doesn't name one, from its short label only, and shows what every subagent cost.

-

Spend less Version 0.3.2

+

Spend less Version 0.3.3

From 64dea02783487a057df2620958b70174c5107b4b Mon Sep 17 00:00:00 2001 From: Hamza Zafar Date: Fri, 23 Oct 2026 23:33:26 +0200 Subject: [PATCH 3/3] heatmap, md-preview: catch background promises --- .claude-plugin/marketplace.json | 4 ++-- mods/heatmap/.claude-plugin/plugin.json | 2 +- mods/heatmap/hooks/register.tsx | 6 +++--- mods/md-preview/.claude-plugin/plugin.json | 2 +- mods/md-preview/hooks/register.tsx | 6 +++--- site/mods/heatmap/index.html | 4 ++-- site/mods/md-preview/index.html | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1e0d7b4..1d36cf9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -58,7 +58,7 @@ { "name": "md-preview", "description": "Shows the Markdown files Claude edits, rendered like GitHub, in a pane next to the chat. Before and after side by side. /md opens it.", - "version": "0.1.7", + "version": "0.1.8", "source": "./mods/md-preview", "category": "See what's happening", "tags": [ @@ -90,7 +90,7 @@ { "name": "heatmap", "description": "A GitHub-style grid of your Claude Code time in a /heatmap pane, with streaks, peaks and top projects.", - "version": "0.1.1", + "version": "0.1.2", "source": "./mods/heatmap", "category": "See what's happening", "tags": [ diff --git a/mods/heatmap/.claude-plugin/plugin.json b/mods/heatmap/.claude-plugin/plugin.json index e1041a4..d841e20 100644 --- a/mods/heatmap/.claude-plugin/plugin.json +++ b/mods/heatmap/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "heatmap", - "version": "0.1.1", + "version": "0.1.2", "description": "A GitHub-style grid of your Claude Code time in a /heatmap pane, with streaks, peaks and top projects.", "author": { "name": "Hamza Zafar", diff --git a/mods/heatmap/hooks/register.tsx b/mods/heatmap/hooks/register.tsx index 188ccc9..b87816a 100644 --- a/mods/heatmap/hooks/register.tsx +++ b/mods/heatmap/hooks/register.tsx @@ -54,7 +54,7 @@ export const register: Register = on => { void (async () => { await refresh($) await milestoneToast($, rows ?? []) - })() + })().catch(() => {}) return r }) @@ -67,7 +67,7 @@ export const register: Register = on => { on('command.run', { command: 'heatmap' }, async $ => { await update($, isLoading, () => rows === null) await $.ui.open({ id: PANE, title: 'Heatmap', focus: true }) - void refresh($).then(() => update($, isLoading, () => false)) // today stays current mid-session + void refresh($).then(() => update($, isLoading, () => false)).catch(() => {}) // today stays current mid-session return { text: 'Opened the heatmap' } }) @@ -86,7 +86,7 @@ export const register: Register = on => { ) } - if (!(await read($, isLoading))) void refresh($) // a hot reload dropped the rows + if (!(await read($, isLoading))) void refresh($).catch(() => {}) // a hot reload dropped the rows return ( Reading your session history… the first time takes a few seconds. diff --git a/mods/md-preview/.claude-plugin/plugin.json b/mods/md-preview/.claude-plugin/plugin.json index be2324f..79d9544 100644 --- a/mods/md-preview/.claude-plugin/plugin.json +++ b/mods/md-preview/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "md-preview", - "version": "0.1.7", + "version": "0.1.8", "description": "Shows the Markdown files Claude edits, rendered like GitHub, in a pane next to the chat. Before and after side by side. /md opens it.", "author": { "name": "Hamza Zafar", diff --git a/mods/md-preview/hooks/register.tsx b/mods/md-preview/hooks/register.tsx index f9e5380..d48b943 100644 --- a/mods/md-preview/hooks/register.tsx +++ b/mods/md-preview/hooks/register.tsx @@ -519,7 +519,7 @@ async function renderSoon($: EngineInterface) { void afterDraw($).catch(() => {}) return } - $.clock.after(300, () => void run($, true)) + $.clock.after(300, () => void run($, true).catch(() => {})) } // Draws a file Claude changed, in the background once the turn ends, so /md opens on it drawn. @@ -536,7 +536,7 @@ async function startAhead($: EngineInterface) { if (ahead.size === 0 || draw.pending) return // the drawing under way picks it up when done draw.pending = true draw.dirty = false - $.clock.after(300, () => void run($, false)) // edits close together wait for each other + $.clock.after(300, () => void run($, false).catch(() => {})) // edits close together wait for each other } // The one drawing at a time, for the pane or ahead: two Chromes must never share the profile. @@ -570,7 +570,7 @@ async function afterDraw($: EngineInterface) { if (draw.pending || ahead.size === 0 || turn.isOn) return draw.pending = true draw.dirty = false - void run($, false) + void run($, false).catch(() => {}) } // Whether the open pane shows something other than what it should. diff --git a/site/mods/heatmap/index.html b/site/mods/heatmap/index.html index 88856b8..29bcf07 100644 --- a/site/mods/heatmap/index.html +++ b/site/mods/heatmap/index.html @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@

heatmap

A GitHub-style grid of your Claude Code time, one square per day.

-

See what's happening Version 0.1.1

+

See what's happening Version 0.1.2

heatmap: a year of Claude Code activity as orange squares, with a 25-day streak, peak hours and top projects
A year of sample activity, with the streak and top projects.
diff --git a/site/mods/md-preview/index.html b/site/mods/md-preview/index.html index e2d9759..d128684 100644 --- a/site/mods/md-preview/index.html +++ b/site/mods/md-preview/index.html @@ -25,7 +25,7 @@ - + @@ -43,7 +43,7 @@

md-preview

Shows the Markdown files Claude edits, rendered like GitHub, in a pane next to the chat. Before and after side by side.

-

See what's happening Version 0.1.7

+

See what's happening Version 0.1.8

md-preview: Claude's Markdown diff on the left, the README rendered like GitHub on the right, with a green bar on the changed blocks
The diff Claude wrote on the left, the page rendered like GitHub on the right.