@comput/pi-intercom 0.6.1 → 0.9.0

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 (47) hide show
  1. package/README.md +113 -8
  2. package/dist/config.d.ts +12 -0
  3. package/dist/config.js +94 -22
  4. package/dist/config.js.map +1 -1
  5. package/dist/core-monitor.d.ts +30 -0
  6. package/dist/core-monitor.js +528 -0
  7. package/dist/core-monitor.js.map +1 -0
  8. package/dist/core-pane-close.d.ts +34 -0
  9. package/dist/core-pane-close.js +335 -0
  10. package/dist/core-pane-close.js.map +1 -0
  11. package/dist/core-worker-layout.d.ts +50 -0
  12. package/dist/core-worker-layout.js +236 -0
  13. package/dist/core-worker-layout.js.map +1 -0
  14. package/dist/herdr-core.d.ts +53 -0
  15. package/dist/herdr-core.js +380 -0
  16. package/dist/herdr-core.js.map +1 -0
  17. package/dist/index.js +60 -14
  18. package/dist/index.js.map +1 -1
  19. package/dist/launcher.d.ts +2 -0
  20. package/dist/launcher.js +59 -3
  21. package/dist/launcher.js.map +1 -1
  22. package/dist/monitor-launcher.d.ts +8 -0
  23. package/dist/monitor-launcher.js +111 -25
  24. package/dist/monitor-launcher.js.map +1 -1
  25. package/dist/pane-close.js +3 -0
  26. package/dist/pane-close.js.map +1 -1
  27. package/dist/reminders.d.ts +28 -0
  28. package/dist/reminders.js +81 -0
  29. package/dist/reminders.js.map +1 -0
  30. package/dist/runtime.d.ts +28 -2
  31. package/dist/runtime.js +139 -11
  32. package/dist/runtime.js.map +1 -1
  33. package/package.json +14 -6
  34. package/references/herdr-core-migration.md +44 -0
  35. package/references/releasing.md +90 -0
  36. package/references/v1-specification.md +11 -8
  37. package/src/config.ts +69 -17
  38. package/src/core-monitor.ts +410 -0
  39. package/src/core-pane-close.ts +328 -0
  40. package/src/core-worker-layout.ts +240 -0
  41. package/src/herdr-core.ts +287 -0
  42. package/src/index.ts +55 -15
  43. package/src/launcher.ts +55 -3
  44. package/src/monitor-launcher.ts +95 -21
  45. package/src/pane-close.ts +2 -0
  46. package/src/reminders.ts +69 -0
  47. package/src/runtime.ts +122 -12
