@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.
- package/README.md +113 -8
- package/dist/config.d.ts +12 -0
- package/dist/config.js +94 -22
- package/dist/config.js.map +1 -1
- package/dist/core-monitor.d.ts +30 -0
- package/dist/core-monitor.js +528 -0
- package/dist/core-monitor.js.map +1 -0
- package/dist/core-pane-close.d.ts +34 -0
- package/dist/core-pane-close.js +335 -0
- package/dist/core-pane-close.js.map +1 -0
- package/dist/core-worker-layout.d.ts +50 -0
- package/dist/core-worker-layout.js +236 -0
- package/dist/core-worker-layout.js.map +1 -0
- package/dist/herdr-core.d.ts +53 -0
- package/dist/herdr-core.js +380 -0
- package/dist/herdr-core.js.map +1 -0
- package/dist/index.js +60 -14
- package/dist/index.js.map +1 -1
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +59 -3
- package/dist/launcher.js.map +1 -1
- package/dist/monitor-launcher.d.ts +8 -0
- package/dist/monitor-launcher.js +111 -25
- package/dist/monitor-launcher.js.map +1 -1
- package/dist/pane-close.js +3 -0
- package/dist/pane-close.js.map +1 -1
- package/dist/reminders.d.ts +28 -0
- package/dist/reminders.js +81 -0
- package/dist/reminders.js.map +1 -0
- package/dist/runtime.d.ts +28 -2
- package/dist/runtime.js +139 -11
- package/dist/runtime.js.map +1 -1
- package/package.json +14 -6
- package/references/herdr-core-migration.md +44 -0
- package/references/releasing.md +90 -0
- package/references/v1-specification.md +11 -8
- package/src/config.ts +69 -17
- package/src/core-monitor.ts +410 -0
- package/src/core-pane-close.ts +328 -0
- package/src/core-worker-layout.ts +240 -0
- package/src/herdr-core.ts +287 -0
- package/src/index.ts +55 -15
- package/src/launcher.ts +55 -3
- package/src/monitor-launcher.ts +95 -21
- package/src/pane-close.ts +2 -0
- package/src/reminders.ts +69 -0
- package/src/runtime.ts +122 -12
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createConnection } from 'node:net';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { lstat } from 'node:fs/promises';
|
|
7
|
+
import type { Readable, Writable } from 'node:stream';
|
|
8
|
+
|
|
9
|
+
export type CoreCall = (method: string, params: Record<string, unknown>) => Promise<any>;
|
|
10
|
+
const failure = (message: string) => new Error(`PiIntercom Herdr core: ${message}`);
|
|
11
|
+
const MAX_BYTES = 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
const CORE_VERSION = '0.1.18';
|
|
14
|
+
const object = (value: any): value is Record<string, any> => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
const identity = (value: any): boolean => object(value) && typeof value.id === 'string' && value.id.length > 0 &&
|
|
16
|
+
value.id.length <= 256 && typeof value.name === 'string';
|
|
17
|
+
const statusValid = (value: any): boolean => object(value) && value.version === CORE_VERSION && identity(value.core) &&
|
|
18
|
+
Number.isSafeInteger(value.pid) && value.pid > 0;
|
|
19
|
+
const sameCore = (a: any, b: any): boolean => a.id === b.id && a.name === b.name;
|
|
20
|
+
const READ_METHODS = new Set(['hello', 'server.status', 'terminal.get', 'terminal.list', 'doc.get']);
|
|
21
|
+
|
|
22
|
+
export interface CoreSessionOptions {
|
|
23
|
+
/** Explicit documented HC_SOCKET endpoint; no endpoint discovery or path guessing. */
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
/** Test-only trusted seams. Supplying startClient requires readStatus. */
|
|
26
|
+
startClient?: () => ChildProcessWithoutNullStreams;
|
|
27
|
+
readStatus?: () => Promise<unknown>;
|
|
28
|
+
/** Tests may shorten, never extend, the ten-second whole-operation deadline. */
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve only documented Unix defaults. An existing config may override the
|
|
33
|
+
* socket, so require explicit HC_SOCKET rather than silently ignoring that file. */
|
|
34
|
+
export async function defaultCoreEndpoint(env: NodeJS.ProcessEnv = process.env, home = homedir(), uid = process.getuid?.()): Promise<string> {
|
|
35
|
+
if (env.HC_SOCKET !== undefined) {
|
|
36
|
+
if (!env.HC_SOCKET || !isAbsolute(env.HC_SOCKET) || env.HC_SOCKET.includes('\0')) throw failure('invalid explicit HC_SOCKET');
|
|
37
|
+
return env.HC_SOCKET;
|
|
38
|
+
}
|
|
39
|
+
const session = env.HC_SESSION ?? 'default';
|
|
40
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(session) || session.trim() !== session || session.length > 64) throw failure('unsupported HC_SESSION; set explicit HC_SOCKET');
|
|
41
|
+
const config = join(env.XDG_CONFIG_HOME || join(home, '.config'), 'herdr-core', 'config.toml');
|
|
42
|
+
try { await lstat(config); throw failure('core config may override socket; set explicit HC_SOCKET'); }
|
|
43
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
44
|
+
let runtime = env.XDG_RUNTIME_DIR;
|
|
45
|
+
if (!runtime && uid !== undefined) {
|
|
46
|
+
const candidate = `/run/user/${uid}`;
|
|
47
|
+
try { if ((await lstat(candidate)).isDirectory()) runtime = candidate; }
|
|
48
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
49
|
+
}
|
|
50
|
+
const base = runtime ? join(runtime, 'herdr-core') : join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'herdr-core', session);
|
|
51
|
+
if (!isAbsolute(base)) throw failure('core endpoint root must be absolute');
|
|
52
|
+
return join(base, `${session}.sock`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cliStatus(): Promise<unknown> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
execFile('hc', ['server', 'status', '--json'], { windowsHide: true, timeout: 3000, maxBuffer: MAX_BYTES, encoding: 'utf8' }, (error, stdout) => {
|
|
58
|
+
if (error) return reject(failure('existing core status preflight failed'));
|
|
59
|
+
try { resolve(JSON.parse(stdout)); } catch { reject(failure('invalid core status preflight')); }
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One bounded, sequential operation on ONE verified connection; never reconnects.
|
|
65
|
+
* Core v0.1.18 schema hello + server.status bind protocol, version, permanent core
|
|
66
|
+
* identity and (with CLI preflight) pid before the callback can send requests.
|
|
67
|
+
* This is not cryptographic authentication or a boot nonce; local endpoint access
|
|
68
|
+
* is the upstream trust boundary. The call handle expires with the callback.
|
|
69
|
+
*
|
|
70
|
+
* IMPORTANT: v0.1.18 cli.rs/server + platform/unix.rs status_may_start_core() show
|
|
71
|
+
* `hc server status` ITSELF autostarts on Unix. Unix therefore connects directly
|
|
72
|
+
* via HC_SOCKET or documented defaults (only when no config override can exist),
|
|
73
|
+
* without starting a process.
|
|
74
|
+
* Public contract: herdr-core v0.1.18 schema/README.md "Connecting" and
|
|
75
|
+
* docs/security.md "State and endpoints". No undocumented endpoint discovery.
|
|
76
|
+
* On Windows only, status is require_existing; preflight precedes hc connect.
|
|
77
|
+
* That check is NOT atomic start avoidance: core could disappear before connect.
|
|
78
|
+
* Use an explicit endpoint even on Windows when strict no-autostart is required.
|
|
79
|
+
*/
|
|
80
|
+
export async function withCoreSession<T>(operation: (call: CoreCall) => Promise<T>, options: CoreSessionOptions = {}): Promise<T> {
|
|
81
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
82
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10000) throw failure('invalid session deadline');
|
|
83
|
+
let input: Writable | undefined, output: Readable | undefined, cleanup: (() => void) | undefined;
|
|
84
|
+
let closed = false, fatal: Error | undefined, mutationAttempted = false;
|
|
85
|
+
let pending: { id: string; resolve: (value: any) => void; reject: (error: Error) => void } | undefined;
|
|
86
|
+
let rejectSession!: (error: Error) => void;
|
|
87
|
+
const failed = new Promise<never>((_, reject) => { rejectSession = reject; });
|
|
88
|
+
const close = () => {
|
|
89
|
+
if (closed) return;
|
|
90
|
+
closed = true;
|
|
91
|
+
try { cleanup?.(); } catch { /* Closing our transport must not mask the result. */ }
|
|
92
|
+
};
|
|
93
|
+
const fail = (reason: string) => {
|
|
94
|
+
if (closed || fatal) return;
|
|
95
|
+
fatal = Object.assign(failure(`${reason}; session unusable; ${mutationAttempted ? 'outcome uncertain; ' : ''}do not replay`), { uncertain: mutationAttempted });
|
|
96
|
+
pending?.reject(fatal); pending = undefined;
|
|
97
|
+
rejectSession(fatal);
|
|
98
|
+
close();
|
|
99
|
+
};
|
|
100
|
+
const timer = setTimeout(() => fail('operation timed out'), timeoutMs);
|
|
101
|
+
let methods: Set<string> | undefined, requests = 0, bytes = 0, buffer = '';
|
|
102
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
103
|
+
const call: CoreCall = (method, params) => {
|
|
104
|
+
if (closed || fatal) return Promise.reject(fatal ?? failure('session is closed; do not replay'));
|
|
105
|
+
if (pending || ++requests > 64 || (methods && !methods.has(method))) {
|
|
106
|
+
fail('concurrent, excessive or unsupported API request');
|
|
107
|
+
return Promise.reject(fatal);
|
|
108
|
+
}
|
|
109
|
+
const id = randomUUID();
|
|
110
|
+
let request: string;
|
|
111
|
+
try { request = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`; }
|
|
112
|
+
catch { fail('invalid API request'); return Promise.reject(fatal); }
|
|
113
|
+
if (Buffer.byteLength(request) > MAX_BYTES) { fail('API request exceeded limit'); return Promise.reject(fatal); }
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
pending = { id, resolve, reject };
|
|
116
|
+
if (!READ_METHODS.has(method)) mutationAttempted = true;
|
|
117
|
+
try { input!.write(request, error => { if (error) fail('API request transport failed'); }); }
|
|
118
|
+
catch { fail('API request transport failed'); }
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
const run = async () => {
|
|
122
|
+
let before: any;
|
|
123
|
+
const endpoint = options.endpoint ?? process.env.HC_SOCKET ?? (!options.startClient && process.platform !== 'win32' ? await defaultCoreEndpoint() : undefined);
|
|
124
|
+
if (options.startClient || !endpoint) {
|
|
125
|
+
if (options.startClient && !options.readStatus) throw failure('test client requires status preflight');
|
|
126
|
+
if (!options.startClient && process.platform !== 'win32') throw failure('explicit HC_SOCKET required: Unix hc server status/connect may autostart; no client started');
|
|
127
|
+
before = await (options.readStatus ?? cliStatus)();
|
|
128
|
+
if (closed) throw fatal;
|
|
129
|
+
if (!statusValid(before)) throw failure('unsupported core status/version; no client started');
|
|
130
|
+
const child = options.startClient ? options.startClient() : spawn('hc', ['connect'], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
131
|
+
input = child.stdin; output = child.stdout;
|
|
132
|
+
cleanup = () => { child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.kill(); };
|
|
133
|
+
child.on('error', () => fail('could not start hc API client'));
|
|
134
|
+
child.on('close', () => fail('API client ended without operation completion'));
|
|
135
|
+
child.stderr.on('error', () => fail('API diagnostic transport failed'));
|
|
136
|
+
child.stderr.on('data', () => {}); // Never expose process diagnostics or credentials.
|
|
137
|
+
} else {
|
|
138
|
+
if (!isAbsolute(endpoint) || endpoint.includes('\0')) throw failure('explicit absolute local core endpoint required');
|
|
139
|
+
const socket = createConnection(endpoint);
|
|
140
|
+
input = output = socket;
|
|
141
|
+
cleanup = () => { socket.destroy(); };
|
|
142
|
+
socket.on('close', () => fail('API connection closed'));
|
|
143
|
+
}
|
|
144
|
+
input.on('error', () => fail('API request transport failed'));
|
|
145
|
+
output.on('error', () => fail('API response transport failed'));
|
|
146
|
+
output.on('end', () => fail('API response EOF; operation not completed'));
|
|
147
|
+
output.on('data', (chunk: Buffer) => {
|
|
148
|
+
if (closed) return;
|
|
149
|
+
if (!pending) return fail('unsolicited API response data');
|
|
150
|
+
bytes += chunk.length;
|
|
151
|
+
if (bytes > MAX_BYTES) return fail('API response exceeded operation limit');
|
|
152
|
+
try { buffer += decoder.decode(chunk, { stream: true }); }
|
|
153
|
+
catch { return fail('invalid API response UTF-8'); }
|
|
154
|
+
const end = buffer.indexOf('\n');
|
|
155
|
+
if (end < 0) return;
|
|
156
|
+
try { decoder.decode(); } catch { return fail('invalid trailing API response UTF-8'); }
|
|
157
|
+
const line = buffer.slice(0, end);
|
|
158
|
+
// Sequential request/reply only: reject unsolicited/trailing data, including
|
|
159
|
+
// a partial next response, BEFORE allowing the callback's next mutation.
|
|
160
|
+
if (buffer.slice(end + 1).trim()) return fail('trailing API response data');
|
|
161
|
+
buffer = '';
|
|
162
|
+
let message: any;
|
|
163
|
+
try { message = JSON.parse(line); } catch { return fail('invalid API response'); }
|
|
164
|
+
if (!object(message) || !pending || message.id !== pending.id) return fail('uncorrelated API response');
|
|
165
|
+
if (message.jsonrpc !== '2.0' || Object.hasOwn(message, 'error') || !Object.hasOwn(message, 'result')) return fail('API rejected request');
|
|
166
|
+
const current = pending; pending = undefined;
|
|
167
|
+
current.resolve(message.result);
|
|
168
|
+
});
|
|
169
|
+
const hello = await call('hello', { generation: 1, framed: false });
|
|
170
|
+
if (!object(hello) || hello.generation !== 1 || hello.version !== CORE_VERSION || hello.framed !== false ||
|
|
171
|
+
!identity(hello.core) || !Array.isArray(hello.methods) || !hello.methods.every((v: unknown) => typeof v === 'string') ||
|
|
172
|
+
!Array.isArray(hello.features) || !hello.features.every((v: unknown) => typeof v === 'string') ||
|
|
173
|
+
(before && !sameCore(hello.core, before.core))) throw failure('unsupported or changed core handshake');
|
|
174
|
+
methods = new Set(hello.methods);
|
|
175
|
+
const status = await call('server.status', {});
|
|
176
|
+
if (!statusValid(status) || !sameCore(status.core, hello.core) || (before && status.pid !== before.pid)) throw failure('core instance changed before operation');
|
|
177
|
+
const result = await operation(call);
|
|
178
|
+
if (fatal) throw fatal; // Catching a request failure never rehabilitates a session.
|
|
179
|
+
if (pending) { fail('callback left an unfinished API request'); throw fatal; }
|
|
180
|
+
return result;
|
|
181
|
+
};
|
|
182
|
+
try { return await Promise.race([run(), failed]); }
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (mutationAttempted && !(error instanceof Error && 'uncertain' in error)) {
|
|
185
|
+
throw Object.assign(failure('operation failed after mutation; outcome uncertain; do not replay'), { uncertain: true, cause: error });
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
finally { clearTimeout(timer); close(); }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Standalone compatibility API: one call per bounded session. Multi-call operations
|
|
193
|
+
* MUST use withCoreSession, not repeated coreCall invocations. */
|
|
194
|
+
export const coreCall: CoreCall = (method, params) => withCoreSession(call => call(method, params));
|
|
195
|
+
|
|
196
|
+
export function terminalAddress(value: unknown): asserts value is string {
|
|
197
|
+
if (typeof value !== 'string' || (!/^t[0-9a-z]+$/.test(value) || value.trim() !== value)) throw failure('explicit local HC_TERMINAL_ID required');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** SOURCE-BACKED PRIVATE FORMAT, not a stable app API or production-readiness claim.
|
|
201
|
+
* herdr-app v0.1.13 src/mirror.rs Document/SharedSpace/SharedTab and src/layout.rs
|
|
202
|
+
* Node/Kind/Content define this opaque core doc. There is NO app-version handshake:
|
|
203
|
+
* core 0.1.18 alone cannot establish app compatibility. An upstream versioned
|
|
204
|
+
* layout contract is still needed. Tested subset: rows/columns, terminal/mod blocks,
|
|
205
|
+
* and mod-only floats (src/layout.rs Float), whose IDs are checked too. Reject
|
|
206
|
+
* stacks, terminal floats and dynamic context/agent content anywhere, including
|
|
207
|
+
* unrelated tabs, rather than silently miss aliases.
|
|
208
|
+
* Unknown metadata is preserved verbatim, not interpreted as a layout extension.
|
|
209
|
+
* Private focus state is separate and is never inferred or changed here. */
|
|
210
|
+
export function locateTerminalTab(document: any, terminal: string): { space: any; tab: any; pane: any } {
|
|
211
|
+
terminalAddress(terminal);
|
|
212
|
+
if (!object(document) || !Array.isArray(document.spaces) || document.spaces.length > 256) throw failure('unsupported app layout document');
|
|
213
|
+
const matches: { space: any; tab: any; pane: any }[] = [];
|
|
214
|
+
const ids = new Set<string>(); let count = 0;
|
|
215
|
+
const unique = (id: unknown) => {
|
|
216
|
+
if (typeof id !== 'string' || !id || id.length > 256 || ids.has(id)) throw failure('ambiguous layout identity');
|
|
217
|
+
ids.add(id);
|
|
218
|
+
};
|
|
219
|
+
for (const space of document.spaces) {
|
|
220
|
+
if (!object(space)) throw failure('unsupported layout space');
|
|
221
|
+
unique(space.id);
|
|
222
|
+
if (!Array.isArray(space.tabs) || space.tabs.length > 512) throw failure('invalid layout tabs');
|
|
223
|
+
for (const tab of space.tabs) {
|
|
224
|
+
if (!object(tab)) throw failure('unsupported layout tab');
|
|
225
|
+
unique(tab.id);
|
|
226
|
+
if (typeof tab.name !== 'string') throw failure('invalid tab name');
|
|
227
|
+
const visit = (node: any, depth: number) => {
|
|
228
|
+
if (++count > 4096 || depth > 64 || !object(node) || typeof node.id !== 'string') throw failure('layout bounds exceeded');
|
|
229
|
+
// The app uses the tab ID again for its root container.
|
|
230
|
+
if (node !== tab.root || node.id !== tab.id) unique(node.id);
|
|
231
|
+
if (Object.hasOwn(node, 'floating')) {
|
|
232
|
+
if (!Array.isArray(node.floating)) throw failure('unsupported floating layout; no layout changes');
|
|
233
|
+
for (const floating of node.floating) {
|
|
234
|
+
if (++count > 4096 || !object(floating) || !object(floating.content) ||
|
|
235
|
+
typeof floating.content.mod !== 'string' || ['terminal', 'tab', 'context', 'agent'].some(key => Object.hasOwn(floating.content, key)) ||
|
|
236
|
+
['children', 'floating', 'type'].some(key => Object.hasOwn(floating, key)) ||
|
|
237
|
+
(Object.hasOwn(floating, 'edge') && !['top', 'right', 'bottom', 'left'].includes(floating.edge)) ||
|
|
238
|
+
(Object.hasOwn(floating, 'align') && !['start', 'center', 'end'].includes(floating.align))) {
|
|
239
|
+
throw failure('unsupported floating layout (only mod floats supported); no layout changes');
|
|
240
|
+
}
|
|
241
|
+
unique(floating.id);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (node.type === 'block') {
|
|
245
|
+
if (Object.hasOwn(node, 'children') || !object(node.content)) throw failure('unsupported block shape');
|
|
246
|
+
const tags = ['terminal', 'mod', 'tab', 'context', 'agent'].filter(key => Object.hasOwn(node.content, key));
|
|
247
|
+
if (tags.length !== 1 || !['terminal', 'mod'].includes(tags[0]) || typeof node.content[tags[0]] !== 'string') throw failure('unsupported block content');
|
|
248
|
+
if (tags[0] === 'terminal') terminalAddress(node.content.terminal);
|
|
249
|
+
if (node.content.terminal === terminal) matches.push({ space, tab, pane: node });
|
|
250
|
+
} else if (['row', 'column'].includes(node.type) && Array.isArray(node.children) && !Object.hasOwn(node, 'content')) {
|
|
251
|
+
for (const child of node.children) visit(child, depth + 1);
|
|
252
|
+
} else throw failure('unsupported layout node (only row/column/block supported)');
|
|
253
|
+
};
|
|
254
|
+
visit(tab.root, 0);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (matches.length !== 1) throw failure('terminal does not map to exactly one app pane; no layout changes');
|
|
258
|
+
return matches[0];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Change only the owning tab name using the app's optimistic document protocol.
|
|
262
|
+
* Success proves shared state, not that an attached Windows window has rendered it. */
|
|
263
|
+
export async function renameCoreTab(terminal: string, name: string, call?: CoreCall): Promise<void> {
|
|
264
|
+
terminalAddress(terminal);
|
|
265
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 128 || /[\x00-\x1f\x7f]/.test(name)) throw failure('invalid worker tab name');
|
|
266
|
+
// Default production rename is one operation, never a chain of reconnecting calls.
|
|
267
|
+
// An injected call remains supported for tests or an enclosing withCoreSession.
|
|
268
|
+
if (!call || call === coreCall) return withCoreSession(session => renameCoreTab(terminal, name, session));
|
|
269
|
+
const record = await call('terminal.get', { terminal });
|
|
270
|
+
if (record?.address !== terminal || record.state !== 'live' || !Number.isSafeInteger(record.created_ms) || record.created_ms <= 0) throw failure('worker terminal identity is not live');
|
|
271
|
+
const snapshot = await call('doc.get', { name: 'herdr-app' });
|
|
272
|
+
if (!Number.isSafeInteger(snapshot?.version) || snapshot.version < 0 || !snapshot.value) throw failure('app layout unavailable');
|
|
273
|
+
const value = structuredClone(snapshot.value);
|
|
274
|
+
const { space, tab, pane } = locateTerminalTab(value, terminal);
|
|
275
|
+
if (record.labels?.['herdr.space'] !== space.id || record.labels?.['herdr.tab'] !== tab.id) throw failure('terminal labels disagree with actual app layout');
|
|
276
|
+
if (tab.name === name) return;
|
|
277
|
+
tab.name = name;
|
|
278
|
+
const fresh = await call('terminal.get', { terminal });
|
|
279
|
+
if (fresh?.address !== terminal || fresh.state !== 'live' || fresh.created_ms !== record.created_ms ||
|
|
280
|
+
fresh.labels?.['herdr.space'] !== space.id || fresh.labels?.['herdr.tab'] !== tab.id) throw failure('terminal identity changed before rename');
|
|
281
|
+
const written = await call('doc.set', { name: 'herdr-app', version: snapshot.version, value });
|
|
282
|
+
if (!Number.isSafeInteger(written?.version) || written.version !== snapshot.version + 1) throw failure('layout write acknowledgment uncertain; do not replay');
|
|
283
|
+
const observed = await call('doc.get', { name: 'herdr-app' });
|
|
284
|
+
if (!Number.isSafeInteger(observed?.version) || observed.version < written.version) throw failure('layout readback version not confirmed; do not replay');
|
|
285
|
+
const target = locateTerminalTab(observed?.value, terminal);
|
|
286
|
+
if (target.space.id !== space.id || target.tab.id !== tab.id || target.pane.id !== pane.id || target.tab.name !== name) throw failure('tab rename not confirmed; do not replay');
|
|
287
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,13 +5,13 @@ import { getAgentDir, SessionManager, SettingsManager, truncateHead, withFileMut
|
|
|
5
5
|
import { ConfigStore } from './config.js';
|
|
6
6
|
import { Intercom, UNSUPPORTED_CANCELLATION } from './runtime.js';
|
|
7
7
|
import { launchers } from './launcher.js';
|
|
8
|
-
import { ensureMonitorPane } from './monitor-launcher.js';
|
|
8
|
+
import { ensureMonitorPane, closeMonitorPane } from './monitor-launcher.js';
|
|
9
9
|
import { createActivityTracker } from './activity.js';
|
|
10
10
|
import { createCloseProvider } from './pane-close.js';
|
|
11
11
|
|
|
12
12
|
const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
|
|
13
13
|
const tools: [string, string, TSchema][] = [
|
|
14
|
-
['create_worker', 'Coordinator only. Launch one anonymous worker
|
|
14
|
+
['create_worker', 'Coordinator only. Launch one anonymous worker at the coordinator root. projectDirectory defaults to root and must resolve to that same directory; subfolder launches cannot use a parent config and are refused. No assignment, configuration, or registration wait.', Type.Object({ projectDirectory: Type.Optional(Type.String()) })],
|
|
15
15
|
['configure_worker', 'Coordinator only. Write explicit reported connection details and responsibility to config. Does not notify, reload, or start work. Use reload_worker separately.', Type.Object({ sessionId: Type.String(), port: Type.Integer({ minimum: 1, maximum: 65535 }), projectDirectory: Type.String(), name: Type.String({ minLength: 1, maxLength: 128 }), description: Type.String({ minLength: 1, maxLength: 4096 }) })],
|
|
16
16
|
['reload_worker', 'Coordinator only. Ask worker to reread responsibility and synchronize names. NOT Pi extension reload, cancellation, restart, or work assignment.', to],
|
|
17
17
|
['send', 'Configured sessions only. Send an explicit work/progress/findings message by recipient name. Idle delivery starts a turn; busy delivery steers at supported boundaries. Receipt is not an agent reply. Progress questions do not cancel assignments.', Type.Object({ to: Type.String(), message: Type.String({ minLength: 1, maxLength: 48000 }) })],
|
|
@@ -19,12 +19,16 @@ const tools: [string, string, TSchema][] = [
|
|
|
19
19
|
['list', 'Configured sessions only. Read current saved names, responsibilities, session IDs, roles and ports. No live probing. Output limited to 50 KiB/2000 lines; full data remains in config.json.', Type.Object({})],
|
|
20
20
|
['request_status', 'Coordinator only. Ask worker extension to send an independent sessionId/port/busy report without a model turn on worker. No synchronous status reply.', to],
|
|
21
21
|
['report_work', 'Configured, responsibility-loaded workers only. Store one public report (blocked, needs_decision, ready_for_review) and notify the coordinator; clear retires it. Summary required except clear (omit or empty). Never include private reasoning, credentials, raw tool payloads, or secrets. Receipt is not approval, verified completion, or permission for follow-up work.', Type.Object({ status: Type.String({ enum: ['blocked', 'needs_decision', 'ready_for_review', 'clear'] }), summary: Type.Optional(Type.String({ maxLength: 2000 })) })],
|
|
22
|
+
['stop_reminders', 'Coordinator only. Disable the five-minute orchestration reminders until the next worker-directed call. Use when there is genuinely nothing to follow up on. Stops only reminders, not workers, close jobs or model work. Reminder state is session-local and clears on shutdown/reload.', Type.Object({})],
|
|
22
23
|
['report_status', 'Worker only (including anonymous). Send runtime sessionId/port/busy report to coordinator. This is NOT registration and creates no config entry.', Type.Object({})],
|
|
23
24
|
['stop_worker', UNSUPPORTED_CANCELLATION, to],
|
|
24
25
|
['close_worker', 'Coordinator only. Begin a background save-handoff-and-close job for a configured worker in Linux Herdr. Requests a public summary, saves it in config, waits for successful tool persistence and final turn settlement, then attempts one verified pane close. Returns job acceptance, not completion. Registration/session history are retained. Check worker_status/list for closeJob; failure/timeout/uncertainty never authorizes retry. Not graceful abort or an all-descendants termination guarantee; Windows/non-Herdr unsupported.', to],
|
|
25
26
|
['report_handoff', 'Configured worker only, in response to its pending close request. Submit the matching jobId and concise public resume handoff (current work, completed work, unfinished items, blockers, next step). Never include secrets, private reasoning or raw tool payloads. Saved receipt is not closure: finish the response and wait; the extension requires final turn settlement before pane closure.', Type.Object({ jobId: Type.String({ minLength: 1, maxLength: 128 }), summary: Type.String({ minLength: 1, maxLength: 4000 }) })],
|
|
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() })],
|
|
27
|
+
['resume_worker', 'Coordinator only. Resume saved session/name/responsibility after the user explicitly confirms the old session was closed: set confirmClosed:true only for that confirmation, never infer it from disconnected status. A matching live health endpoint blocks launch. In-flight/uncertain attempts are fenced until worker status arrives; otherwise inspect process and reload coordinator before considering retry. Saved subfolder launches are refused rather than relocated, because a worker cannot use a parent config. No cross-process uniqueness guarantee, automatic assignment, replacement or removal.', Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }), confirmClosed: Type.Boolean() })],
|
|
27
28
|
['remove_worker', 'Coordinator only. Remove config entry only; never stop process or delete session files. A running worker must be explicitly closed before removal. Retain registration to resume it later; remove only to forget it. Pending or uncertain close jobs block removal.', to],
|
|
29
|
+
['open_monitor', "Coordinator only. Open or reuse the owned status monitor, including with no workers. Explicit placement is required: ask the user if unknown; never guess or fall back to saved settings. Currently supported: 'coordinator-tab' (pane above the coordinator in its current tab), 'new-tab' (separate tab). Does not assign worker work or change saved settings.", Type.Object({ placement: Type.String({ minLength: 1 }) })],
|
|
30
|
+
['close_monitor', 'Coordinator only. Close only the verified owned status monitor pane, not a worker, coordinator or whole tab. Does not change saved auto-open settings.', Type.Object({})],
|
|
31
|
+
['configure_monitor', "Coordinator only. Persist autoOpen and/or placement for future monitor openings. At least one setting is required. Currently supported placement values: 'coordinator-tab' (pane above the coordinator in its current tab), 'new-tab' (separate tab). Does not open, close or move panes.", Type.Object({ autoOpen: Type.Optional(Type.Boolean()), placement: Type.Optional(Type.String({ minLength: 1 })) }, { minProperties: 1, additionalProperties: false })],
|
|
28
32
|
['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'] }) })],
|
|
29
33
|
];
|
|
30
34
|
|
|
@@ -46,6 +50,10 @@ class PiConfigStore extends ConfigStore {
|
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
export default function intercomExtension(pi: ExtensionAPI): void {
|
|
53
|
+
pi.registerFlag('intercom-root', {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: 'Explicit Intercom root must equal this session cwd; parent configuration is never allowed, even with this flag.',
|
|
56
|
+
});
|
|
49
57
|
let runtime: Intercom | undefined;
|
|
50
58
|
let context: ExtensionContext | undefined;
|
|
51
59
|
let generation = 0;
|
|
@@ -57,29 +65,31 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
57
65
|
extension: fileURLToPath(import.meta.url),
|
|
58
66
|
sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
|
|
59
67
|
});
|
|
60
|
-
let
|
|
68
|
+
let monitorAutoEpoch = 0;
|
|
69
|
+
let monitorTask: { runtime: Intercom; generation: number; epoch: number; dirty: boolean; promise: Promise<void> } | undefined;
|
|
61
70
|
async function ensureCoordinatorMonitor(current: Intercom, ctx: ExtensionContext, started: number): Promise<void> {
|
|
62
|
-
const
|
|
71
|
+
const epoch = monitorAutoEpoch;
|
|
72
|
+
const isCurrent = () => started === generation && runtime === current && epoch === monitorAutoEpoch;
|
|
63
73
|
if (!isCurrent()) return;
|
|
64
|
-
if (monitorTask?.runtime === current && monitorTask.generation === started) {
|
|
74
|
+
if (monitorTask?.runtime === current && monitorTask.generation === started && monitorTask.epoch === epoch) {
|
|
65
75
|
monitorTask.dirty = true;
|
|
66
76
|
return monitorTask.promise;
|
|
67
77
|
}
|
|
68
78
|
const check = async () => {
|
|
69
79
|
try {
|
|
70
|
-
const { config, me
|
|
80
|
+
const { config, me } = await current.state();
|
|
71
81
|
if (!isCurrent() || !me?.coordinator || !config.agents.some(agent => !agent.coordinator)) return;
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
})
|
|
82
|
+
const settings = await current.getMonitorSettings();
|
|
83
|
+
if (!isCurrent() || !settings.autoOpen) return;
|
|
84
|
+
const monitor = await current.autoOpenMonitor(() => {
|
|
85
|
+
if (!isCurrent()) throw new Error('Automatic monitor request superseded');
|
|
86
|
+
}) as Awaited<ReturnType<typeof ensureMonitorPane>>;
|
|
77
87
|
if (isCurrent()) ctx.ui.notify(`Intercom status monitor: ${monitor.outcome}${monitor.pane ? ` (${monitor.pane})` : ''}`, 'info');
|
|
78
88
|
} catch (error) {
|
|
79
89
|
if (isCurrent()) ctx.ui.notify(String(error), 'warning');
|
|
80
90
|
}
|
|
81
91
|
};
|
|
82
|
-
const task = { runtime: current, generation: started, dirty: false, promise: Promise.resolve() };
|
|
92
|
+
const task = { runtime: current, generation: started, epoch, dirty: false, promise: Promise.resolve() };
|
|
83
93
|
monitorTask = task;
|
|
84
94
|
task.promise = (async () => {
|
|
85
95
|
do { task.dirty = false; await check(); } while (task.dirty && isCurrent());
|
|
@@ -96,6 +106,10 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
96
106
|
context = ctx;
|
|
97
107
|
if (!['win32', 'linux'].includes(process.platform) || ctx.mode !== 'tui') throw new Error('PiIntercom requires Windows or Linux interactive Pi. No resources started.');
|
|
98
108
|
if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
|
|
109
|
+
// Flags become available at session_start, not extension factory time. Never
|
|
110
|
+
// infer workspace authority from ancestors or inherited environment values.
|
|
111
|
+
const workspaceRoot = pi.getFlag('intercom-root');
|
|
112
|
+
if (workspaceRoot !== undefined && typeof workspaceRoot !== 'string') throw new Error('PiIntercom: intercom-root must be an explicit absolute path.');
|
|
99
113
|
runtime = new Intercom({
|
|
100
114
|
cwd: ctx.cwd,
|
|
101
115
|
sessionId: () => context!.sessionManager.getSessionId(),
|
|
@@ -106,7 +120,23 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
106
120
|
setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
|
|
107
121
|
notify: text => ctx.ui.notify(text, 'info'),
|
|
108
122
|
}, {
|
|
109
|
-
launch: launcher.launch, store: root => new PiConfigStore(root),
|
|
123
|
+
launch: launcher.launch, store: root => new PiConfigStore(root), workspaceRoot,
|
|
124
|
+
monitor: {
|
|
125
|
+
open: request => ensureMonitorPane({ ...request,
|
|
126
|
+
script: fileURLToPath(new URL('../dist/monitor.js', import.meta.url)),
|
|
127
|
+
assertCurrent: () => {
|
|
128
|
+
request.assertCurrent();
|
|
129
|
+
if (started !== generation || runtime !== current) throw new Error('Coordinator session changed');
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
close: request => closeMonitorPane({ ...request,
|
|
133
|
+
script: fileURLToPath(new URL('../dist/monitor.js', import.meta.url)),
|
|
134
|
+
assertCurrent: () => {
|
|
135
|
+
request.assertCurrent();
|
|
136
|
+
if (started !== generation || runtime !== current) throw new Error('Coordinator session changed');
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
110
140
|
closeProvider: createCloseProvider({
|
|
111
141
|
sessionId: () => context!.sessionManager.getSessionId(),
|
|
112
142
|
sessionFile: () => context!.sessionManager.getSessionFile(),
|
|
@@ -185,11 +215,21 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
185
215
|
for (const [operation, description, parameters] of tools) {
|
|
186
216
|
pi.registerTool({
|
|
187
217
|
name: `intercom_${operation}`, label: `Intercom ${operation}`, description, parameters,
|
|
218
|
+
// Handoff readiness requires this call's successful transcript tool result.
|
|
219
|
+
// Pi1.x enforces model-only for nested calls;0.84 ignores this optional field
|
|
220
|
+
// and has no nested-tool API. Never substitute a parent result as proof.
|
|
221
|
+
exposure: operation === 'report_handoff' ? 'model-only' : 'direct',
|
|
188
222
|
async execute(_id, args, signal, _update, ctx) {
|
|
189
223
|
signal?.throwIfAborted(); context = ctx;
|
|
190
224
|
if (!runtime) throw new Error('PiIntercom not initialized; inspect startup error.');
|
|
191
225
|
const current = runtime, started = generation;
|
|
192
|
-
|
|
226
|
+
if (operation === 'close_monitor') {
|
|
227
|
+
const state = await current.state();
|
|
228
|
+
if (started !== generation || runtime !== current) throw new Error('Coordinator session changed');
|
|
229
|
+
if (!state.me?.coordinator) throw new Error('PiIntercom: coordinator permission required');
|
|
230
|
+
monitorAutoEpoch++; // Fence prior automatic passes; lock refusal never schedules a retry.
|
|
231
|
+
}
|
|
232
|
+
const result = await current.tool(operation, args as Record<string, unknown>, _id, signal);
|
|
193
233
|
if (operation === 'configure_worker') await ensureCoordinatorMonitor(current, ctx, started);
|
|
194
234
|
const output = truncateHead(JSON.stringify(result, null, 2));
|
|
195
235
|
return { content: [{ type: 'text', text: output.content + (output.truncated ? `\n[Truncated; full shared configuration: ${current.store.file}]` : '') }], details: {} };
|
package/src/launcher.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { fail } from './config.js';
|
|
4
5
|
import type { LaunchRequest } from './runtime.js';
|
|
6
|
+
import { renameCoreTab, withCoreSession, type CoreCall } from './herdr-core.js';
|
|
7
|
+
import { preflightCoreWorkerLayout, attachCoreWorkerLayout } from './core-worker-layout.js';
|
|
5
8
|
|
|
6
9
|
export type Run = (file: string, args: string[]) => Promise<string>;
|
|
7
10
|
export const run: Run = async (file, args) => {
|
|
@@ -16,6 +19,7 @@ export interface LauncherOptions {
|
|
|
16
19
|
platform?: string;
|
|
17
20
|
env?: NodeJS.ProcessEnv;
|
|
18
21
|
run?: Run;
|
|
22
|
+
coreCall?: CoreCall;
|
|
19
23
|
sessionExists(cwd: string, id: string): Promise<boolean>;
|
|
20
24
|
}
|
|
21
25
|
export function launchers(options: LauncherOptions) {
|
|
@@ -23,11 +27,55 @@ export function launchers(options: LauncherOptions) {
|
|
|
23
27
|
async function launch(request: LaunchRequest): Promise<unknown> {
|
|
24
28
|
const platform = options.platform ?? process.platform;
|
|
25
29
|
if (platform !== 'win32' && platform !== 'linux') fail('launchers support Windows and Linux only');
|
|
30
|
+
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
31
|
+
if (typeof request.workspaceRoot !== 'string' || !request.workspaceRoot.trim() || request.workspaceRoot.includes('\0') || !paths.isAbsolute(request.workspaceRoot)) fail('worker launch requires an explicit absolute workspace root');
|
|
32
|
+
// Runtime performs authoritative realpath equality before reading config.
|
|
33
|
+
// Defend direct launcher calls too, without filesystem access or cwd rewrites.
|
|
34
|
+
const samePath = (value: string) => platform === 'win32' ? paths.resolve(value).toLowerCase() : paths.resolve(value);
|
|
35
|
+
if (typeof request.cwd !== 'string' || !paths.isAbsolute(request.cwd) || samePath(request.workspaceRoot) !== samePath(request.cwd)) fail('worker workspace root must equal its working directory; parent workspace access is not allowed');
|
|
26
36
|
if (platform === 'linux' && request.multiplexer === 'none') fail('Linux worker launching requires Herdr; none is Windows-only. No terminal fallback');
|
|
27
37
|
if (request.sessionId && !await options.sessionExists(request.cwd, request.sessionId)) fail('saved Pi session not found in project directory; never-used sessions may not be persisted. Config unchanged; no replacement launched');
|
|
28
|
-
|
|
38
|
+
// Explicit per-child CLI scope cannot widen cwd or leak from a parent shell environment.
|
|
39
|
+
const args = ['-e', options.extension, '--intercom-root', request.workspaceRoot, ...(request.sessionId ? ['--session', request.sessionId] : [])];
|
|
29
40
|
if (request.multiplexer === 'herdr') {
|
|
30
|
-
|
|
41
|
+
// HC identity takes precedence even when stale legacy variables survive.
|
|
42
|
+
// Never reinterpret an HC failure as permission to create a legacy tab.
|
|
43
|
+
if (env.HC_TERMINAL_ID !== undefined) {
|
|
44
|
+
const caller = env.HC_TERMINAL_ID;
|
|
45
|
+
if (!/^t[0-9a-z]+$/.test(caller) || caller.trim() !== caller) fail('HC launcher requires a local HC_TERMINAL_ID address; names and remote addresses are not accepted; no fallback');
|
|
46
|
+
// No template/task/message/provider override. Use an explicit shell and
|
|
47
|
+
// preserve the user's Pi defaults and literal root/session arguments.
|
|
48
|
+
const argv = platform === 'win32'
|
|
49
|
+
? ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded(`$ErrorActionPreference='Stop'; Set-Location -LiteralPath ${psQuote(request.cwd)}; & (Get-Command pi.ps1 -ErrorAction Stop).Source ${args.map(psQuote).join(' ')}; exit $LASTEXITCODE`)]
|
|
50
|
+
: ['/bin/sh', '-c', `exec pi ${args.map(shQuote).join(' ')}`];
|
|
51
|
+
let attempted = false;
|
|
52
|
+
const operation = async (call: CoreCall) => {
|
|
53
|
+
// Refuse missing/unsupported app layout BEFORE creating a terminal.
|
|
54
|
+
// All reads, spawn, CAS placement and readback share this connection.
|
|
55
|
+
const ticket = await preflightCoreWorkerLayout({ caller, cwd: request.cwd, workspaceRoot: request.workspaceRoot, platform }, call);
|
|
56
|
+
attempted = true;
|
|
57
|
+
const created = await call('terminal.run', {
|
|
58
|
+
argv, cwd: request.cwd, caller, parent: caller,
|
|
59
|
+
outlive_parent: true, no_notify: true, wait: false, keep: false,
|
|
60
|
+
});
|
|
61
|
+
const placement = await attachCoreWorkerLayout(ticket, created, call);
|
|
62
|
+
return { commandSubmitted: true, ...placement, terminalState: created.state,
|
|
63
|
+
ui: 'render-unverified',
|
|
64
|
+
uiReason: 'Dedicated tab confirmed in shared app layout; remote UI rendering is not observed.',
|
|
65
|
+
piReadiness: 'not observed; terminal creation and layout confirmation are not Pi readiness', registrationAwaited: false };
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
// Injection is a trusted test seam; production verifies hello/status
|
|
69
|
+
// for core 0.1.18 once, without reconnecting between launch steps.
|
|
70
|
+
return await (options.coreCall ? operation(options.coreCall) : withCoreSession(operation, { endpoint: env.HC_SOCKET }));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (attempted) throw Object.assign(new Error('HC worker launch or placement failed; outcome unknown, terminal/process and app tab may remain; no automatic cleanup/retry/replacement or legacy fallback'), { outcomeUncertain: true });
|
|
73
|
+
fail(`HC launch preflight failed: ${String(e)}; no fallback or launch attempted`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Compatibility is selected only by explicit legacy identity, never by an
|
|
77
|
+
// HC error or by discovering an arbitrary running Herdr window.
|
|
78
|
+
if (env.HERDR_ENV !== '1' || !env.HERDR_WORKSPACE_ID) fail('Herdr launcher requires HC_TERMINAL_ID or explicit legacy HERDR_ENV=1 and HERDR_WORKSPACE_ID; no fallback');
|
|
31
79
|
let created: { result?: { root_pane?: { pane_id?: string }; tab?: { tab_id?: string } } };
|
|
32
80
|
try { created = JSON.parse(await exec('herdr', ['tab', 'create', '--workspace', env.HERDR_WORKSPACE_ID, '--cwd', request.cwd, '--label', 'Intercom (anonymous)', '--no-focus'])); }
|
|
33
81
|
catch (e) { fail(`Herdr tab launch failed: ${String(e)}; no fallback or cleanup`); }
|
|
@@ -48,7 +96,7 @@ export function launchers(options: LauncherOptions) {
|
|
|
48
96
|
if (request.multiplexer !== 'none') fail('unsupported multiplexer');
|
|
49
97
|
// Use encoded PowerShell rather than constructing cmd.exe command strings.
|
|
50
98
|
// The outer process reports only successful visible terminal creation, not Pi readiness.
|
|
51
|
-
const child = `$ErrorActionPreference='Stop'; Get-ChildItem Env: | Where-Object { $_.Name -like 'HERDR_*' -or $_.Name -like 'PI_SESSION_*' } | ForEach-Object { Remove-Item ('Env:' + $_.Name) }; Set-Location -LiteralPath ${psQuote(request.cwd)}; & (Get-Command pi.cmd -ErrorAction Stop).Source ${args.map(psQuote).join(' ')}; if ($LASTEXITCODE -ne 0) { Write-Error ('Pi exited with code ' + $LASTEXITCODE) }`;
|
|
99
|
+
const child = `$ErrorActionPreference='Stop'; Get-ChildItem Env: | Where-Object { $_.Name -like 'HERDR_*' -or $_.Name -like 'HC_*' -or $_.Name -like 'PI_SESSION_*' } | ForEach-Object { Remove-Item ('Env:' + $_.Name) }; Set-Location -LiteralPath ${psQuote(request.cwd)}; & (Get-Command pi.cmd -ErrorAction Stop).Source ${args.map(psQuote).join(' ')}; if ($LASTEXITCODE -ne 0) { Write-Error ('Pi exited with code ' + $LASTEXITCODE) }`;
|
|
52
100
|
const outer = `$ErrorActionPreference='Stop'; $null=Get-Command pi.cmd -ErrorAction Stop; $p=Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') -WorkingDirectory ${psQuote(request.cwd)} -ArgumentList @('-NoProfile','-NoExit','-EncodedCommand',${psQuote(encoded(child))}) -WindowStyle Normal -PassThru; $p.Id`;
|
|
53
101
|
const result = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded(outer)]);
|
|
54
102
|
const pid = Number(result.trim());
|
|
@@ -56,6 +104,10 @@ export function launchers(options: LauncherOptions) {
|
|
|
56
104
|
return { terminalLaunched: true, pid, piReadiness: 'not observed; inspect visible terminal for startup failures', registrationAwaited: false };
|
|
57
105
|
}
|
|
58
106
|
async function syncName(name: string): Promise<void> {
|
|
107
|
+
if (env.HC_TERMINAL_ID !== undefined) {
|
|
108
|
+
await renameCoreTab(env.HC_TERMINAL_ID, name, options.coreCall);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
59
111
|
if (env.HERDR_ENV !== '1') return;
|
|
60
112
|
// Resolve inherited pane context, including panes moved since launch.
|
|
61
113
|
if (!env.HERDR_PANE_ID) fail('Herdr tab name sync requires caller pane identity');
|