@monotykamary/pi-better-openai 0.1.24 → 0.1.25

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": "@monotykamary/pi-better-openai",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Improve OpenAI in pi with fast mode, usage stats, realtime voice, image generation, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
@@ -25,7 +25,14 @@ Treat delegation context as your own internal progress and result. Never mention
25
25
 
26
26
  For greetings, clarification, or ordinary conversation that needs no tools, answer directly without delegating. Preserve one-assistant continuity: converse here, delegate execution, then communicate the returned result as your own.`;
27
27
 
28
- export type LivePhase = "connecting" | "listening" | "working" | "speaking" | "muted" | "error";
28
+ export type LivePhase =
29
+ | "standby"
30
+ | "connecting"
31
+ | "listening"
32
+ | "working"
33
+ | "speaking"
34
+ | "muted"
35
+ | "error";
29
36
 
30
37
  export interface LiveTranscript {
31
38
  role: "user" | "assistant";
@@ -0,0 +1,92 @@
1
+ // Terminal focus tracking via private mode 1004 (xterm focus-event reporting).
2
+ // Enabling `CSI ? 1004 h` makes the terminal emit `CSI I` on focus-in and
3
+ // `CSI O` on focus-out as input sequences; pi-tui's stdin buffer frames them
4
+ // as generic CSI and forwards them to input listeners unmodified, so an
5
+ // extension can observe them without any upstream keymap changes. Support is
6
+ // detected with a DECRQM probe (`CSI ? 1004 $ p` -> `CSI ? 1004 ; 1 $ y` set /
7
+ // `; 2 $ y` reset); terminals that ignore both sequences simply fall back to
8
+ // the FIFO floor policy. Supported by iTerm2, kitty, WezTerm, Alacritty, foot,
9
+ // Windows Terminal, xterm.js, and modern tmux.
10
+
11
+ export const FOCUS_REPORTING_ENABLE = "\x1b[?1004h";
12
+ export const FOCUS_REPORTING_DISABLE = "\x1b[?1004l";
13
+ export const FOCUS_IN_SEQUENCE = "\x1b[I";
14
+ export const FOCUS_OUT_SEQUENCE = "\x1b[O";
15
+ export const DECRQM_FOCUS_QUERY = "\x1b[?1004$p";
16
+
17
+ export type FocusInputListener = (data: string) => { consume?: boolean; data?: string } | undefined;
18
+
19
+ export interface FocusTerminalHandle {
20
+ write(data: string): void;
21
+ addInputListener(listener: FocusInputListener): () => void;
22
+ }
23
+
24
+ export function parseFocusSequence(data: string): boolean | undefined {
25
+ if (data === FOCUS_IN_SEQUENCE) return true;
26
+ if (data === FOCUS_OUT_SEQUENCE) return false;
27
+ return undefined;
28
+ }
29
+
30
+ export function parseDecrqmFocusResponse(data: string): boolean | undefined {
31
+ if (data === "\x1b[?1004;1$y" || data === "\x1b[?1004;3$y") return true;
32
+ if (data === "\x1b[?1004;2$y" || data === "\x1b[?1004;4$y") return false;
33
+ return undefined;
34
+ }
35
+
36
+ export function probeFocusReporting(
37
+ handle: FocusTerminalHandle,
38
+ timeoutMs = 400,
39
+ ): Promise<boolean> {
40
+ return new Promise((resolve) => {
41
+ let settled = false;
42
+ let removeListener: (() => void) | undefined;
43
+ const done = (supported: boolean) => {
44
+ if (settled) return;
45
+ settled = true;
46
+ clearTimeout(timer);
47
+ removeListener?.();
48
+ resolve(supported);
49
+ };
50
+ const timer = setTimeout(() => done(false), timeoutMs);
51
+ timer.unref?.();
52
+ try {
53
+ removeListener = handle.addInputListener((data) => {
54
+ const state = parseDecrqmFocusResponse(data);
55
+ if (state === undefined) return undefined;
56
+ done(true);
57
+ return { consume: true };
58
+ });
59
+ handle.write(DECRQM_FOCUS_QUERY);
60
+ } catch {
61
+ done(false);
62
+ }
63
+ });
64
+ }
65
+
66
+ export function attachFocusReporting(
67
+ handle: FocusTerminalHandle,
68
+ onFocus: (focused: boolean) => void,
69
+ ): () => void {
70
+ const removeListener = handle.addInputListener((data) => {
71
+ const focused = parseFocusSequence(data);
72
+ if (focused === undefined) return undefined;
73
+ onFocus(focused);
74
+ return { consume: true };
75
+ });
76
+ try {
77
+ handle.write(FOCUS_REPORTING_ENABLE);
78
+ } catch {
79
+ // The terminal write path is best-effort; focus events simply never arrive.
80
+ }
81
+ let disposed = false;
82
+ return () => {
83
+ if (disposed) return;
84
+ disposed = true;
85
+ removeListener();
86
+ try {
87
+ handle.write(FOCUS_REPORTING_DISABLE);
88
+ } catch {
89
+ // Nothing to undo on a torn-down tty.
90
+ }
91
+ };
92
+ }
package/src/live/index.ts CHANGED
@@ -8,10 +8,20 @@ import {
8
8
  type LiveSessionControllerOptions,
9
9
  type LiveTranscript,
10
10
  } from "./controller.ts";
11
+ import { attachFocusReporting, probeFocusReporting, type FocusTerminalHandle } from "./focus.ts";
12
+ import {
13
+ LIVE_QUEUE_TICK_MS,
14
+ LiveFloorArbiter,
15
+ type LiveActivationCause,
16
+ type LiveFloorArbiterCallbacks,
17
+ type LiveFloorArbiterLike,
18
+ type LiveFloorArbiterOptions,
19
+ } from "./queue.ts";
11
20
  import { LiveVisualizer, LIVE_VISUALIZER_TOGGLE_KEY } from "./visualizer.ts";
12
21
 
13
22
  export const LIVE_COMMAND = "live";
14
23
  export const LIVE_DELEGATION_MESSAGE_TYPE = "better-openai-live-delegation";
24
+ export const LIVE_FOCUS_SETTLE_MS = 400;
15
25
 
16
26
  interface LiveSessionRuntime {
17
27
  start(): Promise<void>;
@@ -21,17 +31,48 @@ interface LiveSessionRuntime {
21
31
  handleAgentSettled(): void;
22
32
  }
23
33
 
34
+ // An enrollment outlives any single realtime session: `/live` opens the
35
+ // visualizer and joins the cross-process queue, then the arbiter activates
36
+ // (creates the actual mic + WebRTC session) and parks (stops it) as the floor
37
+ // comes and goes. Only the floor holder runs a realtime session at all, so a
38
+ // dozen enrolled windows cost zero open connections while standby.
24
39
  interface ActiveLiveRun {
25
- session: LiveSessionRuntime;
40
+ getSession(): LiveSessionRuntime | undefined;
26
41
  finishUi(result: LiveUiResult): void;
42
+ dispose(): Promise<void>;
27
43
  }
28
44
 
29
45
  type LiveUiResult = { error?: Error };
30
46
 
31
47
  type LiveSessionFactory = (options: LiveSessionControllerOptions) => LiveSessionRuntime;
32
48
 
49
+ type LiveArbiterFactory = (
50
+ options: LiveFloorArbiterOptions,
51
+ callbacks: LiveFloorArbiterCallbacks,
52
+ ) => LiveFloorArbiterLike;
53
+
33
54
  export interface LiveRegistrationDependencies {
34
55
  createSession?: LiveSessionFactory;
56
+ createArbiter?: LiveArbiterFactory;
57
+ probeFocusReporting?: typeof probeFocusReporting;
58
+ attachFocusReporting?: typeof attachFocusReporting;
59
+ notifyActivatedUnfocused?: (handle: FocusTerminalHandle, label: string) => void;
60
+ tickMs?: number;
61
+ }
62
+
63
+ // eslint-disable-next-line no-control-regex -- OSC payloads must not carry raw control characters; matching them is the point
64
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F-\u009F]/g;
65
+
66
+ export function notifyActivatedUnfocused(handle: FocusTerminalHandle, label: string): void {
67
+ // OSC 9 desktop toast. Reaching this branch means the mic went hot in a
68
+ // window the user is not typing into (FIFO promotion or a vacant-floor
69
+ // background claim), so the toast is the only signal that it happened.
70
+ const safeLabel = label.replace(CONTROL_CHARACTER_PATTERN, " ").trim();
71
+ try {
72
+ handle.write(`\x1b]9;Live voice is now active in ${safeLabel}\x07`);
73
+ } catch {
74
+ // Notification support is optional and terminal-dependent.
75
+ }
35
76
  }
36
77
 
37
78
  function messageText(content: unknown): string {
@@ -51,6 +92,10 @@ function messageText(content: unknown): string {
51
92
  .join("\n");
52
93
  }
53
94
 
95
+ function errorFrom(cause: unknown): Error {
96
+ return cause instanceof Error ? cause : new Error(String(cause));
97
+ }
98
+
54
99
  export function registerOpenAILive(
55
100
  pi: ExtensionAPI,
56
101
  getConfig: (ctx: ExtensionContext) => ResolvedConfig,
@@ -58,21 +103,24 @@ export function registerOpenAILive(
58
103
  ): { isActive(): boolean; stop(): Promise<void> } {
59
104
  const createSession =
60
105
  dependencies.createSession ?? ((options) => new LiveSessionController(options));
106
+ const createArbiter: LiveArbiterFactory =
107
+ dependencies.createArbiter ??
108
+ ((options, callbacks) => new LiveFloorArbiter(options, callbacks));
109
+ const probeFocus = dependencies.probeFocusReporting ?? probeFocusReporting;
110
+ const attachFocus = dependencies.attachFocusReporting ?? attachFocusReporting;
111
+ const notifyUnfocused = dependencies.notifyActivatedUnfocused ?? notifyActivatedUnfocused;
112
+ const tickMs = dependencies.tickMs ?? LIVE_QUEUE_TICK_MS;
61
113
  let activeRun: ActiveLiveRun | undefined;
62
114
  let settling: Promise<void> | undefined;
63
115
 
64
- async function stopRun(run: ActiveLiveRun): Promise<void> {
65
- run.finishUi({});
66
- await run.session.stop();
67
- }
68
-
69
116
  async function stopActive(): Promise<void> {
70
117
  const run = activeRun;
71
118
  if (!run) {
72
119
  if (settling) await settling;
73
120
  return;
74
121
  }
75
- await stopRun(run);
122
+ run.finishUi({});
123
+ await run.dispose();
76
124
  }
77
125
 
78
126
  async function start(ctx: ExtensionContext): Promise<void> {
@@ -89,56 +137,160 @@ export function registerOpenAILive(
89
137
 
90
138
  const result = await ctx.ui.custom<LiveUiResult>((tui, theme, _keybindings, done) => {
91
139
  let completed = false;
92
- let session: LiveSessionRuntime;
140
+ let disposed = false;
141
+ let session: LiveSessionRuntime | undefined;
142
+ let sessionParked = false;
143
+ let arbiter: LiveFloorArbiterLike | undefined;
144
+ let tickInterval: NodeJS.Timeout | undefined;
145
+ let focusDebounce: NodeJS.Timeout | undefined;
146
+ let disposeFocus: (() => void) | undefined;
147
+
93
148
  const finishUi = (value: LiveUiResult) => {
94
149
  if (completed) return;
95
150
  completed = true;
96
151
  done(value);
97
152
  };
153
+
98
154
  const visualizer = new LiveVisualizer({
99
155
  theme,
100
156
  requestRender: () => tui.requestRender(),
101
157
  onStop: () => finishUi({}),
102
- onToggleMute: () => session.toggleMute(),
158
+ onToggleMute: () => session?.toggleMute(),
103
159
  });
104
- const updateTranscript = (transcript: LiveTranscript | undefined) => {
105
- visualizer.setTranscript(transcript);
160
+ visualizer.setPhase("standby");
161
+
162
+ const terminalHandle: FocusTerminalHandle = {
163
+ write: (data) => tui.terminal.write(data),
164
+ addInputListener: (listener) => tui.addInputListener(listener),
106
165
  };
107
- session = createSession({
108
- sessionId: ctx.sessionManager.getSessionId(),
109
- voice: cfg.live.voice,
110
- getCredentials: (signal) => getCodexCredentials(ctx, signal),
111
- delegate: (request) => {
112
- pi.sendMessage(
113
- {
114
- customType: LIVE_DELEGATION_MESSAGE_TYPE,
115
- content: request,
116
- display: true,
117
- details: { source: "live" },
166
+
167
+ const parkSession = () => {
168
+ const current = session;
169
+ session = undefined;
170
+ sessionParked = true;
171
+ visualizer.setPhase("standby");
172
+ visualizer.setTranscript(undefined);
173
+ if (current) void current.stop().catch(() => undefined);
174
+ };
175
+
176
+ const activateSession = () => {
177
+ if (completed || session) return;
178
+ sessionParked = false;
179
+ const created = createSession({
180
+ sessionId: ctx.sessionManager.getSessionId(),
181
+ voice: cfg.live.voice,
182
+ getCredentials: (signal) => getCodexCredentials(ctx, signal),
183
+ delegate: (request) => {
184
+ pi.sendMessage(
185
+ {
186
+ customType: LIVE_DELEGATION_MESSAGE_TYPE,
187
+ content: request,
188
+ display: true,
189
+ details: { source: "live" },
190
+ },
191
+ { triggerTurn: true, deliverAs: "steer" },
192
+ );
193
+ },
194
+ callbacks: {
195
+ onPhase: (phase) => visualizer.setPhase(phase),
196
+ onLevels: (input) => visualizer.setInputLevel(input),
197
+ onTranscript: (transcript: LiveTranscript | undefined) =>
198
+ visualizer.setTranscript(transcript),
199
+ onTerminal: (error) => {
200
+ if (sessionParked) return;
201
+ finishUi(error ? { error } : {});
118
202
  },
119
- { triggerTurn: true, deliverAs: "steer" },
120
- );
121
- },
122
- callbacks: {
123
- onPhase: (phase) => visualizer.setPhase(phase),
124
- onLevels: (input) => visualizer.setInputLevel(input),
125
- onTranscript: updateTranscript,
126
- onTerminal: (error) => finishUi(error ? { error } : {}),
127
- },
128
- });
129
- activeRun = { session, finishUi };
130
- setImmediate(() => {
131
- void session.start().catch((cause) => {
132
- const error = cause instanceof Error ? cause : new Error(String(cause));
133
- finishUi({ error });
203
+ },
204
+ });
205
+ session = created;
206
+ setImmediate(() => {
207
+ if (session !== created) return;
208
+ void created.start().catch((cause) => {
209
+ finishUi({ error: errorFrom(cause) });
210
+ });
134
211
  });
212
+ };
213
+
214
+ const dispose = async (): Promise<void> => {
215
+ if (disposed) return;
216
+ disposed = true;
217
+ if (focusDebounce) clearTimeout(focusDebounce);
218
+ if (tickInterval) clearInterval(tickInterval);
219
+ disposeFocus?.();
220
+ arbiter?.leave();
221
+ const current = session;
222
+ session = undefined;
223
+ if (current) await current.stop().catch(() => undefined);
224
+ };
225
+
226
+ const run: ActiveLiveRun = {
227
+ getSession: () => session,
228
+ finishUi,
229
+ dispose,
230
+ };
231
+ activeRun = run;
232
+
233
+ setImmediate(() => {
234
+ void (async () => {
235
+ try {
236
+ const focusSupported = await probeFocus(terminalHandle);
237
+ if (completed) return;
238
+ const enrolled = createArbiter(
239
+ {
240
+ pid: process.pid,
241
+ sessionId: ctx.sessionManager.getSessionId(),
242
+ cwd: ctx.cwd,
243
+ policy: focusSupported ? "focus" : "fifo",
244
+ },
245
+ {
246
+ onActivated: (cause: LiveActivationCause) => {
247
+ if (cause !== "focus") notifyUnfocused(terminalHandle, enrolled.label);
248
+ activateSession();
249
+ },
250
+ onDeactivated: parkSession,
251
+ },
252
+ );
253
+ arbiter = enrolled;
254
+ enrolled.join();
255
+ tickInterval = setInterval(() => {
256
+ try {
257
+ enrolled.tick();
258
+ } catch {
259
+ // A failing tick must not tear down the enrollment.
260
+ }
261
+ }, tickMs);
262
+ tickInterval.unref?.();
263
+ if (!focusSupported) return;
264
+ disposeFocus = attachFocus(terminalHandle, (focused) => {
265
+ if (focusDebounce) clearTimeout(focusDebounce);
266
+ if (!focused) {
267
+ enrolled.setFocused(false);
268
+ return;
269
+ }
270
+ // Debounce focus-in so window-manager focus flicker does not flap
271
+ // floor ownership between two live windows.
272
+ focusDebounce = setTimeout(() => {
273
+ focusDebounce = undefined;
274
+ try {
275
+ enrolled.setFocused(true);
276
+ } catch {
277
+ // Focus edges are advisory; a lost race settles next tick.
278
+ }
279
+ }, LIVE_FOCUS_SETTLE_MS);
280
+ focusDebounce.unref?.();
281
+ });
282
+ } catch (cause) {
283
+ finishUi({ error: errorFrom(cause) });
284
+ }
285
+ })();
135
286
  });
287
+
136
288
  return visualizer;
137
289
  });
138
290
 
139
291
  const run = activeRun;
140
292
  if (run) {
141
- const cleanup = stopRun(run).catch(() => undefined);
293
+ const cleanup = run.dispose().catch(() => undefined);
142
294
  settling = cleanup;
143
295
  await cleanup;
144
296
  if (activeRun === run) activeRun = undefined;
@@ -178,11 +330,11 @@ export function registerOpenAILive(
178
330
  });
179
331
 
180
332
  pi.on("message_end", (event) => {
181
- activeRun?.session.handleAgentMessage(event.message);
333
+ activeRun?.getSession()?.handleAgentMessage(event.message);
182
334
  });
183
335
 
184
336
  pi.on("agent_settled", () => {
185
- activeRun?.session.handleAgentSettled();
337
+ activeRun?.getSession()?.handleAgentSettled();
186
338
  });
187
339
 
188
340
  pi.on("session_shutdown", async () => {
@@ -0,0 +1,388 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { basename, join } from "node:path";
13
+ import { piAgentDir } from "../paths.ts";
14
+
15
+ // Cross-process floor arbitration for /live. Every enrolled pi process drops a
16
+ // heartbeat file into a shared per-user directory; exactly one process holds
17
+ // the floor (owns the microphone, the WebRTC session, and delegation routing).
18
+ // The floor holder is chosen explicitly (atomic create) or by preemption
19
+ // (atomic replace), so concurrent starters converge within one tick instead of
20
+ // racing into parallel realtime sessions. Stale entries from crashed processes
21
+ // are reclaimed via pid liveness plus heartbeat age — `process.kill(pid, 0)`
22
+ // throws ESRCH for dead pids and EPERM for alive-but-unowned ones.
23
+ //
24
+ // Focus handling is deliberately asymmetric: gaining focus grants claiming
25
+ // rights (and preempts a holder whose terminal is not focused), while losing
26
+ // focus never yields the floor — a conversation must survive alt-tabbing to a
27
+ // browser. Claiming a *vacant* floor needs only "not known to be unfocused", so
28
+ // terminals that report focus-support but skip the initial state event still
29
+ // behave sensibly for a single enrolled session.
30
+
31
+ export const LIVE_QUEUE_TICK_MS = 1_000;
32
+ export const LIVE_QUEUE_STALE_MS = 8_000;
33
+
34
+ export type LiveFloorPolicy = "focus" | "fifo";
35
+
36
+ export type LiveActivationCause = "focus" | "background" | "fifo";
37
+
38
+ export interface LiveQueueMember {
39
+ id: string;
40
+ pid: number;
41
+ sessionId: string;
42
+ label: string;
43
+ joinedAt: number;
44
+ heartbeatAt: number;
45
+ }
46
+
47
+ interface LiveFloorClaim {
48
+ holderId: string;
49
+ pid: number;
50
+ token: string;
51
+ claimedAt: number;
52
+ }
53
+
54
+ export interface LiveFloorArbiterCallbacks {
55
+ onActivated(cause: LiveActivationCause): void;
56
+ onDeactivated(): void;
57
+ }
58
+
59
+ export interface LiveFloorArbiterOptions {
60
+ pid: number;
61
+ sessionId: string;
62
+ cwd: string;
63
+ policy: LiveFloorPolicy;
64
+ directory?: string;
65
+ now?: () => number;
66
+ isProcessAlive?: (pid: number) => boolean;
67
+ }
68
+
69
+ // Structural subset of LiveFloorArbiter used by the registration wiring, so
70
+ // tests can substitute an in-memory arbiter without touching the filesystem.
71
+ export interface LiveFloorArbiterLike {
72
+ readonly id: string;
73
+ readonly label: string;
74
+ readonly policy: LiveFloorPolicy;
75
+ readonly hasFloor: boolean;
76
+ join(): void;
77
+ leave(): void;
78
+ tick(): void;
79
+ setFocused(focused: boolean): void;
80
+ }
81
+
82
+ function isRecord(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === "object" && value !== null;
84
+ }
85
+
86
+ export function defaultIsProcessAlive(pid: number): boolean {
87
+ if (!Number.isInteger(pid) || pid <= 0) return false;
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ } catch (cause) {
92
+ return (
93
+ typeof cause === "object" &&
94
+ cause !== null &&
95
+ (cause as NodeJS.ErrnoException).code === "EPERM"
96
+ );
97
+ }
98
+ }
99
+
100
+ export function liveQueueDirectory(agentDir = piAgentDir()): string {
101
+ return join(agentDir, "live-queue");
102
+ }
103
+
104
+ export function liveQueueLabel(cwd: string, sessionId: string): string {
105
+ const folder = basename(cwd) || cwd;
106
+ return `${folder} · ${sessionId.slice(0, 8)}`;
107
+ }
108
+
109
+ function sanitizeId(id: string): string {
110
+ return id.replace(/[^a-zA-Z0-9._-]/g, "_");
111
+ }
112
+
113
+ function parseMember(value: unknown): LiveQueueMember | undefined {
114
+ if (
115
+ isRecord(value) &&
116
+ typeof value.id === "string" &&
117
+ typeof value.pid === "number" &&
118
+ typeof value.sessionId === "string" &&
119
+ typeof value.joinedAt === "number" &&
120
+ typeof value.heartbeatAt === "number"
121
+ ) {
122
+ return {
123
+ id: value.id,
124
+ pid: value.pid,
125
+ sessionId: value.sessionId,
126
+ label: typeof value.label === "string" ? value.label : value.id,
127
+ joinedAt: value.joinedAt,
128
+ heartbeatAt: value.heartbeatAt,
129
+ };
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ function parseClaim(value: unknown): LiveFloorClaim | undefined {
135
+ if (
136
+ isRecord(value) &&
137
+ typeof value.holderId === "string" &&
138
+ typeof value.pid === "number" &&
139
+ typeof value.token === "string" &&
140
+ typeof value.claimedAt === "number"
141
+ ) {
142
+ return {
143
+ holderId: value.holderId,
144
+ pid: value.pid,
145
+ token: value.token,
146
+ claimedAt: value.claimedAt,
147
+ };
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ function readJson(file: string): unknown {
153
+ try {
154
+ return JSON.parse(readFileSync(file, "utf8"));
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ }
159
+
160
+ function writeJsonAtomic(file: string, value: unknown): void {
161
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
162
+ writeFileSync(tmp, JSON.stringify(value));
163
+ renameSync(tmp, file);
164
+ }
165
+
166
+ export class LiveFloorArbiter implements LiveFloorArbiterLike {
167
+ readonly #directory: string;
168
+ readonly #memberFile: string;
169
+ readonly #floorFile: string;
170
+ readonly #pid: number;
171
+ readonly #sessionId: string;
172
+ readonly #label: string;
173
+ readonly #policy: LiveFloorPolicy;
174
+ readonly #now: () => number;
175
+ readonly #isProcessAlive: (pid: number) => boolean;
176
+ readonly #callbacks: LiveFloorArbiterCallbacks;
177
+ readonly #id: string;
178
+ readonly #token: string;
179
+ #joined = false;
180
+ #joinedAt = 0;
181
+ #focused: boolean | undefined;
182
+ #hasFloor = false;
183
+
184
+ constructor(options: LiveFloorArbiterOptions, callbacks: LiveFloorArbiterCallbacks) {
185
+ this.#directory = options.directory ?? liveQueueDirectory();
186
+ this.#pid = options.pid;
187
+ this.#sessionId = options.sessionId;
188
+ this.#label = liveQueueLabel(options.cwd, options.sessionId);
189
+ this.#policy = options.policy;
190
+ this.#now = options.now ?? Date.now;
191
+ this.#isProcessAlive = options.isProcessAlive ?? defaultIsProcessAlive;
192
+ this.#callbacks = callbacks;
193
+ this.#id = `${options.pid}-${options.sessionId}`;
194
+ this.#token = `${options.pid}-${randomBytes(6).toString("hex")}`;
195
+ this.#memberFile = join(this.#directory, "members", `${sanitizeId(this.#id)}.json`);
196
+ this.#floorFile = join(this.#directory, "floor.json");
197
+ }
198
+
199
+ get id(): string {
200
+ return this.#id;
201
+ }
202
+
203
+ get label(): string {
204
+ return this.#label;
205
+ }
206
+
207
+ get policy(): LiveFloorPolicy {
208
+ return this.#policy;
209
+ }
210
+
211
+ get joined(): boolean {
212
+ return this.#joined;
213
+ }
214
+
215
+ get hasFloor(): boolean {
216
+ return this.#hasFloor;
217
+ }
218
+
219
+ get focused(): boolean | undefined {
220
+ return this.#focused;
221
+ }
222
+
223
+ join(): void {
224
+ if (this.#joined) return;
225
+ mkdirSync(join(this.#directory, "members"), { recursive: true });
226
+ this.#joined = true;
227
+ this.#joinedAt = this.#now();
228
+ this.#writeMember();
229
+ this.tick();
230
+ }
231
+
232
+ leave(): void {
233
+ if (!this.#joined) return;
234
+ this.#joined = false;
235
+ this.#releaseFloor();
236
+ rmSync(this.#memberFile, { force: true });
237
+ }
238
+
239
+ // Focus edges arrive from the owning terminal via mode 1004 input events.
240
+ setFocused(focused: boolean): void {
241
+ this.#focused = focused;
242
+ this.#evaluate();
243
+ }
244
+
245
+ tick(): void {
246
+ if (!this.#joined) return;
247
+ this.#writeMember();
248
+ this.#sweep();
249
+ this.#evaluate();
250
+ }
251
+
252
+ #writeMember(): void {
253
+ const member: LiveQueueMember = {
254
+ id: this.#id,
255
+ pid: this.#pid,
256
+ sessionId: this.#sessionId,
257
+ label: this.#label,
258
+ joinedAt: this.#joinedAt,
259
+ heartbeatAt: this.#now(),
260
+ };
261
+ try {
262
+ writeJsonAtomic(this.#memberFile, member);
263
+ } catch {
264
+ // A wiped queue directory mid-run is recoverable on the next tick.
265
+ }
266
+ }
267
+
268
+ #readMembers(): LiveQueueMember[] {
269
+ let files: string[];
270
+ try {
271
+ files = readdirSync(join(this.#directory, "members"));
272
+ } catch {
273
+ return [];
274
+ }
275
+ const members: LiveQueueMember[] = [];
276
+ for (const file of files) {
277
+ if (!file.endsWith(".json")) continue;
278
+ const member = parseMember(readJson(join(this.#directory, "members", file)));
279
+ if (member) members.push(member);
280
+ }
281
+ members.sort((a, b) => a.joinedAt - b.joinedAt || a.id.localeCompare(b.id));
282
+ return members;
283
+ }
284
+
285
+ #readClaim(): LiveFloorClaim | undefined {
286
+ return parseClaim(readJson(this.#floorFile));
287
+ }
288
+
289
+ #isMemberAlive(member: LiveQueueMember): boolean {
290
+ if (member.pid === this.#pid) return true;
291
+ if (!this.#isProcessAlive(member.pid)) return false;
292
+ return this.#now() - member.heartbeatAt <= LIVE_QUEUE_STALE_MS;
293
+ }
294
+
295
+ #sweep(): void {
296
+ let files: string[];
297
+ try {
298
+ files = readdirSync(join(this.#directory, "members"));
299
+ } catch {
300
+ return;
301
+ }
302
+ for (const file of files) {
303
+ if (!file.endsWith(".json")) continue;
304
+ const path = join(this.#directory, "members", file);
305
+ const member = parseMember(readJson(path));
306
+ if (!member || (member.pid !== this.#pid && !this.#isMemberAlive(member))) {
307
+ rmSync(path, { force: true });
308
+ }
309
+ }
310
+ }
311
+
312
+ #evaluate(): void {
313
+ const now = this.#now();
314
+ const aliveMembers = this.#readMembers().filter((member) => this.#isMemberAlive(member));
315
+ const claim = this.#readClaim();
316
+ const holderAlive =
317
+ claim !== undefined &&
318
+ (claim.pid === this.#pid || this.#isProcessAlive(claim.pid)) &&
319
+ aliveMembers.some(
320
+ (member) => member.id === claim.holderId && now - member.heartbeatAt <= LIVE_QUEUE_STALE_MS,
321
+ );
322
+
323
+ if (this.#hasFloor) {
324
+ if (!this.#joined || claim?.token !== this.#token || claim.holderId !== this.#id) {
325
+ this.#hasFloor = false;
326
+ this.#callbacks.onDeactivated();
327
+ }
328
+ return;
329
+ }
330
+
331
+ if (claim && holderAlive) {
332
+ // Only a genuinely focused challenger preempts; the displaced holder
333
+ // steps down on its next tick when the token no longer matches its own.
334
+ if (this.#policy === "focus" && this.#focused === true && claim.holderId !== this.#id) {
335
+ this.#claim("focus", true);
336
+ }
337
+ return;
338
+ }
339
+
340
+ if (claim && !holderAlive) {
341
+ rmSync(this.#floorFile, { force: true });
342
+ }
343
+ if (this.#policy === "fifo") {
344
+ if (aliveMembers[0]?.id === this.#id) this.#claim("fifo", false);
345
+ return;
346
+ }
347
+ if (this.#focused !== false) {
348
+ this.#claim(this.#focused === true ? "focus" : "background", false);
349
+ }
350
+ }
351
+
352
+ #claim(cause: LiveActivationCause, preempt: boolean): void {
353
+ const claim: LiveFloorClaim = {
354
+ holderId: this.#id,
355
+ pid: this.#pid,
356
+ token: this.#token,
357
+ claimedAt: this.#now(),
358
+ };
359
+ try {
360
+ if (preempt) {
361
+ writeJsonAtomic(this.#floorFile, claim);
362
+ } else {
363
+ const fd = openSync(this.#floorFile, "wx");
364
+ try {
365
+ writeFileSync(fd, JSON.stringify(claim));
366
+ } finally {
367
+ closeSync(fd);
368
+ }
369
+ }
370
+ } catch {
371
+ return;
372
+ }
373
+ const confirmed = this.#readClaim();
374
+ if (confirmed?.token !== this.#token) return;
375
+ this.#hasFloor = true;
376
+ this.#callbacks.onActivated(cause);
377
+ }
378
+
379
+ #releaseFloor(): void {
380
+ const wasHolding = this.#hasFloor;
381
+ this.#hasFloor = false;
382
+ const claim = this.#readClaim();
383
+ if (claim?.token === this.#token) {
384
+ rmSync(this.#floorFile, { force: true });
385
+ }
386
+ if (wasHolding) this.#callbacks.onDeactivated();
387
+ }
388
+ }
@@ -151,7 +151,12 @@ export class LiveVisualizer implements Component {
151
151
 
152
152
  #renderLines(width: number): string[] {
153
153
  if (width === 1) {
154
- const color = this.#phase === "error" ? "error" : this.#phase === "muted" ? "dim" : "success";
154
+ const color =
155
+ this.#phase === "error"
156
+ ? "error"
157
+ : this.#phase === "muted" || this.#phase === "standby"
158
+ ? "dim"
159
+ : "success";
155
160
  return [
156
161
  this.#options.theme.fg("border", "┐"),
157
162
  this.#options.theme.fg(color, "▂"),
@@ -167,7 +172,7 @@ export class LiveVisualizer implements Component {
167
172
  theme.fg("border", "│") + content + theme.fg("border", "│");
168
173
  const top = theme.fg("border", `┌${"─".repeat(innerWidth)}┐`);
169
174
  const spectrumColor: ThemeColor =
170
- this.#phase === "muted" || this.#phase === "connecting"
175
+ this.#phase === "muted" || this.#phase === "connecting" || this.#phase === "standby"
171
176
  ? "dim"
172
177
  : this.#phase === "error"
173
178
  ? "error"
@@ -207,6 +212,7 @@ export class LiveVisualizer implements Component {
207
212
  const theme = this.#options.theme;
208
213
  const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
209
214
  const staticIcons: Record<LivePhase, string> = {
215
+ standby: "◌",
210
216
  connecting: "○",
211
217
  listening: "●",
212
218
  working: "○",
@@ -219,6 +225,7 @@ export class LiveVisualizer implements Component {
219
225
  ? spinners[this.#frame % spinners.length]!
220
226
  : staticIcons[this.#phase];
221
227
  const phaseColors: Record<LivePhase, ThemeColor> = {
228
+ standby: "dim",
222
229
  connecting: "dim",
223
230
  listening: "success",
224
231
  working: "warning",
@@ -251,7 +258,10 @@ export class LiveVisualizer implements Component {
251
258
  const microphoneEnergy = Math.min(1, Math.sqrt(this.#displayLevel * 5));
252
259
  const connectingEnergy =
253
260
  this.#phase === "connecting" ? 0.06 + 0.03 * Math.sin(this.#frame * 0.35) : 0;
254
- const energy = this.#phase === "muted" ? 0 : Math.max(microphoneEnergy, connectingEnergy);
261
+ const energy =
262
+ this.#phase === "muted" || this.#phase === "standby"
263
+ ? 0
264
+ : Math.max(microphoneEnergy, connectingEnergy);
255
265
  const maxHeight = rows * (blocks.length - 1);
256
266
  for (let column = 0; column < width; column += 1) {
257
267
  const carrier = 0.5 + 0.5 * Math.sin(this.#frame * 0.43 + column * 0.71);