@@ -2,17 +2,38 @@ import path from 'node:path';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { access, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
4
4
  import { run, shQuote, psQuote, encoded, type Run } from './launcher.js';
5
+ import { ensureCoreMonitorPane, closeCoreMonitorPane } from './core-monitor.js';
6
+ import { withCoreSession, type CoreCall } from './herdr-core.js';
5
7
 
6
8
  interface Pane { pane_id: string; tab_id: string; workspace_id?: string; label?: string }
7
- interface RecordState { version: 1; sessionId: string; workspace: string; caller: string; pane?: string; stage: string }
9
+ export type MonitorPlacement = 'coordinator-tab' | 'new-tab';
10
+ interface RecordState { version: 1; sessionId: string; workspace: string; caller: string; pane?: string; tab?: string; placement?: MonitorPlacement; stage: string }
8
11
  export interface MonitorLaunchOptions {
9
12
  root: string; sessionId: string; script: string;
10
13
  node?: string; platform?: string; env?: NodeJS.ProcessEnv; run?: Run;
14
+ placement?: MonitorPlacement;
15
+ coreCall?: CoreCall;
11
16
  assertCurrent?(): void;
12
17
  }
13
18
  /** Pane ownership is separate from agent config. Pending records fence uncertain
14
19
  * mutations across reloads; neither errors nor absence of readiness cause retries. */
15
20
  export async function ensureMonitorPane(options: MonitorLaunchOptions): Promise<{ outcome: string; pane?: string }> {
21
+ if ((options.env ?? process.env).HC_TERMINAL_ID !== undefined) {
22
+ const open = (call: CoreCall) => ensureCoreMonitorPane({ ...options, placement: options.placement ?? 'coordinator-tab', call });
23
+ return options.coreCall ? open(options.coreCall) : withCoreSession(open);
24
+ }
25
+ return monitorPane(options, false);
26
+ }
27
+ export async function closeMonitorPane(options: MonitorLaunchOptions): Promise<{ outcome: string; pane?: string }> {
28
+ if ((options.env ?? process.env).HC_TERMINAL_ID !== undefined) {
29
+ const close = (call: CoreCall) => closeCoreMonitorPane({ ...options, call });
30
+ return options.coreCall ? close(options.coreCall) : withCoreSession(close);
31
+ }
32
+ return monitorPane(options, true);
33
+ }
34
+ async function monitorPane(options: MonitorLaunchOptions, closing: boolean): Promise<{ outcome: string; pane?: string }> {
35
+ const placement = options.placement ?? 'coordinator-tab';
36
+ if (!['coordinator-tab', 'new-tab'].includes(placement)) throw new Error('Invalid monitor placement');
16
37
  const env = options.env ?? process.env;
17
38
  if (env.HERDR_ENV !== '1') return { outcome: 'unavailable: coordinator is not inside Herdr' };
18
39
  if (!env.HERDR_PANE_ID) throw new Error('Monitor requires caller pane identity; no focused-pane fallback');
@@ -25,41 +46,59 @@ export async function ensureMonitorPane(options: MonitorLaunchOptions): Promise<
25
46
  const output = await exec('herdr', args);
26
47
  check();
27
48
  // pane run acknowledges command submission with an empty stdout on current Herdr.
28
- if (!output.trim() && args[1] === 'run') return {};
49
+ if (!output.trim() && ['run', 'close'].includes(args[1])) return {};
29
50
  return JSON.parse(output).result;
30
51
  };
31
52
  const caller = (await call(['pane', 'current', '--current']))?.pane as Pane;
32
- if (!caller?.pane_id || !caller.tab_id || !caller.workspace_id) throw new Error('Missing current Herdr pane identity');
53
+ if (!caller?.pane_id || !caller.tab_id || !caller.workspace_id) throw new Error('Missing current Herdr caller pane identity');
54
+ // Herdr documents inherited moved-pane aliases for --current. Preserve that
55
+ // resolution for opening; destructive close conservatively requires exact ID.
56
+ if (closing && caller.pane_id !== env.HERDR_PANE_ID) throw new Error('Mismatched current Herdr caller pane identity; refusing close');
33
57
  const label = 'Intercom monitor';
34
58
  const legacyMarker = `Intercom monitor [${options.sessionId}]`;
35
59
  const key = createHash('sha256').update(options.sessionId).digest('hex').slice(0, 24);
36
60
  const directory = path.join(options.root, '.pi-intercom');
61
+ check();
37
62
  await mkdir(directory, { recursive: true });
63
+ check();
38
64
  const file = path.join(directory, `monitor-${key}.json`), lock = `${file}.lock`;
39
65
  check();
40
66
  try { await writeFile(lock, '', { flag: 'wx', mode: 0o600 }); }
41
67
  catch { throw new Error(`Monitor launch locked; inspect ${lock} before recovery. No pane created.`); }
42
68
  let state: RecordState | undefined;
43
69
  const save = async () => {
70
+ check();
44
71
  const temporary = `${file}.${randomUUID()}.tmp`;
45
72
  try {
46
73
  await writeFile(temporary, JSON.stringify(state), { flag: 'wx', mode: 0o600 });
74
+ check();
47
75
  await rename(temporary, file);
48
76
  } finally { await unlink(temporary).catch(() => {}); }
49
77
  };
50
78
  try {
79
+ check();
51
80
  try {
52
81
  state = JSON.parse(await readFile(file, 'utf8'));
53
- if (state?.version !== 1 || state.sessionId !== options.sessionId || typeof state.stage !== 'string') throw new Error('Invalid monitor ownership record');
82
+ check();
83
+ if (state?.version !== 1 || state.sessionId !== options.sessionId || typeof state.stage !== 'string' ||
84
+ typeof state.workspace !== 'string' || typeof state.caller !== 'string' ||
85
+ (state.placement !== undefined && !['coordinator-tab', 'new-tab'].includes(state.placement)) ||
86
+ (state.tab !== undefined && typeof state.tab !== 'string') ||
87
+ (state.pane !== undefined && typeof state.pane !== 'string') ||
88
+ (state.placement === 'new-tab' && ['submitted', 'observed'].includes(state.stage) && !state.tab)) throw new Error('Invalid monitor ownership record');
54
89
  } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
55
90
  const panes = (await call(['pane', 'list', '--workspace', caller.workspace_id]))?.panes as Pane[];
56
- if (!Array.isArray(panes) || panes.some(p => !p?.pane_id || !p.tab_id)) throw new Error('Invalid Herdr pane inventory');
91
+ if (!Array.isArray(panes) || panes.some(p => !p?.pane_id || !p.tab_id || p.workspace_id && p.workspace_id !== caller.workspace_id) || new Set(panes.map(p => p.pane_id)).size !== panes.length || !panes.some(p => p.pane_id === caller.pane_id && p.tab_id === caller.tab_id)) throw new Error('Invalid Herdr pane inventory');
57
92
  const matches = panes.filter(p => p.label === legacyMarker ||
58
93
  (state && state.workspace === caller.workspace_id && state.pane === p.pane_id));
59
94
  if (matches.length > 1) throw new Error('Multiple monitor markers; inspect panes. No automatic changes.');
60
95
  if (matches.length === 1) {
61
96
  const pane = matches[0];
62
- if (pane.pane_id === caller.pane_id || pane.tab_id !== caller.tab_id) throw new Error('Monitor marker is on caller or moved to another tab; no duplicate created');
97
+ if (panes.some(other => other.pane_id !== pane.pane_id && other.label === label)) throw new Error('Multiple monitor labels in workspace; inspect panes. No automatic changes.');
98
+ const savedPlacement = state?.placement ?? 'coordinator-tab';
99
+ if (pane.pane_id === caller.pane_id || pane.tab_id !== (state?.tab ?? caller.tab_id) ||
100
+ (savedPlacement === 'coordinator-tab' ? pane.tab_id !== caller.tab_id : pane.tab_id === caller.tab_id)) throw new Error('Monitor marker is on caller or moved to another tab; no automatic changes');
101
+ if (!closing && savedPlacement !== placement) throw new Error('Monitor already exists in a different placement; close it first before opening elsewhere');
63
102
  if (state && (state.workspace !== caller.workspace_id || state.caller !== caller.pane_id || (state.pane && state.pane !== pane.pane_id))) {
64
103
  throw new Error('Monitor ownership differs from current caller; no automatic changes');
65
104
  }
@@ -67,35 +106,70 @@ export async function ensureMonitorPane(options: MonitorLaunchOptions): Promise<
67
106
  throw new Error(`Monitor partial launch needs inspection (${state.stage}); no retry or duplicate`);
68
107
  }
69
108
  if (!state) {
70
- state = { version: 1, sessionId: options.sessionId, workspace: caller.workspace_id, caller: caller.pane_id, pane: pane.pane_id, stage: 'observed' };
109
+ if (closing) throw new Error('Legacy monitor marker has no saved ownership; manual inspection required, no automatic close');
110
+ state = { version: 1, sessionId: options.sessionId, workspace: caller.workspace_id, caller: caller.pane_id, pane: pane.pane_id, tab: pane.tab_id, placement: 'coordinator-tab', stage: 'observed' };
71
111
  await save();
72
112
  }
113
+ if (closing) {
114
+ if (state.stage !== 'submitted') throw new Error('Legacy observed monitor lacks launcher-created ownership proof; refusing close');
115
+ if (pane.label !== label && pane.label !== legacyMarker) throw new Error('Owned monitor label changed; refusing to close a potentially reused pane');
116
+ state.stage = 'close_pending'; await save();
117
+ const finalCaller = (await call(['pane', 'current', '--current']))?.pane as Pane;
118
+ const finalPanes = (await call(['pane', 'list', '--workspace', caller.workspace_id]))?.panes as Pane[];
119
+ if (finalCaller?.pane_id !== caller.pane_id || finalCaller.tab_id !== caller.tab_id || finalCaller.workspace_id !== caller.workspace_id ||
120
+ !Array.isArray(finalPanes) || finalPanes.some(p => !p?.pane_id || !p.tab_id || p.workspace_id && p.workspace_id !== caller.workspace_id) ||
121
+ new Set(finalPanes.map(p => p.pane_id)).size !== finalPanes.length ||
122
+ !finalPanes.some(p => p.pane_id === caller.pane_id && p.tab_id === caller.tab_id) ||
123
+ finalPanes.filter(p => p.pane_id === pane.pane_id && p.tab_id === pane.tab_id && p.label === pane.label).length !== 1 ||
124
+ finalPanes.some(p => p.pane_id !== pane.pane_id && [label, legacyMarker].includes(p.label ?? ''))) throw new Error('Monitor ownership changed before close; pending outcome fenced, inspect manually');
125
+ // Herdr has no compare-and-close primitive: this minimizes but cannot
126
+ // eliminate an external topology race between inventory and pane close.
127
+ await call(['pane', 'close', pane.pane_id]);
128
+ const remaining = (await call(['pane', 'list', '--workspace', caller.workspace_id]))?.panes as Pane[];
129
+ if (!Array.isArray(remaining) || remaining.some(p => !p?.pane_id || !p.tab_id || p.workspace_id && p.workspace_id !== caller.workspace_id) || new Set(remaining.map(p => p.pane_id)).size !== remaining.length || remaining.some(p => p.pane_id === pane.pane_id) || !remaining.some(p => p.pane_id === caller.pane_id && p.tab_id === caller.tab_id)) throw new Error('Monitor pane absence not confirmed; close outcome uncertain, do not retry');
130
+ state.stage = 'closed'; await save();
131
+ return { outcome: 'monitor pane absence observed; no claim about child processes', pane: pane.pane_id };
132
+ }
73
133
  if (pane.label !== label) await call(['pane', 'rename', pane.pane_id, label]);
74
134
  return { outcome: 'existing pane preserved; process readiness not checked', pane: pane.pane_id };
75
135
  }
76
- if (panes.some(p => p.tab_id === caller.tab_id && p.label === label)) {
77
- throw new Error('Unowned monitor label in coordinator tab; inspect before creating another pane');
136
+ if (panes.some(p => p.label === label)) {
137
+ throw new Error('Unowned monitor label in workspace; inspect before creating or closing a pane');
78
138
  }
79
- if (state && (!['submitted', 'observed'].includes(state.stage) || state.workspace !== caller.workspace_id || panes.some(p => p.pane_id === state!.pane))) {
139
+ if (state && (!['submitted', 'observed', 'closed'].includes(state.stage) || state.workspace !== caller.workspace_id || state.caller !== caller.pane_id || panes.some(p => p.pane_id === state!.pane))) {
80
140
  throw new Error(`Monitor ownership/partial launch needs inspection (${state.stage}, pane ${state.pane ?? 'unknown'}); no retry or duplicate`);
81
141
  }
142
+ if (closing) return { outcome: 'no owned monitor pane present; no close submitted' };
82
143
  await access(options.script); // Missing build must not leave a new empty pane.
83
144
  check();
84
- state = { version: 1, sessionId: options.sessionId, workspace: caller.workspace_id, caller: caller.pane_id, stage: 'split_pending' };
145
+ state = { version: 1, sessionId: options.sessionId, workspace: caller.workspace_id, caller: caller.pane_id, placement, tab: placement === 'coordinator-tab' ? caller.tab_id : undefined, stage: placement === 'new-tab' ? 'tab_pending' : 'split_pending' };
85
146
  await save();
86
147
  // Small top quarter after swapping pane identities. Verify actual geometry below.
87
- const split = await call(['pane', 'split', '--pane', caller.pane_id, '--direction', 'down', '--ratio', '0.25', '--cwd', options.root, '--no-focus']);
88
- const pane = split?.pane?.pane_id;
89
- if (typeof pane !== 'string' || pane === caller.pane_id) throw new Error('Split returned no distinct pane ID');
148
+ const created = placement === 'new-tab'
149
+ ? await call(['tab', 'create', '--workspace', caller.workspace_id, '--cwd', options.root, '--label', label, '--no-focus'])
150
+ : await call(['pane', 'split', '--pane', caller.pane_id, '--direction', 'down', '--ratio', '0.25', '--cwd', options.root, '--no-focus']);
151
+ const pane = placement === 'new-tab' ? created?.root_pane?.pane_id : created?.pane?.pane_id;
152
+ if (typeof pane !== 'string' || pane === caller.pane_id || panes.some(p => p.pane_id === pane)) throw new Error('Creation returned no distinct new pane ID');
153
+ if (placement === 'new-tab') {
154
+ const tab = created?.tab?.tab_id;
155
+ if (typeof tab !== 'string' || tab === caller.tab_id || panes.some(p => p.tab_id === tab)) throw new Error('Creation returned no distinct new tab ID');
156
+ state.tab = tab;
157
+ }
90
158
  state.pane = pane; state.stage = 'rename_pending'; await save();
91
159
  await call(['pane', 'rename', pane, label]);
92
- state.stage = 'swap_pending'; await save();
93
- const swapped = await call(['pane', 'swap', '--source-pane', caller.pane_id, '--target-pane', pane]);
94
- if (swapped?.swap?.changed !== true) throw new Error('Herdr did not confirm pane swap');
95
- const layout = (await call(['pane', 'layout', '--pane', caller.pane_id]))?.layout;
96
- const top = layout?.panes?.find((p: { pane_id: string }) => p.pane_id === pane)?.rect;
97
- const bottom = layout?.panes?.find((p: { pane_id: string }) => p.pane_id === caller.pane_id)?.rect;
98
- if (!top || !bottom || top.y >= bottom.y || layout.focused_pane_id !== caller.pane_id) throw new Error('Monitor geometry/focus not confirmed; inspect panes before recovery');
160
+ if (placement === 'coordinator-tab') {
161
+ state.stage = 'swap_pending'; await save();
162
+ const swapped = await call(['pane', 'swap', '--source-pane', caller.pane_id, '--target-pane', pane]);
163
+ if (swapped?.swap?.changed !== true) throw new Error('Herdr did not confirm pane swap');
164
+ const layout = (await call(['pane', 'layout', '--pane', caller.pane_id]))?.layout;
165
+ const top = layout?.panes?.find((p: { pane_id: string }) => p.pane_id === pane)?.rect;
166
+ const bottom = layout?.panes?.find((p: { pane_id: string }) => p.pane_id === caller.pane_id)?.rect;
167
+ if (!top || !bottom || top.y >= bottom.y || layout.focused_pane_id !== caller.pane_id) throw new Error('Monitor geometry/focus not confirmed; inspect panes before recovery');
168
+ } else {
169
+ const inventory = (await call(['pane', 'list', '--workspace', caller.workspace_id]))?.panes as Pane[];
170
+ const current = (await call(['pane', 'current', '--current']))?.pane as Pane;
171
+ if (!Array.isArray(inventory) || inventory.some(p => !p?.pane_id || !p.tab_id || p.workspace_id && p.workspace_id !== caller.workspace_id) || new Set(inventory.map(p => p.pane_id)).size !== inventory.length || inventory.filter(p => p?.pane_id === pane && p.tab_id === state!.tab).length !== 1 || !inventory.some(p => p.pane_id === caller.pane_id && p.tab_id === caller.tab_id) || current?.pane_id !== caller.pane_id || current.tab_id !== caller.tab_id || current.workspace_id !== caller.workspace_id) throw new Error('New monitor tab/caller identity not confirmed; inspect before recovery');
172
+ }
99
173
  state.stage = 'run_pending'; await save();
100
174
  const args = [options.script, '--root', options.root];
101
175
  const command = platform === 'win32'
package/src/pane-close.ts CHANGED
@@ -5,6 +5,7 @@ import { open, readFile, realpath } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import type { Run } from './launcher.js';
7
7
  import type { CloseProvider, WorkerPaneIdentity } from './handoff.js';
8
+ import { createCoreCloseProvider } from './core-pane-close.js';
8
9
 
9
10
  interface ProcessIdentity { start: string; state: string }
10
11
  export interface PaneCloseOptions {
@@ -53,6 +54,7 @@ const alive = (identity: ProcessIdentity | undefined, start: string) =>
53
54
  /** Linux Herdr only. Identity-checked best effort; Herdr has no atomic compare-and-close API. */
54
55
  export function createCloseProvider(options: PaneCloseOptions): CloseProvider | undefined {
55
56
  const env = options.env ?? process.env;
57
+ if (env.HC_TERMINAL_ID !== undefined) return createCoreCloseProvider(options);
56
58
  if ((options.platform ?? process.platform) !== 'linux' || env.HERDR_ENV !== '1' || !env.HERDR_PANE_ID) return;
57
59
  const run = options.run ?? exec;
58
60
  const processIdentity = options.processIdentity ?? linuxProcessIdentity;
@@ -0,0 +1,69 @@
1
+ export const REMINDER_INTERVAL_MS = 5 * 60 * 1000;
2
+ export const REMINDER_TEXT = 'Intercom reminder: Review outstanding delegated work and worker reports. If follow-up is needed, take the next appropriate action. Do not assume workers have finished or stopped.\n\nIf there is genuinely nothing to follow up on, call intercom_stop_reminders. Otherwise, reminders continue every five minutes; a new worker call resets the timer.\n\nThis reminder does not authorize new work, retries, worker closure, or publication.';
3
+
4
+ export interface ReminderClock {
5
+ schedule(callback: () => void, milliseconds: number): () => void;
6
+ }
7
+ const clock: ReminderClock = {
8
+ schedule(callback, milliseconds) {
9
+ const timer = setTimeout(callback, milliseconds);
10
+ timer.unref();
11
+ return () => clearTimeout(timer);
12
+ },
13
+ };
14
+ interface ReminderHost {
15
+ isCoordinator(): Promise<boolean>;
16
+ busy(): boolean;
17
+ deliver(text: string): void;
18
+ }
19
+ /** Ephemeral reminder only: never worker actions, network retries or a backlog. */
20
+ export class CoordinatorReminders {
21
+ private generation = 0;
22
+ private tick = 0;
23
+ private checking = false;
24
+ private disposed = false;
25
+ private cancel?: () => void;
26
+ private detachOwner?: () => void;
27
+ constructor(private readonly host: ReminderHost, private readonly timer: ReminderClock = clock) {}
28
+ arm(signal?: AbortSignal): void {
29
+ if (this.disposed || signal?.aborted) return;
30
+ this.stop();
31
+ const generation = this.generation;
32
+ if (signal) {
33
+ const abort = () => { if (this.generation === generation) this.stop(); };
34
+ signal.addEventListener('abort', abort, { once: true });
35
+ this.detachOwner = () => signal.removeEventListener('abort', abort);
36
+ }
37
+ this.schedule(generation);
38
+ }
39
+ stop(): void {
40
+ this.generation++; this.tick++;
41
+ this.cancel?.(); this.cancel = undefined;
42
+ this.detachOwner?.(); this.detachOwner = undefined;
43
+ }
44
+ dispose(): void { this.stop(); this.disposed = true; }
45
+ private current(generation: number, tick: number): boolean {
46
+ return !this.disposed && this.generation === generation && this.tick === tick;
47
+ }
48
+ private schedule(generation: number): void {
49
+ this.cancel = this.timer.schedule(() => {
50
+ if (this.disposed || generation !== this.generation) return;
51
+ this.cancel = undefined;
52
+ const tick = ++this.tick;
53
+ this.schedule(generation);
54
+ // A slow role read never creates concurrent reads or a queued reminder.
55
+ // Advancing tick also invalidates any read from the preceding interval.
56
+ if (this.checking) return;
57
+ this.checking = true;
58
+ void (async () => {
59
+ try {
60
+ const coordinator = await this.host.isCoordinator();
61
+ if (!this.current(generation, tick)) return;
62
+ if (!coordinator) { this.stop(); return; }
63
+ if (!this.host.busy()) this.host.deliver(REMINDER_TEXT);
64
+ } catch { /* Unknown role/read/delivery outcome: no blind immediate replay. */ }
65
+ finally { this.checking = false; }
66
+ })();
67
+ }, REMINDER_INTERVAL_MS);
68
+ }
69
+ }
package/src/runtime.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { realpath } from 'node:fs/promises';
3
4
  import { LocalObserver, LOG_LIMITS, observationError, type Observer, type EventType, type EventMetadata } from './observability.js';
4
- import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, type Agent, type Config } from './config.js';
5
+ import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, text, validateConfig, monitorSettings, validateMonitorSettings, type MonitorSettings, type Agent, type Config } from './config.js';
5
6
  import { listen, send, reportPayload, type Endpoint, type Envelope, type Kind } from './transport.js';
6
7
  import { saveWorkerReport } from './reports.js';
7
8
  import { readObservationSnapshot } from './snapshot.js';
8
9
  import { workerStatusPage } from './worker-status.js';
9
10
  import { probeWorker, probeWorkers } from './connections.js';
10
11
  import { HandoffWorkflow, HANDOFF_KINDS, fencedClose, type CloseProvider, type HandoffKind } from './handoff.js';
12
+ import { CoordinatorReminders, type ReminderClock } from './reminders.js';
11
13
 
12
14
  export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and legacy graceful-close control are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. close_worker uses a separate supported-provider owned-pane closure contract, not graceful cancellation. See references/implementation-blocker.md. A verified supported host API is required for graceful cancellation (no version-only override).';
13
15
  export interface Host {
@@ -18,7 +20,12 @@ export interface Host {
18
20
  setName(name: string): Promise<void>;
19
21
  notify(text: string): void;
20
22
  }
21
- export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; sessionId?: string }
23
+ export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; workspaceRoot: string; sessionId?: string }
24
+ export interface MonitorRequest { root: string; sessionId: string; assertCurrent(): void }
25
+ export interface MonitorOperations {
26
+ open(request: MonitorRequest & { placement: MonitorSettings['placement'] }): Promise<unknown>;
27
+ close(request: MonitorRequest): Promise<unknown>;
28
+ }
22
29
  export interface RuntimeOptions {
23
30
  launch(request: LaunchRequest): Promise<unknown>;
24
31
  listen?: typeof listen;
@@ -29,6 +36,10 @@ export interface RuntimeOptions {
29
36
  closeProvider?: CloseProvider;
30
37
  /** Test seam; production defaults to 120 seconds, values above that are capped. */
31
38
  closeTimeoutMs?: number;
39
+ reminderClock?: ReminderClock;
40
+ monitor?: MonitorOperations;
41
+ /** Explicit launcher-provided root must equal canonical cwd; no parent-root exception. */
42
+ workspaceRoot?: string;
32
43
  }
33
44
  export class Intercom {
34
45
  store!: ConfigStore;
@@ -36,6 +47,8 @@ export class Intercom {
36
47
  private active = false;
37
48
  private generation = 0;
38
49
  private initialId = '';
50
+ private configuredRoot?: string;
51
+ private configuredFile?: string;
39
52
  responsibility?: Agent;
40
53
  private observer?: Observer;
41
54
  private closing?: Promise<void>;
@@ -44,6 +57,9 @@ export class Intercom {
44
57
  private probeController = new AbortController();
45
58
  private resumeFences = new Map<string, { inFlight: boolean; submitted: boolean; announced: boolean }>();
46
59
  private handoff?: HandoffWorkflow;
60
+ private reminders?: CoordinatorReminders;
61
+ private reminderRevision = 0;
62
+ private reminderInvocation = 0;
47
63
  workerStarted(): void { this.handoff?.workerStarted(); }
48
64
  workerSettled(successfulToolCallIds: ReadonlySet<string>): void { this.handoff?.workerSettled(successfulToolCallIds); }
49
65
  private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
@@ -57,6 +73,7 @@ export class Intercom {
57
73
  private id(): string {
58
74
  const id = this.host.sessionId();
59
75
  if (!this.active || id !== this.initialId) fail('session is inactive or replaced');
76
+ if (this.configuredRoot !== undefined && (this.store.root !== this.configuredRoot || this.store.file !== this.configuredFile)) fail('config store boundary changed');
60
77
  return id;
61
78
  }
62
79
  private validity(): () => void {
@@ -71,19 +88,27 @@ export class Intercom {
71
88
  if (this.closing) await this.closing;
72
89
  if (this.active) fail('runtime already active');
73
90
  this.initialId = this.host.sessionId(); this.active = true;
91
+ this.configuredRoot = undefined; this.configuredFile = undefined;
74
92
  this.probeController = new AbortController();
75
93
  this.resumeFences.clear();
76
94
  const generation = ++this.generation, assertValid = this.validity();
77
95
  let record = this.captureObserver();
78
96
  try {
79
- const discovered = await ConfigStore.discover(this.host.cwd);
97
+ const discovered = this.options.workspaceRoot !== undefined
98
+ ? await ConfigStore.workspace(this.host.cwd, this.options.workspaceRoot)
99
+ : await ConfigStore.discover(this.host.cwd);
100
+ assertValid();
101
+ const root = discovered?.root ?? await realpath(this.host.cwd);
102
+ assertValid();
103
+ this.store = this.options.store?.(root) ?? discovered ?? new ConfigStore(root);
104
+ if (this.store.root !== root || this.store.file !== path.join(root, '.pi-intercom', 'config.json')) fail('config store must retain validated session root');
105
+ this.configuredRoot = root; this.configuredFile = this.store.file;
106
+ await this.store.validateRoot(root); assertValid();
107
+ const previous = discovered ? await this.store.read() : undefined;
80
108
  assertValid();
81
- this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
82
109
  try { this.observer = this.options.observe ? this.options.observe(this.store.root, this.initialId) : new LocalObserver(this.store.root, this.initialId); } catch { /* Logging is optional on failure. */ }
83
110
  record = this.captureObserver();
84
111
  record('runtime.starting');
85
- const previous = discovered ? await this.store.read() : undefined;
86
- assertValid();
87
112
  const own = previous?.agents.find(a => a.sessionId === this.id());
88
113
  const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); }, () => {
89
114
  assertValid();
@@ -98,6 +123,11 @@ export class Intercom {
98
123
  const config = await this.store.read();
99
124
  assertValid();
100
125
  const me = config.agents.find(a => a.sessionId === this.id());
126
+ this.reminders = new CoordinatorReminders({
127
+ isCoordinator: async () => { const fresh = await this.state(); assertValid(); return fresh.me?.coordinator === true; },
128
+ busy: () => this.host.busy(),
129
+ deliver: message => { assertValid(); this.host.deliver(message, false); },
130
+ }, this.options.reminderClock);
101
131
  this.handoff = new HandoffWorkflow({
102
132
  state: () => this.state(), assertCurrent: assertValid,
103
133
  update: async (mutate, guard) => {
@@ -152,6 +182,8 @@ export class Intercom {
152
182
  this.active = false; this.generation++;
153
183
  this.probeController.abort();
154
184
  this.handoff?.dispose(); this.handoff = undefined;
185
+ this.reminderRevision = ++this.reminderInvocation;
186
+ this.reminders?.dispose(); this.reminders = undefined;
155
187
  const endpoint = this.endpoint; this.endpoint = undefined;
156
188
  this.responsibility = undefined;
157
189
  const closing = (async () => {
@@ -178,6 +210,32 @@ export class Intercom {
178
210
  assertValid();
179
211
  return { id, config, me: config.agents.find(a => a.sessionId === id) };
180
212
  }
213
+ async getMonitorSettings(): Promise<MonitorSettings> {
214
+ const assertValid = this.validity();
215
+ const { id, config } = await this.state();
216
+ assertValid(); requireCoordinator(config, id);
217
+ return monitorSettings(config);
218
+ }
219
+ /** Automatic admission is internal: explicit open remains allowed with disabled auto-open or no workers. */
220
+ async autoOpenMonitor(assertAdmission: () => void = () => {}): Promise<unknown> { return this.operateMonitor('open_monitor', {}, true, assertAdmission); }
221
+ private async operateMonitor(operation: 'open_monitor' | 'close_monitor', args: Record<string, unknown>, automatic = false, assertAdmission: () => void = () => {}): Promise<unknown> {
222
+ const lifecycle = this.validity();
223
+ const assertValid = () => { lifecycle(); assertAdmission(); };
224
+ assertValid();
225
+ if (Object.keys(args).some(key => operation === 'close_monitor' || key !== 'placement')) fail('invalid monitor operation arguments');
226
+ if (operation === 'open_monitor' && !automatic && !['coordinator-tab', 'new-tab'].includes(args.placement as string)) fail('explicit monitor placement is required; ask the user if unknown');
227
+ const fresh = await this.state(); assertValid(); requireCoordinator(fresh.config, fresh.id);
228
+ const settings = monitorSettings(fresh.config);
229
+ if (automatic && (!settings.autoOpen || !fresh.config.agents.some(agent => !agent.coordinator))) return { outcome: 'automatic monitor skipped' };
230
+ if (!this.options.monitor) fail('monitor adapter unavailable; no pane action attempted');
231
+ const request: MonitorRequest = { root: this.store.root, sessionId: fresh.id, assertCurrent: assertValid };
232
+ const result = operation === 'open_monitor'
233
+ ? await this.options.monitor.open({ ...request, placement: (automatic ? settings.placement : args.placement) as MonitorSettings['placement'] })
234
+ : await this.options.monitor.close(request);
235
+ assertValid();
236
+ const after = await this.state(); assertValid(); requireCoordinator(after.config, fresh.id);
237
+ return result;
238
+ }
181
239
  /** Accept old configs, but retire the removed browser dashboard's discovery metadata. */
182
240
  async retireDashboardMetadata(): Promise<void> {
183
241
  const record = this.captureObserver(), assertValid = this.validity();
@@ -334,7 +392,17 @@ export class Intercom {
334
392
  }
335
393
  fail('unsupported control');
336
394
  }
337
- async tool(operation: string, args: Record<string, unknown>, toolCallId?: string): Promise<unknown> {
395
+ async tool(operation: string, args: Record<string, unknown>, toolCallId?: string, reminderSignal?: AbortSignal): Promise<unknown> {
396
+ // Reserve invocation order before state/directory awaits, but only VALID
397
+ // reminder actions advance the accepted revision. Invalid/nonworker calls
398
+ // must not supersede a pending stop or suppress an older valid preflight.
399
+ const invocation = ++this.reminderInvocation;
400
+ const armReminder = () => {
401
+ if (invocation >= this.reminderRevision && !reminderSignal?.aborted) {
402
+ this.reminderRevision = invocation;
403
+ this.reminders?.arm(reminderSignal);
404
+ }
405
+ };
338
406
  const record = this.captureObserver();
339
407
  const assertValid = this.validity();
340
408
  const { id, config, me } = await this.state();
@@ -370,10 +438,31 @@ export class Intercom {
370
438
  if (operation === 'send') {
371
439
  if (!me || !this.responsibility) fail('anonymous/unloaded worker cannot send agent messages (permissions TODO)');
372
440
  const recipient = named(config, args.to as string);
441
+ text(args.message, 'message', 48000);
373
442
  if (this.handoff?.workerClosing || fencedClose(me.closeJob) || this.handoff?.isClosing(recipient.sessionId) || fencedClose(recipient.closeJob)) fail('worker close is active or uncertain; new Intercom work is fenced');
443
+ if (me.coordinator && !recipient.coordinator) armReminder();
374
444
  await this.transmit(args.to as string, 'message', { message: args.message }); return { accepted: true, completion: 'not awaited' };
375
445
  }
376
446
  requireCoordinator(config, id);
447
+ if (operation === 'configure_monitor') {
448
+ const keys = Object.keys(args);
449
+ if (!keys.length || keys.some(key => !['autoOpen', 'placement'].includes(key))) fail('configure_monitor requires autoOpen and/or placement only');
450
+ validateMonitorSettings({ ...monitorSettings(config), ...args });
451
+ const updated = await this.store.update(id, current => {
452
+ current.monitor = { ...monitorSettings(current), ...args } as MonitorSettings;
453
+ validateMonitorSettings(current.monitor);
454
+ }, assertValid);
455
+ assertValid();
456
+ return { written: true, monitor: monitorSettings(updated), paneEffects: false };
457
+ }
458
+ if (operation === 'open_monitor' || operation === 'close_monitor') {
459
+ return this.operateMonitor(operation, args);
460
+ }
461
+ if (operation === 'stop_reminders') {
462
+ const current = invocation >= this.reminderRevision;
463
+ if (current) { this.reminderRevision = invocation; this.reminders?.stop(); }
464
+ return { stopped: current };
465
+ }
377
466
  if (operation === 'stop_worker') fail(UNSUPPORTED_CANCELLATION);
378
467
  if (operation === 'configure_worker') {
379
468
  const assertConfigurable = () => {
@@ -381,6 +470,17 @@ export class Intercom {
381
470
  if (this.handoff?.isClosing(args.sessionId as string) || this.resumeFences.has(args.sessionId as string)) fail('worker lifecycle operation is in flight; configuration is fenced');
382
471
  };
383
472
  assertConfigurable();
473
+ // Validate the candidate without writing, so invalid input never arms a
474
+ // reminder; persistence failures after valid initiation still do.
475
+ const relative = await directory(this.store.root, args.projectDirectory as string);
476
+ assertConfigurable();
477
+ const candidate: Agent = { sessionId: args.sessionId as string, name: args.name as string, description: args.description as string,
478
+ port: args.port as number, projectDirectory: relative, coordinator: false };
479
+ if (candidate.sessionId === id) fail('cannot configure coordinator as worker');
480
+ const previous = config.agents.find(a => a.sessionId === candidate.sessionId);
481
+ if (fencedClose(previous?.closeJob)) fail('worker close is active or uncertain; configuration is fenced');
482
+ validateConfig({ ...config, agents: [...config.agents.filter(a => a.sessionId !== candidate.sessionId), candidate] });
483
+ armReminder();
384
484
  await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertConfigurable);
385
485
  record('config.changed', { operation: 'configure_worker', peerSessionId: args.sessionId as string, peerName: args.name as string, outcome: 'written' });
386
486
  return { written: true, reloaded: false };
@@ -392,33 +492,43 @@ export class Intercom {
392
492
  if (operation === 'create_worker') {
393
493
  const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
394
494
  assertValid();
395
- return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
495
+ if (relative !== '.') fail('worker working directory must equal the config root; subdirectory launches would read a forbidden parent config');
496
+ armReminder();
497
+ return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), workspaceRoot: this.store.root });
396
498
  }
397
499
  const target = named(config, args.to as string);
398
500
  if (target.coordinator) fail('operation requires a worker target');
399
501
  if (operation === 'close_worker') {
400
502
  if (!this.options.closeProvider || !this.handoff || config.multiplexer !== 'herdr') fail('Owned-pane close requires supported Linux Herdr provider; no closure attempted');
401
503
  if (this.resumeFences.has(target.sessionId)) fail('resume is in flight; close is fenced');
504
+ if (fencedClose(target.closeJob) && !this.handoff.isClosing(target.sessionId)) fail('worker close is active or uncertain; close is fenced');
505
+ armReminder();
402
506
  return { job: await this.handoff.request(target), completion: 'not awaited', contract: 'best-effort owned-pane closure; not all-child-process termination' };
403
507
  }
404
508
  if (['reload_worker', 'resume_worker', 'remove_worker'].includes(operation) && (this.handoff?.isClosing(target.sessionId) || fencedClose(target.closeJob))) fail('worker close is active or uncertain; operation is fenced');
405
509
  if (operation === 'reload_worker' || operation === 'request_status') {
510
+ armReminder();
406
511
  await this.transmit(target.name, operation === 'reload_worker' ? 'reload' : 'request_status');
407
512
  return { accepted: true, completion: 'not awaited' };
408
513
  }
409
514
  if (operation === 'resume_worker') {
410
515
  if (args.confirmClosed !== true) fail('Resume requires explicit user confirmation that the previous worker session is closed (confirmClosed:true). Disconnected alone is not proof of closure.');
411
516
  if (this.resumeFences.has(target.sessionId)) fail('Resume already in flight or awaiting worker status; outcome may be uncertain. Inspect the worker before any retry; no automatic relaunch.');
517
+ const relative = await directory(this.store.root, target.projectDirectory);
518
+ assertValid();
519
+ if (relative !== '.') fail('worker working directory must equal the config root; subdirectory resumes would read a forbidden parent config');
520
+ const cwd = path.resolve(this.store.root, relative);
521
+ // Directory validation is read-only; recheck admission after that await.
522
+ if (this.resumeFences.has(target.sessionId)) fail('Resume already in flight or awaiting worker status; outcome may be uncertain. Inspect the worker before any retry; no automatic relaunch.');
523
+ if (this.handoff?.isClosing(target.sessionId)) fail('worker close is active or uncertain; resume is fenced');
412
524
  const fence = { inFlight: true, submitted: false, announced: false };
413
525
  this.resumeFences.set(target.sessionId, fence);
526
+ armReminder();
414
527
  let completed = false;
415
528
  try {
416
529
  const connection = await (this.options.probe ?? probeWorker)(target, { signal: this.probeController.signal });
417
530
  assertValid();
418
531
  if (connection.state === 'connected') fail('Worker endpoint is still connected; refusing to resume the same session twice.');
419
- const relative = await directory(this.store.root, target.projectDirectory);
420
- assertValid();
421
- const cwd = path.resolve(this.store.root, relative);
422
532
  const fresh = await this.state();
423
533
  assertValid();
424
534
  requireCoordinator(fresh.config, id);
@@ -426,7 +536,7 @@ export class Intercom {
426
536
  if (!current || current.port !== target.port || current.projectDirectory !== target.projectDirectory) fail('Worker configuration changed during resume check; no launch submitted.');
427
537
  if (this.handoff?.isClosing(target.sessionId) || fencedClose(current.closeJob)) fail('worker close is active or uncertain; resume is fenced');
428
538
  fence.submitted = true;
429
- const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, sessionId: current.sessionId });
539
+ const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, workspaceRoot: this.store.root, sessionId: current.sessionId });
430
540
  completed = true;
431
541
  return result;
432
542
  } finally {