@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
package/src/handoff.ts ADDED
@@ -0,0 +1,280 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Agent, Config } from './config.js';
3
+
4
+ export interface WorkerPaneIdentity {
5
+ workspaceId: string; paneId: string; terminalId: string; sessionId: string;
6
+ sessionFile: string; pid: number; processStart: string;
7
+ }
8
+ export interface CloseProvider {
9
+ getIdentity(): Promise<WorkerPaneIdentity>;
10
+ inspect(identity: WorkerPaneIdentity, expectedSessionId: string, assertCurrent: () => void): Promise<void>;
11
+ close(identity: WorkerPaneIdentity, expectedSessionId: string, assertCurrent: () => void, beforeSubmit: () => Promise<void>): Promise<{ paneClosed: boolean; workerExited: boolean }>;
12
+ }
13
+ export type CloseReason = 'timeout' | 'interrupted' | 'delivery_failed' | 'identity_failed' | 'save_failed' | 'worker_changed' | 'worker_busy' | 'commit_rejected' | 'close_unverified' | 'close_failed';
14
+ export type CloseState = 'requested' | 'awaiting_handoff' | 'awaiting_settlement' | 'ready' | 'closing' | 'closed' | 'failed' | 'timed_out' | 'interrupted' | 'uncertain';
15
+ export interface Handoff { version: 1; summary: string; updatedAt: string; jobId: string }
16
+ export interface CloseJob { jobId: string; state: CloseState; createdAt: string; updatedAt: string; deadlineAt: string; reason?: CloseReason }
17
+ export type HandoffKind = 'close_prepare' | 'close_identity' | 'close_request' | 'handoff_report' | 'close_ready' | 'close_commit';
18
+ export const HANDOFF_KINDS: readonly string[] = ['close_prepare', 'close_identity', 'close_request', 'handoff_report', 'close_ready', 'close_commit'];
19
+ const states: CloseState[] = ['requested', 'awaiting_handoff', 'awaiting_settlement', 'ready', 'closing', 'closed', 'failed', 'timed_out', 'interrupted', 'uncertain'];
20
+ const reasons: CloseReason[] = ['timeout', 'interrupted', 'delivery_failed', 'identity_failed', 'save_failed', 'worker_changed', 'worker_busy', 'commit_rejected', 'close_unverified', 'close_failed'];
21
+ export const pendingClose = (job?: CloseJob) => !!job && ['requested', 'awaiting_handoff', 'awaiting_settlement', 'ready', 'closing'].includes(job.state);
22
+ export const fencedClose = (job?: CloseJob) => pendingClose(job) || job?.state === 'uncertain';
23
+ function requireValue(ok: unknown): asserts ok { if (!ok) throw new Error('PiIntercom: invalid handoff protocol/state'); }
24
+ const string = (value: unknown, max: number) => typeof value === 'string' && !!value.trim() && value.length <= max && !value.includes('\0');
25
+ const token = (value: unknown) => typeof value === 'string' && /^[a-zA-Z0-9-]{1,128}$/.test(value);
26
+ const timestamp = (value: unknown) => typeof value === 'string' && value.length <= 32 && Number.isFinite(Date.parse(value));
27
+ export function validateIdentity(value: unknown): asserts value is WorkerPaneIdentity {
28
+ const v = value as WorkerPaneIdentity;
29
+ requireValue(v && ['workspaceId', 'paneId', 'terminalId', 'sessionId', 'processStart'].every(k => string(v[k as keyof WorkerPaneIdentity], 256)) && string(v.sessionFile, 4096) && Number.isSafeInteger(v.pid) && v.pid > 0);
30
+ }
31
+ export function validateHandoff(value: unknown): asserts value is Handoff {
32
+ const v = value as Handoff;
33
+ requireValue(v && v.version === 1 && token(v.jobId) && string(v.summary, 4000) && timestamp(v.updatedAt));
34
+ requireValue(Object.keys(v).every(k => ['version', 'jobId', 'summary', 'updatedAt'].includes(k)));
35
+ }
36
+ export function validateCloseJob(value: unknown): asserts value is CloseJob {
37
+ const v = value as CloseJob;
38
+ requireValue(v && token(v.jobId) && states.includes(v.state) && timestamp(v.createdAt) && timestamp(v.updatedAt) && timestamp(v.deadlineAt) && (v.reason === undefined || reasons.includes(v.reason)));
39
+ requireValue(Object.keys(v).every(k => ['jobId', 'state', 'createdAt', 'updatedAt', 'deadlineAt', 'reason'].includes(k)));
40
+ }
41
+ export function validateHandoffPayload(kind: string, p: Record<string, unknown>): void {
42
+ requireValue(token(p.jobId));
43
+ const keys: Record<string, string[]> = {
44
+ close_prepare: ['jobId', 'deadlineAt'], close_identity: ['jobId', 'instanceId', 'identity'],
45
+ close_request: ['jobId', 'instanceId'], handoff_report: ['jobId', 'instanceId', 'summary'],
46
+ close_ready: ['jobId', 'instanceId', 'readyNonce'], close_commit: ['jobId', 'instanceId', 'readyNonce'],
47
+ };
48
+ requireValue(!!keys[kind] && Object.keys(p).every(k => keys[kind].includes(k)));
49
+ if (kind === 'close_prepare') requireValue(timestamp(p.deadlineAt));
50
+ else requireValue(token(p.instanceId));
51
+ if (kind === 'close_identity') validateIdentity(p.identity);
52
+ if (kind === 'handoff_report') requireValue(string(p.summary, 4000));
53
+ if (kind === 'close_ready' || kind === 'close_commit') requireValue(token(p.readyNonce));
54
+ }
55
+ interface Context {
56
+ state(): Promise<{ id: string; config: Config; me?: Agent }>;
57
+ assertCurrent(): void;
58
+ update(mutate: (config: Config) => void, guard?: () => void): Promise<void>;
59
+ send(sessionId: string, kind: HandoffKind, payload: Record<string, unknown>): Promise<void>;
60
+ deliver(message: string): void;
61
+ busy(): boolean;
62
+ provider?: CloseProvider;
63
+ timeoutMs?: number;
64
+ }
65
+ interface Pending {
66
+ target: Agent; job: CloseJob; timer: ReturnType<typeof setTimeout>;
67
+ identity?: WorkerPaneIdentity; instanceId?: string; initialized?: Promise<void>; processing: boolean; closing: boolean; ended: boolean;
68
+ }
69
+ interface WorkerPending {
70
+ jobId: string; coordinatorId: string; deadline: number; requested: boolean;
71
+ saved: boolean; reporting: boolean; invalidated: boolean; epoch: number;
72
+ readyNonce?: string; toolCallId?: string; committed: boolean; timer: ReturnType<typeof setTimeout>;
73
+ }
74
+ /** Extension-owned asynchronous protocol. No host abort, shutdown, automatic retries or guessed pane targets. */
75
+ export class HandoffWorkflow {
76
+ private readonly instanceId = randomUUID();
77
+ private jobs = new Map<string, Pending>();
78
+ private worker?: WorkerPending;
79
+ private epoch = 0;
80
+ private disposed = false;
81
+ private readonly timeout: number;
82
+ constructor(private readonly ctx: Context) {
83
+ this.timeout = Number.isFinite(ctx.timeoutMs) ? Math.max(1, Math.min(120000, ctx.timeoutMs!)) : 120000;
84
+ }
85
+ private valid() { requireValue(!this.disposed); this.ctx.assertCurrent(); }
86
+ private background(work: () => Promise<void>) { setImmediate(() => { if (!this.disposed) void work().catch(() => {}); }); }
87
+ dispose(): void {
88
+ this.disposed = true;
89
+ for (const p of this.jobs.values()) { p.ended = true; clearTimeout(p.timer); }
90
+ this.jobs.clear();
91
+ if (this.worker) clearTimeout(this.worker.timer);
92
+ this.worker = undefined;
93
+ }
94
+ async recover(): Promise<void> {
95
+ const { me, config } = await this.ctx.state();
96
+ if (!me?.coordinator || !config.agents.some(a => pendingClose(a.closeJob))) return;
97
+ await this.ctx.update(c => {
98
+ for (const a of c.agents) if (pendingClose(a.closeJob)) {
99
+ a.closeJob = { ...a.closeJob!, state: a.closeJob!.state === 'closing' ? 'uncertain' : 'interrupted', reason: 'interrupted', updatedAt: new Date().toISOString() };
100
+ }
101
+ });
102
+ }
103
+ private assertJob(p: Pending) {
104
+ this.valid();
105
+ requireValue(!p.ended && this.jobs.get(p.target.sessionId) === p && Date.now() < Date.parse(p.job.deadlineAt));
106
+ }
107
+ private target(c: Config, p: Pending): Agent {
108
+ this.assertJob(p);
109
+ const a = c.agents.find(a => a.sessionId === p.target.sessionId && !a.coordinator);
110
+ requireValue(a && a.closeJob?.jobId === p.job.jobId && pendingClose(a.closeJob) && a.port === p.target.port && a.projectDirectory === p.target.projectDirectory);
111
+ return a;
112
+ }
113
+ private async transition(p: Pending, state: CloseState, reason?: CloseReason): Promise<void> {
114
+ await this.ctx.update(c => {
115
+ const a = this.target(c, p);
116
+ a.closeJob = { ...a.closeJob!, state, updatedAt: new Date().toISOString(), ...(reason ? { reason } : {}) };
117
+ p.job = { ...a.closeJob };
118
+ }, () => this.assertJob(p));
119
+ }
120
+ private async finish(p: Pending, state: CloseState, reason?: CloseReason): Promise<void> {
121
+ if (p.ended) return;
122
+ p.ended = true; clearTimeout(p.timer);
123
+ // Keep the in-memory fence if persistence fails. Reload recovers durable intent.
124
+ try {
125
+ this.valid();
126
+ await this.ctx.update(c => {
127
+ const a = c.agents.find(a => a.sessionId === p.target.sessionId && !a.coordinator);
128
+ requireValue(a?.closeJob?.jobId === p.job.jobId);
129
+ a.closeJob = { ...a.closeJob, state, updatedAt: new Date().toISOString(), ...(reason ? { reason } : {}) };
130
+ });
131
+ this.jobs.delete(p.target.sessionId);
132
+ } catch { /* Failure never authorizes pane closure or replay. */ }
133
+ }
134
+ async request(target: Agent): Promise<CloseJob> {
135
+ this.valid(); requireValue(this.ctx.provider);
136
+ const existing = this.jobs.get(target.sessionId);
137
+ if (existing) { await existing.initialized; return { ...existing.job }; }
138
+ requireValue(!fencedClose(target.closeJob) && this.jobs.size < 16);
139
+ const now = Date.now();
140
+ const job: CloseJob = { jobId: randomUUID(), state: 'requested', createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(), deadlineAt: new Date(now + this.timeout).toISOString() };
141
+ const p: Pending = { target: { ...target }, job, timer: undefined as unknown as ReturnType<typeof setTimeout>, processing: false, closing: false, ended: false };
142
+ // Install the fence synchronously, before the first asynchronous config write.
143
+ this.jobs.set(target.sessionId, p);
144
+ try {
145
+ p.initialized = this.ctx.update(c => {
146
+ const a = c.agents.find(a => a.sessionId === target.sessionId && !a.coordinator);
147
+ requireValue(a && !fencedClose(a.closeJob) && a.port === target.port && a.projectDirectory === target.projectDirectory && c.multiplexer === 'herdr');
148
+ a.closeJob = { ...job };
149
+ }, () => this.assertJob(p));
150
+ await p.initialized;
151
+ } catch (error) { this.jobs.delete(target.sessionId); throw error; }
152
+ p.timer = setTimeout(() => { void this.finish(p, p.closing ? 'uncertain' : 'timed_out', 'timeout'); }, Math.max(0, Date.parse(job.deadlineAt) - Date.now()));
153
+ this.background(async () => {
154
+ try { this.assertJob(p); await this.ctx.send(target.sessionId, 'close_prepare', { jobId: job.jobId, deadlineAt: job.deadlineAt }); }
155
+ catch { await this.finish(p, 'failed', 'delivery_failed'); }
156
+ });
157
+ return { ...job };
158
+ }
159
+ workerStarted(): void {
160
+ this.epoch++;
161
+ const w = this.worker;
162
+ if (w && (w.saved || w.reporting)) { w.invalidated = true; w.readyNonce = undefined; }
163
+ }
164
+ workerSettled(successfulToolCallIds: ReadonlySet<string>): void {
165
+ const w = this.worker;
166
+ if (!w || !w.saved || w.reporting || w.invalidated || w.readyNonce || w.committed || this.ctx.busy() || w.epoch !== this.epoch || !w.toolCallId || !successfulToolCallIds.has(w.toolCallId)) return;
167
+ w.readyNonce = randomUUID();
168
+ this.background(async () => {
169
+ this.assertWorker(w);
170
+ requireValue(!this.ctx.busy() && w.epoch === this.epoch && !w.invalidated);
171
+ await this.ctx.send(w.coordinatorId, 'close_ready', { jobId: w.jobId, instanceId: this.instanceId, readyNonce: w.readyNonce });
172
+ });
173
+ }
174
+ private assertWorker(w: WorkerPending) {
175
+ this.valid(); requireValue(this.worker === w && Date.now() < w.deadline);
176
+ }
177
+ get workerClosing(): boolean { return !!this.worker; }
178
+ isClosing(sessionId: string): boolean { return this.jobs.has(sessionId); }
179
+ async report(jobId: unknown, summary: unknown, toolCallId?: string): Promise<{ saved: true; closure: 'awaiting_turn_settlement' }> {
180
+ requireValue(string(summary, 4000) && string(toolCallId, 256));
181
+ const w = this.worker; requireValue(w && w.jobId === jobId && w.requested && !w.reporting && !w.saved && !w.invalidated);
182
+ this.assertWorker(w); w.reporting = true; w.toolCallId = toolCallId;
183
+ const reportEpoch = this.epoch;
184
+ try {
185
+ await this.ctx.send(w.coordinatorId, 'handoff_report', { jobId: w.jobId, instanceId: this.instanceId, summary });
186
+ this.assertWorker(w); requireValue(!w.invalidated && this.epoch === reportEpoch);
187
+ w.saved = true; w.epoch = reportEpoch;
188
+ return { saved: true, closure: 'awaiting_turn_settlement' };
189
+ } finally { w.reporting = false; }
190
+ }
191
+ async receive(kind: HandoffKind, from: string, payload: Record<string, unknown>): Promise<void> {
192
+ validateHandoffPayload(kind, payload);
193
+ const { config, me } = await this.ctx.state(); this.valid();
194
+ const sender = config.agents.find(a => a.sessionId === from);
195
+ const coordinatorMessage = ['close_prepare', 'close_request', 'close_commit'].includes(kind);
196
+ requireValue(me && sender && (coordinatorMessage ? sender.coordinator && !me.coordinator : me.coordinator && !sender.coordinator));
197
+ if (coordinatorMessage) {
198
+ requireValue(this.ctx.provider && me.closeJob?.jobId === payload.jobId && pendingClose(me.closeJob));
199
+ if (kind === 'close_prepare') {
200
+ requireValue(!this.worker);
201
+ const deadline = Date.parse(payload.deadlineAt as string);
202
+ requireValue(deadline > Date.now() && deadline <= Date.now() + 120000);
203
+ const w: WorkerPending = { jobId: payload.jobId as string, coordinatorId: from, deadline, requested: false, saved: false, reporting: false, invalidated: false, epoch: this.epoch, committed: false, timer: undefined as unknown as ReturnType<typeof setTimeout> };
204
+ this.worker = w;
205
+ w.timer = setTimeout(() => { if (this.worker === w) this.worker = undefined; }, deadline - Date.now());
206
+ this.background(async () => {
207
+ try {
208
+ const identity = await this.ctx.provider!.getIdentity(); validateIdentity(identity);
209
+ this.assertWorker(w); requireValue(identity.sessionId === me.sessionId);
210
+ await this.ctx.send(from, 'close_identity', { jobId: w.jobId, instanceId: this.instanceId, identity });
211
+ } catch { if (this.worker === w) { clearTimeout(w.timer); this.worker = undefined; } }
212
+ });
213
+ return;
214
+ }
215
+ const w = this.worker;
216
+ requireValue(w && w.jobId === payload.jobId && payload.instanceId === this.instanceId && from === w.coordinatorId);
217
+ this.assertWorker(w);
218
+ if (kind === 'close_request') {
219
+ requireValue(!w.requested); w.requested = true;
220
+ this.ctx.deliver(`Coordinator requests a handoff for owned-pane closure. Job ID: ${w.jobId}. Stop taking new assignments, summarize completed work, remaining work, validation and important files in intercom_report_handoff({jobId:"${w.jobId}",summary:...}) (public summary, max 4000 characters; no credentials or private reasoning), then finish this turn and wait. Do not exit, shut down, close a pane or start further work. Pane closure is asynchronous and is not proof all child processes terminated.`);
221
+ return;
222
+ }
223
+ requireValue(w.saved && !w.reporting && !w.invalidated && !w.committed && !this.ctx.busy() && w.epoch === this.epoch && w.readyNonce === payload.readyNonce);
224
+ w.committed = true; return;
225
+ }
226
+ const p = this.jobs.get(from); requireValue(p && p.job.jobId === payload.jobId);
227
+ this.assertJob(p);
228
+ if (kind === 'close_identity') {
229
+ requireValue(!p.identity && !p.processing && p.job.state === 'requested');
230
+ const identity = payload.identity as WorkerPaneIdentity;
231
+ requireValue(identity.sessionId === from);
232
+ p.identity = { ...identity }; p.instanceId = payload.instanceId as string; p.processing = true;
233
+ this.background(async () => {
234
+ try {
235
+ await this.ctx.provider!.inspect(p.identity!, from, () => this.assertJob(p));
236
+ this.assertJob(p);
237
+ await this.transition(p, 'awaiting_handoff');
238
+ await this.ctx.send(from, 'close_request', { jobId: p.job.jobId, instanceId: p.instanceId });
239
+ } catch { await this.finish(p, 'failed', 'identity_failed'); }
240
+ finally { p.processing = false; }
241
+ });
242
+ return;
243
+ }
244
+ requireValue(payload.instanceId === p.instanceId);
245
+ if (kind === 'handoff_report') {
246
+ requireValue(p.job.state === 'awaiting_handoff' || p.job.state === 'awaiting_settlement');
247
+ try {
248
+ await this.ctx.update(c => {
249
+ const a = this.target(c, p);
250
+ if (a.handoff?.jobId === p.job.jobId) requireValue(a.handoff.summary === payload.summary);
251
+ else a.handoff = { version: 1, jobId: p.job.jobId, summary: payload.summary as string, updatedAt: new Date().toISOString() };
252
+ a.closeJob = { ...a.closeJob!, state: 'awaiting_settlement', updatedAt: new Date().toISOString() };
253
+ }, () => this.assertJob(p));
254
+ this.assertJob(p); p.job = { ...p.job, state: 'awaiting_settlement' };
255
+ } catch (error) { await this.finish(p, 'failed', 'save_failed'); throw error; }
256
+ return;
257
+ }
258
+ requireValue(kind === 'close_ready' && p.job.state === 'awaiting_settlement' && !p.closing && !p.processing);
259
+ p.processing = true;
260
+ this.background(async () => {
261
+ let failure: CloseReason = 'identity_failed';
262
+ try {
263
+ await this.transition(p, 'ready');
264
+ await this.ctx.provider!.inspect(p.identity!, from, () => this.assertJob(p));
265
+ this.assertJob(p);
266
+ failure = 'save_failed'; await this.transition(p, 'closing');
267
+ this.assertJob(p); failure = 'close_failed';
268
+ let commitAttempted = false;
269
+ const result = await this.ctx.provider!.close(p.identity!, from, () => this.assertJob(p), async () => {
270
+ this.assertJob(p); requireValue(!commitAttempted); commitAttempted = true;
271
+ failure = 'commit_rejected';
272
+ await this.ctx.send(from, 'close_commit', { jobId: p.job.jobId, instanceId: p.instanceId, readyNonce: payload.readyNonce });
273
+ this.assertJob(p); p.closing = true; failure = 'close_failed';
274
+ });
275
+ this.assertJob(p); requireValue(p.closing);
276
+ await this.finish(p, result.paneClosed && result.workerExited ? 'closed' : 'uncertain', result.paneClosed && result.workerExited ? undefined : 'close_unverified');
277
+ } catch { await this.finish(p, p.closing ? 'uncertain' : 'failed', failure); }
278
+ });
279
+ }
280
+ }
package/src/index.ts CHANGED
@@ -7,10 +7,11 @@ import { Intercom, UNSUPPORTED_CANCELLATION } from './runtime.js';
7
7
  import { launchers } from './launcher.js';
