@comput/pi-intercom 0.6.1 → 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.
package/src/runtime.ts CHANGED
@@ -1,13 +1,15 @@
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';
10
11
  import { HandoffWorkflow, HANDOFF_KINDS, fencedClose, type CloseProvider, type HandoffKind } from './handoff.js';
12
+ import { CoordinatorReminders, type ReminderClock } from './reminders.js';
11
13
 
12
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).';
13
15
  export interface Host {
@@ -18,7 +20,7 @@ export interface Host {
18
20
  setName(name: string): Promise<void>;
19
21
  notify(text: string): void;
20
22
  }
21
- export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; sessionId?: string }
23
+ export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; workspaceRoot: string; sessionId?: string }
22
24
  export interface RuntimeOptions {
23
25
  launch(request: LaunchRequest): Promise<unknown>;
24
26
  listen?: typeof listen;
@@ -29,6 +31,9 @@ export interface RuntimeOptions {
29
31
  closeProvider?: CloseProvider;
30
32
  /** Test seam; production defaults to 120 seconds, values above that are capped. */
31
33
  closeTimeoutMs?: number;
34
+ reminderClock?: ReminderClock;
35
+ /** Explicit launcher-provided root must equal canonical cwd; no parent-root exception. */
36
+ workspaceRoot?: string;
32
37
  }
33
38
  export class Intercom {
34
39
  store!: ConfigStore;
@@ -36,6 +41,8 @@ export class Intercom {
36
41
  private active = false;
37
42
  private generation = 0;
38
43
  private initialId = '';
44
+ private configuredRoot?: string;
45
+ private configuredFile?: string;
39
46
  responsibility?: Agent;
40
47
  private observer?: Observer;
41
48
  private closing?: Promise<void>;
@@ -44,6 +51,9 @@ export class Intercom {
44
51
  private probeController = new AbortController();
45
52
  private resumeFences = new Map<string, { inFlight: boolean; submitted: boolean; announced: boolean }>();
46
53
  private handoff?: HandoffWorkflow;
54
+ private reminders?: CoordinatorReminders;
55
+ private reminderRevision = 0;
56
+ private reminderInvocation = 0;
47
57
  workerStarted(): void { this.handoff?.workerStarted(); }
48
58
  workerSettled(successfulToolCallIds: ReadonlySet<string>): void { this.handoff?.workerSettled(successfulToolCallIds); }
49
59
  private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
@@ -57,6 +67,7 @@ export class Intercom {
57
67
  private id(): string {
58
68
  const id = this.host.sessionId();
59
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');
60
71
  return id;
61
72
  }
62
73
  private validity(): () => void {
@@ -71,19 +82,27 @@ export class Intercom {
71
82
  if (this.closing) await this.closing;
72
83
  if (this.active) fail('runtime already active');
73
84
  this.initialId = this.host.sessionId(); this.active = true;
85
+ this.configuredRoot = undefined; this.configuredFile = undefined;
74
86
  this.probeController = new AbortController();
75
87
  this.resumeFences.clear();
76
88
  const generation = ++this.generation, assertValid = this.validity();
77
89
  let record = this.captureObserver();
78
90
  try {
79
- 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;
80
102
  assertValid();
81
- this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
82
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. */ }
83
104
  record = this.captureObserver();
84
105
  record('runtime.starting');
85
- const previous = discovered ? await this.store.read() : undefined;
86
- assertValid();
87
106
  const own = previous?.agents.find(a => a.sessionId === this.id());
88
107
  const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); }, () => {
89
108
  assertValid();
@@ -98,6 +117,11 @@ export class Intercom {
98
117
  const config = await this.store.read();
99
118
  assertValid();
100
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);
101
125
  this.handoff = new HandoffWorkflow({
102
126
  state: () => this.state(), assertCurrent: assertValid,
103
127
  update: async (mutate, guard) => {
@@ -152,6 +176,8 @@ export class Intercom {
152
176
  this.active = false; this.generation++;
153
177
  this.probeController.abort();
154
178
  this.handoff?.dispose(); this.handoff = undefined;
179
+ this.reminderRevision = ++this.reminderInvocation;
180
+ this.reminders?.dispose(); this.reminders = undefined;
155
181
  const endpoint = this.endpoint; this.endpoint = undefined;
156
182
  this.responsibility = undefined;
157
183
  const closing = (async () => {
@@ -334,7 +360,17 @@ export class Intercom {
334
360
  }
335
361
  fail('unsupported control');
336
362
  }
337
- async tool(operation: string, args: Record<string, unknown>, toolCallId?: string): 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
+ };
338
374
  const record = this.captureObserver();
339
375
  const assertValid = this.validity();
340
376
  const { id, config, me } = await this.state();
@@ -370,10 +406,17 @@ export class Intercom {
370
406
  if (operation === 'send') {
371
407
  if (!me || !this.responsibility) fail('anonymous/unloaded worker cannot send agent messages (permissions TODO)');
372
408
  const recipient = named(config, args.to as string);
409
+ text(args.message, 'message', 48000);
373
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();
374
412
  await this.transmit(args.to as string, 'message', { message: args.message }); return { accepted: true, completion: 'not awaited' };
375
413
  }
376
414
  requireCoordinator(config, id);
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
+ }
377
420
  if (operation === 'stop_worker') fail(UNSUPPORTED_CANCELLATION);
378
421
  if (operation === 'configure_worker') {
379
422
  const assertConfigurable = () => {
@@ -381,6 +424,17 @@ export class Intercom {
381
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');
382
425
  };
383
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();
384
438
  await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertConfigurable);
385
439
  record('config.changed', { operation: 'configure_worker', peerSessionId: args.sessionId as string, peerName: args.name as string, outcome: 'written' });
386
440
  return { written: true, reloaded: false };
@@ -392,33 +446,43 @@ export class Intercom {
392
446
  if (operation === 'create_worker') {
393
447
  const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
394
448
  assertValid();
395
- 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 });
396
452
  }
397
453
  const target = named(config, args.to as string);
398
454
  if (target.coordinator) fail('operation requires a worker target');
399
455
  if (operation === 'close_worker') {
400
456
  if (!this.options.closeProvider || !this.handoff || config.multiplexer !== 'herdr') fail('Owned-pane close requires supported Linux Herdr provider; no closure attempted');
401
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();
402
460
  return { job: await this.handoff.request(target), completion: 'not awaited', contract: 'best-effort owned-pane closure; not all-child-process termination' };
403
461
  }
404
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');
405
463
  if (operation === 'reload_worker' || operation === 'request_status') {
464
+ armReminder();
406
465
  await this.transmit(target.name, operation === 'reload_worker' ? 'reload' : 'request_status');
407
466
  return { accepted: true, completion: 'not awaited' };
408
467
  }
409
468
  if (operation === 'resume_worker') {
410
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.');
411
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');
412
478
  const fence = { inFlight: true, submitted: false, announced: false };
413
479
  this.resumeFences.set(target.sessionId, fence);
480
+ armReminder();
414
481
  let completed = false;
415
482
  try {
416
483
  const connection = await (this.options.probe ?? probeWorker)(target, { signal: this.probeController.signal });
417
484
  assertValid();
418
485
  if (connection.state === 'connected') fail('Worker endpoint is still connected; refusing to resume the same session twice.');
419
- const relative = await directory(this.store.root, target.projectDirectory);
420
- assertValid();
421
- const cwd = path.resolve(this.store.root, relative);
422
486
  const fresh = await this.state();
423
487
  assertValid();
424
488
  requireCoordinator(fresh.config, id);
@@ -426,7 +490,7 @@ export class Intercom {
426
490
  if (!current || current.port !== target.port || current.projectDirectory !== target.projectDirectory) fail('Worker configuration changed during resume check; no launch submitted.');
427
491
  if (this.handoff?.isClosing(target.sessionId) || fencedClose(current.closeJob)) fail('worker close is active or uncertain; resume is fenced');
428
492
  fence.submitted = true;
429
- 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 });
430
494
  completed = true;
431
495
  return result;
432
496
  } finally {