@comput/pi-intercom 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +51 -10
  2. package/dist/config.d.ts +13 -1
  3. package/dist/config.js +57 -5
  4. package/dist/config.js.map +1 -1
  5. package/dist/handoff.d.ts +87 -0
  6. package/dist/handoff.js +327 -0
  7. package/dist/handoff.js.map +1 -0
  8. package/dist/index.js +76 -15
  9. package/dist/index.js.map +1 -1
  10. package/dist/monitor-view.d.ts +1 -1
  11. package/dist/monitor-view.js +49 -3
  12. package/dist/monitor-view.js.map +1 -1
  13. package/dist/observability.d.ts +1 -1
  14. package/dist/observability.js +1 -1
  15. package/dist/observability.js.map +1 -1
  16. package/dist/pane-close.d.ts +22 -0
  17. package/dist/pane-close.js +166 -0
  18. package/dist/pane-close.js.map +1 -0
  19. package/dist/runtime.d.ts +9 -2
  20. package/dist/runtime.js +75 -6
  21. package/dist/runtime.js.map +1 -1
  22. package/dist/snapshot.d.ts +9 -0
  23. package/dist/snapshot.js +23 -1
  24. package/dist/snapshot.js.map +1 -1
  25. package/dist/transport.d.ts +2 -1
  26. package/dist/transport.js +4 -1
  27. package/dist/transport.js.map +1 -1
  28. package/dist/version.d.ts +1 -0
  29. package/dist/version.js +15 -0
  30. package/dist/version.js.map +1 -0
  31. package/dist/worker-status.d.ts +1 -1
  32. package/dist/worker-status.js +9 -1
  33. package/dist/worker-status.js.map +1 -1
  34. package/package.json +2 -2
  35. package/references/implementation-blocker.md +1 -1
  36. package/references/observability.md +13 -3
  37. package/src/config.ts +49 -4
  38. package/src/handoff.ts +280 -0
  39. package/src/index.ts +60 -13
  40. package/src/monitor-view.ts +48 -4
  41. package/src/observability.ts +1 -1
  42. package/src/pane-close.ts +138 -0
  43. package/src/runtime.ts +63 -7
  44. package/src/snapshot.ts +22 -1
  45. package/src/transport.ts +4 -2
  46. package/src/version.ts +13 -0
  47. package/src/worker-status.ts +9 -2
@@ -1,8 +1,9 @@
1
1
  import { stripVTControlCharacters } from 'node:util';
2
2
  import { truncateToWidth, visibleWidth } from 'pi-intercom-tui';
3
- import type { ObservationSnapshot } from './snapshot.js';
3
+ import { publicCloseMetadata, type ObservationSnapshot } from './snapshot.js';
4
4
  import { workerObservation } from './worker-status.js';
5
5
  import { currentConnection, sortWorkersByConnection } from './connections.js';
6
+ import { INTERCOM_VERSION } from './version.js';
6
7
 
7
8
  const safe = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f\u2028-\u202e\u2066-\u2069]/g, ' ');
8
9
  const clip = (value: string, width: number) => stripVTControlCharacters(truncateToWidth(safe(value), Math.max(0, width)));
@@ -73,7 +74,9 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
73
74
  const selectedIndex = Math.min(workers.length - 1, Math.max(-1, options.selectedIndex ?? -1));
74
75
  const showDetails = Boolean(options.details && selectedIndex >= 0 && height >= 8);
75
76
  const selectedReport = snapshot.reports?.find(report => report.sessionId === workers[selectedIndex]?.sessionId && reportLabels[report.status]);
76
- const detailBudget = showDetails ? Math.min(selectedReport ? 15 : 8, height - 5) : 0;
77
+ const selectedSaved = workers[selectedIndex] ? publicCloseMetadata(workers[selectedIndex]) : {};
78
+ const hasSaved = !!(selectedSaved.handoff || selectedSaved.closeJob);
79
+ const detailBudget = showDetails ? Math.min(hasSaved ? 22 : selectedReport ? 15 : 8, height - 5) : 0;
77
80
  const available = Math.max(0, height - lines.length - 2 - detailBudget);
78
81
  const needsOverflow = workers.length > available;
79
82
  const count = Math.min(workers.length, Math.max(0, available - (needsOverflow && available > 1 ? 1 : 0)));
@@ -93,7 +96,32 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
93
96
  const selected = start + offset === selectedIndex;
94
97
  const report = snapshot.reports?.find(report => report.sessionId === worker.sessionId && reportLabels[report.status]);
95
98
  lines.push(table(worker.name, connection.state, activity, age, evidence, report ? reportLabels[report.status] : '—', tone, selected));