8
8
  import { ensureMonitorPane } from './monitor-launcher.js';
9
9
  import { createActivityTracker } from './activity.js';
10
+ import { createCloseProvider } from './pane-close.js';
10
11
 
11
12
  const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
12
13
  const tools: [string, string, TSchema][] = [
13
- ['create_worker', 'Coordinator only. Launch one anonymous worker in projectDirectory (default root). No assignment, configuration, or registration wait.', Type.Object({ projectDirectory: Type.Optional(Type.String()) })],
14
+ ['create_worker', 'Coordinator only. Launch one anonymous worker at the coordinator root. projectDirectory defaults to root and must resolve to that same directory; subfolder launches cannot use a parent config and are refused. No assignment, configuration, or registration wait.', Type.Object({ projectDirectory: Type.Optional(Type.String()) })],
14
15
  ['configure_worker', 'Coordinator only. Write explicit reported connection details and responsibility to config. Does not notify, reload, or start work. Use reload_worker separately.', Type.Object({ sessionId: Type.String(), port: Type.Integer({ minimum: 1, maximum: 65535 }), projectDirectory: Type.String(), name: Type.String({ minLength: 1, maxLength: 128 }), description: Type.String({ minLength: 1, maxLength: 4096 }) })],
15
16
  ['reload_worker', 'Coordinator only. Ask worker to reread responsibility and synchronize names. NOT Pi extension reload, cancellation, restart, or work assignment.', to],
16
17
  ['send', 'Configured sessions only. Send an explicit work/progress/findings message by recipient name. Idle delivery starts a turn; busy delivery steers at supported boundaries. Receipt is not an agent reply. Progress questions do not cancel assignments.', Type.Object({ to: Type.String(), message: Type.String({ minLength: 1, maxLength: 48000 }) })],
@@ -18,11 +19,13 @@ const tools: [string, string, TSchema][] = [
18
19
  ['list', 'Configured sessions only. Read current saved names, responsibilities, session IDs, roles and ports. No live probing. Output limited to 50 KiB/2000 lines; full data remains in config.json.', Type.Object({})],
19
20
  ['request_status', 'Coordinator only. Ask worker extension to send an independent sessionId/port/busy report without a model turn on worker. No synchronous status reply.', to],
20
21
  ['report_work', 'Configured, responsibility-loaded workers only. Store one public report (blocked, needs_decision, ready_for_review) and notify the coordinator; clear retires it. Summary required except clear (omit or empty). Never include private reasoning, credentials, raw tool payloads, or secrets. Receipt is not approval, verified completion, or permission for follow-up work.', Type.Object({ status: Type.String({ enum: ['blocked', 'needs_decision', 'ready_for_review', 'clear'] }), summary: Type.Optional(Type.String({ maxLength: 2000 })) })],
22
+ ['stop_reminders', 'Coordinator only. Disable the five-minute orchestration reminders until the next worker-directed call. Use when there is genuinely nothing to follow up on. Stops only reminders, not workers, close jobs or model work. Reminder state is session-local and clears on shutdown/reload.', Type.Object({})],
21
23
  ['report_status', 'Worker only (including anonymous). Send runtime sessionId/port/busy report to coordinator. This is NOT registration and creates no config entry.', Type.Object({})],
22
24
  ['stop_worker', UNSUPPORTED_CANCELLATION, to],
23
- ['close_worker', UNSUPPORTED_CANCELLATION, to],
24
- ['resume_worker', 'Coordinator only. Resume saved session/name/responsibility after the user explicitly confirms the old session was closed: set confirmClosed:true only for that confirmation, never infer it from disconnected status. A matching live health endpoint blocks launch. In-flight/uncertain attempts are fenced until worker status arrives; otherwise inspect process and reload coordinator before considering retry. No cross-process uniqueness guarantee, automatic assignment, replacement or removal.', Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }), confirmClosed: Type.Boolean() })],
25
- ['remove_worker', 'Coordinator only. Remove config entry only; never stop process or delete session files. A running worker must be explicitly closed before removal. Stop/close are disabled on current host: arrange explicit user closure first.', to],
25
+ ['close_worker', 'Coordinator only. Begin a background save-handoff-and-close job for a configured worker in Linux Herdr. Requests a public summary, saves it in config, waits for successful tool persistence and final turn settlement, then attempts one verified pane close. Returns job acceptance, not completion. Registration/session history are retained. Check worker_status/list for closeJob; failure/timeout/uncertainty never authorizes retry. Not graceful abort or an all-descendants termination guarantee; Windows/non-Herdr unsupported.', to],
26
+ ['report_handoff', 'Configured worker only, in response to its pending close request. Submit the matching jobId and concise public resume handoff (current work, completed work, unfinished items, blockers, next step). Never include secrets, private reasoning or raw tool payloads. Saved receipt is not closure: finish the response and wait; the extension requires final turn settlement before pane closure.', Type.Object({ jobId: Type.String({ minLength: 1, maxLength: 128 }), summary: Type.String({ minLength: 1, maxLength: 4000 }) })],
27
+ ['resume_worker', 'Coordinator only. Resume saved session/name/responsibility after the user explicitly confirms the old session was closed: set confirmClosed:true only for that confirmation, never infer it from disconnected status. A matching live health endpoint blocks launch. In-flight/uncertain attempts are fenced until worker status arrives; otherwise inspect process and reload coordinator before considering retry. Saved subfolder launches are refused rather than relocated, because a worker cannot use a parent config. No cross-process uniqueness guarantee, automatic assignment, replacement or removal.', Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }), confirmClosed: Type.Boolean() })],
28
+ ['remove_worker', 'Coordinator only. Remove config entry only; never stop process or delete session files. A running worker must be explicitly closed before removal. Retain registration to resume it later; remove only to forget it. Pending or uncertain close jobs block removal.', to],
26
29
  ['set_multiplexer', 'Coordinator only. Set herdr (default, Windows/Linux) or none (separate visible Windows terminals; unsupported on Linux) for future launches. Never move/restart existing workers or fall back.', Type.Object({ multiplexer: Type.String({ enum: ['herdr', 'none'] }) })],
