@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
@@ -0,0 +1,328 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import type { CloseProvider, WorkerPaneIdentity } from './handoff.js';
6
+ import { linuxProcessIdentity } from './pane-close.js';
7
+ import { coreCall, defaultCoreEndpoint, locateTerminalTab, terminalAddress, withCoreSession, type CoreCall } from './herdr-core.js';
8
+
9
+ interface ProcessIdentity { start: string; state: string }
10
+ export interface CorePaneCloseOptions {
11
+ sessionId(): string;
12
+ sessionFile(): string | undefined;
13
+ env?: NodeJS.ProcessEnv;
14
+ platform?: string;
15
+ pid?: number;
16
+ processIdentity?: (pid: number) => Promise<ProcessIdentity | undefined>;
17
+ verifySession?: (file: string, id: string) => Promise<void>;
18
+ /** Trusted test seam, or a call already inside ONE withCoreSession operation.
19
+ * Never inject a reconnecting per-request transport. */
20
+ call?: CoreCall;
21
+ verificationMs?: number;
22
+ }
23
+
24
+ const DOCUMENT = 'herdr-app';
25
+ const blocked = () => new Error('PiIntercom HC pane close blocked: ownership, process, descendants or supported layout could not be verified; no close submitted.');
26
+ export class CorePaneCloseUncertainError extends Error {
27
+ readonly uncertain = true;
28
+ readonly outcomeUncertain = true;
29
+ constructor() {
30
+ super('PiIntercom HC pane close outcome uncertain; terminal/process or pane may remain; inspect before any retry; no automatic replay.');
31
+ this.name = 'CorePaneCloseUncertainError';
32
+ }
33
+ }
34
+ function requireProof(ok: unknown): asserts ok { if (!ok) throw blocked(); }
35
+ const positive = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) > 0;
36
+ const alive = (p: ProcessIdentity | undefined, start: string) => !!p && p.start === start && !['Z', 'X', 'x'].includes(p.state);
37
+ const localId = (v: unknown): v is string => typeof v === 'string' && !!v && v.length <= 256 && !/[\/\x00-\x20\x7f]/.test(v);
38
+ const uuid = (v: unknown) => typeof v === 'string' && /^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(v) && v.trim() === v;
39
+
40
+ async function verifySessionHeader(file: string, id: string): Promise<void> {
41
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
42
+ try {
43
+ requireProof((await handle.stat()).isFile());
44
+ const buffer = Buffer.alloc(4096);
45
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
46
+ const end = buffer.subarray(0, bytesRead).indexOf(10);
47
+ requireProof(end >= 0);
48
+ const header = JSON.parse(buffer.subarray(0, end).toString('utf8'));
49
+ requireProof(header?.type === 'session' && header.id === id);
50
+ } finally { await handle.close(); }
51
+ }
52
+
53
+ interface Placement { space: string; tab: string; pane: string; content: unknown }
54
+ interface View {
55
+ version: number; value: any;
56
+ caller: ReturnType<typeof locateTerminalTab>;
57
+ terminals: Map<string, Placement>;
58
+ blocks: Map<string, Placement>;
59
+ ids: Set<string>;
60
+ }
61
+ function view(snapshot: any, caller: string): View {
62
+ requireProof(Number.isSafeInteger(snapshot?.version) && snapshot.version >= 0 && snapshot.value &&
63
+ Buffer.byteLength(JSON.stringify(snapshot.value)) <= 1024 * 1024);
64
+ // Shared locator checks bounded trees, global IDs, unambiguous content and floats.
65
+ const location = locateTerminalTab(snapshot.value, caller);
66
+ const terminals = new Map<string, Placement>(), blocks = new Map<string, Placement>(), ids = new Set<string>();
67
+ const id = (v: string) => { requireProof(localId(v)); ids.add(v); };
68
+ for (const space of snapshot.value.spaces) {
69
+ id(space.id);
70
+ for (const tab of space.tabs) {
71
+ id(tab.id);
72
+ const visit = (node: any) => {
73
+ id(node.id);
74
+ for (const floating of node.floating ?? []) {
75
+ id(floating.id);
76
+ blocks.set(floating.id, { space: space.id, tab: tab.id, pane: floating.id, content: floating.content });
77
+ }
78
+ if (node.type === 'block') {
79
+ const placement = { space: space.id, tab: tab.id, pane: node.id, content: node.content };
80
+ blocks.set(node.id, placement);
81
+ if (node.content.terminal !== undefined) {
82
+ terminalAddress(node.content.terminal);
83
+ requireProof(!terminals.has(node.content.terminal));
84
+ terminals.set(node.content.terminal, placement);
85
+ }
86
+ } else for (const child of node.children) visit(child);
87
+ };
88
+ visit(tab.root);
89
+ }
90
+ }
91
+ return { version: snapshot.version, value: snapshot.value, caller: location, terminals, blocks, ids };
92
+ }
93
+ function samePlace(a: Placement | undefined, b: Placement | undefined): boolean {
94
+ return !!a && !!b && a.space === b.space && a.tab === b.tab && a.pane === b.pane && isDeepStrictEqual(a.content, b.content);
95
+ }
96
+ function preserveBlocks(before: View, after: View, removed: string): void {
97
+ for (const [id, placement] of before.blocks) if (id !== removed) requireProof(samePlace(placement, after.blocks.get(id)));
98
+ }
99
+
100
+ /** Intentionally narrow mutation subset: the standard worker tab, or flat
101
+ * row/column splits. Never delete a tab containing any sibling block/mod/float.
102
+ * Keep sibling nodes and their sizes/metadata verbatim; singleton roots are valid
103
+ * in app 0.1.13. Nested target trees/root blocks are blocked BEFORE the kill.
104
+ * Source: app 0.1.13 mirror.rs Document/SharedTab, layout.rs Node, model.rs
105
+ * remove_pane, window.rs terminal_ended/reconcile. The app may do its own removal.
106
+ * This is private, source-pinned layout support, NOT an app-version/UI handshake. */
107
+ function removal(current: View, identity: WorkerPaneIdentity): any {
108
+ const value = structuredClone(current.value);
109
+ const { space, tab, pane } = locateTerminalTab(value, identity.terminalId);
110
+ const root = tab.root;
111
+ requireProof(pane.id === identity.paneId && ['row', 'column'].includes(root.type) &&
112
+ root.children.every((n: any) => n.type === 'block') && root.children.includes(pane) &&
113
+ pane.closable !== false && root.closable !== false && !(pane.floating?.length));
114
+ if (root.children.length === 1) {
115
+ requireProof(!(root.floating?.length));
116
+ space.tabs.splice(space.tabs.indexOf(tab), 1);
117
+ // Do not remove the space, even if now empty: it is not our owned object.
118
+ } else root.children.splice(root.children.indexOf(pane), 1);
119
+ return value;
120
+ }
121
+
122
+ function live(record: any, identity: WorkerPaneIdentity, created?: number): number {
123
+ requireProof(record?.address === identity.terminalId && record.state === 'live' && positive(record.created_ms) &&
124
+ (created === undefined || record.created_ms === created) && record.foreground?.pid === identity.pid && record.rm !== true);
125
+ return record.created_ms;
126
+ }
127
+ function terminalResult(record: any, terminal: string, created: number): boolean {
128
+ requireProof(record?.address === terminal && record.created_ms === created && ['live', 'finished'].includes(record.state));
129
+ if (record.state !== 'finished') return false;
130
+ requireProof(record.foreground == null && positive(record.finished_ms) && record.finished_ms >= created);
131
+ return true;
132
+ }
133
+
134
+ /** Include finished ancestors too. Missing ancestry, cycles, duplicate addresses,
135
+ * foreign machines (including disconnected ones) and unknown states fail closed.
136
+ * Core children normally end with their parent; outlive-parent is NOT permission
137
+ * to kill somebody else's worker. This snapshot is not an atomic spawn barrier. */
138
+ function noDescendants(list: any, target: WorkerPaneIdentity, own: WorkerPaneIdentity, targetCreated: number, ownCreated: number): void {
139
+ requireProof(Array.isArray(list?.terminals) && list.terminals.length <= 4096 &&
140
+ list.hidden_finished === 0 && Array.isArray(list.machines) && list.machines.length === 0);
141
+ const records = new Map<string, any>();
142
+ for (const r of list.terminals) {
143
+ terminalAddress(r?.address);
144
+ requireProof(!records.has(r.address) && ['live', 'finished'].includes(r.state));
145
+ if (r.parent != null) terminalAddress(r.parent);
146
+ records.set(r.address, r);
147
+ }
148
+ live(records.get(target.terminalId), target, targetCreated);
149
+ live(records.get(own.terminalId), own, ownCreated);
150
+ for (const r of records.values()) {
151
+ const seen = new Set<string>([r.address]);
152
+ let parent = r.parent;
153
+ while (parent != null) {
154
+ requireProof(!seen.has(parent) && records.has(parent));
155
+ requireProof(r.state !== 'live' || parent !== target.terminalId);
156
+ seen.add(parent); parent = records.get(parent).parent;
157
+ }
158
+ }
159
+ }
160
+
161
+ /** Linux-only owned-worker closure. HandoffWorkflow still owns saved handoff,
162
+ * successful tool persistence, final settlement and same-epoch close_commit.
163
+ * beforeSubmit MUST perform that commit; it runs after all slow ownership reads,
164
+ * immediately before ONE terminal.kill. There is no graceful-cancel enablement,
165
+ * all-descendants guarantee, atomic identity/kill API, automatic retry or rm.
166
+ * Default operations each use a fresh bound CoreSession, never reconnect mid-job.
167
+ * Current shared transport deliberately pins core 0.1.18; later versions require
168
+ * review. No API can establish remote app 0.1.13/rendering from core alone. */
169
+ export function createCoreCloseProvider(options: CorePaneCloseOptions): CloseProvider | undefined {
170
+ const env = options.env ?? process.env;
171
+ if ((options.platform ?? process.platform) !== 'linux' || !env.HC_TERMINAL_ID) return;
172
+ const caller = env.HC_TERMINAL_ID;
173
+ const ownPid = options.pid ?? process.pid;
174
+ const processIdentity = options.processIdentity ?? linuxProcessIdentity;
175
+ const verifySession = options.verifySession ?? verifySessionHeader;
176
+ const requestedMs = options.verificationMs ?? 1500;
177
+ const verificationMs = Number.isFinite(requestedMs) ? Math.max(0, Math.min(2000, requestedMs)) : 1500;
178
+ const attempted = new Set<string>(), active = new Set<string>();
179
+ const operation = <T>(work: (call: CoreCall) => Promise<T>): Promise<T> =>
180
+ options.call && options.call !== coreCall ? work(options.call) :
181
+ defaultCoreEndpoint(env).then(endpoint => withCoreSession(work, { endpoint }));
182
+
183
+ async function context(call: CoreCall, assertCurrent: () => void) {
184
+ terminalAddress(caller);
185
+ const sessionId = options.sessionId(), sourceFile = options.sessionFile();
186
+ requireProof(positive(ownPid) && typeof sessionId === 'string' && !!sessionId && sourceFile);
187
+ const guard = () => {
188
+ assertCurrent();
189
+ requireProof(env.HC_TERMINAL_ID === caller && options.sessionId() === sessionId && options.sessionFile() === sourceFile);
190
+ };
191
+ const query: CoreCall = async (method, params) => { guard(); const result = await call(method, params); guard(); return result; };
192
+ const status = await query('server.status', {});
193
+ requireProof(status?.version === '0.1.18' && uuid(status.core?.id) && typeof status.core.name === 'string' && positive(status.pid));
194
+ const core = async () => {
195
+ const fresh = await query('server.status', {});
196
+ requireProof(fresh?.version === status.version && fresh.core?.id === status.core.id &&
197
+ fresh.core?.name === status.core.name && fresh.pid === status.pid);
198
+ };
199
+ const canonical = async (file: string) => {
200
+ requireProof(typeof file === 'string' && path.isAbsolute(file) && file.length <= 4096 && !file.includes('\0'));
201
+ const resolved = await realpath(file); guard(); return resolved;
202
+ };
203
+ const sessionFile = await canonical(sourceFile);
204
+ const process = await processIdentity(ownPid); guard();
205
+ requireProof(process && /^[0-9]+$/.test(process.start) && alive(process, process.start));
206
+ const own: WorkerPaneIdentity = { workspaceId: status.core.id, terminalId: caller, paneId: '',
207
+ sessionId, sessionFile, pid: ownPid, processStart: process.start };
208
+ const checkProcess = async (identity: WorkerPaneIdentity) => {
209
+ requireProof(positive(identity.pid) && /^[0-9]+$/.test(identity.processStart) &&
210
+ identity.workspaceId === status.core.id && typeof identity.sessionId === 'string' && !!identity.sessionId);
211
+ requireProof(await canonical(identity.sessionFile) === identity.sessionFile);
212
+ await verifySession(identity.sessionFile, identity.sessionId); guard();
213
+ requireProof(await canonical(identity.sessionFile) === identity.sessionFile);
214
+ requireProof(alive(await processIdentity(identity.pid), identity.processStart)); guard();
215
+ };
216
+ const readView = async () => view(await query('doc.get', { name: DOCUMENT }), caller);
217
+ const initial = await readView();
218
+ own.paneId = initial.caller.pane.id;
219
+ const ownPlace = initial.terminals.get(caller)!;
220
+ const ownCreated = live(await query('terminal.get', { terminal: caller }), own);
221
+ const checkOwn = async (current: View) => {
222
+ requireProof(samePlace(ownPlace, current.terminals.get(caller)) && await canonical(sourceFile) === sessionFile);
223
+ await checkProcess(own);
224
+ live(await query('terminal.get', { terminal: caller }), own, ownCreated);
225
+ };
226
+ await checkOwn(initial);
227
+ return { guard, query, core, own, ownCreated, checkProcess, readView, checkOwn };
228
+ }
229
+ type Context = Awaited<ReturnType<typeof context>>;
230
+ async function inspectTarget(ctx: Context, identity: WorkerPaneIdentity, expected: string, prior?: { current: View; created: number }) {
231
+ terminalAddress(identity.terminalId);
232
+ requireProof(identity.sessionId === expected && expected !== ctx.own.sessionId && identity.pid !== ownPid &&
233
+ identity.terminalId !== caller && identity.paneId !== ctx.own.paneId && identity.workspaceId === ctx.own.workspaceId);
234
+ await ctx.core();
235
+ await ctx.checkProcess(identity);
236
+ const current = await ctx.readView();
237
+ const placement = current.terminals.get(identity.terminalId);
238
+ requireProof(placement?.pane === identity.paneId && (!prior || (current.version >= prior.current.version &&
239
+ samePlace(placement, prior.current.terminals.get(identity.terminalId)))));
240
+ removal(current, identity); // Prove our mutation subset BEFORE any effect.
241
+ await ctx.checkOwn(current);
242
+ const created = live(await ctx.query('terminal.get', { terminal: identity.terminalId }), identity, prior?.created);
243
+ ctx.guard();
244
+ return { current, created };
245
+ }
246
+ return {
247
+ async getIdentity() {
248
+ try {
249
+ return await operation(async call => {
250
+ const ctx = await context(call, () => {});
251
+ await ctx.core();
252
+ await ctx.checkOwn(await ctx.readView());
253
+ ctx.guard(); return ctx.own;
254
+ });
255
+ } catch { throw blocked(); }
256
+ },
257
+ async inspect(identity, expectedSessionId, assertCurrent) {
258
+ try { await operation(async call => { await inspectTarget(await context(call, assertCurrent), { ...identity }, expectedSessionId); }); }
259
+ catch { throw blocked(); }
260
+ },
261
+ async close(identity, expectedSessionId, assertCurrent, beforeSubmit) {
262
+ const target = { ...identity };
263
+ const key = JSON.stringify([target.workspaceId, target.terminalId, target.sessionId, target.pid, target.processStart]);
264
+ requireProof(!active.has(key) && !attempted.has(key));
265
+ active.add(key);
266
+ let submitted = false;
267
+ try {
268
+ return await operation(async call => {
269
+ const ctx = await context(call, assertCurrent);
270
+ const first = await inspectTarget(ctx, target, expectedSessionId);
271
+ // Revalidate on the SAME connection after slow header/process/layout reads.
272
+ const proof = await inspectTarget(ctx, target, expectedSessionId, first);
273
+ noDescendants(await ctx.query('terminal.list', { all: true }), target, ctx.own, proof.created, ctx.ownCreated);
274
+ await ctx.checkProcess(target);
275
+ await ctx.core();
276
+ live(await ctx.query('terminal.get', { terminal: target.terminalId }), target, proof.created);
277
+ ctx.guard();
278
+ await beforeSubmit(); // Handoff's same-epoch commit; no intervening slow read.
279
+ ctx.guard();
280
+ attempted.add(key); submitted = true;
281
+ const killed = await ctx.query('terminal.kill', { terminal: target.terminalId });
282
+ let finished = terminalResult(killed, target.terminalId, proof.created), workerExited = false;
283
+ const deadline = Date.now() + verificationMs;
284
+ // Bounded observation only, never resubmit kill. Stay below CoreSession's
285
+ // request budget even when the terminal does not finish promptly.
286
+ for (let i = 0; i < 10; i++) {
287
+ finished = terminalResult(await ctx.query('terminal.get', { terminal: target.terminalId }), target.terminalId, proof.created);
288
+ workerExited = !alive(await processIdentity(target.pid), target.processStart); ctx.guard();
289
+ if ((finished && workerExited) || Date.now() >= deadline) break;
290
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
291
+ ctx.guard();
292
+ }
293
+ if (!finished || !workerExited) return { paneClosed: false, workerExited };
294
+ await ctx.core();
295
+ const current = await ctx.readView();
296
+ requireProof(current.version >= proof.current.version);
297
+ await ctx.checkOwn(current);
298
+ preserveBlocks(proof.current, current, target.paneId);
299
+ const present = current.terminals.get(target.terminalId);
300
+ if (present) {
301
+ requireProof(samePlace(present, proof.current.terminals.get(target.terminalId)));
302
+ const value = removal(current, target);
303
+ const planned = view({ version: current.version, value }, caller);
304
+ requireProof(!planned.terminals.has(target.terminalId) && !planned.ids.has(target.paneId));
305
+ preserveBlocks(current, planned, target.paneId);
306
+ ctx.guard();
307
+ const written = await ctx.query('doc.set', { name: DOCUMENT, version: current.version, value });
308
+ requireProof(Number.isSafeInteger(written?.version) && written.version === current.version + 1);
309
+ const observed = await ctx.readView();
310
+ requireProof(observed.version >= written.version && !observed.terminals.has(target.terminalId) && !observed.ids.has(target.paneId));
311
+ if (observed.version === written.version) requireProof(isDeepStrictEqual(observed.value, value));
312
+ preserveBlocks(planned, observed, target.paneId);
313
+ await ctx.checkOwn(observed);
314
+ } else {
315
+ // The app can reconcile first. Absence alone is NOT ownership proof.
316
+ requireProof(!current.ids.has(target.paneId));
317
+ }
318
+ await ctx.core(); ctx.guard();
319
+ return { paneClosed: true, workerExited: true };
320
+ });
321
+ } catch (error) {
322
+ if (submitted) throw new CorePaneCloseUncertainError();
323
+ // Preserve commit/guard rejection details for HandoffWorkflow callers.
324
+ throw error;
325
+ } finally { active.delete(key); }
326
+ },
327
+ };
328
+ }
@@ -0,0 +1,240 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { coreCall, locateTerminalTab, terminalAddress, type CoreCall } from './herdr-core.js';
5
+
6
+ /** Source-pinned adapter, NOT a public app-layout schema or a UI readiness probe.
7
+ * hc 0.1.18's public doc API treats values as opaque JSON. The layout below is
8
+ * backed by herdr-app 0.1.13 src/{mirror,mirror_sync,model,window}.rs:
9
+ * - Document::capture/replace uses machine-local IDs; View owns private focus.
10
+ * - Tab::new uses a row whose ID is the tab ID and one terminal block.
11
+ * - Model::fresh_id uses a prefix and 32 random hexadecimal digits.
12
+ * - window::reconcile/start_adoption consumes placement labels. Therefore write
13
+ * and verify the document BEFORE labeling, never copy the caller's labels.
14
+ * Unknown metadata is retained. Unsupported/ambiguous trees fail closed.
15
+ */
16
+ const DOCUMENT = 'herdr-app';
17
+ const error = (message: string) => new Error(`PiIntercom HC worker layout: ${message}`);
18
+ const positiveInteger = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) > 0;
19
+
20
+ export interface CoreWorkerLayoutRequest {
21
+ caller: string;
22
+ cwd: string;
23
+ /** Canonically equal to cwd; runtime remains responsible for realpath checks. */
24
+ workspaceRoot: string;
25
+ platform?: string;
26
+ /** Optional already-known resumed worker name; reload uses renameCoreTab later. */
27
+ name?: string;
28
+ }
29
+
30
+ export interface CoreWorkerLayoutPreflight {
31
+ readonly caller: string;
32
+ readonly callerCreatedMs: number;
33
+ readonly coreId: string;
34
+ readonly space: string;
35
+ readonly tab: string;
36
+ readonly pane: string;
37
+ readonly version: number;
38
+ readonly startedAtMs: number;
39
+ readonly cwd: string;
40
+ readonly platform: string;
41
+ readonly name: string;
42
+ }
43
+
44
+ export interface CoreWorkerLayoutAttachment {
45
+ terminal: string;
46
+ terminalCreatedMs: number;
47
+ coreId: string;
48
+ space: string;
49
+ tab: string;
50
+ pane: string;
51
+ version: number;
52
+ layoutConfirmed: true;
53
+ uiAcknowledged: false;
54
+ }
55
+
56
+ /** All attach failures are post-creation uncertainty, including validation and
57
+ * CAS conflicts. No cleanup, retry, replacement, fallback, or terminal killing.
58
+ * The launcher must propagate this to its existing uncertain-launch fence. */
59
+ export class CoreWorkerLayoutUncertainError extends Error {
60
+ readonly outcomeUncertain = true;
61
+ constructor() {
62
+ super('PiIntercom HC worker layout attachment failed; outcome unknown, terminal/process and app tab may remain; no automatic cleanup/retry/replacement or fallback');
63
+ this.name = 'CoreWorkerLayoutUncertainError';
64
+ }
65
+ }
66
+
67
+ // A ticket is one attempt in this process, not persisted launch authorization.
68
+ const tickets = new WeakSet<CoreWorkerLayoutPreflight>();
69
+
70
+ function normalizeCwd(value: unknown, platform: string): string {
71
+ const paths = platform === 'win32' ? path.win32 : path.posix;
72
+ if (typeof value !== 'string' || !value.trim() || value.includes('\0') || !paths.isAbsolute(value)) throw error('absolute root cwd required');
73
+ return platform === 'win32' ? paths.resolve(value).toLowerCase() : paths.resolve(value);
74
+ }
75
+
76
+ function live(record: any, address: string, createdMs?: number): void {
77
+ if (record?.address !== address || record.state !== 'live' || !positiveInteger(record.created_ms) ||
78
+ (createdMs !== undefined && record.created_ms !== createdMs)) throw error('terminal identity is not live or changed');
79
+ }
80
+
81
+ async function identity(call: CoreCall, expected?: string): Promise<string> {
82
+ // CoreInfo.id is available in the verified hc 0.1.18 schema. Do not silently
83
+ // degrade an unavailable identity check into address-only ownership.
84
+ const status = await call('server.status', {});
85
+ const id = status?.core?.id;
86
+ if (typeof id !== 'string' || !id.trim() || (expected !== undefined && id !== expected)) throw error('current core identity unavailable or changed');
87
+ return id;
88
+ }
89
+
90
+ function snapshot(value: any): { version: number; value: any } {
91
+ if (!Number.isSafeInteger(value?.version) || value.version < 0 || !value.value || typeof value.value !== 'object') throw error('app document unavailable');
92
+ if (Buffer.byteLength(JSON.stringify(value.value), 'utf8') > 1024 * 1024) throw error('app document exceeds core limit');
93
+ return value;
94
+ }
95
+
96
+ /** locateTerminalTab supplies bounded tree/ID checks. Additionally reject
97
+ * qualified IDs and inspect floating blocks so they cannot conceal a duplicate
98
+ * terminal or collide with a generated ID. Stack layouts are not supported by
99
+ * the shared locator and are deliberately rejected, not rewritten. */
100
+ function inspect(value: any, caller: string): { location: ReturnType<typeof locateTerminalTab>; ids: Set<string>; terminals: Set<string> } {
101
+ const location = locateTerminalTab(value, caller);
102
+ const ids = new Set<string>(), terminals = new Set<string>();
103
+ const localId = (id: string) => {
104
+ if (!id || id.length > 256 || /[\/\x00-\x20\x7f]/.test(id)) throw error('nonlocal app identity');
105
+ ids.add(id);
106
+ };
107
+ const content = (v: any) => {
108
+ if (v?.terminal !== undefined) {
109
+ terminalAddress(v.terminal);
110
+ if (terminals.has(v.terminal)) throw error('duplicate terminal placement');
111
+ terminals.add(v.terminal);
112
+ }
113
+ };
114
+ let floats = 0;
115
+ const node = (v: any) => {
116
+ localId(v.id);
117
+ if (v.type === 'block') content(v.content);
118
+ if (v.floating !== undefined) {
119
+ if (!Array.isArray(v.floating)) throw error('invalid floating blocks');
120
+ for (const f of v.floating) {
121
+ if (++floats > 4096 || typeof f?.id !== 'string' || ids.has(f.id)) throw error('ambiguous floating identity');
122
+ localId(f.id); content(f.content);
123
+ }
124
+ }
125
+ if (v.type !== 'block') for (const c of v.children) node(c);
126
+ };
127
+ // Reserve all regular IDs before inspecting floats for cross-tree collisions.
128
+ const reserve = (v: any) => { localId(v.id); if (v.type !== 'block') for (const c of v.children) reserve(c); };
129
+ for (const s of value.spaces) { localId(s.id); for (const t of s.tabs) { localId(t.id); reserve(t.root); } }
130
+ for (const s of value.spaces) for (const t of s.tabs) node(t.root);
131
+ return { location, ids, terminals };
132
+ }
133
+
134
+ /** Call BEFORE hc run. Read-only: missing app document/mapping refuses launch.
135
+ * Labels are not authority: the current document must contain the caller once.
136
+ * This proves shared state, not that a remote window is connected/rendering. */
137
+ export async function preflightCoreWorkerLayout(request: CoreWorkerLayoutRequest, call: CoreCall = coreCall): Promise<CoreWorkerLayoutPreflight> {
138
+ const startedAtMs = Date.now();
139
+ terminalAddress(request.caller);
140
+ const platform = request.platform ?? process.platform;
141
+ if (platform !== 'linux' && platform !== 'win32') throw error('unsupported platform');
142
+ const cwd = normalizeCwd(request.cwd, platform);
143
+ if (normalizeCwd(request.workspaceRoot, platform) !== cwd) throw error('worker cwd must equal workspace root');
144
+ const name = request.name ?? 'Intercom (anonymous)';
145
+ if (typeof name !== 'string' || !name.trim() || name.length > 128 || /[\x00-\x1f\x7f]/.test(name)) throw error('invalid tab name');
146
+ const coreId = await identity(call);
147
+ const caller = await call('terminal.get', { terminal: request.caller });
148
+ live(caller, request.caller);
149
+ const doc = snapshot(await call('doc.get', { name: DOCUMENT }));
150
+ const { location } = inspect(doc.value, request.caller);
151
+ if (location.space.tabs.length >= 512) throw error('app tab limit reached');
152
+ await identity(call, coreId);
153
+ live(await call('terminal.get', { terminal: request.caller }), request.caller, caller.created_ms);
154
+ const ticket = Object.freeze({ caller: request.caller, callerCreatedMs: caller.created_ms, coreId,
155
+ space: location.space.id, tab: location.tab.id, pane: location.pane.id,
156
+ version: doc.version, startedAtMs, cwd, platform, name });
157
+ tickets.add(ticket);
158
+ return ticket;
159
+ }
160
+
161
+ function worker(record: any, ticket: CoreWorkerLayoutPreflight, address: string, createdMs: number): void {
162
+ live(record, address, createdMs);
163
+ if (record.parent !== ticket.caller || record.first_message != null ||
164
+ normalizeCwd(record.cwd, ticket.platform) !== ticket.cwd ||
165
+ (record.foreground != null && normalizeCwd(record.foreground.cwd, ticket.platform) !== ticket.cwd)) throw error('worker root cwd/parent differs from launch');
166
+ }
167
+
168
+ /** Call once AFTER hc run, with its full parsed RunResult (created_ms required).
169
+ * Appends exactly one dedicated tab in the caller's AUTHORITATIVE CURRENT space,
170
+ * using the current doc version, not the preflight version. No focus mutations.
171
+ * Returns shared-state confirmation only, never UI/Pi readiness. */
172
+ export async function attachCoreWorkerLayout(ticket: CoreWorkerLayoutPreflight, created: unknown, call: CoreCall = coreCall): Promise<CoreWorkerLayoutAttachment> {
173
+ try {
174
+ if (!tickets.delete(ticket)) throw error('preflight ticket absent or already consumed');
175
+ const result = created as any;
176
+ terminalAddress(result?.address);
177
+ if (result.address === ticket.caller || !positiveInteger(result.created_ms) || result.created_ms < ticket.startedAtMs) throw error('worker is not a fresh launch identity');
178
+ const address: string = result.address, createdMs: number = result.created_ms;
179
+ worker(result, ticket, address, createdMs);
180
+ await identity(call, ticket.coreId);
181
+ live(await call('terminal.get', { terminal: ticket.caller }), ticket.caller, ticket.callerCreatedMs);
182
+ const record = await call('terminal.get', { terminal: address });
183
+ worker(record, ticket, address, createdMs);
184
+ // A pre-labeled worker may already have triggered app adoption. Do not
185
+ // race it, move it, borrow its tab, or attempt to undo that operation.
186
+ const unplaced = (r: any) => {
187
+ if (r.labels?.['herdr.space'] || r.labels?.['herdr.tab']) throw error('worker already has placement labels');
188
+ };
189
+ unplaced(record);
190
+ const doc = snapshot(await call('doc.get', { name: DOCUMENT }));
191
+ if (doc.version < ticket.version) throw error('app document version regressed since preflight');
192
+ const value = structuredClone(doc.value);
193
+ const { location, ids, terminals } = inspect(value, ticket.caller);
194
+ if (terminals.has(address) || location.space.tabs.length >= 512) throw error('worker already placed or app tab limit reached');
195
+ const tab = `tb${randomUUID().replaceAll('-', '')}`, pane = `p${randomUUID().replaceAll('-', '')}`;
196
+ if (ids.has(tab) || ids.has(pane) || tab === pane) throw error('generated app identity collision');
197
+ const added = { id: tab, name: ticket.name, root: { id: tab, type: 'row', children: [{ id: pane, type: 'block', content: { terminal: address } }] } };
198
+ location.space.tabs.push(added);
199
+ inspect(value, ticket.caller); // Also enforce post-append tree bounds.
200
+ snapshot({ version: doc.version, value });
201
+ await identity(call, ticket.coreId);
202
+ live(await call('terminal.get', { terminal: ticket.caller }), ticket.caller, ticket.callerCreatedMs);
203
+ const fresh = await call('terminal.get', { terminal: address });
204
+ worker(fresh, ticket, address, createdMs); unplaced(fresh);
205
+ const written = await call('doc.set', { name: DOCUMENT, version: doc.version, value });
206
+ if (!Number.isSafeInteger(written?.version) || written.version !== doc.version + 1) throw error('unconfirmed document write');
207
+ const confirm = async () => {
208
+ const observed = snapshot(await call('doc.get', { name: DOCUMENT }));
209
+ if (observed.version < written.version) throw error('stale document readback');
210
+ const current = inspect(observed.value, ticket.caller).location;
211
+ const target = locateTerminalTab(observed.value, address);
212
+ if (current.space.id !== location.space.id || target.space.id !== current.space.id ||
213
+ target.tab.id !== tab || target.pane.id !== pane || !isDeepStrictEqual(target.tab, added)) throw error('placement readback differs');
214
+ // At the written version, every field must roundtrip, including metadata.
215
+ if (observed.version === written.version && !isDeepStrictEqual(observed.value, value)) throw error('document write differs');
216
+ return observed.version;
217
+ };
218
+ await confirm();
219
+ await identity(call, ticket.coreId);
220
+ live(await call('terminal.get', { terminal: ticket.caller }), ticket.caller, ticket.callerCreatedMs);
221
+ const beforeLabel = await call('terminal.get', { terminal: address });
222
+ worker(beforeLabel, ticket, address, createdMs); unplaced(beforeLabel);
223
+ const labels = { 'herdr.space': location.space.id, 'herdr.tab': tab };
224
+ const labeled = await call('terminal.label', { terminal: address, labels });
225
+ const verifyLabels = (r: any) => {
226
+ worker(r, ticket, address, createdMs);
227
+ if (r.labels?.['herdr.space'] !== labels['herdr.space'] || r.labels?.['herdr.tab'] !== tab) throw error('placement labels not confirmed');
228
+ };
229
+ verifyLabels(labeled);
230
+ verifyLabels(await call('terminal.get', { terminal: address }));
231
+ const version = await confirm();
232
+ await identity(call, ticket.coreId);
233
+ live(await call('terminal.get', { terminal: ticket.caller }), ticket.caller, ticket.callerCreatedMs);
234
+ return { terminal: address, terminalCreatedMs: createdMs, coreId: ticket.coreId, space: location.space.id, tab, pane, version, layoutConfirmed: true, uiAcknowledged: false };
235
+ } catch {
236
+ // Do not leak transport payloads. This deliberately includes failures before
237
+ // our CAS: hc run has already happened and cannot safely be replayed.
238
+ throw new CoreWorkerLayoutUncertainError();
239
+ }
240
+ }