@comput/pi-intercom 0.5.2 → 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.
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');
@@ -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 };