@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/README.md +65 -6
- package/dist/config.d.ts +5 -0
- package/dist/config.js +84 -22
- package/dist/config.js.map +1 -1
- package/dist/index.js +18 -4
- package/dist/index.js.map +1 -1
- package/dist/launcher.js +11 -1
- package/dist/launcher.js.map +1 -1
- package/dist/reminders.d.ts +28 -0
- package/dist/reminders.js +81 -0
- package/dist/reminders.js.map +1 -0
- package/dist/runtime.d.ts +11 -1
- package/dist/runtime.js +89 -11
- package/dist/runtime.js.map +1 -1
- package/package.json +12 -6
- package/references/releasing.md +90 -0
- package/references/v1-specification.md +4 -4
- package/src/config.ts +59 -16
- package/src/index.ts +17 -4
- package/src/launcher.ts +9 -1
- package/src/reminders.ts +69 -0
- package/src/runtime.ts +76 -12
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 =
|
|
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
|
-
|
|
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 {
|