27
30
  ];
28
31
 
@@ -44,6 +47,10 @@ class PiConfigStore extends ConfigStore {
44
47
  }
45
48
  }
46
49
  export default function intercomExtension(pi: ExtensionAPI): void {
50
+ pi.registerFlag('intercom-root', {
51
+ type: 'string',
52
+ description: 'Explicit Intercom root must equal this session cwd; parent configuration is never allowed, even with this flag.',
53
+ });
47
54
  let runtime: Intercom | undefined;
48
55
  let context: ExtensionContext | undefined;
49
56
  let generation = 0;
@@ -55,6 +62,35 @@ export default function intercomExtension(pi: ExtensionAPI): void {
55
62
  extension: fileURLToPath(import.meta.url),
56
63
  sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
57
64
  });
65
+ let monitorTask: { runtime: Intercom; generation: number; dirty: boolean; promise: Promise<void> } | undefined;
66
+ async function ensureCoordinatorMonitor(current: Intercom, ctx: ExtensionContext, started: number): Promise<void> {
67
+ const isCurrent = () => started === generation && runtime === current;
68
+ if (!isCurrent()) return;
69
+ if (monitorTask?.runtime === current && monitorTask.generation === started) {
70
+ monitorTask.dirty = true;
71
+ return monitorTask.promise;
72
+ }
73
+ const check = async () => {
74
+ try {
75
+ const { config, me, id } = await current.state();
76
+ if (!isCurrent() || !me?.coordinator || !config.agents.some(agent => !agent.coordinator)) return;
77
+ const monitor = await ensureMonitorPane({
78
+ root: current.store.root, sessionId: id,
79
+ script: fileURLToPath(new URL('../dist/monitor.js', import.meta.url)),
80
+ assertCurrent: () => { if (!isCurrent()) throw new Error('Coordinator session changed'); },
81
+ });
82
+ if (isCurrent()) ctx.ui.notify(`Intercom status monitor: ${monitor.outcome}${monitor.pane ? ` (${monitor.pane})` : ''}`, 'info');
83
+ } catch (error) {
84
+ if (isCurrent()) ctx.ui.notify(String(error), 'warning');
85
+ }
86
+ };
87
+ const task = { runtime: current, generation: started, dirty: false, promise: Promise.resolve() };
88
+ monitorTask = task;
89
+ task.promise = (async () => {
90
+ do { task.dirty = false; await check(); } while (task.dirty && isCurrent());
91
+ })();
92
+ try { await task.promise; } finally { if (monitorTask === task) monitorTask = undefined; }
93
+ }
58
94
  pi.on('session_start', async (_event, ctx) => {
59
95
  const started = ++generation;
60
96
  activity.reset();
@@ -65,6 +101,10 @@ export default function intercomExtension(pi: ExtensionAPI): void {
65
101
  context = ctx;
66
102
  if (!['win32', 'linux'].includes(process.platform) || ctx.mode !== 'tui') throw new Error('PiIntercom requires Windows or Linux interactive Pi. No resources started.');
67
103
  if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
104
+ // Flags become available at session_start, not extension factory time. Never
105
+ // infer workspace authority from ancestors or inherited environment values.
106
+ const workspaceRoot = pi.getFlag('intercom-root');
107
+ if (workspaceRoot !== undefined && typeof workspaceRoot !== 'string') throw new Error('PiIntercom: intercom-root must be an explicit absolute path.');
68
108
  runtime = new Intercom({
69
109
  cwd: ctx.cwd,
70
110
  sessionId: () => context!.sessionManager.getSessionId(),
@@ -74,7 +114,13 @@ export default function intercomExtension(pi: ExtensionAPI): void {
74
114
  deliver: content => pi.sendUserMessage(content, { deliverAs: 'steer' }),
75
115
  setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
76
116
  notify: text => ctx.ui.notify(text, 'info'),
77
- }, { launch: launcher.launch, store: root => new PiConfigStore(root) });
117
+ }, {
118
+ launch: launcher.launch, store: root => new PiConfigStore(root), workspaceRoot,
119
+ closeProvider: createCloseProvider({
120
+ sessionId: () => context!.sessionManager.getSessionId(),
121
+ sessionFile: () => context!.sessionManager.getSessionFile(),
122
+ }),
123
+ });
78
124
  const current = runtime;
79
125
  try { await current.start(); }
80
126
  catch (e) { if (runtime === current) runtime = undefined; ctx.ui.notify(String(e), 'error'); return; }
@@ -87,13 +133,7 @@ export default function intercomExtension(pi: ExtensionAPI): void {
87
133
  ctx.ui.notify(`Legacy dashboard metadata cleanup failed; messaging remains available: ${String(error)}`, 'warning');
88
134
  }
89
135
  if (started !== generation) return;
90
- const monitor = await ensureMonitorPane({
91
- root: current.store.root, sessionId: ctx.sessionManager.getSessionId(),
92
- script: fileURLToPath(new URL('../dist/monitor.js', import.meta.url)),
93
- assertCurrent: () => { if (started !== generation || runtime !== current) throw new Error('Coordinator session changed'); },
94
- });
95
- if (started !== generation) return;
96
- ctx.ui.notify(`Intercom status monitor: ${monitor.outcome}${monitor.pane ? ` (${monitor.pane})` : ''}`, 'info');
136
+ await ensureCoordinatorMonitor(current, ctx, started);
97
137
  }
98
138
  } catch (error) {
99
139
  if (started !== generation) return;
@@ -107,8 +147,13 @@ export default function intercomExtension(pi: ExtensionAPI): void {
107
147
  runtime = undefined; context = undefined;
108
148
  await oldRuntime?.close();
109
149
  });
