piher 0.0.0-stage → 0.2.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 +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal herdr-core (`hc`) client used inside the pi extension.
|
|
3
|
+
*
|
|
4
|
+
* Written against hc 0.1.16 (protocol generation 1); the request shapes piher builds are pinned to the
|
|
5
|
+
* OpenRPC snapshot in test/fixtures/hc-contract.json (`hc schema`). After a core upgrade, regenerate
|
|
6
|
+
* the fixture and re-run the contract test.
|
|
7
|
+
* - Transport is JSON-RPC 2.0, one message per line, plain mode; one connection per request.
|
|
8
|
+
* - Errors carry a stable `data.kind` (not_found, blocked, status_mismatch, finished, unavailable…);
|
|
9
|
+
* `CoreError.kind` exposes it so callers branch on kind, never on message text.
|
|
10
|
+
* - Status is whatever the program reports through OSC 7501; piher never writes status through the API.
|
|
11
|
+
* - Metadata lives in terminal labels under `piher.*`; an empty value removes a label.
|
|
12
|
+
*
|
|
13
|
+
* Terminal addresses (`t7`) take the place of herdr TUI pane ids; the Herdr app shows a terminal that
|
|
14
|
+
* carries `herdr.space`/`herdr.tab` labels as a pane of that tab, so callers keep calling them panes.
|
|
15
|
+
*/
|
|
16
|
+
import * as net from 'node:net';
|
|
17
|
+
|
|
18
|
+
/** Core release the request shapes were written and contract-tested against. */
|
|
19
|
+
export const CORE_VERSION_PINNED = '0.1.16';
|
|
20
|
+
|
|
21
|
+
/* ── Types ──────────────────────────────────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
/** OSC 7501 words as core reports them; `unknown` = the program never reported (or its record was cleared). */
|
|
24
|
+
export type CoreStatus = 'idle' | 'working' | 'blocked' | 'done' | 'error' | 'unknown';
|
|
25
|
+
|
|
26
|
+
export interface CoreEnv {
|
|
27
|
+
socketPath: string;
|
|
28
|
+
/** This process's own terminal address (`HC_TERMINAL_ID`). */
|
|
29
|
+
terminalId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TerminalForeground {
|
|
33
|
+
pid: number;
|
|
34
|
+
name: string;
|
|
35
|
+
argv: string[];
|
|
36
|
+
cwd: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TerminalInfo {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string | null;
|
|
42
|
+
state: 'live' | 'finished';
|
|
43
|
+
status: CoreStatus;
|
|
44
|
+
/** permission | question | auth while blocked, when the program said. */
|
|
45
|
+
statusKind: string | null;
|
|
46
|
+
statusMsg: string | null;
|
|
47
|
+
statusApp: string | null;
|
|
48
|
+
labels: Record<string, string>;
|
|
49
|
+
parent: string | null;
|
|
50
|
+
cwd: string;
|
|
51
|
+
foreground: TerminalForeground | null;
|
|
52
|
+
exitCode: number | null;
|
|
53
|
+
reason: string | null;
|
|
54
|
+
turn: number;
|
|
55
|
+
title: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RunOptions {
|
|
59
|
+
/** Program and arguments; omit both argv and line for the machine's shell. */
|
|
60
|
+
argv?: readonly string[];
|
|
61
|
+
/** A shell line run by the machine's shell (`sh -c`). */
|
|
62
|
+
line?: string;
|
|
63
|
+
cwd?: string;
|
|
64
|
+
/** Set on top of the CORE's environment (not the caller's): pass everything the program needs. */
|
|
65
|
+
env?: Record<string, string>;
|
|
66
|
+
labels?: Record<string, string>;
|
|
67
|
+
name?: string;
|
|
68
|
+
/** Default: this terminal is the parent; `null` = no parent. */
|
|
69
|
+
parent?: string | null;
|
|
70
|
+
/** Keep core's `<hc from=…>` notes out of the parent's PTY (D45: PTY input is for humans). */
|
|
71
|
+
noNotify?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type WaitFailureReason = 'timeout' | 'unavailable';
|
|
75
|
+
|
|
76
|
+
export type WaitForOutputResult =
|
|
77
|
+
| { matched: true }
|
|
78
|
+
| { matched: false; reason: WaitFailureReason };
|
|
79
|
+
|
|
80
|
+
export interface ReadBuffer {
|
|
81
|
+
text: string;
|
|
82
|
+
/** False when part of what was asked for may be missing (a full-screen app read while working). */
|
|
83
|
+
complete: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type InterruptOutcome = 'stopped' | 'exited' | 'still_working' | 'unknown';
|
|
87
|
+
|
|
88
|
+
export class CoreError extends Error {
|
|
89
|
+
readonly kind: string;
|
|
90
|
+
readonly hint: string | null;
|
|
91
|
+
constructor(method: string, kind: string, message: string, hint: string | null) {
|
|
92
|
+
super(`${kind}: ${message}${hint ? ` (${hint})` : ''}`);
|
|
93
|
+
this.name = 'CoreError';
|
|
94
|
+
this.kind = kind;
|
|
95
|
+
this.hint = hint;
|
|
96
|
+
void method;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isCoreErrorKind(err: unknown, kind: string): boolean {
|
|
101
|
+
return err instanceof CoreError && err.kind === kind;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── Environment ─────────────────────────────────────────────────────── */
|
|
105
|
+
|
|
106
|
+
export function detectCoreEnv(env: NodeJS.ProcessEnv = process.env): CoreEnv | null {
|
|
107
|
+
const socketPath = env.HC_TERMINAL_SOCKET;
|
|
108
|
+
const terminalId = env.HC_TERMINAL_ID;
|
|
109
|
+
if (!socketPath || !terminalId) return null;
|
|
110
|
+
return { socketPath, terminalId };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Environment a child terminal should start with: core sets env on top of ITS OWN environment, so
|
|
114
|
+
* the caller's (provider keys, PATH from the user's shell, TERM identity) must be passed explicitly.
|
|
115
|
+
* Core's per-terminal identity (`HC_*`) is never forwarded — the child gets its own. */
|
|
116
|
+
export function childEnvironment(
|
|
117
|
+
base: NodeJS.ProcessEnv,
|
|
118
|
+
overrides: Record<string, string>,
|
|
119
|
+
): Record<string, string> {
|
|
120
|
+
const out: Record<string, string> = {};
|
|
121
|
+
for (const [k, v] of Object.entries(base)) {
|
|
122
|
+
if (v === undefined || k.startsWith('HC_')) continue;
|
|
123
|
+
out[k] = v;
|
|
124
|
+
}
|
|
125
|
+
return { ...out, ...overrides };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Tool-result sentence when there is no core terminal: what is missing, and the one command that
|
|
129
|
+
* fixes it. The install hint lives here so a missing `hc` never reads as a piher bug. */
|
|
130
|
+
export const CORE_REQUIRED =
|
|
131
|
+
'no herdr-core terminal (HC_TERMINAL_SOCKET/HC_TERMINAL_ID) — install herdr-core: ' +
|
|
132
|
+
'curl -fsSL https://releases.herdr.dev/core/install.sh | sh';
|
|
133
|
+
|
|
134
|
+
/** Transport failure (core stopped, stale socket) → one sentence the model can act on; null otherwise. */
|
|
135
|
+
export function coreUnavailableHint(err: unknown): string | null {
|
|
136
|
+
if (err instanceof CoreError) return null;
|
|
137
|
+
const msg = String((err as { message?: unknown })?.message ?? err);
|
|
138
|
+
if (!/ENOENT|ECONNREFUSED|ECONNRESET|EPIPE|ENOTCONN|not connected|socket|connect |timeout|connection closed/i.test(msg)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return `herdr-core unreachable (${msg}) — check that the core is running (\`hc server status\`) and HC_TERMINAL_SOCKET matches its socket`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ── Interface ───────────────────────────────────────────────────────── */
|
|
145
|
+
|
|
146
|
+
export interface CoreClientLike {
|
|
147
|
+
readonly available: boolean;
|
|
148
|
+
/** Set (non-empty) or remove (null/empty) labels on this terminal. Best effort, never throws. */
|
|
149
|
+
setLabels(labels: Record<string, string | null>): Promise<void>;
|
|
150
|
+
/** `herdr.space`/`herdr.tab` of this terminal, so children land in the same tab of the app. */
|
|
151
|
+
placementLabels(): Promise<Record<string, string>>;
|
|
152
|
+
getTerminal(id: string): Promise<TerminalInfo | null>;
|
|
153
|
+
listTerminals(opts?: { labels?: Record<string, string>; all?: boolean }): Promise<TerminalInfo[]>;
|
|
154
|
+
run(opts: RunOptions): Promise<TerminalInfo>;
|
|
155
|
+
/** Paste text and press Enter (core waits for ~3 s of human quiet). */
|
|
156
|
+
send(id: string, text: string, opts?: { force?: boolean }): Promise<void>;
|
|
157
|
+
keys(id: string, keys: readonly string[]): Promise<void>;
|
|
158
|
+
interrupt(id: string): Promise<InterruptOutcome>;
|
|
159
|
+
read(id: string, opts?: { lines?: number }): Promise<ReadBuffer>;
|
|
160
|
+
/** This terminal's children (workers, shells it started), kept live by one event subscription. */
|
|
161
|
+
watchChildren(): WorkerWatch;
|
|
162
|
+
waitForOutput(id: string, text: string, timeoutMs: number): Promise<WaitForOutputResult>;
|
|
163
|
+
kill(id: string): Promise<void>;
|
|
164
|
+
/** Remove a finished terminal; a missing one is not an error. */
|
|
165
|
+
remove(id: string): Promise<void>;
|
|
166
|
+
serverVersion(): Promise<string | null>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Live view of this terminal's children: one `events.subscribe {children_of}` connection, a snapshot
|
|
171
|
+
* patched by events, and a per-terminal change counter consumers wait on instead of polling.
|
|
172
|
+
* Opens lazily on first use, reconnects with backoff, and re-snapshots after `missed` or a reconnect;
|
|
173
|
+
* a snapshot rebuild or a disconnect counts as a change for every terminal so waiters re-evaluate.
|
|
174
|
+
* While disconnected the snapshot is stale: callers must check `connected` before trusting it.
|
|
175
|
+
*/
|
|
176
|
+
export interface WorkerWatch {
|
|
177
|
+
readonly connected: boolean;
|
|
178
|
+
/** First snapshot in → true; the first connection attempt failed → false (retries continue). */
|
|
179
|
+
ready(): Promise<boolean>;
|
|
180
|
+
get(id: string): TerminalInfo | undefined;
|
|
181
|
+
list(): TerminalInfo[];
|
|
182
|
+
/** Change counter for one terminal; compare with `changed`. */
|
|
183
|
+
version(id: string): number;
|
|
184
|
+
/** Resolves true once `version(id)` passes `since`, false after `timeoutMs` (or on close). */
|
|
185
|
+
changed(id: string, since: number, timeoutMs: number): Promise<boolean>;
|
|
186
|
+
close(): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ── Decoding ────────────────────────────────────────────────────────── */
|
|
190
|
+
|
|
191
|
+
const STATUS_WORDS: Record<string, true> = { idle: true, working: true, blocked: true, done: true, error: true };
|
|
192
|
+
|
|
193
|
+
function asStatus(word: unknown): CoreStatus {
|
|
194
|
+
return typeof word === 'string' && STATUS_WORDS[word] ? (word as CoreStatus) : 'unknown';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function str(v: unknown): string | null {
|
|
198
|
+
return typeof v === 'string' ? v : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function decodeTerminal(raw: unknown): TerminalInfo {
|
|
202
|
+
const t = (raw ?? {}) as Record<string, unknown>;
|
|
203
|
+
const status = (t.status ?? null) as Record<string, unknown> | null;
|
|
204
|
+
const fg = (t.foreground ?? null) as Record<string, unknown> | null;
|
|
205
|
+
const labels: Record<string, string> = {};
|
|
206
|
+
for (const [k, v] of Object.entries((t.labels ?? {}) as Record<string, unknown>)) {
|
|
207
|
+
if (typeof v === 'string') labels[k] = v;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
id: String(t.address ?? ''),
|
|
211
|
+
name: str(t.name),
|
|
212
|
+
state: t.state === 'finished' ? 'finished' : 'live',
|
|
213
|
+
status: asStatus(status?.state),
|
|
214
|
+
statusKind: str(status?.kind),
|
|
215
|
+
statusMsg: str(status?.msg),
|
|
216
|
+
statusApp: str(status?.app),
|
|
217
|
+
labels,
|
|
218
|
+
parent: str(t.parent),
|
|
219
|
+
cwd: String(t.cwd ?? ''),
|
|
220
|
+
foreground: fg && typeof fg === 'object'
|
|
221
|
+
? {
|
|
222
|
+
pid: Number(fg.pid) || 0,
|
|
223
|
+
name: String(fg.name ?? ''),
|
|
224
|
+
argv: Array.isArray(fg.argv) ? fg.argv.map(String) : [],
|
|
225
|
+
cwd: String(fg.cwd ?? ''),
|
|
226
|
+
}
|
|
227
|
+
: null,
|
|
228
|
+
exitCode: typeof t.exit_code === 'number' ? t.exit_code : null,
|
|
229
|
+
reason: str(t.reason),
|
|
230
|
+
turn: typeof t.turn === 'number' ? t.turn : 0,
|
|
231
|
+
title: String(t.title ?? ''),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ── Noop ────────────────────────────────────────────────────────────── */
|
|
236
|
+
|
|
237
|
+
/** Stand-in outside an hc terminal: queries answer empty, creation throws, labels are a no-op. */
|
|
238
|
+
export class NoopCoreClient implements CoreClientLike {
|
|
239
|
+
readonly available = false;
|
|
240
|
+
async setLabels(): Promise<void> {}
|
|
241
|
+
async placementLabels(): Promise<Record<string, string>> { return {}; }
|
|
242
|
+
async getTerminal(): Promise<null> { return null; }
|
|
243
|
+
async listTerminals(): Promise<TerminalInfo[]> { return []; }
|
|
244
|
+
async run(): Promise<TerminalInfo> { throw new Error(`subagents and terminals: ${CORE_REQUIRED}`); }
|
|
245
|
+
async send(): Promise<void> {}
|
|
246
|
+
async keys(): Promise<void> {}
|
|
247
|
+
async interrupt(): Promise<InterruptOutcome> { return 'unknown'; }
|
|
248
|
+
async read(): Promise<ReadBuffer> { return { text: '', complete: false }; }
|
|
249
|
+
watchChildren(): WorkerWatch { return NOOP_WATCH; }
|
|
250
|
+
async waitForOutput(): Promise<WaitForOutputResult> { return { matched: false, reason: 'unavailable' }; }
|
|
251
|
+
async kill(): Promise<void> {}
|
|
252
|
+
async remove(): Promise<void> {}
|
|
253
|
+
async serverVersion(): Promise<null> { return null; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const NOOP_WATCH: WorkerWatch = {
|
|
257
|
+
connected: false,
|
|
258
|
+
ready: async () => false,
|
|
259
|
+
get: () => undefined,
|
|
260
|
+
list: () => [],
|
|
261
|
+
version: () => 0,
|
|
262
|
+
changed: (_id, _since, timeoutMs) => {
|
|
263
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
264
|
+
setTimeout(() => resolve(false), timeoutMs).unref?.();
|
|
265
|
+
return promise;
|
|
266
|
+
},
|
|
267
|
+
close: () => {},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** core heartbeats an idle subscription every 15 s; three missed beats means the line is dead. */
|
|
271
|
+
const WATCH_IDLE_MS = 45_000;
|
|
272
|
+
const WATCH_BACKOFF_MS = [500, 1_000, 2_000, 5_000, 10_000] as const;
|
|
273
|
+
|
|
274
|
+
/** Patch one snapshot record with an event (schema/README.md "Events"); null drops it. */
|
|
275
|
+
export function applyTerminalEvent(rec: TerminalInfo | undefined, type: string, data: unknown): TerminalInfo | null | undefined {
|
|
276
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
277
|
+
if (type === 'created' || type === 'restarted') return decodeTerminal(data);
|
|
278
|
+
if (type === 'removed') return null;
|
|
279
|
+
if (!rec) return undefined;
|
|
280
|
+
switch (type) {
|
|
281
|
+
case 'status': {
|
|
282
|
+
const s = (d.status ?? null) as Record<string, unknown> | null;
|
|
283
|
+
return { ...rec, status: asStatus(s?.state), statusKind: str(s?.kind), statusMsg: str(s?.msg), statusApp: str(s?.app) };
|
|
284
|
+
}
|
|
285
|
+
case 'turn':
|
|
286
|
+
return { ...rec, turn: typeof d.turn === 'number' ? d.turn : rec.turn };
|
|
287
|
+
case 'finished':
|
|
288
|
+
return { ...rec, state: 'finished', exitCode: typeof d.exit_code === 'number' ? d.exit_code : null, reason: str(d.reason), foreground: null };
|
|
289
|
+
case 'lost':
|
|
290
|
+
return { ...rec, exitCode: typeof d.exit_code === 'number' ? d.exit_code : null, reason: str(d.reason), foreground: null };
|
|
291
|
+
case 'title':
|
|
292
|
+
return { ...rec, title: String(d.title ?? '') };
|
|
293
|
+
case 'labels': {
|
|
294
|
+
if (d.record) return decodeTerminal(d.record);
|
|
295
|
+
return { ...rec, labels: decodeTerminal({ labels: d.labels }).labels };
|
|
296
|
+
}
|
|
297
|
+
case 'foreground':
|
|
298
|
+
return { ...rec, foreground: data ? decodeTerminal({ foreground: data }).foreground : null };
|
|
299
|
+
default:
|
|
300
|
+
return rec;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export class CoreWatch implements WorkerWatch {
|
|
305
|
+
connected = false;
|
|
306
|
+
private readonly socketPath: string;
|
|
307
|
+
private readonly parent: string;
|
|
308
|
+
private sock: net.Socket | null = null;
|
|
309
|
+
private started = false;
|
|
310
|
+
private closed = false;
|
|
311
|
+
private attempt = 0;
|
|
312
|
+
private seq = 0;
|
|
313
|
+
private terminals = new Map<string, TerminalInfo>();
|
|
314
|
+
private tick = 0;
|
|
315
|
+
private epochAt = 0;
|
|
316
|
+
private touchedAt = new Map<string, number>();
|
|
317
|
+
private waiters = new Set<{ id: string; since: number; resolve: (v: boolean) => void; timer: NodeJS.Timeout }>();
|
|
318
|
+
private idleTimer: NodeJS.Timeout | undefined;
|
|
319
|
+
private retryTimer: NodeJS.Timeout | undefined;
|
|
320
|
+
private readonly firstReady = Promise.withResolvers<boolean>();
|
|
321
|
+
private readySettled = false;
|
|
322
|
+
|
|
323
|
+
constructor(env: CoreEnv) {
|
|
324
|
+
this.socketPath = env.socketPath;
|
|
325
|
+
this.parent = env.terminalId;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
ready(): Promise<boolean> {
|
|
329
|
+
this.start();
|
|
330
|
+
return this.firstReady.promise;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
get(id: string): TerminalInfo | undefined {
|
|
334
|
+
this.start();
|
|
335
|
+
return this.terminals.get(id);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
list(): TerminalInfo[] {
|
|
339
|
+
this.start();
|
|
340
|
+
return [...this.terminals.values()];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
version(id: string): number {
|
|
344
|
+
this.start();
|
|
345
|
+
return Math.max(this.touchedAt.get(id) ?? 0, this.epochAt);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
changed(id: string, since: number, timeoutMs: number): Promise<boolean> {
|
|
349
|
+
if (this.version(id) > since) return Promise.resolve(true);
|
|
350
|
+
if (this.closed) return Promise.resolve(false);
|
|
351
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
352
|
+
const waiter = {
|
|
353
|
+
id,
|
|
354
|
+
since,
|
|
355
|
+
resolve,
|
|
356
|
+
timer: setTimeout(() => {
|
|
357
|
+
this.waiters.delete(waiter);
|
|
358
|
+
resolve(false);
|
|
359
|
+
}, Math.max(0, timeoutMs)),
|
|
360
|
+
};
|
|
361
|
+
waiter.timer.unref?.();
|
|
362
|
+
this.waiters.add(waiter);
|
|
363
|
+
return promise;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
close(): void {
|
|
367
|
+
this.closed = true;
|
|
368
|
+
this.connected = false;
|
|
369
|
+
clearTimeout(this.idleTimer);
|
|
370
|
+
clearTimeout(this.retryTimer);
|
|
371
|
+
this.sock?.destroy();
|
|
372
|
+
this.sock = null;
|
|
373
|
+
for (const w of this.waiters) {
|
|
374
|
+
clearTimeout(w.timer);
|
|
375
|
+
w.resolve(false);
|
|
376
|
+
}
|
|
377
|
+
this.waiters.clear();
|
|
378
|
+
this.settleReady(false);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private start(): void {
|
|
382
|
+
if (this.started || this.closed) return;
|
|
383
|
+
this.started = true;
|
|
384
|
+
this.connect();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private settleReady(ok: boolean): void {
|
|
388
|
+
if (this.readySettled) return;
|
|
389
|
+
this.readySettled = true;
|
|
390
|
+
this.firstReady.resolve(ok);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private wake(): void {
|
|
394
|
+
for (const w of this.waiters) {
|
|
395
|
+
if (this.version(w.id) <= w.since) continue;
|
|
396
|
+
this.waiters.delete(w);
|
|
397
|
+
clearTimeout(w.timer);
|
|
398
|
+
w.resolve(true);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private touch(id: string): void {
|
|
403
|
+
this.touchedAt.set(id, ++this.tick);
|
|
404
|
+
this.wake();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private touchAll(): void {
|
|
408
|
+
this.epochAt = ++this.tick;
|
|
409
|
+
this.wake();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private snapshot(raw: unknown, seq: unknown): void {
|
|
413
|
+
this.terminals = new Map((Array.isArray(raw) ? raw : []).map((t) => {
|
|
414
|
+
const rec = decodeTerminal(t);
|
|
415
|
+
return [rec.id, rec] as const;
|
|
416
|
+
}));
|
|
417
|
+
this.seq = typeof seq === 'number' ? seq : 0;
|
|
418
|
+
this.touchAll();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private armIdle(sock: net.Socket): void {
|
|
422
|
+
clearTimeout(this.idleTimer);
|
|
423
|
+
this.idleTimer = setTimeout(() => sock.destroy(new Error('hc events: heartbeat lost')), WATCH_IDLE_MS);
|
|
424
|
+
this.idleTimer.unref?.();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private connect(): void {
|
|
428
|
+
if (this.closed) return;
|
|
429
|
+
const sock = net.createConnection(this.socketPath);
|
|
430
|
+
this.sock = sock;
|
|
431
|
+
sock.setEncoding('utf8');
|
|
432
|
+
let buf = '';
|
|
433
|
+
sock.on('connect', () => {
|
|
434
|
+
this.armIdle(sock);
|
|
435
|
+
sock.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: { children_of: this.parent } }) + '\n');
|
|
436
|
+
});
|
|
437
|
+
sock.on('data', (chunk) => {
|
|
438
|
+
this.armIdle(sock);
|
|
439
|
+
buf += chunk;
|
|
440
|
+
let idx: number;
|
|
441
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
442
|
+
const line = buf.slice(0, idx).trim();
|
|
443
|
+
buf = buf.slice(idx + 1);
|
|
444
|
+
if (line) this.handle(sock, line);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
const drop = (): void => {
|
|
448
|
+
if (this.sock !== sock) return;
|
|
449
|
+
this.sock = null;
|
|
450
|
+
clearTimeout(this.idleTimer);
|
|
451
|
+
const wasConnected = this.connected;
|
|
452
|
+
this.connected = false;
|
|
453
|
+
this.settleReady(false);
|
|
454
|
+
if (wasConnected) this.touchAll();
|
|
455
|
+
if (this.closed) return;
|
|
456
|
+
const delay = WATCH_BACKOFF_MS[Math.min(this.attempt, WATCH_BACKOFF_MS.length - 1)];
|
|
457
|
+
this.attempt += 1;
|
|
458
|
+
this.retryTimer = setTimeout(() => this.connect(), delay);
|
|
459
|
+
this.retryTimer.unref?.();
|
|
460
|
+
};
|
|
461
|
+
sock.on('error', drop);
|
|
462
|
+
sock.on('close', drop);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private handle(sock: net.Socket, line: string): void {
|
|
466
|
+
let msg: { id?: unknown; method?: string; params?: Record<string, unknown>; result?: Record<string, unknown>; error?: unknown };
|
|
467
|
+
try {
|
|
468
|
+
msg = JSON.parse(line);
|
|
469
|
+
} catch {
|
|
470
|
+
sock.destroy();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (msg.id === 1) {
|
|
474
|
+
if (msg.error || !msg.result) {
|
|
475
|
+
sock.destroy();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
this.connected = true;
|
|
479
|
+
this.attempt = 0;
|
|
480
|
+
this.snapshot(msg.result.terminals, msg.result.seq);
|
|
481
|
+
this.settleReady(true);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const p = msg.params ?? {};
|
|
485
|
+
if (msg.method === 'missed') {
|
|
486
|
+
this.snapshot(p.terminals, p.seq);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (msg.method !== 'event') return;
|
|
490
|
+
if (typeof p.seq === 'number') {
|
|
491
|
+
if (p.seq <= this.seq) return; // already covered by the snapshot
|
|
492
|
+
this.seq = p.seq;
|
|
493
|
+
}
|
|
494
|
+
const id = typeof p.terminal === 'string' ? p.terminal : null;
|
|
495
|
+
if (!id || typeof p.type !== 'string') return;
|
|
496
|
+
const next = applyTerminalEvent(this.terminals.get(id), p.type, p.data);
|
|
497
|
+
if (next === undefined) return;
|
|
498
|
+
if (next === null) this.terminals.delete(id);
|
|
499
|
+
else this.terminals.set(id, next);
|
|
500
|
+
this.touch(id);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* ── Implementation ──────────────────────────────────────────────────── */
|
|
505
|
+
|
|
506
|
+
/** Slack over a server-side wait before the client gives up on the socket. */
|
|
507
|
+
const WAIT_SLACK_MS = 10_000;
|
|
508
|
+
|
|
509
|
+
export class CoreClient implements CoreClientLike {
|
|
510
|
+
readonly available = true;
|
|
511
|
+
private readonly env: CoreEnv;
|
|
512
|
+
private placement: Promise<Record<string, string>> | null = null;
|
|
513
|
+
private nextId = 1;
|
|
514
|
+
private watch: CoreWatch | null = null;
|
|
515
|
+
|
|
516
|
+
constructor(env: CoreEnv) {
|
|
517
|
+
this.env = env;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** One connection per request: write one line, read the first reply line, close. */
|
|
521
|
+
request(method: string, params: Record<string, unknown>, timeoutMs = 15_000): Promise<unknown> {
|
|
522
|
+
const id = this.nextId++;
|
|
523
|
+
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
524
|
+
const sock = net.createConnection(this.env.socketPath);
|
|
525
|
+
sock.setEncoding('utf8');
|
|
526
|
+
let buf = '';
|
|
527
|
+
let settled = false;
|
|
528
|
+
const finish = (settle: () => void): void => {
|
|
529
|
+
if (settled) return;
|
|
530
|
+
settled = true;
|
|
531
|
+
clearTimeout(timer);
|
|
532
|
+
sock.destroy();
|
|
533
|
+
settle();
|
|
534
|
+
};
|
|
535
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`hc ${method}: timeout`))), timeoutMs);
|
|
536
|
+
sock.on('connect', () => {
|
|
537
|
+
sock.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', (err) => {
|
|
538
|
+
if (err) finish(() => reject(err));
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
sock.on('data', (chunk) => {
|
|
542
|
+
buf += chunk;
|
|
543
|
+
let idx: number;
|
|
544
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
545
|
+
const line = buf.slice(0, idx).trim();
|
|
546
|
+
buf = buf.slice(idx + 1);
|
|
547
|
+
if (!line) continue;
|
|
548
|
+
let msg: { id?: unknown; result?: unknown; error?: { message?: string; data?: { kind?: string; hint?: string } } };
|
|
549
|
+
try {
|
|
550
|
+
msg = JSON.parse(line);
|
|
551
|
+
} catch {
|
|
552
|
+
finish(() => reject(new Error(`hc ${method}: bad frame`)));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (msg.id !== id) continue;
|
|
556
|
+
const error = msg.error;
|
|
557
|
+
if (error) {
|
|
558
|
+
finish(() => reject(new CoreError(method, error.data?.kind ?? 'error', error.message ?? '', error.data?.hint ?? null)));
|
|
559
|
+
} else {
|
|
560
|
+
finish(() => resolve(msg.result));
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
sock.on('error', (err) => finish(() => reject(err)));
|
|
566
|
+
sock.on('close', () => finish(() => reject(new Error(`hc ${method}: connection closed`))));
|
|
567
|
+
return promise;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async setLabels(labels: Record<string, string | null>): Promise<void> {
|
|
571
|
+
const wire: Record<string, string> = {};
|
|
572
|
+
for (const [k, v] of Object.entries(labels)) wire[k] = v ?? '';
|
|
573
|
+
if (Object.keys(wire).length === 0) return;
|
|
574
|
+
try {
|
|
575
|
+
await this.request('terminal.label', { terminal: this.env.terminalId, labels: wire });
|
|
576
|
+
} catch {
|
|
577
|
+
/* best effort: metadata never affects the pi flow */
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
placementLabels(): Promise<Record<string, string>> {
|
|
582
|
+
this.placement ??= this.getTerminal(this.env.terminalId)
|
|
583
|
+
.then((self) => {
|
|
584
|
+
const out: Record<string, string> = {};
|
|
585
|
+
for (const key of ['herdr.space', 'herdr.tab']) {
|
|
586
|
+
const v = self?.labels[key];
|
|
587
|
+
if (v) out[key] = v;
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
})
|
|
591
|
+
.catch(() => {
|
|
592
|
+
this.placement = null;
|
|
593
|
+
return {};
|
|
594
|
+
});
|
|
595
|
+
return this.placement;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async getTerminal(id: string): Promise<TerminalInfo | null> {
|
|
599
|
+
try {
|
|
600
|
+
return decodeTerminal(await this.request('terminal.get', { terminal: id, caller: this.env.terminalId }));
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (isCoreErrorKind(err, 'not_found')) return null;
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async listTerminals(opts: { labels?: Record<string, string>; all?: boolean } = {}): Promise<TerminalInfo[]> {
|
|
608
|
+
const result = (await this.request('terminal.list', {
|
|
609
|
+
...(opts.all ? { all: true } : {}),
|
|
610
|
+
...(opts.labels ? { labels: opts.labels } : {}),
|
|
611
|
+
})) as { terminals?: unknown[] } | null;
|
|
612
|
+
return (result?.terminals ?? []).map(decodeTerminal);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async run(opts: RunOptions): Promise<TerminalInfo> {
|
|
616
|
+
const params: Record<string, unknown> = {
|
|
617
|
+
caller: this.env.terminalId,
|
|
618
|
+
...(opts.argv ? { argv: [...opts.argv] } : {}),
|
|
619
|
+
...(opts.line ? { line: opts.line } : {}),
|
|
620
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
621
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
622
|
+
...(opts.labels ? { labels: opts.labels } : {}),
|
|
623
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
624
|
+
...(opts.noNotify ? { no_notify: true } : {}),
|
|
625
|
+
};
|
|
626
|
+
if (opts.parent === null) params.no_parent = true;
|
|
627
|
+
else params.parent = opts.parent ?? this.env.terminalId;
|
|
628
|
+
return decodeTerminal(await this.request('terminal.run', params));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async send(id: string, text: string, opts: { force?: boolean } = {}): Promise<void> {
|
|
632
|
+
await this.request('terminal.send', {
|
|
633
|
+
terminal: id,
|
|
634
|
+
text,
|
|
635
|
+
caller: this.env.terminalId,
|
|
636
|
+
...(opts.force ? { force: true } : {}),
|
|
637
|
+
}, 30_000);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async keys(id: string, keys: readonly string[]): Promise<void> {
|
|
641
|
+
await this.request('terminal.keys', { terminal: id, keys: [...keys], caller: this.env.terminalId }, 30_000);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async interrupt(id: string): Promise<InterruptOutcome> {
|
|
645
|
+
const result = (await this.request('terminal.interrupt', { terminal: id }, 30_000)) as { outcome?: string } | null;
|
|
646
|
+
const o = result?.outcome;
|
|
647
|
+
return o === 'stopped' || o === 'exited' || o === 'still_working' ? o : 'unknown';
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async read(id: string, opts: { lines?: number } = {}): Promise<ReadBuffer> {
|
|
651
|
+
const result = (await this.request('terminal.read', {
|
|
652
|
+
terminal: id,
|
|
653
|
+
caller: this.env.terminalId,
|
|
654
|
+
lines: Math.max(0, Math.floor(opts.lines ?? 0)),
|
|
655
|
+
})) as { text?: unknown; complete?: unknown } | null;
|
|
656
|
+
return { text: typeof result?.text === 'string' ? result.text : '', complete: result?.complete !== false };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
watchChildren(): WorkerWatch {
|
|
660
|
+
this.watch ??= new CoreWatch(this.env);
|
|
661
|
+
return this.watch;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async waitForOutput(id: string, text: string, timeoutMs: number): Promise<WaitForOutputResult> {
|
|
665
|
+
try {
|
|
666
|
+
const result = (await this.request('terminal.wait', {
|
|
667
|
+
terminal: id,
|
|
668
|
+
caller: this.env.terminalId,
|
|
669
|
+
output: text,
|
|
670
|
+
timeout: Math.max(1, timeoutMs) / 1000,
|
|
671
|
+
}, timeoutMs + WAIT_SLACK_MS)) as { ended?: boolean; reason?: string } | null;
|
|
672
|
+
if (result?.reason === 'output') return { matched: true };
|
|
673
|
+
if (!result || result.ended === false || result.reason === 'timeout') return { matched: false, reason: 'timeout' };
|
|
674
|
+
return { matched: false, reason: 'unavailable' };
|
|
675
|
+
} catch (err) {
|
|
676
|
+
if (err instanceof CoreError) return { matched: false, reason: 'unavailable' };
|
|
677
|
+
throw err;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async kill(id: string): Promise<void> {
|
|
682
|
+
try {
|
|
683
|
+
await this.request('terminal.kill', { terminal: id, caller: this.env.terminalId }, 30_000);
|
|
684
|
+
} catch (err) {
|
|
685
|
+
if (isCoreErrorKind(err, 'not_found') || isCoreErrorKind(err, 'finished')) return;
|
|
686
|
+
throw err;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async remove(id: string): Promise<void> {
|
|
691
|
+
try {
|
|
692
|
+
await this.request('terminal.rm', { terminal: id, caller: this.env.terminalId });
|
|
693
|
+
} catch (err) {
|
|
694
|
+
if (isCoreErrorKind(err, 'not_found')) return;
|
|
695
|
+
throw err;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async serverVersion(): Promise<string | null> {
|
|
700
|
+
try {
|
|
701
|
+
const result = (await this.request('server.status', {})) as { version?: unknown } | null;
|
|
702
|
+
return typeof result?.version === 'string' ? result.version : null;
|
|
703
|
+
} catch {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function createCoreClient(
|
|
710
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
711
|
+
): { client: CoreClientLike; env: CoreEnv | null } {
|
|
712
|
+
const detected = detectCoreEnv(env);
|
|
713
|
+
if (!detected) return { client: new NoopCoreClient(), env: null };
|
|
714
|
+
return { client: new CoreClient(detected), env: detected };
|
|
715
|
+
}
|