@comput/pi-intercom 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -25
- package/dist/activity.d.ts +12 -0
- package/dist/activity.js +73 -0
- package/dist/activity.js.map +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +182 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +186 -0
- package/dist/index.js.map +1 -0
- package/dist/launcher.d.ts +17 -0
- package/dist/launcher.js +77 -0
- package/dist/launcher.js.map +1 -0
- package/dist/monitor-launcher.d.ts +17 -0
- package/dist/monitor-launcher.js +136 -0
- package/dist/monitor-launcher.js.map +1 -0
- package/dist/monitor-view.d.ts +6 -0
- package/dist/monitor-view.js +134 -0
- package/dist/monitor-view.js.map +1 -0
- package/dist/monitor.d.ts +22 -0
- package/dist/monitor.js +171 -0
- package/dist/monitor.js.map +1 -0
- package/dist/observability.d.ts +79 -0
- package/dist/observability.js +208 -0
- package/dist/observability.js.map +1 -0
- package/dist/reports.d.ts +12 -0
- package/dist/reports.js +160 -0
- package/dist/reports.js.map +1 -0
- package/dist/runtime.d.ts +59 -0
- package/dist/runtime.js +458 -0
- package/dist/runtime.js.map +1 -0
- package/dist/snapshot.d.ts +15 -0
- package/dist/snapshot.js +128 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/transport.d.ts +24 -0
- package/dist/transport.js +122 -0
- package/dist/transport.js.map +1 -0
- package/dist/worker-status.d.ts +26 -0
- package/dist/worker-status.js +76 -0
- package/dist/worker-status.js.map +1 -0
- package/package.json +13 -7
- package/references/observability.md +66 -60
- package/references/roadmap.md +68 -0
- package/src/activity.ts +66 -0
- package/src/config.ts +1 -1
- package/src/index.ts +51 -23
- package/src/monitor-launcher.ts +110 -0
- package/src/monitor-view.ts +123 -0
- package/src/monitor.ts +143 -0
- package/src/observability.ts +6 -1
- package/src/reports.ts +122 -0
- package/src/runtime.ts +45 -7
- package/src/{dashboard.ts → snapshot.ts} +9 -60
- package/src/transport.ts +11 -2
- package/src/worker-status.ts +66 -0
- package/dashboard/app.js +0 -182
- package/dashboard/index.html +0 -41
- package/dashboard/style.css +0 -82
package/src/activity.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Public event categories only: never accept content, arguments, or results. */
|
|
2
|
+
export type ActivityPhase = 'working' | 'thinking' | 'responding' | 'tool' | 'idle';
|
|
3
|
+
export type ActivityDetail = 'processing' | 'thinking' | 'responding' | 'reading_files' |
|
|
4
|
+
'editing_files' | 'running_command' | 'using_tool' | 'multiple_tools' | 'settled';
|
|
5
|
+
|
|
6
|
+
function toolDetail(name: string): ActivityDetail {
|
|
7
|
+
switch (name) {
|
|
8
|
+
case 'read': return 'reading_files';
|
|
9
|
+
case 'edit':
|
|
10
|
+
case 'write': return 'editing_files';
|
|
11
|
+
case 'bash':
|
|
12
|
+
case 'powershell': return 'running_command';
|
|
13
|
+
default: return 'using_tool';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Stores only opaque active tool IDs, public categories, and the last emitted state. */
|
|
18
|
+
export function createActivityTracker(record: (phase: ActivityPhase, detail: ActivityDetail) => void) {
|
|
19
|
+
const tools = new Map<string, ActivityDetail>();
|
|
20
|
+
let lastPhase: ActivityPhase | undefined;
|
|
21
|
+
let lastDetail: ActivityDetail | undefined;
|
|
22
|
+
function emit(phase: ActivityPhase, detail: ActivityDetail): void {
|
|
23
|
+
if (lastPhase === phase && lastDetail === detail) return;
|
|
24
|
+
lastPhase = phase;
|
|
25
|
+
lastDetail = detail;
|
|
26
|
+
record(phase, detail);
|
|
27
|
+
}
|
|
28
|
+
function emitTools(): void {
|
|
29
|
+
const detail = tools.size > 1 ? 'multiple_tools' : tools.values().next().value;
|
|
30
|
+
if (detail) emit('tool', detail);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
start(): void {
|
|
34
|
+
tools.clear();
|
|
35
|
+
emit('working', 'processing');
|
|
36
|
+
},
|
|
37
|
+
settled(): void {
|
|
38
|
+
tools.clear();
|
|
39
|
+
emit('idle', 'settled');
|
|
40
|
+
},
|
|
41
|
+
message(type: string): void {
|
|
42
|
+
if (tools.size) return;
|
|
43
|
+
switch (type) {
|
|
44
|
+
case 'thinking_start':
|
|
45
|
+
case 'thinking_delta': emit('thinking', 'thinking'); break;
|
|
46
|
+
case 'thinking_end': emit('working', 'processing'); break;
|
|
47
|
+
case 'text_start':
|
|
48
|
+
case 'text_delta': emit('responding', 'responding'); break;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
toolStart(id: string, name: string): void {
|
|
52
|
+
tools.set(id, toolDetail(name));
|
|
53
|
+
emitTools();
|
|
54
|
+
},
|
|
55
|
+
toolEnd(id: string): void {
|
|
56
|
+
if (!tools.delete(id)) return;
|
|
57
|
+
if (tools.size) emitTools();
|
|
58
|
+
else emit('working', 'processing');
|
|
59
|
+
},
|
|
60
|
+
reset(): void {
|
|
61
|
+
tools.clear();
|
|
62
|
+
lastPhase = undefined;
|
|
63
|
+
lastDetail = undefined;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface Agent {
|
|
|
8
8
|
coordinator: boolean;
|
|
9
9
|
description: string;
|
|
10
10
|
port: number;
|
|
11
|
-
/**
|
|
11
|
+
/** Legacy browser dashboard field, accepted for migration only; removed at coordinator startup. */
|
|
12
12
|
dashboardPort?: number;
|
|
13
13
|
projectDirectory: string;
|
|
14
14
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { getAgentDir, SessionManager, SettingsManager, truncateHead, withFileMut
|
|
|
5
5
|
import { ConfigStore } from './config.js';
|
|
6
6
|
import { Intercom, UNSUPPORTED_CANCELLATION } from './runtime.js';
|
|
7
7
|
import { launchers } from './launcher.js';
|
|
8
|
-
import {
|
|
8
|
+
import { ensureMonitorPane } from './monitor-launcher.js';
|
|
9
|
+
import { createActivityTracker } from './activity.js';
|
|
9
10
|
|
|
10
11
|
const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
|
|
11
12
|
const tools: [string, string, TSchema][] = [
|
|
@@ -13,8 +14,10 @@ const tools: [string, string, TSchema][] = [
|
|
|
13
14
|
['configure_worker', 'Coordinator only. Write explicit reported connection details and responsibility to config. Does not notify, reload, or start work. Use reload_worker separately.', Type.Object({ sessionId: Type.String(), port: Type.Integer({ minimum: 1, maximum: 65535 }), projectDirectory: Type.String(), name: Type.String({ minLength: 1, maxLength: 128 }), description: Type.String({ minLength: 1, maxLength: 4096 }) })],
|
|
14
15
|
['reload_worker', 'Coordinator only. Ask worker to reread responsibility and synchronize names. NOT Pi extension reload, cancellation, restart, or work assignment.', to],
|
|
15
16
|
['send', 'Configured sessions only. Send an explicit work/progress/findings message by recipient name. Idle delivery starts a turn; busy delivery steers at supported boundaries. Receipt is not an agent reply. Progress questions do not cancel assignments.', Type.Object({ to: Type.String(), message: Type.String({ minLength: 1, maxLength: 48000 }) })],
|
|
17
|
+
['worker_status', 'Configured sessions only. Read paginated JSON worker observations and public reports from local files, optionally by worker name. Last-observed status is not live liveness or task completion; reports are self-reported, not approval. No worker prompts, live probing or Telegram dependency. Follow nextOffset for further pages; null report means no readable active report.', Type.Object({ name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })), offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })) })],
|
|
16
18
|
['list', 'Configured sessions only. Read current saved names, responsibilities, session IDs, roles and ports. No live probing. Output limited to 50 KiB/2000 lines; full data remains in config.json.', Type.Object({})],
|
|
17
19
|
['request_status', 'Coordinator only. Ask worker extension to send an independent sessionId/port/busy report without a model turn on worker. No synchronous status reply.', to],
|
|
20
|
+
['report_work', 'Configured, responsibility-loaded workers only. Store one public report (blocked, needs_decision, ready_for_review) and notify the coordinator; clear retires it. Summary required except clear (omit or empty). Never include private reasoning, credentials, raw tool payloads, or secrets. Receipt is not approval, verified completion, or permission for follow-up work.', Type.Object({ status: Type.String({ enum: ['blocked', 'needs_decision', 'ready_for_review', 'clear'] }), summary: Type.Optional(Type.String({ maxLength: 2000 })) })],
|
|
18
21
|
['report_status', 'Worker only (including anonymous). Send runtime sessionId/port/busy report to coordinator. This is NOT registration and creates no config entry.', Type.Object({})],
|
|
19
22
|
['stop_worker', UNSUPPORTED_CANCELLATION, to],
|
|
20
23
|
['close_worker', UNSUPPORTED_CANCELLATION, to],
|
|
@@ -43,16 +46,23 @@ class PiConfigStore extends ConfigStore {
|
|
|
43
46
|
export default function intercomExtension(pi: ExtensionAPI): void {
|
|
44
47
|
let runtime: Intercom | undefined;
|
|
45
48
|
let context: ExtensionContext | undefined;
|
|
46
|
-
let dashboard: Dashboard | undefined;
|
|
47
49
|
let generation = 0;
|
|
50
|
+
let activityOutcome: 'started' | 'settled' | 'snapshot' = 'snapshot';
|
|
51
|
+
const activity = createActivityTracker((phase, detail) => {
|
|
52
|
+
runtime?.recordObservation('host.activity', { busy: phase !== 'idle', phase, detail, outcome: activityOutcome });
|
|
53
|
+
});
|
|
48
54
|
const launcher = launchers({
|
|
49
55
|
extension: fileURLToPath(import.meta.url),
|
|
50
56
|
sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
|
|
51
57
|
});
|
|
52
58
|
pi.on('session_start', async (_event, ctx) => {
|
|
53
59
|
const started = ++generation;
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
activity.reset();
|
|
61
|
+
const oldRuntime = runtime;
|
|
62
|
+
runtime = undefined;
|
|
63
|
+
await oldRuntime?.close();
|
|
64
|
+
if (started !== generation) return;
|
|
65
|
+
context = ctx;
|
|
56
66
|
if (!['win32', 'linux'].includes(process.platform) || ctx.mode !== 'tui') throw new Error('PiIntercom requires Windows or Linux interactive Pi. No resources started.');
|
|
57
67
|
if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
|
|
58
68
|
runtime = new Intercom({
|
|
@@ -68,39 +78,57 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
68
78
|
const current = runtime;
|
|
69
79
|
try { await current.start(); }
|
|
70
80
|
catch (e) { if (runtime === current) runtime = undefined; ctx.ui.notify(String(e), 'error'); return; }
|
|
71
|
-
if (started !== generation) return;
|
|
72
|
-
let server: Dashboard | undefined;
|
|
73
|
-
let savingPort = false;
|
|
81
|
+
if (started !== generation) { await current.close(); return; }
|
|
74
82
|
try {
|
|
75
83
|
if ((await current.state()).me?.coordinator) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
try { await current.retireDashboardMetadata(); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (started !== generation) return;
|
|
87
|
+
ctx.ui.notify(`Legacy dashboard metadata cleanup failed; messaging remains available: ${String(error)}`, 'warning');
|
|
88
|
+
}
|
|
89
|
+
if (started !== generation) return;
|
|
90
|
+
const monitor = await ensureMonitorPane({
|
|
91
|
+
root: current.store.root, sessionId: ctx.sessionManager.getSessionId(),
|
|
92
|
+
script: fileURLToPath(new URL('../dist/monitor.js', import.meta.url)),
|
|
93
|
+
assertCurrent: () => { if (started !== generation || runtime !== current) throw new Error('Coordinator session changed'); },
|
|
94
|
+
});
|
|
95
|
+
if (started !== generation) return;
|
|
96
|
+
ctx.ui.notify(`Intercom status monitor: ${monitor.outcome}${monitor.pane ? ` (${monitor.pane})` : ''}`, 'info');
|
|
83
97
|
}
|
|
84
|
-
} catch {
|
|
85
|
-
await server?.close().catch(() => {});
|
|
98
|
+
} catch (error) {
|
|
86
99
|
if (started !== generation) return;
|
|
87
|
-
ctx.ui.notify(
|
|
88
|
-
? 'Intercom dashboard port could not be saved; the new dashboard was closed. Any saved URL is last-known only. Messaging remains available.'
|
|
89
|
-
: 'Intercom dashboard unavailable; communication remains independent.', 'error');
|
|
100
|
+
ctx.ui.notify(String(error), 'warning');
|
|
90
101
|
}
|
|
91
102
|
});
|
|
92
103
|
pi.on('session_shutdown', async () => {
|
|
93
104
|
generation++;
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
activity.reset();
|
|
106
|
+
const oldRuntime = runtime;
|
|
107
|
+
runtime = undefined; context = undefined;
|
|
108
|
+
await oldRuntime?.close();
|
|
96
109
|
});
|
|
97
110
|
pi.on('agent_start', async (_event, ctx) => {
|
|
98
111
|
context = ctx;
|
|
99
|
-
|
|
112
|
+
activityOutcome = 'started';
|
|
113
|
+
try { activity.start(); } finally { activityOutcome = 'snapshot'; }
|
|
100
114
|
});
|
|
101
115
|
pi.on('agent_settled', async (_event, ctx) => {
|
|
102
116
|
context = ctx;
|
|
103
|
-
|
|
117
|
+
activityOutcome = 'settled';
|
|
118
|
+
try { activity.settled(); } finally { activityOutcome = 'snapshot'; }
|
|
119
|
+
});
|
|
120
|
+
pi.on('message_update', (event, ctx) => {
|
|
121
|
+
context = ctx;
|
|
122
|
+
// Inspect the event discriminator only. Never access reasoning/text payloads.
|
|
123
|
+
activity.message(event.assistantMessageEvent.type);
|
|
124
|
+
});
|
|
125
|
+
pi.on('tool_execution_start', (event, ctx) => {
|
|
126
|
+
context = ctx;
|
|
127
|
+
activity.toolStart(event.toolCallId, event.toolName);
|
|
128
|
+
});
|
|
129
|
+
pi.on('tool_execution_end', (event, ctx) => {
|
|
130
|
+
context = ctx;
|
|
131
|
+
activity.toolEnd(event.toolCallId);
|
|
104
132
|
});
|
|
105
133
|
pi.on('before_agent_start', async (event, ctx) => {
|
|
106
134
|
context = ctx;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { access, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { run, shQuote, psQuote, encoded, type Run } from './launcher.js';
|
|
5
|
+
|
|
6
|
+
interface Pane { pane_id: string; tab_id: string; workspace_id?: string; label?: string }
|
|
7
|
+
interface RecordState { version: 1; sessionId: string; workspace: string; caller: string; pane?: string; stage: string }
|
|
8
|
+
export interface MonitorLaunchOptions {
|
|
9
|
+
root: string; sessionId: string; script: string;
|
|
10
|
+
node?: string; platform?: string; env?: NodeJS.ProcessEnv; run?: Run;
|
|
11
|
+
assertCurrent?(): void;
|
|
12
|
+
}
|
|
13
|
+
/** Pane ownership is separate from agent config. Pending records fence uncertain
|
|
14
|
+
* mutations across reloads; neither errors nor absence of readiness cause retries. */
|
|
15
|
+
export async function ensureMonitorPane(options: MonitorLaunchOptions): Promise<{ outcome: string; pane?: string }> {
|
|
16
|
+
const env = options.env ?? process.env;
|
|
17
|
+
if (env.HERDR_ENV !== '1') return { outcome: 'unavailable: coordinator is not inside Herdr' };
|
|
18
|
+
if (!env.HERDR_PANE_ID) throw new Error('Monitor requires caller pane identity; no focused-pane fallback');
|
|
19
|
+
const platform = options.platform ?? process.platform;
|
|
20
|
+
if (!['win32', 'linux'].includes(platform)) throw new Error('Monitor launcher supports Windows/Linux only');
|
|
21
|
+
const exec = options.run ?? run;
|
|
22
|
+
const check = () => options.assertCurrent?.();
|
|
23
|
+
const call = async (args: string[]) => {
|
|
24
|
+
check();
|
|
25
|
+
const output = await exec('herdr', args);
|
|
26
|
+
check();
|
|
27
|
+
// pane run acknowledges command submission with an empty stdout on current Herdr.
|
|
28
|
+
if (!output.trim() && args[1] === 'run') return {};
|
|
29
|
+
return JSON.parse(output).result;
|
|
30
|
+
};
|
|
31
|
+
const caller = (await call(['pane', 'current', '--current']))?.pane as Pane;
|
|
32
|
+
if (!caller?.pane_id || !caller.tab_id || !caller.workspace_id) throw new Error('Missing current Herdr pane identity');
|
|
33
|
+
const label = 'Intercom monitor';
|
|
34
|
+
const legacyMarker = `Intercom monitor [${options.sessionId}]`;
|
|
35
|
+
const key = createHash('sha256').update(options.sessionId).digest('hex').slice(0, 24);
|
|
36
|
+
const directory = path.join(options.root, '.pi-intercom');
|
|
37
|
+
await mkdir(directory, { recursive: true });
|
|
38
|
+
const file = path.join(directory, `monitor-${key}.json`), lock = `${file}.lock`;
|
|
39
|
+
check();
|
|
40
|
+
try { await writeFile(lock, '', { flag: 'wx', mode: 0o600 }); }
|
|
41
|
+
catch { throw new Error(`Monitor launch locked; inspect ${lock} before recovery. No pane created.`); }
|
|
42
|
+
let state: RecordState | undefined;
|
|
43
|
+
const save = async () => {
|
|
44
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
await writeFile(temporary, JSON.stringify(state), { flag: 'wx', mode: 0o600 });
|
|
47
|
+
await rename(temporary, file);
|
|
48
|
+
} finally { await unlink(temporary).catch(() => {}); }
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
try {
|
|
52
|
+
state = JSON.parse(await readFile(file, 'utf8'));
|
|
53
|
+
if (state?.version !== 1 || state.sessionId !== options.sessionId || typeof state.stage !== 'string') throw new Error('Invalid monitor ownership record');
|
|
54
|
+
} catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
|
55
|
+
const panes = (await call(['pane', 'list', '--workspace', caller.workspace_id]))?.panes as Pane[];
|
|
56
|
+
if (!Array.isArray(panes) || panes.some(p => !p?.pane_id || !p.tab_id)) throw new Error('Invalid Herdr pane inventory');
|
|
57
|
+
const matches = panes.filter(p => p.label === legacyMarker ||
|
|
58
|
+
(state && state.workspace === caller.workspace_id && state.pane === p.pane_id));
|
|
59
|
+
if (matches.length > 1) throw new Error('Multiple monitor markers; inspect panes. No automatic changes.');
|
|
60
|
+
if (matches.length === 1) {
|
|
61
|
+
const pane = matches[0];
|
|
62
|
+
if (pane.pane_id === caller.pane_id || pane.tab_id !== caller.tab_id) throw new Error('Monitor marker is on caller or moved to another tab; no duplicate created');
|
|
63
|
+
if (state && (state.workspace !== caller.workspace_id || state.caller !== caller.pane_id || (state.pane && state.pane !== pane.pane_id))) {
|
|
64
|
+
throw new Error('Monitor ownership differs from current caller; no automatic changes');
|
|
65
|
+
}
|
|
66
|
+
if (state && !['submitted', 'observed'].includes(state.stage)) {
|
|
67
|
+
throw new Error(`Monitor partial launch needs inspection (${state.stage}); no retry or duplicate`);
|
|
68
|
+
}
|
|
69
|
+
if (!state) {
|
|
70
|
+
state = { version: 1, sessionId: options.sessionId, workspace: caller.workspace_id, caller: caller.pane_id, pane: pane.pane_id, stage: 'observed' };
|
|
71
|
+
await save();
|
|
72
|
+
}
|
|
73
|
+
if (pane.label !== label) await call(['pane', 'rename', pane.pane_id, label]);
|
|
74
|
+
return { outcome: 'existing pane preserved; process readiness not checked', pane: pane.pane_id };
|
|
75
|
+
}
|
|
76
|
+
if (panes.some(p => p.tab_id === caller.tab_id && p.label === label)) {
|
|
77
|
+
throw new Error('Unowned monitor label in coordinator tab; inspect before creating another pane');
|
|
78
|
+
}
|
|
79
|
+
if (state && (!['submitted', 'observed'].includes(state.stage) || state.workspace !== caller.workspace_id || panes.some(p => p.pane_id === state!.pane))) {
|
|
80
|
+
throw new Error(`Monitor ownership/partial launch needs inspection (${state.stage}, pane ${state.pane ?? 'unknown'}); no retry or duplicate`);
|
|
81
|
+
}
|
|
82
|
+
await access(options.script); // Missing build must not leave a new empty pane.
|
|
83
|
+
check();
|
|
84
|
+
state = { version: 1, sessionId: options.sessionId, workspace: caller.workspace_id, caller: caller.pane_id, stage: 'split_pending' };
|
|
85
|
+
await save();
|
|
86
|
+
// Small top quarter after swapping pane identities. Verify actual geometry below.
|
|
87
|
+
const split = await call(['pane', 'split', '--pane', caller.pane_id, '--direction', 'down', '--ratio', '0.25', '--cwd', options.root, '--no-focus']);
|
|
88
|
+
const pane = split?.pane?.pane_id;
|
|
89
|
+
if (typeof pane !== 'string' || pane === caller.pane_id) throw new Error('Split returned no distinct pane ID');
|
|
90
|
+
state.pane = pane; state.stage = 'rename_pending'; await save();
|
|
91
|
+
await call(['pane', 'rename', pane, label]);
|
|
92
|
+
state.stage = 'swap_pending'; await save();
|
|
93
|
+
const swapped = await call(['pane', 'swap', '--source-pane', caller.pane_id, '--target-pane', pane]);
|
|
94
|
+
if (swapped?.swap?.changed !== true) throw new Error('Herdr did not confirm pane swap');
|
|
95
|
+
const layout = (await call(['pane', 'layout', '--pane', caller.pane_id]))?.layout;
|
|
96
|
+
const top = layout?.panes?.find((p: { pane_id: string }) => p.pane_id === pane)?.rect;
|
|
97
|
+
const bottom = layout?.panes?.find((p: { pane_id: string }) => p.pane_id === caller.pane_id)?.rect;
|
|
98
|
+
if (!top || !bottom || top.y >= bottom.y || layout.focused_pane_id !== caller.pane_id) throw new Error('Monitor geometry/focus not confirmed; inspect panes before recovery');
|
|
99
|
+
state.stage = 'run_pending'; await save();
|
|
100
|
+
const args = [options.script, '--root', options.root];
|
|
101
|
+
const command = platform === 'win32'
|
|
102
|
+
? `powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded(`& ${psQuote(options.node ?? process.execPath)} ${args.map(psQuote).join(' ')}`)}`
|
|
103
|
+
: `sh -c ${shQuote(`exec ${shQuote(options.node ?? process.execPath)} ${args.map(shQuote).join(' ')}`)}`;
|
|
104
|
+
await call(['pane', 'run', pane, command]);
|
|
105
|
+
state.stage = 'submitted'; await save();
|
|
106
|
+
return { outcome: 'command submitted; monitor readiness not awaited', pane };
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(`Intercom monitor: ${String(e)}. Pane ${state?.pane ?? 'unknown'} may remain; no automatic cleanup.`);
|
|
109
|
+
} finally { await unlink(lock).catch(() => {}); }
|
|
110
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { truncateToWidth, visibleWidth } from 'pi-intercom-tui';
|
|
3
|
+
import type { ObservationSnapshot } from './snapshot.js';
|
|
4
|
+
import { workerObservation } from './worker-status.js';
|
|
5
|
+
|
|
6
|
+
const safe = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f\u2028-\u202e\u2066-\u2069]/g, ' ');
|
|
7
|
+
const clip = (value: string, width: number) => stripVTControlCharacters(truncateToWidth(safe(value), Math.max(0, width)));
|
|
8
|
+
const cell = (value: string, width: number) => {
|
|
9
|
+
const text = clip(value, width);
|
|
10
|
+
return text + ' '.repeat(Math.max(0, width - visibleWidth(text)));
|
|
11
|
+
};
|
|
12
|
+
const ageLabel = (milliseconds: number) => {
|
|
13
|
+
const seconds = Math.floor(milliseconds / 1000);
|
|
14
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
15
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
16
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
17
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const reportLabels: Record<string, string> = {
|
|
21
|
+
blocked: 'Blocked', needs_decision: 'Needs decision', ready_for_review: 'Ready for review',
|
|
22
|
+
};
|
|
23
|
+
// Wrap only sanitized grapheme clusters, bounded by the terminal's remaining rows.
|
|
24
|
+
function wrapSummary(value: string, width: number, rows: number): string[] {
|
|
25
|
+
if (width <= 0 || rows <= 0) return [];
|
|
26
|
+
const result: string[] = [];
|
|
27
|
+
let current = '';
|
|
28
|
+
for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(safe(value))) {
|
|
29
|
+
if (visibleWidth(current + segment) > width) {
|
|
30
|
+
if (current) result.push(current);
|
|
31
|
+
current = '';
|
|
32
|
+
if (result.length >= rows) break;
|
|
33
|
+
}
|
|
34
|
+
current += visibleWidth(segment) <= width ? segment : clip(segment, width);
|
|
35
|
+
}
|
|
36
|
+
if (current && result.length < rows) result.push(current);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Presentation only. All user-controlled text is sanitized before trusted ANSI styling. */
|
|
41
|
+
export function renderMonitor(snapshot: ObservationSnapshot | undefined, width: number, height: number,
|
|
42
|
+
now = Date.now(), color = false, options: { selectedIndex?: number; details?: boolean } = {}): string[] {
|
|
43
|
+
width = Math.max(0, Math.min(1000, Math.floor(width)));
|
|
44
|
+
height = Math.max(0, Math.min(1000, Math.floor(height)));
|
|
45
|
+
if (!width || !height) return [];
|
|
46
|
+
const paint = (code: string, text: string) => color ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
47
|
+
const dim = (text: string) => paint('90', text);
|
|
48
|
+
const line = (text: string) => clip(text, width);
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
if (!snapshot?.config) {
|
|
51
|
+
lines.push(paint('33', line(' Status unavailable — waiting for local observations')));
|
|
52
|
+
} else {
|
|
53
|
+
const workers = snapshot.config.agents.filter(agent => !agent.coordinator);
|
|
54
|
+
// Report remains distinct from observed status; last activity gives way first.
|
|
55
|
+
const wide = width >= 100;
|
|
56
|
+
const nameWidth = wide ? Math.min(22, Math.max(10, width - 88)) : 10;
|
|
57
|
+
const activityWidth = wide ? 12 : 8, ageWidth = 16, reportWidth = 16;
|
|
58
|
+
const table = (name: string, activity: string, age: string, detail: string, report = '—', tone = '90', selected = false) => {
|
|
59
|
+
const prefix = `${selected ? '› ' : ' '}${cell(name, nameWidth)} ${cell(age, ageWidth)} `;
|
|
60
|
+
const status = cell(activity, activityWidth);
|
|
61
|
+
const tail = ` ${cell(report, reportWidth)}${wide ? ` ${detail}` : ''}`;
|
|
62
|
+
// Clip as plain text first; no untrusted escape sequences enter the output.
|
|
63
|
+
if (!color) return line(prefix + status + tail);
|
|
64
|
+
const namePart = clip(prefix, width);
|
|
65
|
+
const statusRoom = Math.max(0, width - visibleWidth(namePart));
|
|
66
|
+
const statusPart = clip(status, statusRoom);
|
|
67
|
+
const tailRoom = Math.max(0, statusRoom - visibleWidth(statusPart));
|
|
68
|
+
return (selected ? paint('1;36', namePart) : namePart) + paint(tone, statusPart) + dim(clip(tail, tailRoom));
|
|
69
|
+
};
|
|
70
|
+
lines.push(paint('1', table('Worker', 'Status', 'Seen', 'Last activity', 'Report')));
|
|
71
|
+
lines.push(dim(line(' ' + '─'.repeat(Math.max(0, width - 2)))));
|
|
72
|
+
const selectedIndex = Math.min(workers.length - 1, Math.max(-1, options.selectedIndex ?? -1));
|
|
73
|
+
const showDetails = Boolean(options.details && selectedIndex >= 0 && height >= 8);
|
|
74
|
+
const selectedReport = snapshot.reports?.find(report => report.sessionId === workers[selectedIndex]?.sessionId && reportLabels[report.status]);
|
|
75
|
+
const detailBudget = showDetails ? Math.min(selectedReport ? 12 : 5, height - 5) : 0;
|
|
76
|
+
const available = Math.max(0, height - lines.length - 2 - detailBudget);
|
|
77
|
+
const needsOverflow = workers.length > available;
|
|
78
|
+
const count = Math.min(workers.length, Math.max(0, available - (needsOverflow && available > 1 ? 1 : 0)));
|
|
79
|
+
const start = Math.max(0, Math.min(Math.max(0, workers.length - count), selectedIndex - count + 1));
|
|
80
|
+
let selectedDetail: string[] = [];
|
|
81
|
+
for (const [offset, worker] of workers.slice(start, start + count).entries()) {
|
|
82
|
+
const observation = workerObservation(snapshot, worker.sessionId, now);
|
|
83
|
+
const activity = observation.observedStatus, evidence = observation.lastActivity;
|
|
84
|
+
const age = observation.observationAgeSeconds === null ? '—' : ageLabel(observation.observationAgeSeconds * 1000) + (observation.stale ? ' (old)' : '');
|
|
85
|
+
const tone = observation.stale ? '90' : activity === 'thinking' ? '35' : activity === 'working' ? '36' : activity === 'idle' ? '32' : '90';
|
|
86
|
+
const selected = start + offset === selectedIndex;
|
|
87
|
+
const report = snapshot.reports?.find(report => report.sessionId === worker.sessionId && reportLabels[report.status]);
|
|
88
|
+
lines.push(table(worker.name, activity, age, evidence, report ? reportLabels[report.status] : '—', tone, selected));
|
|
89
|
+
if (selected && showDetails && report) {
|
|
90
|
+
const elapsed = now - Date.parse(report.updatedAt);
|
|
91
|
+
const reportAge = !Number.isFinite(elapsed) || elapsed < 0 ? 'clock uncertain' : ageLabel(elapsed) + (elapsed > snapshot.staleAfterMs ? ' (old)' : '');
|
|
92
|
+
const contextRows = detailBudget >= 5 ? [
|
|
93
|
+
...(detailBudget >= 6 ? [paint('1', line(` ${worker.name}`))] : []),
|
|
94
|
+
line(` Responsibility: ${worker.description || 'Not specified'}`),
|
|
95
|
+
line(` Observed status: ${activity} · ${age}`),
|
|
96
|
+
] : [];
|
|
97
|
+
selectedDetail = [
|
|
98
|
+
...contextRows,
|
|
99
|
+
line(` Report: ${reportLabels[report.status]} · ${reportAge}`),
|
|
100
|
+
line(' Self-reported, pending review'),
|
|
101
|
+
...wrapSummary(`Summary: ${report.summary}`, Math.max(0, width - 2), Math.max(0, detailBudget - 2 - contextRows.length)).map(text => line(` ${text}`)),
|
|
102
|
+
];
|
|
103
|
+
} else if (selected && showDetails) selectedDetail = [
|
|
104
|
+
dim(line(' ' + '─'.repeat(Math.max(0, width - 2)))),
|
|
105
|
+
paint('1', line(` ${worker.name}`)),
|
|
106
|
+
line(` Responsibility: ${worker.description || 'Not specified'}`),
|
|
107
|
+
line(` Observed status: ${activity} · ${age}`),
|
|
108
|
+
line(` Last activity: ${evidence}`),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
if (workers.length > count && available > count) lines.push(dim(line(` ${workers.length - count} more · rows ${count ? start + 1 : 0}–${start + count} of ${workers.length} · ↑↓ to browse`)));
|
|
112
|
+
lines.push(...(selectedReport ? selectedDetail.slice(0, detailBudget) : selectedDetail.slice(-detailBudget || selectedDetail.length)));
|
|
113
|
+
if (!workers.length && available) lines.push(dim(line(' No workers configured yet')));
|
|
114
|
+
}
|
|
115
|
+
// Anchor the hint at the bottom; do not fill the pane with decorative boxes.
|
|
116
|
+
const partial = snapshot && (snapshot.truncated || snapshot.errors.length);
|
|
117
|
+
const controls = options.details ? 'q quit · Esc back · ↑↓ select' : 'q quit · ↑↓ select · Enter details';
|
|
118
|
+
const hasReports = snapshot?.reports?.some(report => reportLabels[report.status] && snapshot.config?.agents.some(agent => !agent.coordinator && agent.sessionId === report.sessionId));
|
|
119
|
+
const footer = width < 18 ? 'q quit' : ` ${controls}${partial ? ' · Partial observations' : ''}${hasReports ? ' · Reports: self-reported, pending review' : ''}`;
|
|
120
|
+
while (lines.length < height - 1) lines.push('');
|
|
121
|
+
lines.push(dim(line(footer)));
|
|
122
|
+
return lines.slice(0, height);
|
|
123
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { Key, matchesKey, ProcessTerminal, TuiAltScreen, type Component, type TUI } from 'pi-intercom-tui';
|
|
5
|
+
import { readObservationSnapshot, type ObservationSnapshot } from './snapshot.js';
|
|
6
|
+
import { renderMonitor } from './monitor-view.js';
|
|
7
|
+
|
|
8
|
+
export function parseMonitorRoot(args: string[]): string {
|
|
9
|
+
if (args.length !== 2 || args[0] !== '--root' || !path.isAbsolute(args[1])) {
|
|
10
|
+
throw new Error('Usage: node dist/monitor.js --root <absolute-project-path>');
|
|
11
|
+
}
|
|
12
|
+
return path.normalize(args[1]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MonitorOptions {
|
|
16
|
+
read?: typeof readObservationSnapshot;
|
|
17
|
+
rows: () => number;
|
|
18
|
+
requestRender: () => void;
|
|
19
|
+
onQuit: () => void;
|
|
20
|
+
intervalMs?: number;
|
|
21
|
+
color?: boolean;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
/** Returns a cancellation function; injected by deterministic tests. */
|
|
24
|
+
schedule?: (callback: () => void, delayMs: number) => () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** No host, probing, network or writes. Closing never awaits an outstanding read. */
|
|
28
|
+
export function createMonitor(root: string, options: MonitorOptions): Component & { start(): void; close(): void } {
|
|
29
|
+
let snapshot: ObservationSnapshot | undefined;
|
|
30
|
+
let selectedIndex = -1;
|
|
31
|
+
let selectedSessionId: string | undefined;
|
|
32
|
+
let details = false;
|
|
33
|
+
const workers = () => snapshot?.config?.agents.filter(agent => !agent.coordinator) ?? [];
|
|
34
|
+
const requestRender = () => { try { options.requestRender(); } catch { /* UI shutdown is independent of reading. */ } };
|
|
35
|
+
let closed = false, started = false;
|
|
36
|
+
let cancel: (() => void) | undefined;
|
|
37
|
+
const schedule = options.schedule ?? ((callback, delay) => {
|
|
38
|
+
const timer = setTimeout(callback, delay);
|
|
39
|
+
timer.unref();
|
|
40
|
+
return () => clearTimeout(timer);
|
|
41
|
+
});
|
|
42
|
+
const interval = Math.max(100, options.intervalMs ?? 3000);
|
|
43
|
+
async function refresh(): Promise<void> {
|
|
44
|
+
if (closed) return;
|
|
45
|
+
let next: ObservationSnapshot | undefined;
|
|
46
|
+
try { next = await (options.read ?? readObservationSnapshot)(root); } catch { /* Unavailable, no raw errors. */ }
|
|
47
|
+
if (closed) return;
|
|
48
|
+
snapshot = next;
|
|
49
|
+
// Preserve identity through reordering, and retain it across transient read failures.
|
|
50
|
+
if (snapshot?.config) {
|
|
51
|
+
const roster = workers();
|
|
52
|
+
const previous = roster.findIndex(agent => agent.sessionId === selectedSessionId);
|
|
53
|
+
selectedIndex = roster.length ? (previous >= 0 ? previous : Math.max(0, Math.min(selectedIndex, roster.length - 1))) : -1;
|
|
54
|
+
selectedSessionId = roster[selectedIndex]?.sessionId;
|
|
55
|
+
if (selectedIndex < 0) details = false;
|
|
56
|
+
}
|
|
57
|
+
requestRender();
|
|
58
|
+
if (!closed) cancel = schedule(() => { cancel = undefined; void refresh(); }, interval);
|
|
59
|
+
}
|
|
60
|
+
const close = () => {
|
|
61
|
+
if (closed) return;
|
|
62
|
+
closed = true;
|
|
63
|
+
cancel?.(); cancel = undefined;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
start() { if (started || closed) return; started = true; void refresh(); },
|
|
67
|
+
close,
|
|
68
|
+
invalidate() {},
|
|
69
|
+
render(width) {
|
|
70
|
+
const height = Math.max(0, Math.floor(options.rows()));
|
|
71
|
+
if (width <= 0 || !height) return [];
|
|
72
|
+
const view = { selectedIndex: snapshot?.config ? selectedIndex : -1, details: !!snapshot?.config && details };
|
|
73
|
+
try { return renderMonitor(snapshot, width, height, (options.now ?? Date.now)(), options.color, view); }
|
|
74
|
+
catch { return renderMonitor(undefined, width, height, Date.now(), options.color, { selectedIndex: -1, details: false }); }
|
|
75
|
+
},
|
|
76
|
+
handleInput(data) {
|
|
77
|
+
if (closed) return;
|
|
78
|
+
if (matchesKey(data, Key.escape) && details) {
|
|
79
|
+
details = false; requestRender(); return;
|
|
80
|
+
}
|
|
81
|
+
if (matchesKey(data, 'q') || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
|
|
82
|
+
close(); options.onQuit(); return;
|
|
83
|
+
}
|
|
84
|
+
const roster = workers();
|
|
85
|
+
if (!roster.length) return;
|
|
86
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
87
|
+
const next = Math.max(0, Math.min(roster.length - 1, selectedIndex + (matchesKey(data, Key.up) ? -1 : 1)));
|
|
88
|
+
if (next !== selectedIndex) {
|
|
89
|
+
selectedIndex = next; selectedSessionId = roster[next].sessionId; requestRender();
|
|
90
|
+
}
|
|
91
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
92
|
+
details = !details; requestRender();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** CLI-only setup: importing this module never touches raw mode or project files. */
|
|
99
|
+
export function runMonitor(args = process.argv.slice(2)): void {
|
|
100
|
+
const root = parseMonitorRoot(args);
|
|
101
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Intercom monitor requires an interactive terminal.');
|
|
102
|
+
// pi-tui supports optional disk diagnostics; this read-only process must not enable them.
|
|
103
|
+
delete process.env.PI_TUI_WRITE_LOG;
|
|
104
|
+
delete process.env.PI_TUI_DEBUG;
|
|
105
|
+
delete process.env.PI_DEBUG_REDRAW;
|
|
106
|
+
const terminal = new ProcessTerminal();
|
|
107
|
+
const tui: TUI = new TuiAltScreen(terminal, false, undefined, { mouse: false });
|
|
108
|
+
let stopped = false;
|
|
109
|
+
const stop = () => {
|
|
110
|
+
if (stopped) return;
|
|
111
|
+
stopped = true;
|
|
112
|
+
monitor.close();
|
|
113
|
+
process.removeListener('SIGINT', signalQuit);
|
|
114
|
+
process.removeListener('SIGTERM', signalQuit);
|
|
115
|
+
process.removeListener('exit', stop);
|
|
116
|
+
try { tui.stop(); } finally { terminal.stop(); }
|
|
117
|
+
};
|
|
118
|
+
// Explicit exit after restoring the terminal: an unresponsive filesystem read
|
|
119
|
+
// must not hold the standalone process open after the owner requests quit.
|
|
120
|
+
const quit = () => { stop(); process.exit(0); };
|
|
121
|
+
const signalQuit = () => quit();
|
|
122
|
+
const monitor = createMonitor(root, {
|
|
123
|
+
rows: () => terminal.rows,
|
|
124
|
+
color: process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb',
|
|
125
|
+
requestRender: () => tui.requestRender(),
|
|
126
|
+
onQuit: quit,
|
|
127
|
+
});
|
|
128
|
+
tui.addChild(monitor);
|
|
129
|
+
tui.setFocus(monitor);
|
|
130
|
+
process.on('SIGINT', signalQuit);
|
|
131
|
+
process.on('SIGTERM', signalQuit);
|
|
132
|
+
process.on('exit', stop);
|
|
133
|
+
try { tui.start(); monitor.start(); }
|
|
134
|
+
catch (error) { stop(); throw error; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
138
|
+
try { runMonitor(); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
console.error(error instanceof Error ? error.message : 'Intercom monitor unavailable.');
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/observability.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import type { ActivityPhase, ActivityDetail } from './activity.js';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { appendFile, lstat, mkdir, opendir, rename, stat, unlink } from 'node:fs/promises';
|
|
4
5
|
|
|
@@ -9,7 +10,7 @@ export const EVENT_TYPES = ['runtime.starting', 'runtime.ready', 'runtime.closed
|
|
|
9
10
|
export type EventType = typeof EVENT_TYPES[number];
|
|
10
11
|
const OUTCOMES = ['attempted', 'returned', 'failed', 'http_receipt', 'handler_failed', 'started', 'settled', 'snapshot', 'ended', 'written', 'removed', 'ready'] as const;
|
|
11
12
|
const ERROR_CODES = ['operation_failed', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC'] as const;
|
|
12
|
-
const KINDS = ['message', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'] as const;
|
|
13
|
+
const KINDS = ['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'] as const;
|
|
13
14
|
const OPERATIONS = ['configure_worker', 'set_multiplexer', 'remove_worker', 'create_worker', 'resume_worker', 'port_update', 'initialize'] as const;
|
|
14
15
|
export interface EventMetadata {
|
|
15
16
|
peerSessionId?: string;
|
|
@@ -17,6 +18,8 @@ export interface EventMetadata {
|
|
|
17
18
|
correlationId?: string;
|
|
18
19
|
kind?: typeof KINDS[number];
|
|
19
20
|
busy?: boolean;
|
|
21
|
+
phase?: ActivityPhase;
|
|
22
|
+
detail?: ActivityDetail;
|
|
20
23
|
port?: number;
|
|
21
24
|
role?: 'coordinator' | 'worker' | 'anonymous';
|
|
22
25
|
outcome?: typeof OUTCOMES[number];
|
|
@@ -49,6 +52,8 @@ export function sanitizeObservation(value: unknown): Observation | undefined {
|
|
|
49
52
|
if (safeText(v.correlationId, 128) && /^[a-zA-Z0-9-]+$/.test(v.correlationId)) result.correlationId = v.correlationId;
|
|
50
53
|
if (includes(KINDS, v.kind)) result.kind = v.kind;
|
|
51
54
|
if (typeof v.busy === 'boolean') result.busy = v.busy;
|
|
55
|
+
if (includes(['working', 'thinking', 'responding', 'tool', 'idle'], v.phase)) result.phase = v.phase;
|
|
56
|
+
if (includes(['processing', 'thinking', 'responding', 'reading_files', 'editing_files', 'running_command', 'using_tool', 'multiple_tools', 'settled'], v.detail)) result.detail = v.detail;
|
|
52
57
|
if (Number.isInteger(v.port) && Number(v.port) >= 1 && Number(v.port) <= 65535) result.port = Number(v.port);
|
|
53
58
|
if (includes(['coordinator', 'worker', 'anonymous'], v.role)) result.role = v.role;
|
|
54
59
|
if (includes(OUTCOMES, v.outcome)) result.outcome = v.outcome;
|