@comput/pi-intercom 0.5.2 → 0.8.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 (52) hide show
  1. package/README.md +109 -15
  2. package/dist/config.d.ts +18 -1
  3. package/dist/config.js +141 -27
  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 +92 -17
  9. package/dist/index.js.map +1 -1
  10. package/dist/launcher.js +11 -1
  11. package/dist/launcher.js.map +1 -1
  12. package/dist/monitor-view.d.ts +1 -1
  13. package/dist/monitor-view.js +31 -2
  14. package/dist/monitor-view.js.map +1 -1
  15. package/dist/observability.d.ts +1 -1
  16. package/dist/observability.js +1 -1
  17. package/dist/observability.js.map +1 -1
  18. package/dist/pane-close.d.ts +22 -0
  19. package/dist/pane-close.js +166 -0
  20. package/dist/pane-close.js.map +1 -0
  21. package/dist/reminders.d.ts +28 -0
  22. package/dist/reminders.js +81 -0
  23. package/dist/reminders.js.map +1 -0
  24. package/dist/runtime.d.ts +19 -2
  25. package/dist/runtime.js +163 -16
  26. package/dist/runtime.js.map +1 -1
  27. package/dist/snapshot.d.ts +9 -0
  28. package/dist/snapshot.js +23 -1
  29. package/dist/snapshot.js.map +1 -1
  30. package/dist/transport.d.ts +2 -1
  31. package/dist/transport.js +4 -1
  32. package/dist/transport.js.map +1 -1
  33. package/dist/worker-status.d.ts +1 -1
  34. package/dist/worker-status.js +9 -1
  35. package/dist/worker-status.js.map +1 -1
  36. package/package.json +12 -6
  37. package/references/implementation-blocker.md +1 -1
  38. package/references/observability.md +13 -3
  39. package/references/releasing.md +90 -0
  40. package/references/v1-specification.md +4 -4
  41. package/src/config.ts +107 -19
  42. package/src/handoff.ts +280 -0
  43. package/src/index.ts +75 -15
  44. package/src/launcher.ts +9 -1
  45. package/src/monitor-view.ts +30 -3
  46. package/src/observability.ts +1 -1
  47. package/src/pane-close.ts +138 -0
  48. package/src/reminders.ts +69 -0
  49. package/src/runtime.ts +138 -18
  50. package/src/snapshot.ts +22 -1
  51. package/src/transport.ts +4 -2
  52. package/src/worker-status.ts +9 -2
