@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 +1 -1
- package/src/live/controller.ts +8 -1
- package/src/live/focus.ts +92 -0
- package/src/live/index.ts +192 -40
- package/src/live/queue.ts +388 -0
- package/src/live/visualizer.ts +13 -3
package/package.json
CHANGED
package/src/live/controller.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
158
|
+
onToggleMute: () => session?.toggleMute(),
|
|
103
159
|
});
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 =
|
|
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?.
|
|
333
|
+
activeRun?.getSession()?.handleAgentMessage(event.message);
|
|
182
334
|
});
|
|
183
335
|
|
|
184
336
|
pi.on("agent_settled", () => {
|
|
185
|
-
activeRun?.
|
|
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
|
+
}
|
package/src/live/visualizer.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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);
|