pi-tmux-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # pi-tmux-bridge
2
+
3
+ A pi extension that feeds this terminal's tmux state from a running pi session — a status line reflecting model and context usage, an attention bell on the pane's own tty, and session-bus (muster) registration — so a pi session presents in tmux the way a harnessed coding session should.
4
+
5
+ ## What it does
6
+
7
+ - **Status.** On session start and as context fills, writes per-pane state (`src/state.ts`) that a tmux status line can render (model, context %). Removed again on shutdown.
8
+ - **Attention.** Rings the bell (`src/bell.ts`) on *this* pane's tty only — never every same-directory session — lighting tmux's attention banner / window indicator and the terminal's tab bell.
9
+ - **Bus.** Registers the session with a lightweight muster bus (`src/muster.ts`) at start, drains queued messages on settle, and deregisters at end.
10
+ - **tmux resolution.** `src/tmux.ts` resolves the current socket/pane/session and sanitizes the session id before it reaches tmux (it is later used to build file paths).
11
+
12
+ Every handler is best-effort: it sits directly on a pi lifecycle event, and a harness that fails a session start, a turn, or a shutdown over a status bar or a bus registration is worse than no harness at all.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pi install npm:pi-tmux-bridge
18
+ ```
19
+
20
+ ## Tests
21
+
22
+ ```sh
23
+ node --test src/*.test.ts
24
+ ```
25
+
26
+ The pure tmux/state/bell/muster logic is unit-tested against injectable stand-ins for tmux and the shell, and `src/index.test.ts` exercises the wired lifecycle against a fake pi.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-tmux-bridge",
3
+ "version": "0.1.0",
4
+ "description": "A pi coding-agent extension that feeds this terminal's tmux state (status bar, attention bell, session bus) from a pi session.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/schuettc/pi-extensions.git",
10
+ "directory": "packages/pi-tmux-bridge"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi-extension",
15
+ "pi-coding-agent",
16
+ "tmux",
17
+ "harness",
18
+ "status-bar"
19
+ ],
20
+ "files": [
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/index.ts"
27
+ ]
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit -p tsconfig.json",
31
+ "test": "node --test src/*.test.ts"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-coding-agent": "^0.84.3",
35
+ "typescript": "^5.6.0"
36
+ }
37
+ }
@@ -0,0 +1,38 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ringBell, raiseAttention } from "./bell.ts";
4
+
5
+ const ctx = { socket: "proj-pi", pane: "%47" };
6
+ const BEL = String.fromCharCode(7);
7
+
8
+ test("the bell is a BEL byte appended to the pane's own tty", () => {
9
+ const writes: Array<{ path: string; data: string }> = [];
10
+ ringBell(ctx, { tty: () => "/dev/ttys004", append: (path, data) => writes.push({ path, data }) });
11
+ assert.deepEqual(writes, [{ path: "/dev/ttys004", data: BEL }]);
12
+ });
13
+
14
+ test("no tty means no write, and no throw", () => {
15
+ const writes: string[] = [];
16
+ ringBell(ctx, { tty: () => undefined, append: (p) => writes.push(p) });
17
+ assert.deepEqual(writes, []);
18
+ });
19
+
20
+ test("a failing write is swallowed — a bell must not fail a turn", () => {
21
+ assert.doesNotThrow(() =>
22
+ ringBell(ctx, { tty: () => "/dev/ttys004", append: () => { throw new Error("gone"); } }),
23
+ );
24
+ });
25
+
26
+ test("attention calls claude-attn raise with the SESSION, not a pid", () => {
27
+ const calls: Array<{ cmd: string; args: string[] }> = [];
28
+ raiseAttention(ctx, { session: () => "pi-97", run: (cmd, args) => calls.push({ cmd, args }) });
29
+ assert.equal(calls.length, 1);
30
+ assert.match(calls[0].cmd, /claude-attn$/);
31
+ assert.deepEqual(calls[0].args, ["raise", "pi-97"]);
32
+ });
33
+
34
+ test("an unresolvable session raises nothing rather than raising the wrong thing", () => {
35
+ const calls: string[] = [];
36
+ raiseAttention(ctx, { session: () => undefined, run: (cmd) => calls.push(cmd) });
37
+ assert.deepEqual(calls, []);
38
+ });
package/src/bell.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ttyOfPane, sessionOfPane } from "./tmux.ts";
6
+ import type { TmuxContext } from "./tmux.ts";
7
+
8
+ const ATTN = join(homedir(), ".local", "bin", "claude-attn");
9
+ const BEL = String.fromCharCode(7);
10
+
11
+ // The bell lights tmux's status-left attention banner, the window indicator,
12
+ // and Ghostty's tab bell and border. It goes to THIS pane's tty and nowhere
13
+ // else: ringing every same-directory session is worse than ringing none.
14
+ export function ringBell(
15
+ ctx: TmuxContext,
16
+ deps: {
17
+ tty?: (socket: string, pane: string) => string | undefined;
18
+ append?: (path: string, data: string) => void;
19
+ } = {},
20
+ ): void {
21
+ const readTty = deps.tty ?? ttyOfPane;
22
+ const write = deps.append ?? ((path: string, data: string) => appendFileSync(path, data));
23
+ try {
24
+ const tty = readTty(ctx.socket, ctx.pane);
25
+ if (!tty) return;
26
+ write(tty, BEL);
27
+ } catch {
28
+ // Best-effort: a bell must not fail a turn.
29
+ }
30
+ }
31
+
32
+ // `claude-attn raise <session>` takes a session directly. raise-pid exists only
33
+ // because a Claude Code hook cannot know its own pane and must walk process
34
+ // ancestry to find it; we know ours. claude-attn is NOT modified.
35
+ export function raiseAttention(
36
+ ctx: TmuxContext,
37
+ deps: {
38
+ session?: (socket: string, pane: string) => string | undefined;
39
+ run?: (cmd: string, args: string[]) => void;
40
+ } = {},
41
+ ): void {
42
+ const readSession = deps.session ?? sessionOfPane;
43
+ const run =
44
+ deps.run ??
45
+ ((cmd: string, args: string[]) => {
46
+ execFileSync(cmd, args, { stdio: "ignore" });
47
+ });
48
+ try {
49
+ const session = readSession(ctx.socket, ctx.pane);
50
+ if (!session) return;
51
+ run(ATTN, ["raise", session]);
52
+ } catch {
53
+ // Best-effort: an attention flag must not fail a turn.
54
+ }
55
+ }
@@ -0,0 +1,499 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHarness } from "./index.ts";
4
+
5
+ type Handler = (event: unknown, ctx: unknown) => unknown;
6
+
7
+ function fakePi() {
8
+ const handlers = new Map<string, Handler[]>();
9
+ const sent: Array<{ message: unknown; options: unknown }> = [];
10
+ const pi = {
11
+ on(event: string, handler: Handler) {
12
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
13
+ },
14
+ sendMessage(message: unknown, options?: unknown) {
15
+ sent.push({ message, options });
16
+ },
17
+ };
18
+ const ctx = {
19
+ mode: "tui",
20
+ sessionManager: { getSessionId: () => "sess-1" },
21
+ cwd: "/work/proj",
22
+ model: { id: "Qwen3.8-27B" },
23
+ getContextUsage: () => ({ tokens: 12000, contextWindow: 32000, percent: 37 }),
24
+ ui: { setStatus: () => {} },
25
+ };
26
+ async function fire(event: string, payload: unknown = {}, overrideCtx?: unknown) {
27
+ const out: unknown[] = [];
28
+ for (const h of handlers.get(event) ?? []) out.push(await h(payload, overrideCtx ?? ctx));
29
+ return out;
30
+ }
31
+ return { pi, ctx, fire, events: () => [...handlers.keys()], sent };
32
+ }
33
+
34
+ // A tmux address that cannot collide with a real server or pane on the
35
+ // operator's machine: no `-L harness-test` server exists and `%999999` is far
36
+ // past any live pane id. Combined with injecting writeState/ringBell/runTmux in
37
+ // every test that would otherwise reach a default, the suite touches no real
38
+ // tmux server and no real state file.
39
+ const ctxTmux = { socket: "harness-test", pane: "%999999" };
40
+ const noopTmux = (_s: string, _a: string[]) => undefined;
41
+ const noopBell = (_c: unknown) => {};
42
+
43
+ test("it subscribes to exactly the lifecycle events it needs", async () => {
44
+ const { pi, events } = fakePi();
45
+ createHarness(pi as never, { raiseAttention: noopBell, tmux: () => ctxTmux });
46
+ const subscribed = events().sort();
47
+ assert.deepEqual(subscribed, [
48
+ "agent_settled", "session_info_changed", "session_shutdown", "session_start", "turn_end",
49
+ ].sort());
50
+ });
51
+
52
+ test("session_start stamps the tmux session option and calls the SessionStart hook", async () => {
53
+ const { pi, fire } = fakePi();
54
+ const stamps: string[][] = [];
55
+ const musterCalls: Array<{ args: string[]; input: string }> = [];
56
+ createHarness(pi as never, { raiseAttention: noopBell,
57
+ tmux: () => ctxTmux,
58
+ runTmux: (_s, args) => { stamps.push(args); return undefined; },
59
+ runMuster: (_c, args, input) => { musterCalls.push({ args, input }); return ""; },
60
+ });
61
+ await fire("session_start", { reason: "startup" });
62
+ assert.ok(stamps.some((a) => a.includes("@harness_session") && a.includes("sess-1")),
63
+ `expected a @harness_session stamp, got ${JSON.stringify(stamps)}`);
64
+ const hookCall = musterCalls.find((c) => c.args[1] === "SessionStart");
65
+ assert.ok(hookCall, "expected a hook SessionStart pi call");
66
+ assert.deepEqual(JSON.parse(hookCall.input), { session_id: "sess-1", cwd: "/work/proj", source: "startup" });
67
+ });
68
+
69
+ test("a resume or fork reason passes source: resume to the SessionStart hook", async () => {
70
+ for (const reason of ["resume", "fork"]) {
71
+ const { pi, fire } = fakePi();
72
+ const musterCalls: Array<{ input: string }> = [];
73
+ createHarness(pi as never, { raiseAttention: noopBell,
74
+ tmux: () => ctxTmux,
75
+ runTmux: noopTmux,
76
+ runMuster: (_c, _a, input) => { musterCalls.push({ input }); return ""; },
77
+ });
78
+ await fire("session_start", { reason });
79
+ assert.equal(JSON.parse(musterCalls[0].input).source, "resume", `reason ${reason} should map to source: resume`);
80
+ }
81
+ });
82
+
83
+ test("session_start injects non-empty SessionStart stdout, triggering a turn only when it mentions unread mail", async () => {
84
+ const { pi, fire, sent } = fakePi();
85
+ createHarness(pi as never, { raiseAttention: noopBell,
86
+ tmux: () => ctxTmux,
87
+ runTmux: noopTmux,
88
+ runMuster: () => "muster: reconnected as 'pi-97' (revived) — 2 unread thread(s); call get_inbox with alias 'pi-97'",
89
+ });
90
+ await fire("session_start", { reason: "resume" });
91
+ assert.equal(sent.length, 1);
92
+ assert.match(String((sent[0].message as { content: string }).content), /unread/);
93
+ assert.deepEqual(sent[0].options, { triggerTurn: true });
94
+ });
95
+
96
+ test("session_start does not inject when SessionStart stdout is empty", async () => {
97
+ const { pi, fire, sent } = fakePi();
98
+ createHarness(pi as never, { raiseAttention: noopBell, tmux: () => ctxTmux, runTmux: noopTmux, runMuster: () => "" });
99
+ await fire("session_start", { reason: "startup" });
100
+ assert.deepEqual(sent, []);
101
+ });
102
+
103
+ test("session_start injects a plain registration line without triggering a turn", async () => {
104
+ const { pi, fire, sent } = fakePi();
105
+ createHarness(pi as never, { raiseAttention: noopBell, tmux: () => ctxTmux, runTmux: noopTmux, runMuster: () => "muster: registered as 'pi-97'" });
106
+ await fire("session_start", { reason: "startup" });
107
+ assert.equal(sent.length, 1);
108
+ assert.deepEqual(sent[0].options, { triggerTurn: false });
109
+ });
110
+
111
+ test("turn_end writes the state file", async () => {
112
+ const { pi, fire } = fakePi();
113
+ const written: Array<{ pct: number; model: string }> = [];
114
+ createHarness(pi as never, { raiseAttention: noopBell,
115
+ tmux: () => ctxTmux,
116
+ writeState: (o) => written.push({ pct: o.contextPct, model: o.model }),
117
+ ringBell: noopBell,
118
+ });
119
+ await fire("turn_end", {});
120
+ assert.deepEqual(written, [{ pct: 37, model: "Qwen3.8-27B" }]);
121
+ });
122
+
123
+ test("turn_end skips the write when getContextUsage returns undefined, but still rings the bell path", async () => {
124
+ const { pi, fire } = fakePi();
125
+ const written: unknown[] = [];
126
+ createHarness(pi as never, { raiseAttention: noopBell, tmux: () => ctxTmux, writeState: (o) => written.push(o), ringBell: noopBell });
127
+ const ctx = {
128
+ mode: "tui",
129
+ sessionManager: { getSessionId: () => "sess-1" },
130
+ cwd: "/work/proj",
131
+ model: { id: "Qwen3.8-27B" },
132
+ getContextUsage: () => undefined,
133
+ ui: { setStatus: () => {} },
134
+ };
135
+ await fire("turn_end", {}, ctx);
136
+ assert.deepEqual(written, [], "an unknown usage must not fabricate a state write");
137
+ });
138
+
139
+ test("turn_end skips the write when percent is null (fresh post-compaction)", async () => {
140
+ const { pi, fire } = fakePi();
141
+ const written: unknown[] = [];
142
+ createHarness(pi as never, { raiseAttention: noopBell, tmux: () => ctxTmux, writeState: (o) => written.push(o), ringBell: noopBell });
143
+ const ctx = {
144
+ mode: "tui",
145
+ sessionManager: { getSessionId: () => "sess-1" },
146
+ cwd: "/work/proj",
147
+ model: { id: "Qwen3.8-27B" },
148
+ getContextUsage: () => ({ tokens: null, contextWindow: 32000, percent: null }),
149
+ ui: { setStatus: () => {} },
150
+ };
151
+ await fire("turn_end", {}, ctx);
152
+ assert.deepEqual(written, [], "a null percent must not be written as a fabricated 0");
153
+ });
154
+
155
+ // The state write and the bell are independent notifications that happen to
156
+ // share turn_end. A throw in getContextUsage/writeState (or a missing
157
+ // ctx.model) must not suppress the bell for that turn.
158
+ test("a throwing writeState does not suppress the bell", async () => {
159
+ const { pi, fire } = fakePi();
160
+ const bellCalls: unknown[] = [];
161
+ createHarness(pi as never, { raiseAttention: noopBell,
162
+ tmux: () => ctxTmux,
163
+ writeState: () => { throw new Error("disk full"); },
164
+ ringBell: (ctx) => bellCalls.push(ctx),
165
+ });
166
+ await fire("turn_end", {});
167
+ assert.equal(bellCalls.length, 1, "the bell must still ring when writeState throws");
168
+ });
169
+
170
+ test("the drain runs on agent_settled and NOT on turn_end", async () => {
171
+ const { pi, fire } = fakePi();
172
+ const musterCalls: Array<{ args: string[] }> = [];
173
+ createHarness(pi as never, { raiseAttention: noopBell,
174
+ tmux: () => ctxTmux,
175
+ runTmux: noopTmux,
176
+ writeState: () => {},
177
+ ringBell: noopBell,
178
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
179
+ });
180
+ await fire("turn_end", {});
181
+ assert.ok(!musterCalls.some((c) => c.args[1] === "Stop"), "turn_end must not drain");
182
+ await fire("agent_settled", {});
183
+ assert.ok(musterCalls.some((c) => c.args[1] === "Stop"), "agent_settled must drain");
184
+ });
185
+
186
+ test("drain injects the block reason and passes stop_hook_active on the settle", async () => {
187
+ const { pi, fire, sent } = fakePi();
188
+ const inputs: string[] = [];
189
+ createHarness(pi as never, { raiseAttention: noopBell,
190
+ tmux: () => ctxTmux,
191
+ runMuster: (_c, _a, input) => {
192
+ inputs.push(input);
193
+ return JSON.stringify({ decision: "block", reason: "You have 2 unread threads" });
194
+ },
195
+ });
196
+ await fire("agent_settled", {});
197
+ assert.equal(JSON.parse(inputs[0]).stop_hook_active, false);
198
+ assert.equal(sent.length, 1);
199
+ assert.equal((sent[0].message as { content: string }).content, "You have 2 unread threads");
200
+ assert.deepEqual(sent[0].options, { triggerTurn: true });
201
+ });
202
+
203
+ // This is the assertion the drain-loop guard exists for: injecting a reason
204
+ // starts a turn, which produces another settle. Without tracking that WE
205
+ // triggered it, that settle re-drains, sees the same unread mail, and
206
+ // injects again — forever, for as long as mail stays unread. Two consecutive
207
+ // settles with mail waiting must call drain twice, pass stop_hook_active:
208
+ // true on the second, and inject exactly once.
209
+ test("the loop guard: two consecutive settles with mail waiting drain twice but inject only once", async () => {
210
+ const { pi, fire, sent } = fakePi();
211
+ const inputs: string[] = [];
212
+ createHarness(pi as never, { raiseAttention: noopBell,
213
+ tmux: () => ctxTmux,
214
+ runMuster: (_c, _a, input) => {
215
+ inputs.push(input);
216
+ return JSON.stringify({ decision: "block", reason: "You have 2 unread threads" });
217
+ },
218
+ });
219
+ await fire("agent_settled", {});
220
+ await fire("agent_settled", {});
221
+ assert.equal(inputs.length, 2, "drain must be called on every settle");
222
+ assert.equal(JSON.parse(inputs[0]).stop_hook_active, false, "first settle: we did not just trigger it");
223
+ assert.equal(JSON.parse(inputs[1]).stop_hook_active, true, "second settle: we triggered it via injection");
224
+ assert.equal(sent.length, 1, "must inject exactly once, not once per settle");
225
+ });
226
+
227
+ test("the loop guard resets once mail clears: a third settle with no reason re-arms it", async () => {
228
+ const { pi, fire, sent } = fakePi();
229
+ let call = 0;
230
+ createHarness(pi as never, { raiseAttention: noopBell,
231
+ tmux: () => ctxTmux,
232
+ runMuster: () => {
233
+ call += 1;
234
+ // Mail waiting for the first two settles, then cleared.
235
+ if (call <= 2) return JSON.stringify({ decision: "block", reason: "You have 2 unread threads" });
236
+ return "";
237
+ },
238
+ });
239
+ await fire("agent_settled", {}); // injects, flag -> true
240
+ await fire("agent_settled", {}); // flag true, still reason present -> skip, flag -> false
241
+ await fire("agent_settled", {}); // no reason -> nothing to inject regardless
242
+ assert.equal(sent.length, 1);
243
+ });
244
+
245
+ test("session_info_changed renames tmux and calls become with --no-inject", async () => {
246
+ const { pi, fire } = fakePi();
247
+ const tmuxCalls: string[][] = [];
248
+ const musterCalls: Array<{ args: string[] }> = [];
249
+ createHarness(pi as never, { raiseAttention: noopBell,
250
+ tmux: () => ctxTmux,
251
+ runTmux: (_s, a) => { tmuxCalls.push(a); return undefined; },
252
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
253
+ });
254
+ await fire("session_info_changed", { name: "new-name" });
255
+ assert.ok(tmuxCalls.some((a) => a[0] === "rename-session"), "expected a tmux rename");
256
+ const becomeCall = musterCalls.find((c) => c.args[0] === "become");
257
+ assert.deepEqual(becomeCall?.args, ["become", "new-name", "--no-inject"]);
258
+ });
259
+
260
+ test("a bare /name keeps the session's project prefix, like prefix T", async () => {
261
+ const { pi, fire } = fakePi();
262
+ const tmuxCalls: string[][] = [];
263
+ const musterCalls: Array<{ args: string[] }> = [];
264
+ createHarness(pi as never, { raiseAttention: noopBell,
265
+ tmux: () => ctxTmux,
266
+ runTmux: (_s, a) => {
267
+ tmuxCalls.push(a);
268
+ return a[0] === "display-message" ? "proj/old-work" : undefined;
269
+ },
270
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
271
+ });
272
+ await fire("session_info_changed", { name: "new-work" });
273
+ const rename = tmuxCalls.find((a) => a[0] === "rename-session");
274
+ assert.ok(rename?.includes("proj/new-work"), `expected proj/new-work, got ${JSON.stringify(rename)}`);
275
+ const becomeCall = musterCalls.find((c) => c.args[0] === "become");
276
+ assert.deepEqual(becomeCall?.args, ["become", "proj/new-work", "--no-inject"]);
277
+ });
278
+
279
+ test("a /name carrying '/' is taken verbatim: the re-homing escape hatch", async () => {
280
+ const { pi, fire } = fakePi();
281
+ const musterCalls: Array<{ args: string[] }> = [];
282
+ createHarness(pi as never, { raiseAttention: noopBell,
283
+ tmux: () => ctxTmux,
284
+ runTmux: (_s, a) => (a[0] === "display-message" ? "proj/old-work" : undefined),
285
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
286
+ });
287
+ await fire("session_info_changed", { name: "other/work" });
288
+ const becomeCall = musterCalls.find((c) => c.args[0] === "become");
289
+ assert.deepEqual(becomeCall?.args, ["become", "other/work", "--no-inject"]);
290
+ });
291
+
292
+ test("muster's prefixed /name echo does not round-trip a second become", async () => {
293
+ const { pi, fire } = fakePi();
294
+ const musterCalls: Array<{ args: string[] }> = [];
295
+ createHarness(pi as never, { raiseAttention: noopBell,
296
+ tmux: () => ctxTmux,
297
+ runTmux: (_s, a) => (a[0] === "display-message" ? "proj/old-work" : undefined),
298
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
299
+ });
300
+ await fire("session_info_changed", { name: "new-work" });
301
+ await fire("session_info_changed", { name: "proj/new-work" });
302
+ const becomeCalls = musterCalls.filter((c) => c.args[0] === "become");
303
+ assert.equal(becomeCalls.length, 1, "the composed-name echo must be recognized as our own rename");
304
+ });
305
+
306
+ test("a cleared name renames nothing rather than claiming an empty alias", async () => {
307
+ const { pi, fire } = fakePi();
308
+ const musterCalls: Array<{ args: string[] }> = [];
309
+ createHarness(pi as never, { raiseAttention: noopBell,
310
+ tmux: () => ctxTmux,
311
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
312
+ });
313
+ await fire("session_info_changed", { name: undefined });
314
+ assert.ok(!musterCalls.some((c) => c.args[0] === "become"), "an empty name must not become");
315
+ });
316
+
317
+ test("repeating the same name is a no-op: only the first occurrence renames and becomes", async () => {
318
+ const { pi, fire } = fakePi();
319
+ const musterCalls: Array<{ args: string[] }> = [];
320
+ const tmuxCalls: string[][] = [];
321
+ createHarness(pi as never, { raiseAttention: noopBell,
322
+ tmux: () => ctxTmux,
323
+ runTmux: (_s, a) => { tmuxCalls.push(a); return undefined; },
324
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
325
+ });
326
+ await fire("session_info_changed", { name: "same-name" });
327
+ await fire("session_info_changed", { name: "same-name" });
328
+ const becomeCalls = musterCalls.filter((c) => c.args[0] === "become");
329
+ const renameCalls = tmuxCalls.filter((a) => a[0] === "rename-session");
330
+ assert.equal(becomeCalls.length, 1, "the outside-in echo must not round-trip a second become");
331
+ assert.equal(renameCalls.length, 1);
332
+ });
333
+
334
+ test("a non-tui session is inert on every event: subagents and headless runs are guests, not pane owners", async () => {
335
+ const { pi, fire } = fakePi();
336
+ const calls: unknown[] = [];
337
+ createHarness(pi as never, { raiseAttention: (c) => calls.push(c),
338
+ tmux: () => ctxTmux,
339
+ runTmux: (_s, a) => { calls.push(a); return undefined; },
340
+ runMuster: (_c, a, i) => { calls.push({ a, i }); return ""; },
341
+ writeState: (o) => calls.push(o),
342
+ removeState: (c) => calls.push(c),
343
+ ringBell: (c) => calls.push(c),
344
+ });
345
+ const subagentCtx = {
346
+ mode: "print",
347
+ sessionManager: { getSessionId: () => "sub-1" },
348
+ cwd: "/work/proj",
349
+ model: { id: "m" },
350
+ getContextUsage: () => ({ tokens: 1, contextWindow: 2, percent: 50 }),
351
+ ui: { setStatus: () => {} },
352
+ };
353
+ for (const e of ["session_start", "turn_end", "agent_settled", "session_info_changed", "session_shutdown"]) {
354
+ await fire(e, { name: "Explore#deadbeef", reason: "quit" }, subagentCtx);
355
+ }
356
+ assert.deepEqual(calls, [], "a print-mode session must not touch the pane's tmux name, bus row, or state file");
357
+ });
358
+
359
+ test("session_shutdown removes the state file and calls the SessionEnd hook", async () => {
360
+ const { pi, fire } = fakePi();
361
+ const removed: unknown[] = [];
362
+ const musterCalls: Array<{ args: string[]; input: string }> = [];
363
+ createHarness(pi as never, { raiseAttention: noopBell,
364
+ tmux: () => ctxTmux,
365
+ removeState: (c) => removed.push(c),
366
+ runMuster: (_c, args, input) => { musterCalls.push({ args, input }); return ""; },
367
+ });
368
+ await fire("session_shutdown", {});
369
+ assert.equal(removed.length, 1);
370
+ const hookCall = musterCalls.find((c) => c.args[1] === "SessionEnd");
371
+ assert.ok(hookCall, "expected a hook SessionEnd pi call");
372
+ assert.deepEqual(JSON.parse(hookCall.input), { session_id: "sess-1" });
373
+ });
374
+
375
+ test("outside tmux every handler is inert and none throws", async () => {
376
+ const { pi, fire } = fakePi();
377
+ const calls: unknown[] = [];
378
+ createHarness(pi as never, { raiseAttention: noopBell,
379
+ tmux: () => undefined,
380
+ runTmux: (_s, a) => { calls.push(a); return undefined; },
381
+ runMuster: (_c, a, i) => { calls.push({ a, i }); return ""; },
382
+ writeState: (o) => calls.push(o),
383
+ removeState: (c) => calls.push(c),
384
+ });
385
+ for (const e of ["session_start", "turn_end", "agent_settled", "session_info_changed", "session_shutdown"]) {
386
+ await fire(e, { name: "x" });
387
+ }
388
+ assert.deepEqual(calls, [], "nothing pane-scoped may run without a pane");
389
+ });
390
+
391
+ test("a throwing handler dependency never escapes into pi", async () => {
392
+ const { pi, fire } = fakePi();
393
+ createHarness(pi as never, { raiseAttention: noopBell,
394
+ tmux: () => ctxTmux,
395
+ runTmux: () => { throw new Error("no tmux"); },
396
+ writeState: () => { throw new Error("disk full"); },
397
+ ringBell: () => { throw new Error("no tty"); },
398
+ removeState: () => { throw new Error("gone"); },
399
+ runMuster: () => { throw new Error("no muster"); },
400
+ });
401
+ for (const e of ["session_start", "turn_end", "agent_settled", "session_info_changed", "session_shutdown"]) {
402
+ await fire(e, { name: "x" });
403
+ }
404
+ });
405
+
406
+ // F1: a settled agent with no queued continuation is a pane waiting on input.
407
+ // Acceptance #4 — waiting on input raises the SwiftBar attention item — is
408
+ // unmet unless agent_settled raises it.
409
+ test("agent_settled raises the SwiftBar attention item with the tmux context", async () => {
410
+ const { pi, fire } = fakePi();
411
+ const raised: unknown[] = [];
412
+ createHarness(pi as never, {
413
+ tmux: () => ctxTmux,
414
+ raiseAttention: (c) => raised.push(c),
415
+ runMuster: () => "",
416
+ });
417
+ await fire("agent_settled", {});
418
+ assert.deepEqual(raised, [ctxTmux], "a settled agent is waiting on input; raise attention");
419
+ });
420
+
421
+ // F4 (RED-first): the state file is the liveness signal. If the session-id read
422
+ // throws — a torn-down ctx during quit — removeState must already have run, or
423
+ // the bar shows a dead session until the reader's 7-day backstop.
424
+ test("session_shutdown removes the state file even when the session-id read throws", async () => {
425
+ const { pi, fire } = fakePi();
426
+ const removed: unknown[] = [];
427
+ createHarness(pi as never, { raiseAttention: noopBell,
428
+ tmux: () => ctxTmux,
429
+ removeState: (c) => removed.push(c),
430
+ runMuster: () => "",
431
+ });
432
+ const brokenCtx = {
433
+ mode: "tui",
434
+ sessionManager: { getSessionId: () => { throw new Error("ctx torn down"); } },
435
+ };
436
+ await fire("session_shutdown", {}, brokenCtx);
437
+ assert.equal(removed.length, 1, "removeState must run before the session-id read, not after");
438
+ });
439
+
440
+ // F5 (RED-first): a resume with unread mail injects on session_start (triggering
441
+ // a turn); the settle that turn produces must NOT re-inject the same mail as a
442
+ // drain block reason. One resume, one post.
443
+ test("a resume with unread mail then a settle injects exactly once, not twice", async () => {
444
+ const { pi, fire, sent } = fakePi();
445
+ createHarness(pi as never, { raiseAttention: noopBell,
446
+ tmux: () => ctxTmux,
447
+ runTmux: noopTmux,
448
+ runMuster: (_c, args) => {
449
+ if (args[1] === "SessionStart") return "muster: reconnected as 'pi-97' — 2 unread thread(s)";
450
+ if (args[1] === "Stop") return JSON.stringify({ decision: "block", reason: "You have 2 unread threads" });
451
+ return "";
452
+ },
453
+ });
454
+ await fire("session_start", { reason: "resume" });
455
+ await fire("agent_settled", {});
456
+ assert.equal(sent.length, 1, "one resume with mail must post exactly once, not once per turn");
457
+ assert.deepEqual(sent[0].options, { triggerTurn: true }, "the single post is the session_start injection");
458
+ });
459
+
460
+ // A rename fires become() to claim the new alias live; if a reload or switch
461
+ // then fires session_shutdown, tombstoning on that non-quit reason races the
462
+ // become and leaves both the old and new aliases departed (live galley
463
+ // trial). Only a real quit ends the conversation on the bus.
464
+ test("session_shutdown tombstones and drops state ONLY on reason quit", async () => {
465
+ const { pi, fire } = fakePi();
466
+ const musterCalls: Array<{ args: string[] }> = [];
467
+ const removed: unknown[] = [];
468
+ const mk = () =>
469
+ createHarness(pi as never, {
470
+ raiseAttention: noopBell,
471
+ tmux: () => ctxTmux,
472
+ runTmux: noopTmux,
473
+ writeState: () => {},
474
+ ringBell: noopBell,
475
+ removeState: (ctx) => removed.push(ctx),
476
+ runMuster: (_c, args) => { musterCalls.push({ args }); return ""; },
477
+ });
478
+
479
+ mk();
480
+ for (const reason of ["reload", "new", "resume", "fork"]) {
481
+ musterCalls.length = 0;
482
+ removed.length = 0;
483
+ await fire("session_shutdown", { reason });
484
+ assert.ok(
485
+ !musterCalls.some((c) => c.args[1] === "SessionEnd"),
486
+ `reason ${reason} must NOT tombstone`,
487
+ );
488
+ assert.equal(removed.length, 0, `reason ${reason} must NOT drop the state file`);
489
+ }
490
+
491
+ musterCalls.length = 0;
492
+ removed.length = 0;
493
+ await fire("session_shutdown", { reason: "quit" });
494
+ assert.ok(
495
+ musterCalls.some((c) => c.args[1] === "SessionEnd"),
496
+ "reason quit must tombstone",
497
+ );
498
+ assert.equal(removed.length, 1, "reason quit must drop the state file");
499
+ });
package/src/index.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { resolveTmux, tmux as runTmuxDefault } from "./tmux.ts";
2
+ import type { TmuxContext } from "./tmux.ts";
3
+ import { writeState as writeStateDefault, removeState as removeStateDefault } from "./state.ts";
4
+ import { ringBell as ringBellDefault, raiseAttention as raiseAttentionDefault } from "./bell.ts";
5
+ import { sessionStart, drain, sessionEnd, become } from "./muster.ts";
6
+ import type { Run } from "./muster.ts";
7
+
8
+ type Deps = {
9
+ tmux?: () => TmuxContext | undefined;
10
+ runTmux?: (socket: string, args: string[]) => string | undefined;
11
+ runMuster?: Run;
12
+ writeState?: (opts: { ctx: TmuxContext; contextPct: number; model: string }) => void;
13
+ removeState?: (ctx: TmuxContext) => void;
14
+ ringBell?: (ctx: TmuxContext) => void;
15
+ raiseAttention?: (ctx: TmuxContext) => void;
16
+ };
17
+
18
+ // The session id reaches tmux as a session option value; sanitize before it
19
+ // gets there. Same character class the Claude Code hook this replaces used,
20
+ // and for the same reason: the id is later used by consumers to build file
21
+ // paths. Only the tmux-bound copy is sanitized — muster's hooks take the raw
22
+ // id in a JSON payload, not a tmux target or a filesystem path.
23
+ function sanitize(id: string): string {
24
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
25
+ }
26
+
27
+ // Every handler is best-effort: it sits directly on a pi lifecycle event, and
28
+ // a harness that fails a session start, a turn, or a shutdown over a status
29
+ // bar or a bus registration is worse than no harness at all.
30
+ function safe(fn: () => void | Promise<void>): void {
31
+ try {
32
+ const result = fn();
33
+ if (result && typeof (result as Promise<void>).catch === "function") {
34
+ (result as Promise<void>).catch(() => {});
35
+ }
36
+ } catch {
37
+ // Best-effort: nothing here may escape into pi.
38
+ }
39
+ }
40
+
41
+ export function createHarness(pi: any, deps: Deps = {}): void {
42
+ const getTmux = deps.tmux ?? resolveTmux;
43
+ const runTmux = deps.runTmux ?? runTmuxDefault;
44
+ const musterDeps = deps.runMuster ? { run: deps.runMuster } : {};
45
+ const writeState = deps.writeState ?? ((opts) => writeStateDefault({ ...opts }));
46
+ const removeState = deps.removeState ?? ((ctx) => removeStateDefault(ctx));
47
+ const ringBell = deps.ringBell ?? ((ctx) => ringBellDefault(ctx));
48
+ const raiseAttention = deps.raiseAttention ?? ((ctx) => raiseAttentionDefault(ctx));
49
+
50
+ // Loop guard for the drain on agent_settled: injecting a block reason
51
+ // starts a turn, which produces another settle. Without tracking whether
52
+ // WE triggered the settle we are currently handling, that settle re-drains,
53
+ // sees the same unread mail, and injects again — forever, for as long as
54
+ // mail stays unread. Scoped to this createHarness call so each session
55
+ // (and each test) starts clean.
56
+ let stopHookActive = false;
57
+
58
+ // Guard for session_info_changed: the alias muster last confirmed for this
59
+ // pane, whether from our own become() or simply the last name we acted on.
60
+ // Comparing against it (rather than the raw previous event) is what
61
+ // collapses the outside-in loop: `muster label X` types `/name X` into our
62
+ // pane, firing this event with a name muster already has — becoming it
63
+ // again would be a pointless round-trip.
64
+ let lastAlias: string | undefined;
65
+
66
+ // Only an interactive session owns the pane's identity. Subagent sessions
67
+ // (pi-subagents' createAgentSession) run IN-PROCESS: they load this
68
+ // extension too, inherit $TMUX/$TMUX_PANE, and their extension mode stays
69
+ // at the SDK default "print" — only the TUI upgrades it, before extensions
70
+ // initialize, so a real session always sees "tui" from session_start on.
71
+ // Without this gate a subagent's auto-name hijacked the host session's
72
+ // tmux name and bus alias, and its quit tombstoned the pane's row (caught
73
+ // live 2026-08-27, twice). Headless `pi -p` runs in a pane are excluded
74
+ // for the same reason — they are guests, not the pane's owner.
75
+ const ownsPane = (ctx: any): boolean => ctx?.mode === "tui";
76
+
77
+ pi.on("session_start", (event: any, ctx: any) => safe(() => {
78
+ if (!ownsPane(ctx)) return;
79
+ const t = getTmux();
80
+ if (!t) return;
81
+ const rawSessionId = String(ctx.sessionManager.getSessionId());
82
+ const sanitizedSessionId = sanitize(rawSessionId);
83
+ runTmux(t.socket, ["set-option", "-t", t.pane, "@harness_session", sanitizedSessionId]);
84
+
85
+ const source = event?.reason === "resume" || event?.reason === "fork" ? "resume" : "startup";
86
+ const out = sessionStart({ sessionId: rawSessionId, cwd: ctx.cwd, source, ...musterDeps });
87
+ if (out.stdout) {
88
+ // Require a positive unread count: `/unread/i` also matched a
89
+ // "— 0 unread thread(s)" line, triggering a turn on every resume with
90
+ // nothing to do.
91
+ const triggerTurn = /[1-9]\d* unread/.test(out.stdout);
92
+ if (triggerTurn) {
93
+ // The turn we start here ends in agent_settled, which drains and would
94
+ // get this same unread mail as a block reason and inject it AGAIN —
95
+ // two prompts for one resume. Arming the loop guard now makes that
96
+ // following settle skip the re-inject, collapsing it to one.
97
+ stopHookActive = true;
98
+ }
99
+ pi.sendMessage(
100
+ { customType: "harness", content: out.stdout, display: false },
101
+ { triggerTurn },
102
+ );
103
+ }
104
+ }));
105
+
106
+ pi.on("turn_end", (_event: unknown, ctx: any) => safe(() => {
107
+ if (!ownsPane(ctx)) return;
108
+ const t = getTmux();
109
+ if (!t) return;
110
+ // The state write and the bell are independent notifications that
111
+ // happen to share this event: a throw from getContextUsage/writeState
112
+ // (or a missing ctx.model) must not suppress the bell for this turn.
113
+ try {
114
+ const usage = ctx.getContextUsage?.();
115
+ if (usage != null && usage.percent != null) {
116
+ writeState({ ctx: t, contextPct: usage.percent, model: ctx.model.id });
117
+ }
118
+ } catch {
119
+ // Best-effort: the meter is not worth losing the bell over.
120
+ }
121
+ ringBell(t);
122
+ }));
123
+
124
+ pi.on("agent_settled", (_event: unknown, ctx: any) => safe(() => {
125
+ if (!ownsPane(ctx)) return;
126
+ const t = getTmux();
127
+ if (!t) return;
128
+ // A settled agent with no queued continuation is a pane waiting on input:
129
+ // raise the SwiftBar attention item (Acceptance #4). pi has no event
130
+ // literally named "input waiting"; agent_settled is that boundary. Its own
131
+ // try/catch keeps it best-effort, and the enclosing safe() is a backstop —
132
+ // a failed attention flag must not lose this settle's drain.
133
+ raiseAttention(t);
134
+ const sessionId = String(ctx.sessionManager.getSessionId());
135
+ const wasStopHookActive = stopHookActive;
136
+ const out = drain({ sessionId, stopHookActive: wasStopHookActive, ...musterDeps });
137
+ if (out.reason && !wasStopHookActive) {
138
+ pi.sendMessage(
139
+ { customType: "harness", content: out.reason, display: false },
140
+ { triggerTurn: true },
141
+ );
142
+ stopHookActive = true;
143
+ } else {
144
+ stopHookActive = false;
145
+ }
146
+ }));
147
+
148
+ pi.on("session_info_changed", (event: any, ctx: any) => safe(() => {
149
+ if (!ownsPane(ctx)) return;
150
+ const t = getTmux();
151
+ if (!t) return;
152
+ const name = event?.name;
153
+ if (!name) return;
154
+ if (name === lastAlias) return;
155
+ // Prefix-T's contract (dotfiles tmux-session-rename.sh): the operator
156
+ // names the WORK; the <project>/ prefix is a property of where the
157
+ // session lives. A bare /name gets the current first segment re-attached
158
+ // so the session never renames out of its project grouping (and the bus
159
+ // alias — which IS the tmux name — stays grouped too); a name already
160
+ // carrying '/' is taken verbatim, the same re-homing escape hatch the
161
+ // script offers. For a home-base session (no '/' in #S) the whole
162
+ // current name is the project.
163
+ const cur = runTmux(t.socket, ["display-message", "-p", "-t", t.pane, "#{session_name}"]);
164
+ const full = name.includes("/") || !cur ? name : `${cur.split("/")[0]}/${name}`;
165
+ if (full === lastAlias) return;
166
+ // tmux rejects session names containing `.` or `:`, so the tmux-bound name
167
+ // is sanitized to `-`; muster's alias takes the raw name. Without this the
168
+ // rename would fail while become() succeeded and lastAlias recorded the
169
+ // name as done, leaving the two divergent with no retry. Sanitizing here
170
+ // keeps both renames landing for any name pi allows.
171
+ const tmuxName = full.replace(/[.:]/g, "-");
172
+ runTmux(t.socket, ["rename-session", "-t", t.pane, tmuxName]);
173
+ become(full, musterDeps);
174
+ // Record the COMPOSED name: muster label confirms a become by typing
175
+ // /name <full> back into the pane, so the echo arrives prefixed and must
176
+ // match what we stored to collapse the loop. The raw-name guard above
177
+ // still catches a repeat of the operator's own bare /name before the
178
+ // tmux round-trip.
179
+ lastAlias = full;
180
+ }));
181
+
182
+ pi.on("session_shutdown", (event: any, ctx: any) => safe(() => {
183
+ if (!ownsPane(ctx)) return;
184
+ // Fires on every session SWITCH, not only quit: reason ∈
185
+ // quit|reload|new|resume|fork. Only a REAL end (quit) may tombstone this
186
+ // session's bus row and drop its state file; every other reason is a
187
+ // continuation whose paired session_start re-registers, and tombstoning
188
+ // there races other events on the same tuple. Live repro (galley
189
+ // trial): a rename fires become() → the new alias goes live, then a reload
190
+ // or switch fired session_shutdown → sessionEnd tombstoned it, leaving BOTH
191
+ // the old and new aliases departed. muster's SessionEnd is "the
192
+ // conversation ended" (Claude Code fires it once, at the real end); pi's
193
+ // broader session_shutdown must be filtered to match that contract.
194
+ if (event?.reason && event.reason !== "quit") return;
195
+ const t = getTmux();
196
+ if (!t) return;
197
+ // Remove the state file FIRST, before touching ctx. A torn-down ctx during
198
+ // a quit — plausible exactly at shutdown — would otherwise throw on the
199
+ // session-id read and leak the state file, so the bar shows a dead session
200
+ // until the reader's 7-day backstop. The state file is the liveness signal;
201
+ // dropping it must not depend on the bus call succeeding.
202
+ removeState(t);
203
+ try {
204
+ const sessionId = String(ctx.sessionManager.getSessionId());
205
+ sessionEnd({ sessionId, ...musterDeps });
206
+ } catch {
207
+ // Best-effort: a torn-down ctx must not resurrect the state file we just
208
+ // removed. The tombstone is muster's to miss, not ours to block on.
209
+ }
210
+ }));
211
+ }
212
+
213
+ export default function (pi: any) {
214
+ createHarness(pi);
215
+ }
@@ -0,0 +1,123 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { register, sessionStart, drain, sessionEnd, become } from "./muster.ts";
4
+ import type { Run } from "./muster.ts";
5
+
6
+ type Call = { cmd: string; args: string[]; input: string };
7
+ function recorder(returns = "") {
8
+ const calls: Call[] = [];
9
+ return {
10
+ calls,
11
+ run: (cmd: string, args: string[], input: string) => {
12
+ calls.push({ cmd, args, input });
13
+ return returns;
14
+ },
15
+ };
16
+ }
17
+
18
+ test("register passes the model as pi and the alias when given", () => {
19
+ const r = recorder();
20
+ register({ alias: "pi-97", run: r.run });
21
+ assert.equal(r.calls.length, 1);
22
+ assert.match(r.calls[0].cmd, /muster$/);
23
+ assert.deepEqual(r.calls[0].args, ["register", "pi-97", "--model", "pi"]);
24
+ });
25
+
26
+ test("register omits the alias when none is given, letting muster fall back to the tmux session name", () => {
27
+ const r = recorder();
28
+ register({ run: r.run });
29
+ assert.deepEqual(r.calls[0].args, ["register", "--model", "pi"]);
30
+ });
31
+
32
+ test("become passes --no-inject so a rename cannot type back into its own pane", () => {
33
+ const r = recorder();
34
+ become("new-name", { run: r.run });
35
+ assert.deepEqual(r.calls[0].args, ["become", "new-name", "--no-inject"]);
36
+ });
37
+
38
+ test("become with an empty name does nothing rather than claiming an empty alias", () => {
39
+ const r = recorder();
40
+ become("", { run: r.run });
41
+ assert.deepEqual(r.calls, []);
42
+ });
43
+
44
+ test("a failing muster call is swallowed — the bus is not worth failing a turn over", () => {
45
+ const boom: Run = () => { throw new Error("muster is not installed"); };
46
+ assert.doesNotThrow(() => register({ run: boom }));
47
+ assert.doesNotThrow(() => drain({ sessionId: "s", stopHookActive: false, run: boom }));
48
+ assert.doesNotThrow(() => sessionEnd({ sessionId: "s", run: boom }));
49
+ assert.doesNotThrow(() => become("x", { run: boom }));
50
+ });
51
+
52
+ test("sessionStart sends the payload muster expects and returns its stdout", () => {
53
+ const seen: Array<{ args: string[]; input: string }> = [];
54
+ const out = sessionStart({
55
+ sessionId: "sess-1", cwd: "/w", source: "startup",
56
+ run: (_c, args, input) => { seen.push({ args, input }); return "muster: registered as 'pi-97'\n"; },
57
+ });
58
+ assert.deepEqual(seen[0].args, ["hook", "SessionStart", "pi"]);
59
+ assert.deepEqual(JSON.parse(seen[0].input), { session_id: "sess-1", cwd: "/w", source: "startup" });
60
+ assert.match(out.stdout, /registered as/);
61
+ });
62
+
63
+ test("a resume passes source:resume, which is what makes muster reclaim rows", () => {
64
+ let input = "";
65
+ sessionStart({ sessionId: "s", cwd: "/w", source: "resume", run: (_c, _a, i) => { input = i; return ""; } });
66
+ assert.equal(JSON.parse(input).source, "resume");
67
+ });
68
+
69
+ test("drain passes stop_hook_active and surfaces the block reason", () => {
70
+ const seen: string[] = [];
71
+ const out = drain({
72
+ sessionId: "s", stopHookActive: false,
73
+ run: (_c, _a, i) => { seen.push(i); return JSON.stringify({ decision: "block", reason: "You have 2 unread threads" }); },
74
+ });
75
+ assert.equal(JSON.parse(seen[0]).stop_hook_active, false);
76
+ assert.equal(out.reason, "You have 2 unread threads");
77
+ });
78
+
79
+ test("drain with no mail yields no reason rather than an empty one", () => {
80
+ const out = drain({ sessionId: "s", stopHookActive: false, run: () => "" });
81
+ assert.equal(out.reason, undefined);
82
+ });
83
+
84
+ test("drain recovers the reason from JSON that follows a leading warning line", () => {
85
+ // A hook may log a warning before its JSON. Parsing the whole stdout would
86
+ // throw on the warning and silently drop the mail notice; the last JSON-object
87
+ // line is what carries the decision.
88
+ const out = drain({
89
+ sessionId: "s", stopHookActive: false,
90
+ run: () => 'warning: x\n{"decision":"block","reason":"You have 2 unread threads"}',
91
+ });
92
+ assert.equal(out.reason, "You have 2 unread threads");
93
+ });
94
+
95
+ test("drain tolerates stdout that carries no JSON at all", () => {
96
+ // A warning with no JSON behind it must not throw into a settle handler, and
97
+ // yields no reason rather than a fabricated one.
98
+ const out = drain({ sessionId: "s", stopHookActive: false, run: () => "warning: something\n" });
99
+ assert.equal(out.reason, undefined);
100
+ });
101
+
102
+ test("a decision that is not block yields no reason", () => {
103
+ const out = drain({
104
+ sessionId: "s", stopHookActive: false,
105
+ run: () => JSON.stringify({ decision: "approve", reason: "ignored" }),
106
+ });
107
+ assert.equal(out.reason, undefined);
108
+ });
109
+
110
+ test("sessionEnd sends the session id so it tombstones this pane's rows only", () => {
111
+ let input = "";
112
+ sessionEnd({ sessionId: "s", run: (_c, _a, i) => { input = i; return ""; } });
113
+ assert.deepEqual(JSON.parse(input), { session_id: "s" });
114
+ });
115
+
116
+ test("a throwing hook yields an empty outcome rather than propagating", () => {
117
+ const boom: Run = () => { throw new Error("no muster"); };
118
+ assert.doesNotThrow(() => {
119
+ const out = drain({ sessionId: "s", stopHookActive: false, run: boom });
120
+ assert.equal(out.reason, undefined);
121
+ assert.equal(out.stdout, "");
122
+ });
123
+ });
package/src/muster.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const MUSTER = join(homedir(), ".local", "bin", "muster");
6
+
7
+ export type Run = (cmd: string, args: string[], input: string) => string;
8
+
9
+ export type HookOutcome = { stdout: string; reason?: string };
10
+
11
+ // The default seam. Deliberately NOT passing `env`: muster's hooks resolve
12
+ // the calling pane from $TMUX / $TMUX_PANE, falling back to an ancestry walk
13
+ // when that is unset. Inheriting the child's environment for free is what
14
+ // makes that resolution work — passing an explicit env here (even one that
15
+ // looks like a superset) would break it invisibly. Do not "tidy" this.
16
+ function defaultRun(cmd: string, args: string[], input: string): string {
17
+ return execFileSync(cmd, args, {
18
+ input,
19
+ encoding: "utf-8",
20
+ stdio: ["pipe", "pipe", "ignore"],
21
+ });
22
+ }
23
+
24
+ // Every hook call is best-effort. A session whose bus call fails is worse off
25
+ // than one that succeeded, but it is still a working session — and a handler
26
+ // that threw would fail a session start, a settle, or a shutdown outright.
27
+ function callHook(event: string, payload: unknown, run: Run): HookOutcome {
28
+ try {
29
+ const stdout = run(MUSTER, ["hook", event, "pi"], JSON.stringify(payload));
30
+ return { stdout };
31
+ } catch {
32
+ // Best-effort: the bus is not worth failing a turn, a start, or a shutdown.
33
+ return { stdout: "" };
34
+ }
35
+ }
36
+
37
+ // hook SessionStart pi replaces register() for the pane path: beyond
38
+ // registering, it reclaims this pane's rows on a resume and prints
39
+ // reconnection text to stdout. Claude Code injects hook stdout into context
40
+ // automatically; pi does not, so the caller must inject `stdout` itself or a
41
+ // resumed session silently knows less than it should.
42
+ export function sessionStart(
43
+ opts: { sessionId: string; cwd: string; source: "startup" | "resume"; run?: Run },
44
+ ): HookOutcome {
45
+ const run = opts.run ?? defaultRun;
46
+ return callHook(
47
+ "SessionStart",
48
+ { session_id: opts.sessionId, cwd: opts.cwd, source: opts.source },
49
+ run,
50
+ );
51
+ }
52
+
53
+ // Drain belongs on agent_settled, NOT on agent_end / turn end. Claude Code
54
+ // wires its drain to the Stop hook because that is the only boundary it can
55
+ // express; pi's agent_end is that same boundary, but after it pi may still
56
+ // auto-retry, auto-compact and retry, or continue with queued follow-up
57
+ // messages. An earlier phase of this program hit exactly that edge with
58
+ // buffered mail: delivering there lands it mid-retry or loses it to
59
+ // compaction. This module only provides drain() — Task 5 wires it to
60
+ // agent_settled, the point where pi will not continue on its own.
61
+ //
62
+ // stopHookActive must be true on the settle immediately following a drain
63
+ // this module triggered, otherwise every settle re-drains for as long as
64
+ // mail is unread — which, with pi-channels also pushing, is a session that
65
+ // talks to itself indefinitely.
66
+ export function drain(
67
+ opts: { sessionId: string; stopHookActive: boolean; run?: Run },
68
+ ): HookOutcome {
69
+ const run = opts.run ?? defaultRun;
70
+ const outcome = callHook(
71
+ "Stop",
72
+ { session_id: opts.sessionId, stop_hook_active: opts.stopHookActive },
73
+ run,
74
+ );
75
+ try {
76
+ // The hook may print a warning line before its JSON, so parse the LAST
77
+ // non-empty line that looks like a JSON object rather than the whole
78
+ // stdout — parsing the whole thing would throw on the leading warning and
79
+ // silently drop the mail notice. stdout may still be empty or carry no
80
+ // JSON at all; a malformed decision must not throw into a settle handler.
81
+ const jsonLine = outcome.stdout
82
+ .split("\n")
83
+ .map((l) => l.trim())
84
+ .filter((l) => l.startsWith("{") && l.endsWith("}"))
85
+ .pop();
86
+ if (jsonLine) {
87
+ const parsed = JSON.parse(jsonLine);
88
+ if (parsed && parsed.decision === "block" && typeof parsed.reason === "string") {
89
+ return { stdout: outcome.stdout, reason: parsed.reason };
90
+ }
91
+ }
92
+ } catch {
93
+ // A line that looked like JSON but was not must not throw into a settle
94
+ // handler — the mail is best-effort, not worth failing the settle over.
95
+ }
96
+ return outcome;
97
+ }
98
+
99
+ export function sessionEnd(opts: { sessionId: string; run?: Run }): void {
100
+ const run = opts.run ?? defaultRun;
101
+ // sessionId is sent so muster tombstones only this pane's rows, not every
102
+ // row for the alias.
103
+ callHook("SessionEnd", { session_id: opts.sessionId }, run);
104
+ }
105
+
106
+ // Adapts a plain arg-list call onto the new (cmd, args, input) => stdout
107
+ // seam for register/become, which carry no payload and ignore any reply.
108
+ function call(args: string[], run: Run): void {
109
+ try {
110
+ run(MUSTER, args, "");
111
+ } catch {
112
+ // Best-effort: the bus is not worth failing a turn, a start, or a shutdown.
113
+ }
114
+ }
115
+
116
+ // --model pi is what puts this session in muster's harness tables. Those
117
+ // tables decide, among other things, whether an outside-in rename can reach
118
+ // this pane at all. Omitting the alias is deliberate when none is given:
119
+ // muster falls back to $MUSTER_ALIAS and then the tmux session name, which is
120
+ // the behaviour every other harness gets.
121
+ //
122
+ // This is no longer the session-start call — `sessionStart` (hook
123
+ // SessionStart pi) is. `register` stays for claiming an explicit alias AFTER
124
+ // the hook has run.
125
+ //
126
+ // NOT currently wired: index.ts calls sessionStart, never register. Exported
127
+ // and retained deliberately, per the amendment, for a future explicit-alias
128
+ // claim after sessionStart — not dead code left by accident.
129
+ export function register(opts: { alias?: string; run?: Run } = {}): void {
130
+ const run = opts.run ?? defaultRun;
131
+ const args = opts.alias ? ["register", opts.alias] : ["register"];
132
+ call([...args, "--model", "pi"], run);
133
+ }
134
+
135
+ // --no-inject: become normally types the new name into the agent's pane so
136
+ // the harness session name follows. Our caller is a handler already reacting
137
+ // to a rename, so typing it back would loop text into the pane that just
138
+ // renamed itself. muster's own help names this exact case, and Claude Code's
139
+ // statusline passes the same flag for the same reason.
140
+ export function become(name: string, deps: { run?: Run } = {}): void {
141
+ if (!name) return;
142
+ const run = deps.run ?? defaultRun;
143
+ call(["become", name, "--no-inject"], run);
144
+ }
@@ -0,0 +1,54 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { stateKey, writeState, removeState } from "./state.ts";
7
+
8
+ const ctx = { socket: "proj-pi", pane: "%47" };
9
+
10
+ test("the key is socket plus pane without the percent", () => {
11
+ assert.equal(stateKey(ctx), "proj-pi_47");
12
+ });
13
+
14
+ test("characters outside the safe set become underscores", () => {
15
+ // The key becomes a filename, and a socket name is whatever the operator
16
+ // called their project.
17
+ assert.equal(stateKey({ socket: "proj/weird name", pane: "%3" }), "proj_weird_name_3");
18
+ });
19
+
20
+ test("writeState emits exactly the five keys the reader parses, in order", () => {
21
+ const dir = mkdtempSync(join(tmpdir(), "harness-state-"));
22
+ writeState({ ctx, contextPct: 42, model: "Qwen3.8-27B", dir });
23
+ const body = readFileSync(join(dir, "proj-pi_47"), "utf-8");
24
+ const keys = body.trim().split("\n").map((l) => l.split("=")[0]);
25
+ assert.deepEqual(keys, ["context_pct", "model", "updated", "pane", "socket"]);
26
+ assert.match(body, /^context_pct=42$/m);
27
+ assert.match(body, /^model=Qwen3\.8-27B$/m);
28
+ assert.match(body, /^pane=%47$/m);
29
+ assert.match(body, /^socket=proj-pi$/m);
30
+ assert.match(body, /^updated=\d{10}$/m);
31
+ });
32
+
33
+ test("context_pct is written as an integer, never a float", () => {
34
+ const dir = mkdtempSync(join(tmpdir(), "harness-state-"));
35
+ writeState({ ctx, contextPct: 42.7, model: "m", dir });
36
+ assert.match(readFileSync(join(dir, "proj-pi_47"), "utf-8"), /^context_pct=42$/m);
37
+ });
38
+
39
+ test("a newline in the model name cannot forge a second key", () => {
40
+ const dir = mkdtempSync(join(tmpdir(), "harness-state-"));
41
+ writeState({ ctx, contextPct: 1, model: "evil\nupdated=0", dir });
42
+ const body = readFileSync(join(dir, "proj-pi_47"), "utf-8");
43
+ assert.equal(body.match(/^updated=/gm)?.length, 1);
44
+ });
45
+
46
+ test("removeState deletes the file, and is safe when it is already gone", () => {
47
+ const dir = mkdtempSync(join(tmpdir(), "harness-state-"));
48
+ writeState({ ctx, contextPct: 1, model: "m", dir });
49
+ const path = join(dir, "proj-pi_47");
50
+ assert.ok(existsSync(path));
51
+ removeState(ctx, dir);
52
+ assert.ok(!existsSync(path));
53
+ removeState(ctx, dir); // must not throw
54
+ });
package/src/state.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { TmuxContext } from "./tmux.ts";
5
+
6
+ function defaultDir(): string {
7
+ const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
8
+ return join(base, "claude-status");
9
+ }
10
+
11
+ // TWO-SIDED CONTRACT. dotfiles/bin/tmux-claude-context.sh derives the identical
12
+ // key from '#{socket_path}' on the reading side. Change the two together.
13
+ //
14
+ // Keying on the pane alone is what this replaced: pane ids are only unique per
15
+ // server, this machine runs a socket per project, so every server's %NN
16
+ // collided on one file and the bar showed some other socket's context.
17
+ export function stateKey(ctx: TmuxContext): string {
18
+ const raw = `${ctx.socket}_${ctx.pane.replace(/^%/, "")}`;
19
+ return raw.replace(/[^A-Za-z0-9_.-]/g, "_");
20
+ }
21
+
22
+ export function writeState(opts: {
23
+ ctx: TmuxContext;
24
+ contextPct: number;
25
+ model: string;
26
+ dir?: string;
27
+ }): void {
28
+ const dir = opts.dir ?? defaultDir();
29
+ try {
30
+ mkdirSync(dir, { recursive: true });
31
+ // The file is line-oriented key=value and the reader splits on the first
32
+ // '='. A newline in a value would forge a key, so values are flattened.
33
+ const model = opts.model.replace(/[\r\n]+/g, " ");
34
+ const body =
35
+ `context_pct=${Math.trunc(opts.contextPct)}\n` +
36
+ `model=${model}\n` +
37
+ `updated=${Math.floor(Date.now() / 1000)}\n` +
38
+ `pane=${opts.ctx.pane}\n` +
39
+ `socket=${opts.ctx.socket}\n`;
40
+ writeFileSync(join(dir, stateKey(opts.ctx)), body);
41
+ } catch {
42
+ // Best-effort: a status bar is not worth failing a turn over.
43
+ }
44
+ }
45
+
46
+ // Removing this on shutdown is what lets the reader stop guessing. Claude Code
47
+ // has no reliable way to say it is gone, so tmux-claude-context.sh sniffs
48
+ // pane_current_command instead; a pi session cleans up after itself.
49
+ export function removeState(ctx: TmuxContext, dir?: string): void {
50
+ try {
51
+ rmSync(join(dir ?? defaultDir(), stateKey(ctx)), { force: true });
52
+ } catch {
53
+ // Best-effort.
54
+ }
55
+ }
@@ -0,0 +1,28 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveTmux } from "./tmux.ts";
4
+
5
+ test("resolves socket and pane from a real TMUX value", () => {
6
+ const ctx = resolveTmux({ TMUX: "/private/tmp/tmux-501/proj-pi,12345,3", TMUX_PANE: "%47" });
7
+ assert.equal(ctx?.socket, "proj-pi");
8
+ assert.equal(ctx?.pane, "%47");
9
+ });
10
+
11
+ test("returns undefined outside tmux", () => {
12
+ assert.equal(resolveTmux({}), undefined);
13
+ assert.equal(resolveTmux({ TMUX_PANE: "%1" }), undefined);
14
+ assert.equal(resolveTmux({ TMUX: "/tmp/sock,1,0" }), undefined);
15
+ });
16
+
17
+ test("socket is the basename of the first comma field, not the whole path", () => {
18
+ // Pane ids are only unique per server and this machine runs a socket per
19
+ // project, so the socket name is half the state-file key. Taking the path
20
+ // rather than its basename would put slashes in a filename.
21
+ const ctx = resolveTmux({ TMUX: "/very/long/path/to/default,9,0", TMUX_PANE: "%1" });
22
+ assert.equal(ctx?.socket, "default");
23
+ });
24
+
25
+ test("an empty or malformed TMUX yields undefined rather than a junk socket", () => {
26
+ assert.equal(resolveTmux({ TMUX: "", TMUX_PANE: "%1" }), undefined);
27
+ assert.equal(resolveTmux({ TMUX: ",,", TMUX_PANE: "%1" }), undefined);
28
+ });
package/src/tmux.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export type TmuxContext = {
4
+ socket: string;
5
+ pane: string;
6
+ };
7
+
8
+ // $TMUX is "<socket-path>,<pid>,<session>". The socket NAME is the basename of
9
+ // the first field — the full path would put slashes into the state-file key,
10
+ // and the bare pane id would collide across servers, since pane ids are only
11
+ // unique per server and this machine runs one socket per project.
12
+ export function resolveTmux(env: NodeJS.ProcessEnv = process.env): TmuxContext | undefined {
13
+ const tmuxEnv = env.TMUX;
14
+ const pane = env.TMUX_PANE;
15
+ if (!tmuxEnv || !pane) return undefined;
16
+ const socketPath = tmuxEnv.split(",")[0];
17
+ if (!socketPath) return undefined;
18
+ const socket = socketPath.slice(socketPath.lastIndexOf("/") + 1);
19
+ if (!socket) return undefined;
20
+ return { socket, pane };
21
+ }
22
+
23
+ // Every tmux call is best-effort: a harness handler must never fail a session
24
+ // start, a turn, or a shutdown because the terminal did something unexpected.
25
+ export function tmux(socket: string, args: string[]): string | undefined {
26
+ try {
27
+ return execFileSync("tmux", ["-L", socket, ...args], {
28
+ encoding: "utf-8",
29
+ stdio: ["ignore", "pipe", "ignore"],
30
+ }).trim();
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ // The tmux SESSION name for a pane. @harness_session is a session option, not a
37
+ // pane one, because the consumer is a sibling pane (`scratch`) which cannot
38
+ // read the agent's environment.
39
+ export function sessionOfPane(socket: string, pane: string): string | undefined {
40
+ const name = tmux(socket, ["display-message", "-p", "-t", pane, "#{session_name}"]);
41
+ return name === "" ? undefined : name;
42
+ }
43
+
44
+ // The pane's tty, for the bell.
45
+ export function ttyOfPane(socket: string, pane: string): string | undefined {
46
+ const tty = tmux(socket, ["display-message", "-p", "-t", pane, "#{pane_tty}"]);
47
+ return tty === "" ? undefined : tty;
48
+ }