96
- if (selected && showDetails && report) {
99
+ if (selected && showDetails && hasSaved) {
100
+ const savedAge = (timestamp: string) => {
101
+ const elapsed = now - Date.parse(timestamp);
102
+ return elapsed < 0 || !Number.isFinite(elapsed) ? 'clock uncertain' : ageLabel(elapsed) + (elapsed > snapshot.staleAfterMs ? ' (old)' : '');
103
+ };
104
+ const { handoff, closeJob } = selectedSaved;
105
+ const context = [
106
+ ...(closeJob ? [line(` Close workflow: ${closeJob.state}${closeJob.reason ? ` · ${closeJob.reason}` : ''} · ${savedAge(closeJob.updatedAt)}`)] : []),
107
+ line(handoff ? ` Saved handoff (public): ${savedAge(handoff.updatedAt)}` : ' Saved handoff: none'),
108
+ ];
109
+ selectedDetail = [
110
+ ...context,
111
+ ...(handoff ? wrapSummary(`Handoff: ${handoff.summary}`, Math.max(0, width - 2), Math.min(4, Math.max(0, detailBudget - context.length))).map(text => line(` ${text}`)) : []),
112
+ ...(handoff ? [line(` Handoff saved: ${handoff.updatedAt}`)] : []),
113
+ ...(report ? [
114
+ line(` Report: ${reportLabels[report.status]} · ${savedAge(report.updatedAt)}`),
115
+ line(' Self-reported, pending review'),
116
+ ...wrapSummary(`Summary: ${report.summary}`, Math.max(0, width - 2), 2).map(text => line(` ${text}`)),
117
+ ] : []),
118
+ ...connectionDetails,
119
+ line(` Responsibility: ${worker.description || 'Not specified'}`),
120
+ line(` Observed status: ${activity} · ${age}`),
121
+ line(` Last activity: ${evidence}`),
122
+ ...(closeJob ? [line(` Close updated: ${closeJob.updatedAt}`), line(' Workflow state is not proof all child processes terminated')] : []),
123
+ ];
124
+ } else if (selected && showDetails && report) {
97
125
  const elapsed = now - Date.parse(report.updatedAt);
98
126
  const reportAge = !Number.isFinite(elapsed) || elapsed < 0 ? 'clock uncertain' : ageLabel(elapsed) + (elapsed > snapshot.staleAfterMs ? ' (old)' : '');
99
127
  const contextRows = detailBudget >= 8 ? [
@@ -124,7 +152,23 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
124
152
  const partial = snapshot && (snapshot.truncated || snapshot.errors.length);
125
153
  const controls = options.details ? 'q quit · Esc back · ↑↓ select' : 'q quit · ↑↓ select · Enter details';
126
154
  const hasReports = snapshot?.reports?.some(report => reportLabels[report.status] && snapshot.config?.agents.some(agent => !agent.coordinator && agent.sessionId === report.sessionId));
127
- const footer = width < 18 ? 'q quit' : ` ${controls}${partial ? ' · Partial observations' : ''}${hasReports ? ' · Reports: self-reported, pending review' : ''}`;
155
+ const version = `v${safe(INTERCOM_VERSION)}`;
156
+ const fullVersion = `Intercom ${version}`;
157
+ const label = width >= visibleWidth(controls) + visibleWidth(fullVersion) + 4 ? fullVersion : version;
158
+ const room = width - visibleWidth(label) - 2;
159
+ let footer = width < 6 ? 'q' : 'q quit';
160
+ if (room >= 6) {
161
+ const compactControls = options.details ? 'q quit · Esc · ↑↓' : 'q quit · ↑↓ · Enter';
162
+ const prefix = room >= 8 ? ' ' : '';
163
+ const available = room - prefix.length;
164
+ const hint = visibleWidth(controls) <= available ? controls : visibleWidth(compactControls) <= available ? compactControls : 'q quit';
165
+ const caveats = `${partial ? ' · Partial observations' : ''}${hasReports ? ' · Reports: self-reported, pending review' : ''}`;
166
+ // Lower-priority caveats may truncate, but never consume the quit hint or
167
+ // the reserved version label. Keep the version flush with the right edge.
168
+ const left = prefix + hint + clip(caveats, Math.max(0, available - visibleWidth(hint)));
169
+ footer = left + ' '.repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(label))) + label;
170
+ }
171
+ lines.splice(Math.max(0, height - 1));
128
172
  while (lines.length < height - 1) lines.push('');
129
173
  lines.push(dim(line(footer)));
130
174
  return lines.slice(0, height);
@@ -10,7 +10,7 @@ export const EVENT_TYPES = ['runtime.starting', 'runtime.ready', 'runtime.closed
10
10
  export type EventType = typeof EVENT_TYPES[number];
11
11
  const OUTCOMES = ['attempted', 'returned', 'failed', 'http_receipt', 'handler_failed', 'started', 'settled', 'snapshot', 'ended', 'written', 'removed', 'ready'] as const;
12
12
  const ERROR_CODES = ['operation_failed', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC'] as const;
13
- const KINDS = ['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'] as const;
13
+ const KINDS = ['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close', 'close_prepare', 'close_identity', 'close_request', 'handoff_report', 'close_ready', 'close_commit'] as const;
14
14
  const OPERATIONS = ['configure_worker', 'set_multiplexer', 'remove_worker', 'create_worker', 'resume_worker', 'port_update', 'initialize'] as const;
15
15
  export interface EventMetadata {
16
16
  peerSessionId?: string;
@@ -0,0 +1,138 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { constants } from 'node:fs';
4
+ import { open, readFile, realpath } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import type { Run } from './launcher.js';
7
+ import type { CloseProvider, WorkerPaneIdentity } from './handoff.js';
8
+
9
+ interface ProcessIdentity { start: string; state: string }
10
+ export interface PaneCloseOptions {
11
+ sessionId(): string;
12
+ sessionFile(): string | undefined;
13
+ env?: NodeJS.ProcessEnv;
14
+ platform?: string;
15
+ pid?: number;
16
+ run?: Run;
17
+ processIdentity?: (pid: number) => Promise<ProcessIdentity | undefined>;
18
+ verifySession?: (file: string, id: string) => Promise<void>;
19
+ verificationMs?: number;
20
+ }
21
+ const rejected = () => new Error('PiIntercom: worker pane identity could not be verified; no close submitted.');
22
+ const exec: Run = async (file, args) => (await promisify(execFile)(file, args, {
23
+ windowsHide: true, timeout: 4000, maxBuffer: 1024 * 1024,
24
+ })).stdout;
25
+
26
+ /** Linux process start ticks distinguish a reused PID from the original worker. */
27
+ export async function linuxProcessIdentity(pid: number): Promise<ProcessIdentity | undefined> {
28
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw rejected();
29
+ let stat: string;
30
+ try { stat = await readFile(`/proc/${pid}/stat`, 'utf8'); }
31
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw rejected(); }
32
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
33
+ if (!/^[0-9]+$/.test(fields[19] ?? '') || !/^[A-Za-z]$/.test(fields[0] ?? '')) throw rejected();
34
+ return { start: fields[19], state: fields[0] };
35
+ }
36
+ async function verifySessionHeader(file: string, id: string): Promise<void> {
37
+ if (!path.isAbsolute(file) || file.length > 4096 || file.includes('\0')) throw rejected();
38
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
39
+ try {
40
+ if (!(await handle.stat()).isFile()) throw rejected();
41
+ // Inspect only the first session-header line, never transcript messages.
42
+ const buffer = Buffer.alloc(4096);
43
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
44
+ const end = buffer.subarray(0, bytesRead).indexOf(10);
45
+ if (end < 0) throw rejected();
46
+ const header = JSON.parse(buffer.subarray(0, end).toString('utf8'));
47
+ if (header?.type !== 'session' || header.id !== id) throw rejected();
48
+ } finally { await handle.close(); }
49
+ }
50
+ const alive = (identity: ProcessIdentity | undefined, start: string) =>
51
+ !!identity && identity.start === start && !['Z', 'X', 'x'].includes(identity.state);
52
+
53
+ /** Linux Herdr only. Identity-checked best effort; Herdr has no atomic compare-and-close API. */
54
+ export function createCloseProvider(options: PaneCloseOptions): CloseProvider | undefined {
55
+ const env = options.env ?? process.env;
56
+ if ((options.platform ?? process.platform) !== 'linux' || env.HERDR_ENV !== '1' || !env.HERDR_PANE_ID) return;
57
+ const run = options.run ?? exec;
58
+ const processIdentity = options.processIdentity ?? linuxProcessIdentity;
59
+ const verifySession = options.verifySession ?? verifySessionHeader;
60
+ const ownPid = options.pid ?? process.pid;
61
+ const verificationMs = Math.max(0, Math.min(2000, options.verificationMs ?? 1500));
62
+ async function query(args: string[]): Promise<any> {
63
+ const output = await run('herdr', args);
64
+ if (Buffer.byteLength(output) > 1024 * 1024) throw rejected();
65
+ return JSON.parse(output)?.result;
66
+ }
67
+ async function caller(): Promise<any> {
68
+ const pane = (await query(['pane', 'current', '--current']))?.pane;
69
+ if (!pane || typeof pane.pane_id !== 'string' || typeof pane.workspace_id !== 'string' || typeof pane.terminal_id !== 'string') throw rejected();
70
+ return pane;
71
+ }
72
+ async function verifyPane(pane: any, identity: WorkerPaneIdentity): Promise<void> {
73
+ if (!pane || pane.pane_id !== identity.paneId || pane.workspace_id !== identity.workspaceId ||
74
+ pane.terminal_id !== identity.terminalId || pane.agent !== 'pi' ||
75
+ pane.agent_session?.source !== 'herdr:pi' || pane.agent_session?.kind !== 'path' ||
76
+ typeof pane.agent_session.value !== 'string' || !path.isAbsolute(identity.sessionFile) ||
77
+ !Number.isSafeInteger(identity.pid) || identity.pid <= 0 || !/^[0-9]+$/.test(identity.processStart)) throw rejected();
78
+ if (await realpath(pane.agent_session.value) !== await realpath(identity.sessionFile)) throw rejected();
79
+ await verifySession(identity.sessionFile, identity.sessionId);
80
+ const info = (await query(['pane', 'process-info', '--pane', identity.paneId]))?.process_info;
81
+ if (info?.pane_id !== identity.paneId || !Array.isArray(info.foreground_processes) ||
82
+ !info.foreground_processes.some((p: { pid?: number }) => p.pid === identity.pid) ||
83
+ !alive(await processIdentity(identity.pid), identity.processStart)) throw rejected();
84
+ }
85
+ async function inspect(identity: WorkerPaneIdentity, expectedSessionId: string, assertCurrent: () => void): Promise<void> {
86
+ assertCurrent();
87
+ if (identity.sessionId !== expectedSessionId || expectedSessionId === options.sessionId() || identity.pid === ownPid) throw rejected();
88
+ const own = await caller();
89
+ assertCurrent();
90
+ if (own.pane_id === identity.paneId || own.workspace_id !== identity.workspaceId) throw rejected();
91
+ await verifyPane((await query(['pane', 'get', identity.paneId]))?.pane, identity);
92
+ assertCurrent();
93
+ }
94
+ return {
95
+ async getIdentity() {
96
+ try {
97
+ const sessionId = options.sessionId(), sessionFile = options.sessionFile();
98
+ if (!sessionFile) throw rejected();
99
+ const pane = await caller();
100
+ const process = await processIdentity(ownPid);
101
+ if (!process || !alive(process, process.start)) throw rejected();
102
+ const identity: WorkerPaneIdentity = { workspaceId: pane.workspace_id, paneId: pane.pane_id,
103
+ terminalId: pane.terminal_id, sessionId, sessionFile, pid: ownPid, processStart: process.start };
104
+ await verifyPane(pane, identity);
105
+ if (options.sessionId() !== sessionId || options.sessionFile() !== sessionFile) throw rejected();
106
+ return identity;
107
+ } catch { throw rejected(); }
108
+ },
109
+ async inspect(identity, expectedSessionId, assertCurrent) {
110
+ try { await inspect(identity, expectedSessionId, assertCurrent); } catch { throw rejected(); }
111
+ },
112
+ async close(identity, expectedSessionId, assertCurrent, beforeSubmit) {
113
+ try { await inspect(identity, expectedSessionId, assertCurrent); } catch { throw rejected(); }
114
+ assertCurrent();
115
+ // Revalidate worker settlement only AFTER potentially slow ownership reads.
116
+ // Herdr still has no atomic cross-process identity/idle compare-and-close.
117
+ await beforeSubmit();
118
+ assertCurrent();
119
+ // Exactly one explicit pane target. No tab/workspace close, retry, or force kill.
120
+ // A CLI timeout can mean the mutation happened: caller must persist uncertainty.
121
+ try { await run('herdr', ['pane', 'close', identity.paneId]); }
122
+ catch { throw new Error('PiIntercom: pane close outcome uncertain; inspect before any retry.'); }
123
+ let paneClosed = false, workerExited = false;
124
+ try {
125
+ const panes = (await query(['pane', 'list', '--workspace', identity.workspaceId]))?.panes;
126
+ paneClosed = Array.isArray(panes) && panes.every(p => typeof p?.pane_id === 'string') &&
127
+ !panes.some(p => p.pane_id === identity.paneId);
128
+ const deadline = Date.now() + verificationMs;
129
+ do {
130
+ workerExited = !alive(await processIdentity(identity.pid), identity.processStart);
131
+ if (workerExited || Date.now() >= deadline) break;
132
+ await new Promise(resolve => setTimeout(resolve, 50));
133
+ } while (true);
134
+ } catch { /* Verification failure is uncertainty, never an automatic retry. */ }
135
+ return { paneClosed, workerExited };
136
+ },
137
+ };
138
+ }
package/src/runtime.ts CHANGED
@@ -7,8 +7,9 @@ import { saveWorkerReport } from './reports.js';
7
7
  import { readObservationSnapshot } from './snapshot.js';
8
8
  import { workerStatusPage } from './worker-status.js';
9
9
  import { probeWorker, probeWorkers } from './connections.js';
10
+ import { HandoffWorkflow, HANDOFF_KINDS, fencedClose, type CloseProvider, type HandoffKind } from './handoff.js';
10
11
 
11
- export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and close_worker are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. See references/implementation-blocker.md. A verified supported host API is required (no version-only override).';
12
+ export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and legacy graceful-close control are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. close_worker uses a separate supported-provider owned-pane closure contract, not graceful cancellation. See references/implementation-blocker.md. A verified supported host API is required for graceful cancellation (no version-only override).';
12
13
  export interface Host {
13
14
  sessionId(): string;
14
15
  cwd: string;
@@ -25,6 +26,9 @@ export interface RuntimeOptions {
25
26
  probe?: typeof probeWorker;
26
27
  store?: (root: string) => ConfigStore;
27
28
  observe?: (root: string, sessionId: string) => Observer;
29
+ closeProvider?: CloseProvider;
30
+ /** Test seam; production defaults to 120 seconds, values above that are capped. */
31
+ closeTimeoutMs?: number;
28
32
  }
29
33
  export class Intercom {
30
34
  store!: ConfigStore;
@@ -39,6 +43,9 @@ export class Intercom {
39
43
  private pendingReports = 0;
40
44
  private probeController = new AbortController();
41
45
  private resumeFences = new Map<string, { inFlight: boolean; submitted: boolean; announced: boolean }>();
46
+ private handoff?: HandoffWorkflow;
47
+ workerStarted(): void { this.handoff?.workerStarted(); }
48
+ workerSettled(successfulToolCallIds: ReadonlySet<string>): void { this.handoff?.workerSettled(successfulToolCallIds); }
42
49
  private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
43
50
  const observer = this.observer;
44
51
  return (event, metadata = {}) => {
@@ -91,7 +98,23 @@ export class Intercom {
91
98
  const config = await this.store.read();
92
99
  assertValid();
93
100
  const me = config.agents.find(a => a.sessionId === this.id());
101
+ this.handoff = new HandoffWorkflow({
102
+ state: () => this.state(), assertCurrent: assertValid,
103
+ update: async (mutate, guard) => {
104
+ await this.store.update(this.initialId, mutate, () => { assertValid(); guard?.(); });
105
+ },
106
+ send: async (sessionId, kind, payload) => {
107
+ const state = await this.state(); assertValid();
108
+ const target = state.config.agents.find(a => a.sessionId === sessionId);
109
+ if (!target) fail('handoff recipient no longer configured');
110
+ await (this.options.send ?? send)(target.port, { version: 1, kind, from: state.id, to: sessionId, payload, correlationId: randomUUID() });
111
+ assertValid();
112
+ },
113
+ deliver: message => { assertValid(); this.host.deliver(message, this.host.busy()); },
114
+ busy: () => this.host.busy(), provider: this.options.closeProvider, timeoutMs: this.options.closeTimeoutMs,
115
+ });
94
116
  if (me?.coordinator) {
117
+ await this.handoff.recover(); assertValid();
95
118
  try { this.observer?.maintain?.(); } catch { /* No cleanup failure affects readiness. */ }
96
119
  await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
97
120
  assertValid();
@@ -128,6 +151,7 @@ export class Intercom {
128
151
  const observer = this.observer; this.observer = undefined;
129
152
  this.active = false; this.generation++;
130
153
  this.probeController.abort();
154
+ this.handoff?.dispose(); this.handoff = undefined;
131
155
  const endpoint = this.endpoint; this.endpoint = undefined;
132
156
  this.responsibility = undefined;
133
157
  const closing = (async () => {
@@ -170,7 +194,8 @@ export class Intercom {
170
194
  assertValid();
171
195
  if (!me) fail('worker not configured; configure_worker must run before reload_worker');
172
196
  this.responsibility = { ...me };
173
- await this.host.setName(me.name);
197
+ // Coordinator is a routing identity, not ownership of the user's Pi/tab title.
198
+ if (!me.coordinator) await this.host.setName(me.name);
174
199
  assertValid();
175
200
  record('config.reloaded', { role: me.coordinator ? 'coordinator' : 'worker' });
176
201
  this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
@@ -181,6 +206,7 @@ export class Intercom {
181
206
  const { id, config } = await this.state();
182
207
  assertValid();
183
208
  const target = named(config, to), correlationId = randomUUID();
209
+ if (['message', 'reload'].includes(kind) && (this.handoff?.isClosing(target.sessionId) || fencedClose(target.closeJob))) fail('worker close is active or uncertain; new Intercom work is fenced');
184
210
  const metadata = { peerSessionId: target.sessionId, peerName: target.name, correlationId, kind };
185
211
  record('transport.send', { ...metadata, outcome: 'attempted' });
186
212
  try {
@@ -232,6 +258,10 @@ export class Intercom {
232
258
  throw error;
233
259
  }
234
260
  };
261
+ if (HANDOFF_KINDS.includes(message.kind)) {
262
+ if (!this.handoff || !me || (!me.coordinator && !this.responsibility)) fail('handoff requires a configured, responsibility-loaded worker');
263
+ await this.handoff.receive(message.kind as HandoffKind, message.from, message.payload); return;
264
+ }
235
265
  if (message.kind === 'registration' || message.kind === 'status') {
236
266
  requireCoordinator(config, id);
237
267
  if (sender?.coordinator) fail('coordinator cannot report as worker');
@@ -281,12 +311,16 @@ export class Intercom {
281
311
  if (message.kind === 'message') {
282
312
  if (!sender || !me) fail('agent messaging requires configured sender and recipient; anonymous permissions TODO');
283
313
  if (!this.responsibility) fail('worker must load responsibility before receiving work');
314
+ if (this.handoff?.workerClosing || fencedClose(me.closeJob)) fail('worker close is active or uncertain; new Intercom work is fenced');
284
315
  notify('agent message', message.payload.message as string); return;
285
316
  }
286
317
  if (!sender?.coordinator) fail('control requires current coordinator sender session ID');
287
318
  if (me?.coordinator) fail('worker control cannot target coordinator');
288
319
  if (message.kind === 'stop' || message.kind === 'close') fail(UNSUPPORTED_CANCELLATION);
289
- if (message.kind === 'reload') { await this.reload(); return; }
320
+ if (message.kind === 'reload') {
321
+ if (this.handoff?.workerClosing || fencedClose(me?.closeJob)) fail('worker close is active or uncertain; reload is fenced');
322
+ await this.reload(); return;
323
+ }
290
324
  if (message.kind === 'request_status') {
291
325
  // Independent one-way report, never a synchronous status in the HTTP response.
292
326
  setImmediate(() => {
@@ -300,7 +334,7 @@ export class Intercom {
300
334
  }
301
335
  fail('unsupported control');
302
336
  }
303
- async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
337
+ async tool(operation: string, args: Record<string, unknown>, toolCallId?: string): Promise<unknown> {
304
338
  const record = this.captureObserver();
305
339
  const assertValid = this.validity();
306
340
  const { id, config, me } = await this.state();
@@ -322,6 +356,10 @@ export class Intercom {
322
356
  if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
323
357
  return { ...config, agents: config.agents.map(({ dashboardPort: _retired, ...agent }) => agent) };
324
358
  }
359
+ if (operation === 'report_handoff') {
360
+ if (!me || me.coordinator || !this.responsibility || !this.handoff) fail('report_handoff requires a configured, responsibility-loaded worker');
361
+ return this.handoff.report(args.jobId, args.summary, toolCallId);
362
+ }
325
363
  if (operation === 'report_status') { await this.report(); return { accepted: true }; }
326
364
  if (operation === 'report_work') {
327
365
  if (!me || me.coordinator || !this.responsibility) fail('report_work requires a configured, responsibility-loaded worker');
@@ -331,12 +369,19 @@ export class Intercom {
331
369
  }
332
370
  if (operation === 'send') {
333
371
  if (!me || !this.responsibility) fail('anonymous/unloaded worker cannot send agent messages (permissions TODO)');
372
+ const recipient = named(config, args.to as string);
373
+ if (this.handoff?.workerClosing || fencedClose(me.closeJob) || this.handoff?.isClosing(recipient.sessionId) || fencedClose(recipient.closeJob)) fail('worker close is active or uncertain; new Intercom work is fenced');
334
374
  await this.transmit(args.to as string, 'message', { message: args.message }); return { accepted: true, completion: 'not awaited' };
335
375
  }
336
376
  requireCoordinator(config, id);
337
- if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
377
+ if (operation === 'stop_worker') fail(UNSUPPORTED_CANCELLATION);
338
378
  if (operation === 'configure_worker') {
339
- await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertValid);
379
+ const assertConfigurable = () => {
380
+ assertValid();
381
+ if (this.handoff?.isClosing(args.sessionId as string) || this.resumeFences.has(args.sessionId as string)) fail('worker lifecycle operation is in flight; configuration is fenced');
382
+ };
383
+ assertConfigurable();
384
+ await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertConfigurable);
340
385
  record('config.changed', { operation: 'configure_worker', peerSessionId: args.sessionId as string, peerName: args.name as string, outcome: 'written' });
341
386
  return { written: true, reloaded: false };
342
387
  }
@@ -351,6 +396,12 @@ export class Intercom {
351
396
  }
352
397
  const target = named(config, args.to as string);
353
398
  if (target.coordinator) fail('operation requires a worker target');
399
+ if (operation === 'close_worker') {
400
+ if (!this.options.closeProvider || !this.handoff || config.multiplexer !== 'herdr') fail('Owned-pane close requires supported Linux Herdr provider; no closure attempted');
401
+ if (this.resumeFences.has(target.sessionId)) fail('resume is in flight; close is fenced');
402
+ return { job: await this.handoff.request(target), completion: 'not awaited', contract: 'best-effort owned-pane closure; not all-child-process termination' };
403
+ }
404
+ if (['reload_worker', 'resume_worker', 'remove_worker'].includes(operation) && (this.handoff?.isClosing(target.sessionId) || fencedClose(target.closeJob))) fail('worker close is active or uncertain; operation is fenced');
354
405
  if (operation === 'reload_worker' || operation === 'request_status') {
355
406
  await this.transmit(target.name, operation === 'reload_worker' ? 'reload' : 'request_status');
356
407
  return { accepted: true, completion: 'not awaited' };
@@ -373,6 +424,7 @@ export class Intercom {
373
424
  requireCoordinator(fresh.config, id);
374
425
  const current = fresh.config.agents.find(agent => agent.sessionId === target.sessionId && !agent.coordinator);
375
426
  if (!current || current.port !== target.port || current.projectDirectory !== target.projectDirectory) fail('Worker configuration changed during resume check; no launch submitted.');
427
+ if (this.handoff?.isClosing(target.sessionId) || fencedClose(current.closeJob)) fail('worker close is active or uncertain; resume is fenced');
376
428
  fence.submitted = true;
377
429
  const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, sessionId: current.sessionId });
378
430
  completed = true;
@@ -384,7 +436,11 @@ export class Intercom {
384
436
  }
385
437
  if (operation === 'remove_worker') {
386
438
  // No live process claim: the caller must explicitly ensure closure first.
387
- await this.store.update(id, c => { c.agents = c.agents.filter(a => a.sessionId !== target.sessionId); }, assertValid);
439
+ await this.store.update(id, c => {
440
+ const current = c.agents.find(a => a.sessionId === target.sessionId);
441
+ if (this.handoff?.isClosing(target.sessionId) || this.resumeFences.has(target.sessionId) || fencedClose(current?.closeJob)) fail('worker lifecycle operation is active or uncertain; removal is fenced');
442
+ c.agents = c.agents.filter(a => a.sessionId !== target.sessionId);
443
+ }, assertValid);
388
444
  record('config.changed', { operation: 'remove_worker', peerSessionId: target.sessionId, peerName: target.name, outcome: 'removed' });
389
445
  return { removed: true, processStopped: false, sessionDeleted: false };
390
446
  }
package/src/snapshot.ts CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { validateConfig, type Config } from './config.js';
5
5
  import { readWorkerReports, type WorkerReport } from './reports.js';
6
6
  import type { WorkerConnection } from './connections.js';
7
+ import { validateHandoff, validateCloseJob, type Handoff, type CloseJob } from './handoff.js';
7
8
  import { LOG_DIRECTORY, LOG_FILE_PATTERN, sanitizeObservation, type Observation } from './observability.js';
8
9
 
9
10
  const CONFIG_BYTES = 1024 * 1024, TAIL_BYTES = 128 * 1024, TOTAL_BYTES = 2 * 1024 * 1024;
@@ -21,6 +22,26 @@ export interface ObservationSnapshot {
21
22
  errors: string[];
22
23
  }
23
24
 
25
+ /** Public saved context only; reject malformed/private protocol metadata. */
26
+ export function publicCloseMetadata(agent: { handoff?: unknown; closeJob?: unknown }): { handoff?: Handoff; closeJob?: CloseJob } {
27
+ const result: { handoff?: Handoff; closeJob?: CloseJob } = {};
28
+ try {
29
+ if (agent.handoff !== undefined) {
30
+ validateHandoff(agent.handoff);
31
+ const { version, summary, updatedAt, jobId } = agent.handoff;
32
+ result.handoff = { version, summary, updatedAt, jobId };
33
+ }
34
+ } catch { /* Invalid optional context is not public evidence. */ }
35
+ try {
36
+ if (agent.closeJob !== undefined) {
37
+ validateCloseJob(agent.closeJob);
38
+ const { jobId, state, createdAt, updatedAt, deadlineAt, reason } = agent.closeJob;
39
+ result.closeJob = { jobId, state, createdAt, updatedAt, deadlineAt, ...(reason ? { reason } : {}) };
40
+ }
41
+ } catch { /* Never expose arbitrary errors or private protocol fields. */ }
42
+ return result;
43
+ }
44
+
24
45
  // Only fixed, extension-owned paths are read. Reject symlink/junction components
25
46
  // rather than allowing a local log/config link to expose an unrelated file.
26
47
  async function confined(root: string, relative: string): Promise<string> {
@@ -65,7 +86,7 @@ export async function readObservationSnapshot(root: string): Promise<Observation
65
86
  result.truncated ||= config.agents.length > MAX_AGENTS;
66
87
  result.config = { multiplexer: config.multiplexer, agents: config.agents.slice(0, MAX_AGENTS).map(a => ({
67
88
  sessionId: a.sessionId, name: a.name, coordinator: a.coordinator, description: a.description,
68
- projectDirectory: a.projectDirectory, port: a.port,
89
+ projectDirectory: a.projectDirectory, port: a.port, ...publicCloseMetadata(a),
69
90
  })) };
70
91
  } catch { result.errors.push('config_unavailable'); }
71
92
  try {
package/src/transport.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import http from 'node:http';
2
2
  import { fail, port, text } from './config.js';
3
3
  import type { ReportStatus } from './reports.js';
4
+ import { HANDOFF_KINDS, validateHandoffPayload, type HandoffKind } from './handoff.js';
4
5
 
5
6
  export const BODY_LIMIT = 64 * 1024;
6
7
  export const RECEIPT_TIMEOUT = 5000;
7
- export type Kind = 'message' | 'report' | 'registration' | 'status' | 'request_status' | 'reload' | 'stop' | 'close';
8
+ export type Kind = 'message' | 'report' | 'registration' | 'status' | 'request_status' | 'reload' | 'stop' | 'close' | HandoffKind;
8
9
  export interface Envelope {
9
10
  version: 1;
10
11
  kind: Kind;
@@ -23,12 +24,13 @@ export function reportPayload(payload: Record<string, unknown>): { status: Repor
23
24
  }
24
25
  export function envelope(value: unknown): Envelope {
25
26
  const m = value as Envelope;
26
- if (!m || m.version !== 1 || !['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'].includes(m.kind)) fail('invalid wire schema/version/kind');
27
+ if (!m || m.version !== 1 || !['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close', ...HANDOFF_KINDS].includes(m.kind)) fail('invalid wire schema/version/kind');
27
28
  text(m.from, 'sender sessionId', 256); text(m.to, 'recipient sessionId', 256);
28
29
  if (m.correlationId !== undefined && (typeof m.correlationId !== 'string' || !/^[a-zA-Z0-9-]{1,128}$/.test(m.correlationId))) fail('invalid correlation ID');
29
30
  if (!m.payload || Array.isArray(m.payload) || typeof m.payload !== 'object') fail('invalid payload');
30
31
  if (m.kind === 'message') text(m.payload.message, 'message', 48000);
31
32
  if (m.kind === 'report') reportPayload(m.payload);
33
+ if (HANDOFF_KINDS.includes(m.kind)) validateHandoffPayload(m.kind, m.payload);
32
34
  if (m.kind === 'registration' || m.kind === 'status') port(m.payload.port);
33
35
  if (m.kind === 'registration') text(m.payload.projectDirectory, 'projectDirectory');
34
36
  if (m.kind === 'status' && typeof m.payload.busy !== 'boolean') fail('invalid busy flag');
package/src/version.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ // Both src/ and dist/ sit directly below this package's own manifest. Capture
4
+ // once at process startup, not from cwd or on each render after an update.
5
+ function packageVersion(): string {
6
+ try {
7
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
+ const version: unknown = manifest.version;
9
+ if (typeof version === 'string' && version.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) return version;
10
+ } catch { /* A display label must never prevent the monitor starting. */ }
11
+ return 'unknown';
12
+ }
13
+ export const INTERCOM_VERSION = packageVersion();
@@ -1,4 +1,4 @@
1
- import type { ObservationSnapshot } from './snapshot.js';
1
+ import { publicCloseMetadata, type ObservationSnapshot } from './snapshot.js';
2
2
  import { currentConnection, sortWorkersByConnection } from './connections.js';
3
3
 
4
4
  const labels: Record<string, string> = {
@@ -49,14 +49,21 @@ export function workerStatusPage(snapshot: ObservationSnapshot, options: { name?
49
49
  }
50
50
  const workers: Array<{ sessionId: string; [key: string]: unknown }> = [];
51
51
  const result = { version: 1, source: 'local-observations', generatedAt: new Date(now).toISOString(),
52
- statusMeaning: 'Activity is last observed, not task completion. Connectivity is a separate endpoint check, never proof of termination. Reports are self-reported, not approval.',
52
+ statusMeaning: 'Activity is last observed, not task completion. Connectivity is a separate endpoint check, never proof of termination. Reports are self-reported, not approval. Handoff is saved public context, not task completion; closeJob is workflow state, never proof that all child processes terminated.',
53
53
  truncated: snapshot.truncated, errors: snapshot.errors, totalInSnapshot: roster.length, offset,
54
54
  nextOffset: null as number | null, workers };
55
55
  for (const agent of roster.slice(offset, offset + limit)) {
56
56
  const report = snapshot.reports?.find(r => r.sessionId === agent.sessionId && r.status !== 'clear');
57
57
  const reportAge = report ? now - Date.parse(report.updatedAt) : NaN;
58
+ const saved = publicCloseMetadata(agent);
59
+ const ageSeconds = (timestamp: string) => {
60
+ const age = now - Date.parse(timestamp);
61
+ return Number.isFinite(age) && age >= 0 ? Math.floor(age / 1000) : null;
62
+ };
58
63
  const entry = { sessionId: agent.sessionId, name: agent.name, ...workerObservation(snapshot, agent.sessionId, now),
59
64
  connection: currentConnection(agent.sessionId, snapshot.connections, now),
65
+ handoff: saved.handoff ? { ...saved.handoff, ageSeconds: ageSeconds(saved.handoff.updatedAt), selfReported: true } : null,
66
+ closeJob: saved.closeJob ? { ...saved.closeJob, ageSeconds: ageSeconds(saved.closeJob.updatedAt) } : null,
60
67
  report: report ? { status: report.status, summary: report.summary, updatedAt: report.updatedAt,
61
68
  ageSeconds: Number.isFinite(reportAge) && reportAge >= 0 ? Math.floor(reportAge / 1000) : null,
62
69
  selfReported: true } : null };