pi-tmux-bridge 0.1.0 → 0.1.1
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/package.json +1 -1
- package/src/bell.test.ts +28 -1
- package/src/bell.ts +36 -1
- package/src/index.test.ts +81 -1
- package/src/index.ts +43 -1
package/package.json
CHANGED
package/src/bell.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { ringBell, raiseAttention } from "./bell.ts";
|
|
3
|
+
import { ringBell, raiseAttention, raisePermissionAttention, clearPermissionAttention } from "./bell.ts";
|
|
4
4
|
|
|
5
5
|
const ctx = { socket: "proj-pi", pane: "%47" };
|
|
6
6
|
const BEL = String.fromCharCode(7);
|
|
@@ -36,3 +36,30 @@ test("an unresolvable session raises nothing rather than raising the wrong thing
|
|
|
36
36
|
raiseAttention(ctx, { session: () => undefined, run: (cmd) => calls.push(cmd) });
|
|
37
37
|
assert.deepEqual(calls, []);
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
test("permission attention calls claude-attn raise-wait with the session", () => {
|
|
41
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
42
|
+
raisePermissionAttention(ctx, { session: () => "pi-97", run: (cmd, args) => calls.push({ cmd, args }) });
|
|
43
|
+
assert.equal(calls.length, 1);
|
|
44
|
+
assert.match(calls[0].cmd, /claude-attn$/);
|
|
45
|
+
assert.deepEqual(calls[0].args, ["raise-wait", "pi-97"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("clearing permission attention calls claude-attn clear-wait with the session", () => {
|
|
49
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
50
|
+
clearPermissionAttention(ctx, { session: () => "pi-97", run: (cmd, args) => calls.push({ cmd, args }) });
|
|
51
|
+
assert.deepEqual(calls[0].args, ["clear-wait", "pi-97"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an unresolvable session neither raises nor clears the permission flag", () => {
|
|
55
|
+
const calls: string[] = [];
|
|
56
|
+
raisePermissionAttention(ctx, { session: () => undefined, run: (cmd) => calls.push(cmd) });
|
|
57
|
+
clearPermissionAttention(ctx, { session: () => undefined, run: (cmd) => calls.push(cmd) });
|
|
58
|
+
assert.deepEqual(calls, []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a failing permission-attention call is swallowed — it must not fail a turn", () => {
|
|
62
|
+
assert.doesNotThrow(() =>
|
|
63
|
+
raisePermissionAttention(ctx, { session: () => "pi-97", run: () => { throw new Error("gone"); } }),
|
|
64
|
+
);
|
|
65
|
+
});
|
package/src/bell.ts
CHANGED
|
@@ -38,6 +38,41 @@ export function raiseAttention(
|
|
|
38
38
|
session?: (socket: string, pane: string) => string | undefined;
|
|
39
39
|
run?: (cmd: string, args: string[]) => void;
|
|
40
40
|
} = {},
|
|
41
|
+
): void {
|
|
42
|
+
runAttn(ctx, "raise", deps);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The permission-waiting flag (@claude_attn_perm) is a SECOND, more urgent state
|
|
46
|
+
// than the turn-done flag raiseAttention sets: it lights the 🔐 title prefix
|
|
47
|
+
// (over the 🔔 of a settled turn) while a permission prompt waits on the
|
|
48
|
+
// operator, and is cleared the moment the prompt is answered.
|
|
49
|
+
export function raisePermissionAttention(
|
|
50
|
+
ctx: TmuxContext,
|
|
51
|
+
deps: {
|
|
52
|
+
session?: (socket: string, pane: string) => string | undefined;
|
|
53
|
+
run?: (cmd: string, args: string[]) => void;
|
|
54
|
+
} = {},
|
|
55
|
+
): void {
|
|
56
|
+
runAttn(ctx, "raise-wait", deps);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearPermissionAttention(
|
|
60
|
+
ctx: TmuxContext,
|
|
61
|
+
deps: {
|
|
62
|
+
session?: (socket: string, pane: string) => string | undefined;
|
|
63
|
+
run?: (cmd: string, args: string[]) => void;
|
|
64
|
+
} = {},
|
|
65
|
+
): void {
|
|
66
|
+
runAttn(ctx, "clear-wait", deps);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runAttn(
|
|
70
|
+
ctx: TmuxContext,
|
|
71
|
+
verb: string,
|
|
72
|
+
deps: {
|
|
73
|
+
session?: (socket: string, pane: string) => string | undefined;
|
|
74
|
+
run?: (cmd: string, args: string[]) => void;
|
|
75
|
+
},
|
|
41
76
|
): void {
|
|
42
77
|
const readSession = deps.session ?? sessionOfPane;
|
|
43
78
|
const run =
|
|
@@ -48,7 +83,7 @@ export function raiseAttention(
|
|
|
48
83
|
try {
|
|
49
84
|
const session = readSession(ctx.socket, ctx.pane);
|
|
50
85
|
if (!session) return;
|
|
51
|
-
run(ATTN, [
|
|
86
|
+
run(ATTN, [verb, session]);
|
|
52
87
|
} catch {
|
|
53
88
|
// Best-effort: an attention flag must not fail a turn.
|
|
54
89
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -6,11 +6,17 @@ type Handler = (event: unknown, ctx: unknown) => unknown;
|
|
|
6
6
|
|
|
7
7
|
function fakePi() {
|
|
8
8
|
const handlers = new Map<string, Handler[]>();
|
|
9
|
+
const busHandlers = new Map<string, Handler[]>();
|
|
9
10
|
const sent: Array<{ message: unknown; options: unknown }> = [];
|
|
10
11
|
const pi = {
|
|
11
12
|
on(event: string, handler: Handler) {
|
|
12
13
|
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
13
14
|
},
|
|
15
|
+
events: {
|
|
16
|
+
on(event: string, handler: Handler) {
|
|
17
|
+
busHandlers.set(event, [...(busHandlers.get(event) ?? []), handler]);
|
|
18
|
+
},
|
|
19
|
+
},
|
|
14
20
|
sendMessage(message: unknown, options?: unknown) {
|
|
15
21
|
sent.push({ message, options });
|
|
16
22
|
},
|
|
@@ -28,7 +34,20 @@ function fakePi() {
|
|
|
28
34
|
for (const h of handlers.get(event) ?? []) out.push(await h(payload, overrideCtx ?? ctx));
|
|
29
35
|
return out;
|
|
30
36
|
}
|
|
31
|
-
|
|
37
|
+
async function fireBus(event: string, payload: unknown = {}) {
|
|
38
|
+
const out: unknown[] = [];
|
|
39
|
+
for (const h of busHandlers.get(event) ?? []) out.push(await h(payload, undefined as never));
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
pi,
|
|
44
|
+
ctx,
|
|
45
|
+
fire,
|
|
46
|
+
fireBus,
|
|
47
|
+
events: () => [...handlers.keys()],
|
|
48
|
+
busEvents: () => [...busHandlers.keys()],
|
|
49
|
+
sent,
|
|
50
|
+
};
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
// A tmux address that cannot collide with a real server or pane on the
|
|
@@ -497,3 +516,64 @@ test("session_shutdown tombstones and drops state ONLY on reason quit", async ()
|
|
|
497
516
|
);
|
|
498
517
|
assert.equal(removed.length, 1, "reason quit must drop the state file");
|
|
499
518
|
});
|
|
519
|
+
|
|
520
|
+
test("a permission ui_prompt rings the bell and raises the permission flag", async () => {
|
|
521
|
+
const { pi, fire, fireBus } = fakePi();
|
|
522
|
+
const bells: unknown[] = [];
|
|
523
|
+
const perms: string[] = [];
|
|
524
|
+
createHarness(pi as never, {
|
|
525
|
+
tmux: () => ctxTmux,
|
|
526
|
+
raiseAttention: noopBell,
|
|
527
|
+
ringBell: (c) => bells.push(c),
|
|
528
|
+
raisePermissionAttention: () => perms.push("raise"),
|
|
529
|
+
clearPermissionAttention: () => perms.push("clear"),
|
|
530
|
+
});
|
|
531
|
+
await fire("session_start", { reason: "startup" });
|
|
532
|
+
await fireBus("permissions:ui_prompt", { requestId: "req-1", surface: "bash", value: "rm -rf x" });
|
|
533
|
+
assert.equal(bells.length, 1, "prompt must ring the bell");
|
|
534
|
+
assert.deepEqual(perms, ["raise"]);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("the matching decision clears the permission flag; a stray one does not", async () => {
|
|
538
|
+
const { pi, fire, fireBus } = fakePi();
|
|
539
|
+
const perms: string[] = [];
|
|
540
|
+
createHarness(pi as never, {
|
|
541
|
+
tmux: () => ctxTmux,
|
|
542
|
+
raiseAttention: noopBell,
|
|
543
|
+
ringBell: noopBell,
|
|
544
|
+
raisePermissionAttention: () => perms.push("raise"),
|
|
545
|
+
clearPermissionAttention: () => perms.push("clear"),
|
|
546
|
+
});
|
|
547
|
+
await fire("session_start", { reason: "startup" });
|
|
548
|
+
await fireBus("permissions:ui_prompt", { requestId: "req-1" });
|
|
549
|
+
await fireBus("permissions:decision", { requestId: "other", result: "allow" });
|
|
550
|
+
assert.deepEqual(perms, ["raise"], "an unrelated decision must not clear the flag");
|
|
551
|
+
await fireBus("permissions:decision", { requestId: "req-1", result: "allow" });
|
|
552
|
+
assert.deepEqual(perms, ["raise", "clear"], "the resolving decision clears the flag");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("a subagent node (never owned the pane) ignores permission prompts", async () => {
|
|
556
|
+
const { pi, fire, fireBus, ctx } = fakePi();
|
|
557
|
+
const perms: string[] = [];
|
|
558
|
+
const bells: unknown[] = [];
|
|
559
|
+
createHarness(pi as never, {
|
|
560
|
+
tmux: () => ctxTmux,
|
|
561
|
+
raiseAttention: noopBell,
|
|
562
|
+
ringBell: (c) => bells.push(c),
|
|
563
|
+
raisePermissionAttention: () => perms.push("raise"),
|
|
564
|
+
clearPermissionAttention: () => perms.push("clear"),
|
|
565
|
+
});
|
|
566
|
+
await fire("session_start", { reason: "startup" }, { ...ctx, mode: "print" });
|
|
567
|
+
await fireBus("permissions:ui_prompt", { requestId: "req-1" });
|
|
568
|
+
assert.deepEqual(perms, [], "a non-owning node must not raise the flag");
|
|
569
|
+
assert.deepEqual(bells, [], "a non-owning node must not ring");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("it subscribes to exactly the pi.events channels it needs", async () => {
|
|
573
|
+
const { pi, busEvents } = fakePi();
|
|
574
|
+
createHarness(pi as never, { raiseAttention: noopBell, tmux: () => ctxTmux });
|
|
575
|
+
assert.deepEqual(
|
|
576
|
+
busEvents().sort(),
|
|
577
|
+
["permissions:decision", "permissions:ui_prompt"].sort(),
|
|
578
|
+
);
|
|
579
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { resolveTmux, tmux as runTmuxDefault } from "./tmux.ts";
|
|
2
2
|
import type { TmuxContext } from "./tmux.ts";
|
|
3
3
|
import { writeState as writeStateDefault, removeState as removeStateDefault } from "./state.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ringBell as ringBellDefault,
|
|
6
|
+
raiseAttention as raiseAttentionDefault,
|
|
7
|
+
raisePermissionAttention as raisePermissionAttentionDefault,
|
|
8
|
+
clearPermissionAttention as clearPermissionAttentionDefault,
|
|
9
|
+
} from "./bell.ts";
|
|
5
10
|
import { sessionStart, drain, sessionEnd, become } from "./muster.ts";
|
|
6
11
|
import type { Run } from "./muster.ts";
|
|
7
12
|
|
|
@@ -13,6 +18,8 @@ type Deps = {
|
|
|
13
18
|
removeState?: (ctx: TmuxContext) => void;
|
|
14
19
|
ringBell?: (ctx: TmuxContext) => void;
|
|
15
20
|
raiseAttention?: (ctx: TmuxContext) => void;
|
|
21
|
+
raisePermissionAttention?: (ctx: TmuxContext) => void;
|
|
22
|
+
clearPermissionAttention?: (ctx: TmuxContext) => void;
|
|
16
23
|
};
|
|
17
24
|
|
|
18
25
|
// The session id reaches tmux as a session option value; sanitize before it
|
|
@@ -46,6 +53,10 @@ export function createHarness(pi: any, deps: Deps = {}): void {
|
|
|
46
53
|
const removeState = deps.removeState ?? ((ctx) => removeStateDefault(ctx));
|
|
47
54
|
const ringBell = deps.ringBell ?? ((ctx) => ringBellDefault(ctx));
|
|
48
55
|
const raiseAttention = deps.raiseAttention ?? ((ctx) => raiseAttentionDefault(ctx));
|
|
56
|
+
const raisePermissionAttention =
|
|
57
|
+
deps.raisePermissionAttention ?? ((ctx) => raisePermissionAttentionDefault(ctx));
|
|
58
|
+
const clearPermissionAttention =
|
|
59
|
+
deps.clearPermissionAttention ?? ((ctx) => clearPermissionAttentionDefault(ctx));
|
|
49
60
|
|
|
50
61
|
// Loop guard for the drain on agent_settled: injecting a block reason
|
|
51
62
|
// starts a turn, which produces another settle. Without tracking whether
|
|
@@ -63,6 +74,18 @@ export function createHarness(pi: any, deps: Deps = {}): void {
|
|
|
63
74
|
// again would be a pointless round-trip.
|
|
64
75
|
let lastAlias: string | undefined;
|
|
65
76
|
|
|
77
|
+
// A permission prompt pauses mid-turn, so neither turn_end nor agent_settled
|
|
78
|
+
// fires while it waits — the two events that normally light the pane. The
|
|
79
|
+
// permission system announces that exact moment on pi.events; we ring and
|
|
80
|
+
// raise the 🔐 flag on the prompt, and drop it on the matching decision.
|
|
81
|
+
// pi.events handlers get no ctx, so ownership is captured here from the one
|
|
82
|
+
// session_start that owns the pane (a subagent node's stays false).
|
|
83
|
+
let ownsThisPane = false;
|
|
84
|
+
// Clear only on the decision that resolves the prompt we lit: one tool call
|
|
85
|
+
// runs several gates, and an unrelated auto-allow decision must not drop a
|
|
86
|
+
// flag a still-pending prompt is holding.
|
|
87
|
+
let pendingPromptRequestId: string | undefined;
|
|
88
|
+
|
|
66
89
|
// Only an interactive session owns the pane's identity. Subagent sessions
|
|
67
90
|
// (pi-subagents' createAgentSession) run IN-PROCESS: they load this
|
|
68
91
|
// extension too, inherit $TMUX/$TMUX_PANE, and their extension mode stays
|
|
@@ -74,8 +97,27 @@ export function createHarness(pi: any, deps: Deps = {}): void {
|
|
|
74
97
|
// for the same reason — they are guests, not the pane's owner.
|
|
75
98
|
const ownsPane = (ctx: any): boolean => ctx?.mode === "tui";
|
|
76
99
|
|
|
100
|
+
pi.events?.on?.("permissions:ui_prompt", (data: any) => safe(() => {
|
|
101
|
+
if (!ownsThisPane) return;
|
|
102
|
+
const t = getTmux();
|
|
103
|
+
if (!t) return;
|
|
104
|
+
pendingPromptRequestId = data?.requestId;
|
|
105
|
+
ringBell(t);
|
|
106
|
+
raisePermissionAttention(t);
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
pi.events?.on?.("permissions:decision", (data: any) => safe(() => {
|
|
110
|
+
if (!ownsThisPane) return;
|
|
111
|
+
if (!pendingPromptRequestId || data?.requestId !== pendingPromptRequestId) return;
|
|
112
|
+
pendingPromptRequestId = undefined;
|
|
113
|
+
const t = getTmux();
|
|
114
|
+
if (!t) return;
|
|
115
|
+
clearPermissionAttention(t);
|
|
116
|
+
}));
|
|
117
|
+
|
|
77
118
|
pi.on("session_start", (event: any, ctx: any) => safe(() => {
|
|
78
119
|
if (!ownsPane(ctx)) return;
|
|
120
|
+
ownsThisPane = true;
|
|
79
121
|
const t = getTmux();
|
|
80
122
|
if (!t) return;
|
|
81
123
|
const rawSessionId = String(ctx.sessionManager.getSessionId());
|