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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tmux-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
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, ["raise", session]);
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
- return { pi, ctx, fire, events: () => [...handlers.keys()], sent };
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 { ringBell as ringBellDefault, raiseAttention as raiseAttentionDefault } from "./bell.ts";
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());