@comput/pi-intercom 0.5.1 → 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/README.md +51 -10
- package/dist/config.d.ts +13 -1
- package/dist/config.js +57 -5
- package/dist/config.js.map +1 -1
- package/dist/handoff.d.ts +87 -0
- package/dist/handoff.js +327 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.js +76 -15
- package/dist/index.js.map +1 -1
- package/dist/monitor-view.d.ts +1 -1
- package/dist/monitor-view.js +49 -3
- package/dist/monitor-view.js.map +1 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +1 -1
- package/dist/observability.js.map +1 -1
- package/dist/pane-close.d.ts +22 -0
- package/dist/pane-close.js +166 -0
- package/dist/pane-close.js.map +1 -0
- package/dist/runtime.d.ts +9 -2
- package/dist/runtime.js +75 -6
- package/dist/runtime.js.map +1 -1
- package/dist/snapshot.d.ts +9 -0
- package/dist/snapshot.js +23 -1
- package/dist/snapshot.js.map +1 -1
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +4 -1
- package/dist/transport.js.map +1 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/dist/worker-status.d.ts +1 -1
- package/dist/worker-status.js +9 -1
- package/dist/worker-status.js.map +1 -1
- package/package.json +2 -2
- package/references/implementation-blocker.md +1 -1
- package/references/observability.md +13 -3
- package/src/config.ts +49 -4
- package/src/handoff.ts +280 -0
- package/src/index.ts +60 -13
- package/src/monitor-view.ts +48 -4
- package/src/observability.ts +1 -1
- package/src/pane-close.ts +138 -0
- package/src/runtime.ts +63 -7
- package/src/snapshot.ts +22 -1
- package/src/transport.ts +4 -2
- package/src/version.ts +13 -0
- package/src/worker-status.ts +9 -2
|
@@ -4,7 +4,7 @@ Intercom's terminal monitor is a standalone Node/pi-tui process above the coordi
|
|
|
4
4
|
|
|
5
5
|
## Terminal-first monitoring
|
|
6
6
|
|
|
7
|
-
Coordinator startup checks its saved monitor ownership and either preserves the existing pane or creates one above the coordinator.
|
|
7
|
+
Coordinator startup checks its saved monitor ownership only when the roster contains a worker, and either preserves the existing pane or creates one above the coordinator. An empty roster skips monitor setup; configuring the first worker triggers setup, and retained disconnected workers count. Existing monitor panes are not automatically closed when the roster becomes empty. Worker sessions do not create monitors. Coordinator Pi/Herdr names are preserved rather than overwritten with the internal Coordinator role. The pane is named `Intercom monitor`; its generic label alone is not proof of ownership. Machine-local `monitor-*.json` files record the coordinator/workspace/pane identities and partial-launch state. Uncertain operations are fenced rather than blindly repeated.
|
|
8
8
|
|
|
9
9
|
The monitor refreshes from disk and performs bounded loopback identity checks, then schedules the next pass approximately three seconds after completion. It never launches model turns or resends messages. Probes do not write config or observation logs; cancelling the monitor aborts in-flight probes. Arrow keys select a worker; Enter toggles its details; Escape closes details before quitting, while q/Ctrl+C always quit the monitor only. Selection follows the worker's session ID across roster reorder. Details show configured responsibility, observations and explicit public worker reports, not inferred task state or approval controls.
|
|
10
10
|
|
|
@@ -58,7 +58,17 @@ Activity phases are `working`, `thinking`, `responding`, `tool`, `idle`. Details
|
|
|
58
58
|
|
|
59
59
|
Checks expire to unknown after 30 seconds. Monitor batches rotate to avoid starvation; skipped checks may retain previous unexpired evidence. Saved-port changes/removal invalidate cached checks. Shared classification/sorting preserves config order within connected-or-unknown and disconnected groups. UI selection follows session ID across these order changes.
|
|
60
60
|
|
|
61
|
-
Closing a worker does not remove config. Explicit resume uses the saved session ID, name and responsibility and requires user-confirmed closure (`confirmClosed:true`). A live matching health endpoint rejects duplicate resume. In-flight/uncertain submissions are fenced within this coordinator runtime until a later configured status announcement; preflight announcements do not qualify, and uncertain launch errors preserve the fence. Config/identity/lifecycle are rechecked before launch. These safeguards do not guarantee cross-process uniqueness, survive coordinator restart or certify OS process death.
|
|
61
|
+
Closing a worker does not remove config. Explicit resume uses the saved session ID, name and responsibility and requires user-confirmed closure (`confirmClosed:true`). A live matching health endpoint rejects duplicate resume. In-flight/uncertain submissions are fenced within this coordinator runtime until a later configured status announcement; preflight announcements do not qualify, and uncertain launch errors preserve the fence. Config/identity/lifecycle are rechecked before launch. These safeguards do not guarantee cross-process uniqueness, survive coordinator restart or certify OS process death. Connectivity monitoring never automatically closes, retries, restarts or assigns work. An explicit close request (0.6.1) may separately initiate the handoff workflow described below.
|
|
62
|
+
|
|
63
|
+
## Public handoff and background pane-close jobs (0.6.1)
|
|
64
|
+
|
|
65
|
+
`Agent.handoff` saves only `{version, jobId, summary, updatedAt}`; the summary is explicitly public worker-authored context, at most 4000 characters. `Agent.closeJob` stores bounded workflow ID/state/timestamps and a fixed reason. Both remain alongside registration after a close. No PID, private session path, terminal ID or readiness nonce is saved in these public config fields. Config validation rejects extra metadata fields; snapshot/status views explicitly project supported fields. Old saved handoffs may precede a newer failed close job; they are not merged or treated as current acceptance.
|
|
66
|
+
|
|
67
|
+
Explicit `close_worker` returns acceptance while the coordinator extension continues in the background. It requests a correlated handoff, awaits atomic config-save acknowledgment, then requires a successful exact report tool-result ID in the current branch plus final `agent_settled`. The adapter reads only tool-result metadata (role, toolCallId, isError), never arguments, results or reasoning content. New input/agent starts invalidate readiness. No model polling on the coordinator is required.
|
|
68
|
+
|
|
69
|
+
Closing intent is durable before mutation. After final Linux Herdr pane/session/PID/start-identity checks, a nonce-bound worker commit is checked immediately before one pane-close command. No summary alone, timeout, save failure or busy/stale worker permits a close. Job deadlines are checked again at config publication and before pane mutation. Failed/uncertain outcomes preserve registration/handoff. Interrupted jobs are not replayed after reload; closing intent recovers as uncertain.
|
|
70
|
+
|
|
71
|
+
The monitor and JSON status expose public handoff age and close workflow separately from connectivity/activity/reports. `closed` means this workflow verified pane absence and original worker-process exit; it does not certify all descendants terminated. The workflow is available in npm 0.6.1 for Linux Herdr only. See README for retained race/flush limits and operator recovery requirements.
|
|
62
72
|
|
|
63
73
|
## Read-only JSON status
|
|
64
74
|
|
|
@@ -81,7 +91,7 @@ Readers consider only configured workers, at most 256 IDs and 8 KiB per file. Un
|
|
|
81
91
|
- `agent_settled` supplies the idle boundary, not `agent_end`. Activity observations are not availability guarantees or assignment states.
|
|
82
92
|
- `status.received` describes the reporting peer, not the event writer's own activity. It is not used as the writer's status.
|
|
83
93
|
- Launch submission does not establish readiness. A correlation ID is not deduplication or a reply promise.
|
|
84
|
-
-
|
|
94
|
+
- Graceful stop remains disabled. The explicit handoff-and-pane-close workflow is separate from monitoring; monitoring adds no cancellation, recovery, retries, commit or rollback.
|
|
85
95
|
|
|
86
96
|
## Storage bounds and failure behavior
|
|
87
97
|
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, realpath, rename, unlink, open, link, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { fencedClose, validateHandoff, validateCloseJob, type Handoff, type CloseJob } from './handoff.js';
|
|
4
5
|
|
|
5
6
|
export interface Agent {
|
|
6
7
|
sessionId: string;
|
|
@@ -11,6 +12,8 @@ export interface Agent {
|
|
|
11
12
|
/** Legacy browser dashboard field, accepted for migration only; removed at coordinator startup. */
|
|
12
13
|
dashboardPort?: number;
|
|
13
14
|
projectDirectory: string;
|
|
15
|
+
handoff?: Handoff;
|
|
16
|
+
closeJob?: CloseJob;
|
|
14
17
|
}
|
|
15
18
|
export interface Config { version: 1; multiplexer: 'herdr' | 'none'; agents: Agent[] }
|
|
16
19
|
export const DEFAULT_DESCRIPTION = 'Coordinate workers, delegate work, and manage shared configuration.';
|
|
@@ -35,6 +38,8 @@ export function validateConfig(value: unknown): Config {
|
|
|
35
38
|
text(a.sessionId, 'sessionId', 256); text(a.name, 'name', 128); text(a.description, 'description');
|
|
36
39
|
if (a.name !== a.name.trim() || /[\r\n\x00-\x1f]/.test(a.name)) fail('invalid name');
|
|
37
40
|
port(a.port); relativeDirectory(a.projectDirectory);
|
|
41
|
+
if (a.handoff !== undefined) { if (a.coordinator) fail('handoff is worker-only'); validateHandoff(a.handoff); }
|
|
42
|
+
if (a.closeJob !== undefined) { if (a.coordinator) fail('closeJob is worker-only'); validateCloseJob(a.closeJob); }
|
|
38
43
|
if (a.dashboardPort !== undefined) {
|
|
39
44
|
if (!a.coordinator) fail('dashboardPort is coordinator-only');
|
|
40
45
|
port(a.dashboardPort);
|
|
@@ -63,10 +68,19 @@ export async function directory(root: string, requested: string): Promise<string
|
|
|
63
68
|
if (!(await stat(target)).isDirectory()) fail('projectDirectory is not a directory');
|
|
64
69
|
return rel.split(path.sep).join('/') || '.';
|
|
65
70
|
}
|
|
71
|
+
export interface ConfigStoreOptions {
|
|
72
|
+
/** Publication seams for deterministic platform/race tests. */
|
|
73
|
+
platform?: NodeJS.Platform;
|
|
74
|
+
rename?: (from: string, to: string) => Promise<void>;
|
|
75
|
+
delay?: (milliseconds: number) => Promise<void>;
|
|
76
|
+
now?: () => number;
|
|
77
|
+
}
|
|
78
|
+
const RENAME_DELAYS = [10, 20, 40, 80] as const;
|
|
79
|
+
const RENAME_BUDGET_MS = 250;
|
|
66
80
|
export class ConfigStore {
|
|
67
81
|
readonly file: string;
|
|
68
82
|
private tail: Promise<unknown> = Promise.resolve();
|
|
69
|
-
constructor(readonly root: string) { this.file = path.join(root, '.pi-intercom', 'config.json'); }
|
|
83
|
+
constructor(readonly root: string, private readonly options: ConfigStoreOptions = {}) { this.file = path.join(root, '.pi-intercom', 'config.json'); }
|
|
70
84
|
async read(): Promise<Config> {
|
|
71
85
|
// Missing, malformed and unreadable files propagate; never overwrite them.
|
|
72
86
|
return validateConfig(JSON.parse(await readFile(this.file, 'utf8')));
|
|
@@ -113,6 +127,29 @@ export class ConfigStore {
|
|
|
113
127
|
await unlink(temp); return true;
|
|
114
128
|
} catch (error) { await unlink(temp).catch(() => {}); throw error; }
|
|
115
129
|
}
|
|
130
|
+
private async publish(temp: string, assertValid: () => void): Promise<void> {
|
|
131
|
+
const replace = this.options.rename ?? rename;
|
|
132
|
+
const now = this.options.now ?? (() => performance.now());
|
|
133
|
+
const delay = this.options.delay ?? (milliseconds => new Promise<void>(resolve => setTimeout(resolve, milliseconds)));
|
|
134
|
+
const started = now();
|
|
135
|
+
let originalError: unknown;
|
|
136
|
+
for (let attempt = 0; ; attempt++) {
|
|
137
|
+
// Windows readers can briefly deny replace-rename. Retry only publication
|
|
138
|
+
// of this already synced/closed temp, never the mutation or any OS action
|
|
139
|
+
// outside config persistence. The elapsed budget bounds retry initiation,
|
|
140
|
+
// not the duration of an already-submitted OS rename.
|
|
141
|
+
assertValid();
|
|
142
|
+
if (attempt > 0 && now() - started >= RENAME_BUDGET_MS) throw originalError;
|
|
143
|
+
try { await replace(temp, this.file); return; }
|
|
144
|
+
catch (error) {
|
|
145
|
+
if ((this.options.platform ?? process.platform) !== 'win32' || (error as NodeJS.ErrnoException).code !== 'EPERM') throw error;
|
|
146
|
+
originalError ??= error;
|
|
147
|
+
const backoff = RENAME_DELAYS[attempt];
|
|
148
|
+
if (backoff === undefined || now() - started + backoff >= RENAME_BUDGET_MS) throw originalError;
|
|
149
|
+
await delay(backoff);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
116
153
|
async update(id: string, mutate: (config: Config) => void | Promise<void>, assertValid: () => void = () => {}): Promise<Config> {
|
|
117
154
|
const operation = this.tail.then(async () => {
|
|
118
155
|
assertValid();
|
|
@@ -120,8 +157,9 @@ export class ConfigStore {
|
|
|
120
157
|
await mutate(c); assertValid(); validateConfig(c);
|
|
121
158
|
const temp = await this.temporary(c);
|
|
122
159
|
try {
|
|
123
|
-
//
|
|
124
|
-
|
|
160
|
+
// Every publication attempt checks the lifecycle/deadline guard; an
|
|
161
|
+
// already submitted OS rename still cannot be undone.
|
|
162
|
+
await this.publish(temp, assertValid);
|
|
125
163
|
} catch (error) { await unlink(temp).catch(() => {}); throw error; }
|
|
126
164
|
return c;
|
|
127
165
|
});
|
|
@@ -133,7 +171,14 @@ export class ConfigStore {
|
|
|
133
171
|
const projectDirectory = await directory(this.root, values.projectDirectory);
|
|
134
172
|
const a: Agent = { sessionId: values.sessionId, name: values.name, description: values.description, port: values.port, projectDirectory, coordinator: false };
|
|
135
173
|
const index = c.agents.findIndex(old => old.sessionId === a.sessionId);
|
|
136
|
-
if (index < 0) c.agents.push(a);
|
|
174
|
+
if (index < 0) c.agents.push(a);
|
|
175
|
+
else {
|
|
176
|
+
const old = c.agents[index];
|
|
177
|
+
if (fencedClose(old.closeJob)) fail('worker close is active or uncertain; configuration is fenced');
|
|
178
|
+
if (old.handoff) a.handoff = old.handoff;
|
|
179
|
+
if (old.closeJob) a.closeJob = old.closeJob;
|
|
180
|
+
c.agents[index] = a;
|
|
181
|
+
}
|
|
137
182
|
}, assertValid);
|
|
138
183
|
}
|
|
139
184
|
}
|
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,6 +7,7 @@ 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][] = [
|
|
@@ -20,9 +21,10 @@ const tools: [string, string, TSchema][] = [
|
|
|
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 })) })],
|
|
21
22
|
['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
23
|
['stop_worker', UNSUPPORTED_CANCELLATION, to],
|
|
23
|
-
['close_worker',
|
|
24
|
+
['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],
|
|
25
|
+
['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 }) })],
|
|
24
26
|
['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.
|
|
27
|
+
['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
28
|
['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
29
|
];
|
|
28
30
|
|
|
@@ -55,6 +57,35 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
55
57
|
extension: fileURLToPath(import.meta.url),
|
|
56
58
|
sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
|
|
57
59
|
});
|
|
60
|
+
let monitorTask: { runtime: Intercom; generation: number; dirty: boolean; promise: Promise<void> } | undefined;
|
|
61
|
+
async function ensureCoordinatorMonitor(current: Intercom, ctx: ExtensionContext, started: number): Promise<void> {
|
|
62
|
+
const isCurrent = () => started === generation && runtime === current;
|
|
63
|
+
if (!isCurrent()) return;
|
|
64
|
+
if (monitorTask?.runtime === current && monitorTask.generation === started) {
|
|
65
|
+
monitorTask.dirty = true;
|
|
66
|
+
return monitorTask.promise;
|
|
67
|
+
}
|
|
68
|
+
const check = async () => {
|
|
69
|
+
try {
|
|
70
|
+
const { config, me, id } = await current.state();
|
|
71
|
+
if (!isCurrent() || !me?.coordinator || !config.agents.some(agent => !agent.coordinator)) return;
|
|
72
|
+
const monitor = await ensureMonitorPane({
|
|
73
|
+
root: current.store.root, sessionId: id,
|
|
74
|
+
script: fileURLToPath(new URL('../dist/monitor.js', import.meta.url)),
|
|
75
|
+
assertCurrent: () => { if (!isCurrent()) throw new Error('Coordinator session changed'); },
|
|
76
|
+
});
|
|
77
|
+
if (isCurrent()) ctx.ui.notify(`Intercom status monitor: ${monitor.outcome}${monitor.pane ? ` (${monitor.pane})` : ''}`, 'info');
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (isCurrent()) ctx.ui.notify(String(error), 'warning');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const task = { runtime: current, generation: started, dirty: false, promise: Promise.resolve() };
|
|
83
|
+
monitorTask = task;
|
|
84
|
+
task.promise = (async () => {
|
|
85
|
+
do { task.dirty = false; await check(); } while (task.dirty && isCurrent());
|
|
86
|
+
})();
|
|
87
|
+
try { await task.promise; } finally { if (monitorTask === task) monitorTask = undefined; }
|
|
88
|
+
}
|
|
58
89
|
pi.on('session_start', async (_event, ctx) => {
|
|
59
90
|
const started = ++generation;
|
|
60
91
|
activity.reset();
|
|
@@ -74,7 +105,13 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
74
105
|
deliver: content => pi.sendUserMessage(content, { deliverAs: 'steer' }),
|
|
75
106
|
setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
|
|
76
107
|
notify: text => ctx.ui.notify(text, 'info'),
|
|
77
|
-
}, {
|
|
108
|
+
}, {
|
|
109
|
+
launch: launcher.launch, store: root => new PiConfigStore(root),
|
|
110
|
+
closeProvider: createCloseProvider({
|
|
111
|
+
sessionId: () => context!.sessionManager.getSessionId(),
|
|
112
|
+
sessionFile: () => context!.sessionManager.getSessionFile(),
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
78
115
|
const current = runtime;
|
|
79
116
|
try { await current.start(); }
|
|
80
117
|
catch (e) { if (runtime === current) runtime = undefined; ctx.ui.notify(String(e), 'error'); return; }
|
|
@@ -87,13 +124,7 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
87
124
|
ctx.ui.notify(`Legacy dashboard metadata cleanup failed; messaging remains available: ${String(error)}`, 'warning');
|
|
88
125
|
}
|
|
89
126
|
if (started !== generation) return;
|
|
90
|
-
|
|
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');
|
|
127
|
+
await ensureCoordinatorMonitor(current, ctx, started);
|
|
97
128
|
}
|
|
98
129
|
} catch (error) {
|
|
99
130
|
if (started !== generation) return;
|
|
@@ -107,8 +138,13 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
107
138
|
runtime = undefined; context = undefined;
|
|
108
139
|
await oldRuntime?.close();
|
|
109
140
|
});
|
|
141
|
+
pi.on('input', (_event, ctx) => {
|
|
142
|
+
context = ctx;
|
|
143
|
+
runtime?.workerStarted(); // New owner input invalidates a pending close-readiness epoch.
|
|
144
|
+
});
|
|
110
145
|
pi.on('agent_start', async (_event, ctx) => {
|
|
111
146
|
context = ctx;
|
|
147
|
+
runtime?.workerStarted();
|
|
112
148
|
activityOutcome = 'started';
|
|
113
149
|
try { activity.start(); } finally { activityOutcome = 'snapshot'; }
|
|
114
150
|
});
|
|
@@ -116,6 +152,15 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
116
152
|
context = ctx;
|
|
117
153
|
activityOutcome = 'settled';
|
|
118
154
|
try { activity.settled(); } finally { activityOutcome = 'snapshot'; }
|
|
155
|
+
// Settlement is notification-only and follows tool-result persistence. Inspect
|
|
156
|
+
// only entry kinds and successful tool call IDs, never message/reasoning bodies.
|
|
157
|
+
const successful = new Set<string>();
|
|
158
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
159
|
+
if (entry.type === 'message' && entry.message.role === 'toolResult' && !entry.message.isError) {
|
|
160
|
+
successful.add(entry.message.toolCallId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
runtime?.workerSettled(successful);
|
|
119
164
|
});
|
|
120
165
|
pi.on('message_update', (event, ctx) => {
|
|
121
166
|
context = ctx;
|
|
@@ -135,7 +180,7 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
135
180
|
if (!runtime) return;
|
|
136
181
|
const { me } = await runtime.state();
|
|
137
182
|
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
|
|
183
|
+
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
184
|
});
|
|
140
185
|
for (const [operation, description, parameters] of tools) {
|
|
141
186
|
pi.registerTool({
|
|
@@ -143,9 +188,11 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
143
188
|
async execute(_id, args, signal, _update, ctx) {
|
|
144
189
|
signal?.throwIfAborted(); context = ctx;
|
|
145
190
|
if (!runtime) throw new Error('PiIntercom not initialized; inspect startup error.');
|
|
146
|
-
const
|
|
191
|
+
const current = runtime, started = generation;
|
|
192
|
+
const result = await current.tool(operation, args as Record<string, unknown>, _id);
|
|
193
|
+
if (operation === 'configure_worker') await ensureCoordinatorMonitor(current, ctx, started);
|
|
147
194
|
const output = truncateHead(JSON.stringify(result, null, 2));
|
|
148
|
-
return { content: [{ type: 'text', text: output.content + (output.truncated ? `\n[Truncated; full shared configuration: ${
|
|
195
|
+
return { content: [{ type: 'text', text: output.content + (output.truncated ? `\n[Truncated; full shared configuration: ${current.store.file}]` : '') }], details: {} };
|
|
149
196
|
},
|
|
150
197
|
});
|
|
151
198
|
}
|