150
+ pi.on('input', (_event, ctx) => {
151
+ context = ctx;
152
+ runtime?.workerStarted(); // New owner input invalidates a pending close-readiness epoch.
153
+ });
110
154
  pi.on('agent_start', async (_event, ctx) => {
111
155
  context = ctx;
156
+ runtime?.workerStarted();
112
157
  activityOutcome = 'started';
113
158
  try { activity.start(); } finally { activityOutcome = 'snapshot'; }
114
159
  });
@@ -116,6 +161,15 @@ export default function intercomExtension(pi: ExtensionAPI): void {
116
161
  context = ctx;
117
162
  activityOutcome = 'settled';
118
163
  try { activity.settled(); } finally { activityOutcome = 'snapshot'; }
164
+ // Settlement is notification-only and follows tool-result persistence. Inspect
165
+ // only entry kinds and successful tool call IDs, never message/reasoning bodies.
166
+ const successful = new Set<string>();
167
+ for (const entry of ctx.sessionManager.getBranch()) {
168
+ if (entry.type === 'message' && entry.message.role === 'toolResult' && !entry.message.isError) {
169
+ successful.add(entry.message.toolCallId);
170
+ }
171
+ }
172
+ runtime?.workerSettled(successful);
119
173
  });
120
174
  pi.on('message_update', (event, ctx) => {
121
175
  context = ctx;
@@ -135,17 +189,23 @@ export default function intercomExtension(pi: ExtensionAPI): void {
135
189
  if (!runtime) return;
136
190
  const { me } = await runtime.state();
137
191
  const loaded = runtime.responsibility;
138
- return { systemPrompt: event.systemPrompt + '\n\nPiIntercom: communicates; Pi decides orchestration. Responsibility is not a work assignment. Do not implement unrelated work from findings. After assigned work, report as instructed and wait; do not autonomously exit. Progress questions require reporting and continuing unless explicitly redirected. stop_worker/close_worker are disabled due to host cancellation blocker.\n' + (me && loaded ? `Identity: ${loaded.name} (${me.sessionId}). Responsibility: ${loaded.description}.` : 'Anonymous/unloaded worker: wait for coordinator configuration and separate reload; do not treat registration as authorization to work.') };
192
+ return { systemPrompt: event.systemPrompt + '\n\nPiIntercom: communicates; Pi decides orchestration. Responsibility is not a work assignment. Do not implement unrelated work from findings. After assigned work, report as instructed and wait; do not autonomously exit. Progress questions require reporting and continuing unless explicitly redirected. stop_worker remains disabled due to the host cancellation blocker. close_worker is a separate Linux Herdr background handoff-and-pane-close workflow: do not self-exit, remove registration, force kill or infer completion from job acceptance.\n' + (me && loaded ? `Identity: ${loaded.name} (${me.sessionId}). Responsibility: ${loaded.description}.` : 'Anonymous/unloaded worker: wait for coordinator configuration and separate reload; do not treat registration as authorization to work.') };
139
193
  });
140
194
  for (const [operation, description, parameters] of tools) {
141
195
  pi.registerTool({
142
196
  name: `intercom_${operation}`, label: `Intercom ${operation}`, description, parameters,
197
+ // Handoff readiness requires this call's successful transcript tool result.
198
+ // Pi1.x enforces model-only for nested calls;0.84 ignores this optional field
199
+ // and has no nested-tool API. Never substitute a parent result as proof.
200
+ exposure: operation === 'report_handoff' ? 'model-only' : 'direct',
143
201
  async execute(_id, args, signal, _update, ctx) {
144
202
  signal?.throwIfAborted(); context = ctx;
145
203
  if (!runtime) throw new Error('PiIntercom not initialized; inspect startup error.');
146
- const result = await runtime.tool(operation, args as Record<string, unknown>);
204
+ const current = runtime, started = generation;
205
+ const result = await current.tool(operation, args as Record<string, unknown>, _id, signal);
206
+ if (operation === 'configure_worker') await ensureCoordinatorMonitor(current, ctx, started);
147
207
  const output = truncateHead(JSON.stringify(result, null, 2));
148
- return { content: [{ type: 'text', text: output.content + (output.truncated ? `\n[Truncated; full shared configuration: ${runtime.store.file}]` : '') }], details: {} };
208
+ return { content: [{ type: 'text', text: output.content + (output.truncated ? `\n[Truncated; full shared configuration: ${current.store.file}]` : '') }], details: {} };
149
209
  },
150
210
  });
151
211
  }
package/src/launcher.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import path from 'node:path';
3
4
  import { fail } from './config.js';
4
5
  import type { LaunchRequest } from './runtime.js';
5
6
 
@@ -23,9 +24,16 @@ export function launchers(options: LauncherOptions) {
23
24
  async function launch(request: LaunchRequest): Promise<unknown> {
24
25
  const platform = options.platform ?? process.platform;
25
26
  if (platform !== 'win32' && platform !== 'linux') fail('launchers support Windows and Linux only');
27
+ const paths = platform === 'win32' ? path.win32 : path.posix;
28
+ if (typeof request.workspaceRoot !== 'string' || !request.workspaceRoot.trim() || request.workspaceRoot.includes('\0') || !paths.isAbsolute(request.workspaceRoot)) fail('worker launch requires an explicit absolute workspace root');
29
+ // Runtime performs authoritative realpath equality before reading config.
30
+ // Defend direct launcher calls too, without filesystem access or cwd rewrites.
31
+ const samePath = (value: string) => platform === 'win32' ? paths.resolve(value).toLowerCase() : paths.resolve(value);
32
+ if (typeof request.cwd !== 'string' || !paths.isAbsolute(request.cwd) || samePath(request.workspaceRoot) !== samePath(request.cwd)) fail('worker workspace root must equal its working directory; parent workspace access is not allowed');
26
33
  if (platform === 'linux' && request.multiplexer === 'none') fail('Linux worker launching requires Herdr; none is Windows-only. No terminal fallback');
27
34
  if (request.sessionId && !await options.sessionExists(request.cwd, request.sessionId)) fail('saved Pi session not found in project directory; never-used sessions may not be persisted. Config unchanged; no replacement launched');
28
- const args = ['-e', options.extension, ...(request.sessionId ? ['--session', request.sessionId] : [])];
35
+ // Explicit per-child CLI scope cannot widen cwd or leak from a parent shell environment.
36
+ const args = ['-e', options.extension, '--intercom-root', request.workspaceRoot, ...(request.sessionId ? ['--session', request.sessionId] : [])];
29
37
  if (request.multiplexer === 'herdr') {
30
38
  if (env.HERDR_ENV !== '1' || !env.HERDR_WORKSPACE_ID) fail('Herdr launcher requires this Pi to run inside a Herdr-managed workspace; no fallback');
31
39
  let created: { result?: { root_pane?: { pane_id?: string }; tab?: { tab_id?: string } } };
@@ -1,6 +1,6 @@
1
1
  import { stripVTControlCharacters } from 'node:util';
2
2
  import { truncateToWidth, visibleWidth } from 'pi-intercom-tui';
3
- import type { ObservationSnapshot } from './snapshot.js';
3
+ import { publicCloseMetadata, type ObservationSnapshot } from './snapshot.js';
4
4
  import { workerObservation } from './worker-status.js';
5
5
  import { currentConnection, sortWorkersByConnection } from './connections.js';
6
6
  import { INTERCOM_VERSION } from './version.js';
@@ -74,7 +74,9 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
74
74
  const selectedIndex = Math.min(workers.length - 1, Math.max(-1, options.selectedIndex ?? -1));
75
75
  const showDetails = Boolean(options.details && selectedIndex >= 0 && height >= 8);
76
76
  const selectedReport = snapshot.reports?.find(report => report.sessionId === workers[selectedIndex]?.sessionId && reportLabels[report.status]);
77
- const detailBudget = showDetails ? Math.min(selectedReport ? 15 : 8, height - 5) : 0;
77
+ const selectedSaved = workers[selectedIndex] ? publicCloseMetadata(workers[selectedIndex]) : {};
78
+ const hasSaved = !!(selectedSaved.handoff || selectedSaved.closeJob);
79
+ const detailBudget = showDetails ? Math.min(hasSaved ? 22 : selectedReport ? 15 : 8, height - 5) : 0;
78
80
  const available = Math.max(0, height - lines.length - 2 - detailBudget);
79
81
  const needsOverflow = workers.length > available;
80
82
  const count = Math.min(workers.length, Math.max(0, available - (needsOverflow && available > 1 ? 1 : 0)));
@@ -94,7 +96,32 @@ export function renderMonitor(snapshot: ObservationSnapshot | undefined, width:
94
96
  const selected = start + offset === selectedIndex;
95
97
  const report = snapshot.reports?.find(report => report.sessionId === worker.sessionId && reportLabels[report.status]);
96
98
  lines.push(table(worker.name, connection.state, activity, age, evidence, report ? reportLabels[report.status] : '—', tone, selected));
97
- if (selected && showDetails && report) {
99
+ if (selected && showDetails && hasSaved) {
100
+ const savedAge = (timestamp: string) => {
101
+ const elapsed = now - Date.parse(timestamp);
102
+ return elapsed < 0 || !Number.isFinite(elapsed) ? 'clock uncertain' : ageLabel(elapsed) + (elapsed > snapshot.staleAfterMs ? ' (old)' : '');
103
+ };
104
+ const { handoff, closeJob } = selectedSaved;
105
+ const context = [
106
+ ...(closeJob ? [line(` Close workflow: ${closeJob.state}${closeJob.reason ? ` · ${closeJob.reason}` : ''} · ${savedAge(closeJob.updatedAt)}`)] : []),
107
+ line(handoff ? ` Saved handoff (public): ${savedAge(handoff.updatedAt)}` : ' Saved handoff: none'),
108
+ ];
109
+ selectedDetail = [
110
+ ...context,
111
+ ...(handoff ? wrapSummary(`Handoff: ${handoff.summary}`, Math.max(0, width - 2), Math.min(4, Math.max(0, detailBudget - context.length))).map(text => line(` ${text}`)) : []),
112
+ ...(handoff ? [line(` Handoff saved: ${handoff.updatedAt}`)] : []),
113
+ ...(report ? [
114
+ line(` Report: ${reportLabels[report.status]} · ${savedAge(report.updatedAt)}`),
115
+ line(' Self-reported, pending review'),
116
+ ...wrapSummary(`Summary: ${report.summary}`, Math.max(0, width - 2), 2).map(text => line(` ${text}`)),
117
+ ] : []),
118
+ ...connectionDetails,
119
+ line(` Responsibility: ${worker.description || 'Not specified'}`),
120
+ line(` Observed status: ${activity} · ${age}`),
121
+ line(` Last activity: ${evidence}`),
122
+ ...(closeJob ? [line(` Close updated: ${closeJob.updatedAt}`), line(' Workflow state is not proof all child processes terminated')] : []),
123
+ ];
124
+ } else if (selected && showDetails && report) {
98
125
  const elapsed = now - Date.parse(report.updatedAt);
99
126
  const reportAge = !Number.isFinite(elapsed) || elapsed < 0 ? 'clock uncertain' : ageLabel(elapsed) + (elapsed > snapshot.staleAfterMs ? ' (old)' : '');
100
127
  const contextRows = detailBudget >= 8 ? [
@@ -10,7 +10,7 @@ export const EVENT_TYPES = ['runtime.starting', 'runtime.ready', 'runtime.closed
10
10
  export type EventType = typeof EVENT_TYPES[number];
11
11
  const OUTCOMES = ['attempted', 'returned', 'failed', 'http_receipt', 'handler_failed', 'started', 'settled', 'snapshot', 'ended', 'written', 'removed', 'ready'] as const;
12
12
  const ERROR_CODES = ['operation_failed', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC'] as const;
13
- const KINDS = ['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'] as const;
13
+ const KINDS = ['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close', 'close_prepare', 'close_identity', 'close_request', 'handoff_report', 'close_ready', 'close_commit'] as const;
14
14
  const OPERATIONS = ['configure_worker', 'set_multiplexer', 'remove_worker', 'create_worker', 'resume_worker', 'port_update', 'initialize'] as const;
15
15
  export interface EventMetadata {
16
16
  peerSessionId?: string;