@@ -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
+ }
@@ -0,0 +1,69 @@
1
+ export const REMINDER_INTERVAL_MS = 5 * 60 * 1000;
2
+ export const REMINDER_TEXT = 'Intercom reminder: Review outstanding delegated work and worker reports. If follow-up is needed, take the next appropriate action. Do not assume workers have finished or stopped.\n\nIf there is genuinely nothing to follow up on, call intercom_stop_reminders. Otherwise, reminders continue every five minutes; a new worker call resets the timer.\n\nThis reminder does not authorize new work, retries, worker closure, or publication.';
3
+
4
+ export interface ReminderClock {
5
+ schedule(callback: () => void, milliseconds: number): () => void;
6
+ }
7
+ const clock: ReminderClock = {
8
+ schedule(callback, milliseconds) {
9
+ const timer = setTimeout(callback, milliseconds);
10
+ timer.unref();
11
+ return () => clearTimeout(timer);
12
+ },
13
+ };
14
+ interface ReminderHost {
15
+ isCoordinator(): Promise<boolean>;
16
+ busy(): boolean;
17
+ deliver(text: string): void;
18
+ }
19
+ /** Ephemeral reminder only: never worker actions, network retries or a backlog. */
20
+ export class CoordinatorReminders {
21
+ private generation = 0;
22
+ private tick = 0;
23
+ private checking = false;
24
+ private disposed = false;
25
+ private cancel?: () => void;
26
+ private detachOwner?: () => void;
27
+ constructor(private readonly host: ReminderHost, private readonly timer: ReminderClock = clock) {}
28
+ arm(signal?: AbortSignal): void {
29
+ if (this.disposed || signal?.aborted) return;
30
+ this.stop();
31
+ const generation = this.generation;
32
+ if (signal) {
33
+ const abort = () => { if (this.generation === generation) this.stop(); };
34
+ signal.addEventListener('abort', abort, { once: true });
35
+ this.detachOwner = () => signal.removeEventListener('abort', abort);
36
+ }
37
+ this.schedule(generation);
38
+ }
39
+ stop(): void {
40
+ this.generation++; this.tick++;
41
+ this.cancel?.(); this.cancel = undefined;
42
+ this.detachOwner?.(); this.detachOwner = undefined;
43
+ }
44
+ dispose(): void { this.stop(); this.disposed = true; }
45
+ private current(generation: number, tick: number): boolean {
46
+ return !this.disposed && this.generation === generation && this.tick === tick;
47
+ }
48
+ private schedule(generation: number): void {
49
+ this.cancel = this.timer.schedule(() => {
50
+ if (this.disposed || generation !== this.generation) return;
51
+ this.cancel = undefined;
52
+ const tick = ++this.tick;
53
+ this.schedule(generation);
54
+ // A slow role read never creates concurrent reads or a queued reminder.
55
+ // Advancing tick also invalidates any read from the preceding interval.
56
+ if (this.checking) return;
57
+ this.checking = true;
58
+ void (async () => {
59
+ try {
60
+ const coordinator = await this.host.isCoordinator();
61
+ if (!this.current(generation, tick)) return;
62
+ if (!coordinator) { this.stop(); return; }
63
+ if (!this.host.busy()) this.host.deliver(REMINDER_TEXT);
64
+ } catch { /* Unknown role/read/delivery outcome: no blind immediate replay. */ }
65
+ finally { this.checking = false; }
66
+ })();
67
+ }, REMINDER_INTERVAL_MS);
68
+ }
69
+ }
package/src/runtime.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { realpath } from 'node:fs/promises';
3
4
  import { LocalObserver, LOG_LIMITS, observationError, type Observer, type EventType, type EventMetadata } from './observability.js';
4
- import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, type Agent, type Config } from './config.js';
5
+ import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, text, validateConfig, type Agent, type Config } from './config.js';
5
6
  import { listen, send, reportPayload, type Endpoint, type Envelope, type Kind } from './transport.js';
6
7
  import { saveWorkerReport } from './reports.js';
7
8
  import { readObservationSnapshot } from './snapshot.js';
8
9
  import { workerStatusPage } from './worker-status.js';
9
10
  import { probeWorker, probeWorkers } from './connections.js';
11
+ import { HandoffWorkflow, HANDOFF_KINDS, fencedClose, type CloseProvider, type HandoffKind } from './handoff.js';
12
+ import { CoordinatorReminders, type ReminderClock } from './reminders.js';
10
13
 
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).';
14
+ 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
15
  export interface Host {
13
16
  sessionId(): string;
14
17
  cwd: string;
@@ -17,7 +20,7 @@ export interface Host {
17
20
  setName(name: string): Promise<void>;
18
21
  notify(text: string): void;
19
22
  }
20
- export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; sessionId?: string }
23
+ export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; workspaceRoot: string; sessionId?: string }
21
24
  export interface RuntimeOptions {
22
25
  launch(request: LaunchRequest): Promise<unknown>;
23
26
  listen?: typeof listen;
@@ -25,6 +28,12 @@ export interface RuntimeOptions {
25
28
  probe?: typeof probeWorker;
26
29
  store?: (root: string) => ConfigStore;
27
30
  observe?: (root: string, sessionId: string) => Observer;
31
+ closeProvider?: CloseProvider;
32
+ /** Test seam; production defaults to 120 seconds, values above that are capped. */
33
+ closeTimeoutMs?: number;
34
+ reminderClock?: ReminderClock;
35
+ /** Explicit launcher-provided root must equal canonical cwd; no parent-root exception. */
36
+ workspaceRoot?: string;
28
37
  }
29
38
  export class Intercom {
30
39
  store!: ConfigStore;
@@ -32,6 +41,8 @@ export class Intercom {
32
41
  private active = false;
33
42
  private generation = 0;
34
43
  private initialId = '';
44
+ private configuredRoot?: string;
45
+ private configuredFile?: string;
35
46
  responsibility?: Agent;
36
47
  private observer?: Observer;
37
48
  private closing?: Promise<void>;
@@ -39,6 +50,12 @@ export class Intercom {
39
50
  private pendingReports = 0;
40
51
  private probeController = new AbortController();
41
52
  private resumeFences = new Map<string, { inFlight: boolean; submitted: boolean; announced: boolean }>();
53
+ private handoff?: HandoffWorkflow;
54
+ private reminders?: CoordinatorReminders;
55
+ private reminderRevision = 0;
56
+ private reminderInvocation = 0;
57
+ workerStarted(): void { this.handoff?.workerStarted(); }
58
+ workerSettled(successfulToolCallIds: ReadonlySet<string>): void { this.handoff?.workerSettled(successfulToolCallIds); }
42
59
  private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
43
60
  const observer = this.observer;
44
61
  return (event, metadata = {}) => {
@@ -50,6 +67,7 @@ export class Intercom {
50
67
  private id(): string {
51
68
  const id = this.host.sessionId();
52
69
  if (!this.active || id !== this.initialId) fail('session is inactive or replaced');
70
+ if (this.configuredRoot !== undefined && (this.store.root !== this.configuredRoot || this.store.file !== this.configuredFile)) fail('config store boundary changed');
53
71
  return id;
54
72
  }
55
73
  private validity(): () => void {
@@ -64,19 +82,27 @@ export class Intercom {
64
82
  if (this.closing) await this.closing;
65
83
  if (this.active) fail('runtime already active');
66
84
  this.initialId = this.host.sessionId(); this.active = true;
85
+ this.configuredRoot = undefined; this.configuredFile = undefined;
67
86
  this.probeController = new AbortController();
68
87
  this.resumeFences.clear();
69
88
  const generation = ++this.generation, assertValid = this.validity();
70
89
  let record = this.captureObserver();
71
90
  try {
72
- const discovered = await ConfigStore.discover(this.host.cwd);
91
+ const discovered = this.options.workspaceRoot !== undefined
92
+ ? await ConfigStore.workspace(this.host.cwd, this.options.workspaceRoot)
93
+ : await ConfigStore.discover(this.host.cwd);
94
+ assertValid();
95
+ const root = discovered?.root ?? await realpath(this.host.cwd);
96
+ assertValid();
97
+ this.store = this.options.store?.(root) ?? discovered ?? new ConfigStore(root);
98
+ if (this.store.root !== root || this.store.file !== path.join(root, '.pi-intercom', 'config.json')) fail('config store must retain validated session root');
99
+ this.configuredRoot = root; this.configuredFile = this.store.file;
100
+ await this.store.validateRoot(root); assertValid();
101
+ const previous = discovered ? await this.store.read() : undefined;
73
102
  assertValid();
74
- this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
75
103
  try { this.observer = this.options.observe ? this.options.observe(this.store.root, this.initialId) : new LocalObserver(this.store.root, this.initialId); } catch { /* Logging is optional on failure. */ }
76
104
  record = this.captureObserver();
77
105
  record('runtime.starting');
78
- const previous = discovered ? await this.store.read() : undefined;
79
- assertValid();
80
106
  const own = previous?.agents.find(a => a.sessionId === this.id());
81
107
  const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); }, () => {
82
108
  assertValid();
@@ -91,7 +117,28 @@ export class Intercom {
91
117
  const config = await this.store.read();
92
118
  assertValid();
93
119
  const me = config.agents.find(a => a.sessionId === this.id());
120
+ this.reminders = new CoordinatorReminders({
121
+ isCoordinator: async () => { const fresh = await this.state(); assertValid(); return fresh.me?.coordinator === true; },
122
+ busy: () => this.host.busy(),
123
+ deliver: message => { assertValid(); this.host.deliver(message, false); },
124
+ }, this.options.reminderClock);
125
+ this.handoff = new HandoffWorkflow({
126
+ state: () => this.state(), assertCurrent: assertValid,
127
+ update: async (mutate, guard) => {
128
+ await this.store.update(this.initialId, mutate, () => { assertValid(); guard?.(); });
129
+ },
130
+ send: async (sessionId, kind, payload) => {
131
+ const state = await this.state(); assertValid();
132
+ const target = state.config.agents.find(a => a.sessionId === sessionId);
133
+ if (!target) fail('handoff recipient no longer configured');
134
+ await (this.options.send ?? send)(target.port, { version: 1, kind, from: state.id, to: sessionId, payload, correlationId: randomUUID() });
135
+ assertValid();
136
+ },
137
+ deliver: message => { assertValid(); this.host.deliver(message, this.host.busy()); },
138
+ busy: () => this.host.busy(), provider: this.options.closeProvider, timeoutMs: this.options.closeTimeoutMs,
139
+ });
94
140
  if (me?.coordinator) {
141
+ await this.handoff.recover(); assertValid();
95
142
  try { this.observer?.maintain?.(); } catch { /* No cleanup failure affects readiness. */ }
96
143
  await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
97
144
  assertValid();
@@ -128,6 +175,9 @@ export class Intercom {
128
175
  const observer = this.observer; this.observer = undefined;
129
176
  this.active = false; this.generation++;
130
177
  this.probeController.abort();
178
+ this.handoff?.dispose(); this.handoff = undefined;
179
+ this.reminderRevision = ++this.reminderInvocation;
180
+ this.reminders?.dispose(); this.reminders = undefined;
131
181
  const endpoint = this.endpoint; this.endpoint = undefined;
132
182
  this.responsibility = undefined;
133
183
  const closing = (async () => {
@@ -170,7 +220,8 @@ export class Intercom {
170
220
  assertValid();
171
221
  if (!me) fail('worker not configured; configure_worker must run before reload_worker');
172
222
  this.responsibility = { ...me };
173
- await this.host.setName(me.name);
223
+ // Coordinator is a routing identity, not ownership of the user's Pi/tab title.
224
+ if (!me.coordinator) await this.host.setName(me.name);
174
225
  assertValid();
175
226
  record('config.reloaded', { role: me.coordinator ? 'coordinator' : 'worker' });
176
227
  this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
@@ -181,6 +232,7 @@ export class Intercom {
181
232
  const { id, config } = await this.state();
182
233
  assertValid();
183
234
  const target = named(config, to), correlationId = randomUUID();
235
+ 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
236
  const metadata = { peerSessionId: target.sessionId, peerName: target.name, correlationId, kind };
185
237
  record('transport.send', { ...metadata, outcome: 'attempted' });
186
238
  try {
@@ -232,6 +284,10 @@ export class Intercom {
232
284
  throw error;
233
285
  }
234
286
  };
287
+ if (HANDOFF_KINDS.includes(message.kind)) {
288
+ if (!this.handoff || !me || (!me.coordinator && !this.responsibility)) fail('handoff requires a configured, responsibility-loaded worker');
289
+ await this.handoff.receive(message.kind as HandoffKind, message.from, message.payload); return;
290
+ }
235
291
  if (message.kind === 'registration' || message.kind === 'status') {
236
292
  requireCoordinator(config, id);
237
293
  if (sender?.coordinator) fail('coordinator cannot report as worker');
@@ -281,12 +337,16 @@ export class Intercom {
281
337
  if (message.kind === 'message') {
282
338
  if (!sender || !me) fail('agent messaging requires configured sender and recipient; anonymous permissions TODO');
283
339
  if (!this.responsibility) fail('worker must load responsibility before receiving work');
340
+ if (this.handoff?.workerClosing || fencedClose(me.closeJob)) fail('worker close is active or uncertain; new Intercom work is fenced');
284
341
  notify('agent message', message.payload.message as string); return;
285
342
  }
286
343
  if (!sender?.coordinator) fail('control requires current coordinator sender session ID');
287
344
  if (me?.coordinator) fail('worker control cannot target coordinator');
288
345
  if (message.kind === 'stop' || message.kind === 'close') fail(UNSUPPORTED_CANCELLATION);
289
- if (message.kind === 'reload') { await this.reload(); return; }
346
+ if (message.kind === 'reload') {
347
+ if (this.handoff?.workerClosing || fencedClose(me?.closeJob)) fail('worker close is active or uncertain; reload is fenced');
348
+ await this.reload(); return;
349
+ }
290
350
  if (message.kind === 'request_status') {
291
351
  // Independent one-way report, never a synchronous status in the HTTP response.
292
352
  setImmediate(() => {
@@ -300,7 +360,17 @@ export class Intercom {
300
360
  }
301
361
  fail('unsupported control');
302
362
  }
303
- async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
363
+ async tool(operation: string, args: Record<string, unknown>, toolCallId?: string, reminderSignal?: AbortSignal): Promise<unknown> {
364
+ // Reserve invocation order before state/directory awaits, but only VALID
365
+ // reminder actions advance the accepted revision. Invalid/nonworker calls
366
+ // must not supersede a pending stop or suppress an older valid preflight.
367
+ const invocation = ++this.reminderInvocation;
368
+ const armReminder = () => {
369
+ if (invocation >= this.reminderRevision && !reminderSignal?.aborted) {
370
+ this.reminderRevision = invocation;
371
+ this.reminders?.arm(reminderSignal);
372
+ }
373
+ };
304
374
  const record = this.captureObserver();
305
375
  const assertValid = this.validity();
306
376
  const { id, config, me } = await this.state();
@@ -322,6 +392,10 @@ export class Intercom {
322
392
  if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
323
393
  return { ...config, agents: config.agents.map(({ dashboardPort: _retired, ...agent }) => agent) };
324
394
  }
395
+ if (operation === 'report_handoff') {
396
+ if (!me || me.coordinator || !this.responsibility || !this.handoff) fail('report_handoff requires a configured, responsibility-loaded worker');
397
+ return this.handoff.report(args.jobId, args.summary, toolCallId);
398
+ }
325
399
  if (operation === 'report_status') { await this.report(); return { accepted: true }; }
326
400
  if (operation === 'report_work') {
327
401
  if (!me || me.coordinator || !this.responsibility) fail('report_work requires a configured, responsibility-loaded worker');
@@ -331,12 +405,37 @@ export class Intercom {
331
405
  }
332
406
  if (operation === 'send') {
333
407
  if (!me || !this.responsibility) fail('anonymous/unloaded worker cannot send agent messages (permissions TODO)');
408
+ const recipient = named(config, args.to as string);
409
+ text(args.message, 'message', 48000);
410
+ 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');
411
+ if (me.coordinator && !recipient.coordinator) armReminder();
334
412
  await this.transmit(args.to as string, 'message', { message: args.message }); return { accepted: true, completion: 'not awaited' };
335
413
  }
336
414
  requireCoordinator(config, id);
337
- if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
415
+ if (operation === 'stop_reminders') {
416
+ const current = invocation >= this.reminderRevision;
417
+ if (current) { this.reminderRevision = invocation; this.reminders?.stop(); }
418
+ return { stopped: current };
419
+ }
420
+ if (operation === 'stop_worker') fail(UNSUPPORTED_CANCELLATION);
338
421
  if (operation === 'configure_worker') {
339
- await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertValid);
422
+ const assertConfigurable = () => {
423
+ assertValid();
424
+ 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');
425
+ };
426
+ assertConfigurable();
427
+ // Validate the candidate without writing, so invalid input never arms a
428
+ // reminder; persistence failures after valid initiation still do.
429
+ const relative = await directory(this.store.root, args.projectDirectory as string);
430
+ assertConfigurable();
431
+ const candidate: Agent = { sessionId: args.sessionId as string, name: args.name as string, description: args.description as string,
432
+ port: args.port as number, projectDirectory: relative, coordinator: false };
433
+ if (candidate.sessionId === id) fail('cannot configure coordinator as worker');
434
+ const previous = config.agents.find(a => a.sessionId === candidate.sessionId);
435
+ if (fencedClose(previous?.closeJob)) fail('worker close is active or uncertain; configuration is fenced');
436
+ validateConfig({ ...config, agents: [...config.agents.filter(a => a.sessionId !== candidate.sessionId), candidate] });
437
+ armReminder();
438
+ await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertConfigurable);
340
439
  record('config.changed', { operation: 'configure_worker', peerSessionId: args.sessionId as string, peerName: args.name as string, outcome: 'written' });
341
440
  return { written: true, reloaded: false };
342
441
  }
@@ -347,34 +446,51 @@ export class Intercom {
347
446
  if (operation === 'create_worker') {
348
447
  const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
349
448
  assertValid();
350
- return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
449
+ if (relative !== '.') fail('worker working directory must equal the config root; subdirectory launches would read a forbidden parent config');
450
+ armReminder();
451
+ return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), workspaceRoot: this.store.root });
351
452
  }
352
453
  const target = named(config, args.to as string);
353
454
  if (target.coordinator) fail('operation requires a worker target');
455
+ if (operation === 'close_worker') {
456
+ if (!this.options.closeProvider || !this.handoff || config.multiplexer !== 'herdr') fail('Owned-pane close requires supported Linux Herdr provider; no closure attempted');
457
+ if (this.resumeFences.has(target.sessionId)) fail('resume is in flight; close is fenced');
458
+ if (fencedClose(target.closeJob) && !this.handoff.isClosing(target.sessionId)) fail('worker close is active or uncertain; close is fenced');
459
+ armReminder();
460
+ return { job: await this.handoff.request(target), completion: 'not awaited', contract: 'best-effort owned-pane closure; not all-child-process termination' };
461
+ }
462
+ 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
463
  if (operation === 'reload_worker' || operation === 'request_status') {
464
+ armReminder();
355
465
  await this.transmit(target.name, operation === 'reload_worker' ? 'reload' : 'request_status');
356
466
  return { accepted: true, completion: 'not awaited' };
357
467
  }
358
468
  if (operation === 'resume_worker') {
359
469
  if (args.confirmClosed !== true) fail('Resume requires explicit user confirmation that the previous worker session is closed (confirmClosed:true). Disconnected alone is not proof of closure.');
360
470
  if (this.resumeFences.has(target.sessionId)) fail('Resume already in flight or awaiting worker status; outcome may be uncertain. Inspect the worker before any retry; no automatic relaunch.');
471
+ const relative = await directory(this.store.root, target.projectDirectory);
472
+ assertValid();
473
+ if (relative !== '.') fail('worker working directory must equal the config root; subdirectory resumes would read a forbidden parent config');
474
+ const cwd = path.resolve(this.store.root, relative);
475
+ // Directory validation is read-only; recheck admission after that await.
476
+ if (this.resumeFences.has(target.sessionId)) fail('Resume already in flight or awaiting worker status; outcome may be uncertain. Inspect the worker before any retry; no automatic relaunch.');
477
+ if (this.handoff?.isClosing(target.sessionId)) fail('worker close is active or uncertain; resume is fenced');
361
478
  const fence = { inFlight: true, submitted: false, announced: false };
362
479
  this.resumeFences.set(target.sessionId, fence);
480
+ armReminder();
363
481
  let completed = false;
364
482
  try {
365
483
  const connection = await (this.options.probe ?? probeWorker)(target, { signal: this.probeController.signal });
366
484
  assertValid();
367
485
  if (connection.state === 'connected') fail('Worker endpoint is still connected; refusing to resume the same session twice.');
368
- const relative = await directory(this.store.root, target.projectDirectory);
369
- assertValid();
370
- const cwd = path.resolve(this.store.root, relative);
371
486
  const fresh = await this.state();
372
487
  assertValid();
373
488
  requireCoordinator(fresh.config, id);
374
489
  const current = fresh.config.agents.find(agent => agent.sessionId === target.sessionId && !agent.coordinator);
375
490
  if (!current || current.port !== target.port || current.projectDirectory !== target.projectDirectory) fail('Worker configuration changed during resume check; no launch submitted.');
491
+ if (this.handoff?.isClosing(target.sessionId) || fencedClose(current.closeJob)) fail('worker close is active or uncertain; resume is fenced');
376
492
  fence.submitted = true;
377
- const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, sessionId: current.sessionId });
493
+ const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, workspaceRoot: this.store.root, sessionId: current.sessionId });
378
494
  completed = true;
379
495
  return result;
380
496
  } finally {
@@ -384,7 +500,11 @@ export class Intercom {
384
500
  }
385
501
  if (operation === 'remove_worker') {
386
502
  // 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);
503
+ await this.store.update(id, c => {
504
+ const current = c.agents.find(a => a.sessionId === target.sessionId);
505
+ 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');
506
+ c.agents = c.agents.filter(a => a.sessionId !== target.sessionId);
507
+ }, assertValid);
388
508
  record('config.changed', { operation: 'remove_worker', peerSessionId: target.sessionId, peerName: target.name, outcome: 'removed' });
389
509
  return { removed: true, processStopped: false, sessionDeleted: false };
390
510
  }
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');
@@ -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 };