omp-conductor 0.15.12 → 0.15.13
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/REFERENCE.md +9 -4
- package/package.json +1 -1
- package/src/commands/restart.ts +81 -54
- package/src/commands/stop.ts +45 -22
- package/src/daemon.ts +253 -54
- package/src/doctor.ts +241 -4
- package/src/escalate.ts +8 -0
- package/src/fleet.ts +49 -2
- package/src/lifecycle.ts +113 -2
- package/src/omp.ts +24 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +7 -0
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +278 -113
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +181 -0
- package/src/types.ts +111 -0
- package/src/upgrade.ts +26 -6
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +21 -0
package/src/wizard-ui.ts
CHANGED
|
@@ -36,8 +36,9 @@ export interface WizardUi {
|
|
|
36
36
|
/** Single-line text prompt. `undefined` dismisses it; an empty submit accepts
|
|
37
37
|
* the placeholder. */
|
|
38
38
|
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
39
|
+
/** Single-choice list. On an interactive TTY the current option is rendered
|
|
40
|
+
* inline and moved with ↑/↓ or j/k; on a pipe it stays the numbered wall.
|
|
41
|
+
* Resolves the chosen option's label, or `undefined` when dismissed. */
|
|
41
42
|
select(
|
|
42
43
|
title: string,
|
|
43
44
|
options: { label: string; description?: string }[],
|
|
@@ -62,6 +63,17 @@ import type { Readable, Writable } from "node:stream";
|
|
|
62
63
|
* as long as the wizard, and {@link TerminalUi.close} releases it once, from the
|
|
63
64
|
* caller's `finally`.
|
|
64
65
|
*
|
|
66
|
+
* The one exception is {@link TerminalUi.select} on a real TTY, which takes the
|
|
67
|
+
* terminal over in raw mode. A live readline interface echoes every typed
|
|
68
|
+
* character back to the output and folds Enter into a `line` event, so it would
|
|
69
|
+
* smear the inline option row with pressed keys and drop stray lines into the
|
|
70
|
+
* buffer underneath it. That prompt closes the interface, reads the terminal
|
|
71
|
+
* byte by byte, and then re-creates the interface so the line protocol is still
|
|
72
|
+
* alive for the next prompt. The re-opening works on a TTY (the stream itself
|
|
73
|
+
* is untouched; closing only pauses it, and it is resumed), and the closed
|
|
74
|
+
* interface's key listeners are dropped, so nothing leaks across repeated
|
|
75
|
+
* passes of a menu.
|
|
76
|
+
*
|
|
65
77
|
* Dismissal is the interface's own `close`: `rl.question()` on an exhausted
|
|
66
78
|
* stream never settles on its own, so EOF (a script that ran out of answers) and
|
|
67
79
|
* Ctrl-C both close the interface, and the line reader turns that into the
|
|
@@ -78,18 +90,18 @@ export interface TerminalUi extends WizardUi {
|
|
|
78
90
|
export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi {
|
|
79
91
|
const input = io.input ?? stdin;
|
|
80
92
|
const output = io.output ?? stdout;
|
|
81
|
-
|
|
93
|
+
let rl: Interface = createInterface({ input, output });
|
|
82
94
|
let closed = false;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
let buffered: string[] = [];
|
|
96
|
+
let waiting: ((value: string | undefined) => void) | undefined;
|
|
97
|
+
let ended = false;
|
|
98
|
+
const deliver = (value: string | undefined): boolean => {
|
|
99
|
+
const resolve = waiting;
|
|
100
|
+
if (resolve === undefined) return false;
|
|
101
|
+
waiting = undefined;
|
|
102
|
+
resolve(value);
|
|
103
|
+
return true;
|
|
87
104
|
};
|
|
88
|
-
// Ctrl-C is a cancel, not a signal to the whole process: closing the interface
|
|
89
|
-
// makes every pending and subsequent prompt resolve `undefined`, which the
|
|
90
|
-
// wizard already turns into `Cancelled`.
|
|
91
|
-
rl.once("SIGINT", close);
|
|
92
|
-
|
|
93
105
|
// Lines are buffered as they arrive rather than read on demand, because on a
|
|
94
106
|
// non-TTY stdin readline drains the whole pipe at once and emits `line` for
|
|
95
107
|
// every one of them immediately. `rl.question()` only captures the line that
|
|
@@ -97,23 +109,40 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
|
|
|
97
109
|
// already flown past and then hit EOF — three prompts resolving `undefined`
|
|
98
110
|
// and a wizard that cancelled itself. Buffering makes a pipe and a terminal
|
|
99
111
|
// behave the same: nothing is read before it is asked for, nothing is lost.
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
const attach = (iface: Interface): void => {
|
|
113
|
+
// Ctrl-C on the line protocol is a cancel, not a signal to the whole
|
|
114
|
+
// process: closing the interface (on SIGINT or on EOF) makes every pending
|
|
115
|
+
// and subsequent prompt resolve `undefined`, which the wizard already turns
|
|
116
|
+
// into `Cancelled`. The raw-mode select handles Ctrl-C itself; every other
|
|
117
|
+
// prompt relies on this.
|
|
118
|
+
iface.once("SIGINT", close);
|
|
119
|
+
iface.on("line", (text: string) => {
|
|
120
|
+
if (!deliver(text)) buffered.push(text);
|
|
121
|
+
});
|
|
122
|
+
iface.once("close", () => {
|
|
123
|
+
ended = true;
|
|
124
|
+
deliver(undefined);
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
const close = (): void => {
|
|
128
|
+
if (closed) return;
|
|
129
|
+
closed = true;
|
|
130
|
+
rl.close();
|
|
131
|
+
};
|
|
132
|
+
// The line protocol runs on the *current* interface. An interactive select
|
|
133
|
+
// takes the terminal over in raw mode, so the interface that was echoing
|
|
134
|
+
// keystrokes and folding Enter into lines has to be closed out of the way and
|
|
135
|
+
// a fresh one opened for the prompts that follow (see the lifecycle comment
|
|
136
|
+
// at the top of the interface declaration).
|
|
137
|
+
const reopen = (): void => {
|
|
138
|
+
rl.close();
|
|
139
|
+
buffered = [];
|
|
106
140
|
waiting = undefined;
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
ended = false;
|
|
142
|
+
rl = createInterface({ input, output });
|
|
143
|
+
attach(rl);
|
|
109
144
|
};
|
|
110
|
-
rl
|
|
111
|
-
if (!deliver(text)) buffered.push(text);
|
|
112
|
-
});
|
|
113
|
-
rl.once("close", () => {
|
|
114
|
-
ended = true;
|
|
115
|
-
deliver(undefined);
|
|
116
|
-
});
|
|
145
|
+
attach(rl);
|
|
117
146
|
|
|
118
147
|
/** One line, or `undefined` when the operator dismissed the prompt. */
|
|
119
148
|
const line = async (query: string): Promise<string | undefined> => {
|
|
@@ -127,6 +156,37 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
|
|
|
127
156
|
});
|
|
128
157
|
};
|
|
129
158
|
|
|
159
|
+
// -------- the interactive select: raw keys, one changing row --------
|
|
160
|
+
//
|
|
161
|
+
// On a real TTY the numbered wall is replaced by a single row that shows the
|
|
162
|
+
// option under the cursor and follows ↑/↓ or j/k. The keys are read from the
|
|
163
|
+
// raw stream byte by byte rather than through readline's keypress decode:
|
|
164
|
+
// readline only emits a lone Escape after its own escape-sequence timeout,
|
|
165
|
+
// and CSI sequences split across chunks have to be reassembled anyway. The
|
|
166
|
+
// terminal is taken over only for the duration of the prompt and restored on
|
|
167
|
+
// every exit path.
|
|
168
|
+
type TtyInput = Readable & {
|
|
169
|
+
isTTY: boolean;
|
|
170
|
+
isRaw?: boolean;
|
|
171
|
+
setRawMode(mode: boolean): void;
|
|
172
|
+
};
|
|
173
|
+
const asTty = (stream: Readable): TtyInput | undefined => {
|
|
174
|
+
const candidate = stream as Readable & Partial<TtyInput>;
|
|
175
|
+
return candidate.isTTY === true && typeof candidate.setRawMode === "function"
|
|
176
|
+
? (candidate as TtyInput)
|
|
177
|
+
: undefined;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const ESC = 27;
|
|
181
|
+
const ENTERS = new Set([13, 10]);
|
|
182
|
+
const CTRL_C = 3;
|
|
183
|
+
const UP = 65; // CSI A after ESC [
|
|
184
|
+
const DOWN = 66; // CSI B after ESC [
|
|
185
|
+
const KEY_J = 106; // lower-case j: down
|
|
186
|
+
const KEY_K = 107; // lower-case k: up
|
|
187
|
+
const KEY_J_UP = 74; // J
|
|
188
|
+
const KEY_K_UP = 75; // K
|
|
189
|
+
|
|
130
190
|
return {
|
|
131
191
|
close,
|
|
132
192
|
notify(message) {
|
|
@@ -156,25 +216,178 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
|
|
|
156
216
|
return answer.trim().length === 0 ? (placeholder ?? "") : answer;
|
|
157
217
|
},
|
|
158
218
|
async select(title, options, dialogOptions) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
219
|
+
const tty = asTty(input);
|
|
220
|
+
if (tty === undefined) {
|
|
221
|
+
// Non-TTY stdin (a pipe, a test harness, CI): the numbered wall, kept
|
|
222
|
+
// byte-identical so the scripted protocol cannot drift.
|
|
223
|
+
output.write(`${title}\n`);
|
|
224
|
+
options.forEach((o, i) => {
|
|
225
|
+
output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` — ${o.description}` : ""}\n`);
|
|
226
|
+
});
|
|
227
|
+
const initial = dialogOptions?.initialIndex ?? 0;
|
|
228
|
+
const defaultLabel = options[initial]?.label ?? "";
|
|
229
|
+
for (;;) {
|
|
230
|
+
const answer = await line(
|
|
231
|
+
`Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `,
|
|
232
|
+
);
|
|
233
|
+
if (answer === undefined) return undefined;
|
|
234
|
+
const a = answer.trim();
|
|
235
|
+
if (a === "") return defaultLabel; // a bare Enter re-affirms the current row
|
|
236
|
+
const n = Number(a);
|
|
237
|
+
if (Number.isInteger(n)) {
|
|
238
|
+
const picked = options[n - 1];
|
|
239
|
+
if (picked !== undefined) return picked.label;
|
|
240
|
+
}
|
|
241
|
+
output.write(`Please enter a number between 1 and ${options.length}.\n`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (options.length === 0) return undefined;
|
|
245
|
+
// Clamp rather than wrap: the row is the operator's place in the list and
|
|
246
|
+
// pressing past an edge should not fly from bottom to top (or the other
|
|
247
|
+
// way); a bare Enter is "keep the current row", so the position is a
|
|
248
|
+
// setting, not a spinner.
|
|
249
|
+
const bound = (index: number): number => Math.max(0, Math.min(index, options.length - 1));
|
|
250
|
+
let index = bound(dialogOptions?.initialIndex ?? 0);
|
|
251
|
+
const row = (at: number): string => {
|
|
252
|
+
const o = options[at];
|
|
253
|
+
return ` › ${o?.label ?? ""}${o?.description !== undefined ? ` — ${o.description}` : ""}`;
|
|
254
|
+
};
|
|
255
|
+
let prevLen = 0;
|
|
256
|
+
const paint = (): void => {
|
|
257
|
+
const text = row(index);
|
|
258
|
+
output.write(`\r${" ".repeat(prevLen)}\r${text}`);
|
|
259
|
+
prevLen = text.length;
|
|
260
|
+
};
|
|
261
|
+
let settled: ((value: string | undefined) => void) | undefined;
|
|
262
|
+
let failed: ((err: unknown) => void) | undefined;
|
|
263
|
+
let escapeState: "idle" | "esc" | "seq" = "idle";
|
|
264
|
+
let escapeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
265
|
+
const clearEscape = (): void => {
|
|
266
|
+
if (escapeTimer !== undefined) {
|
|
267
|
+
clearTimeout(escapeTimer);
|
|
268
|
+
escapeTimer = undefined;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
// Escape sequences arrive byte by byte and a lone ESC cannot be told
|
|
272
|
+
// apart from the start of `ESC [ A` until the next byte lands; reading
|
|
273
|
+
// the whole chunk at once handles the common atomic write, and a short
|
|
274
|
+
// timer turns an unfinished sequence into a plain Escape.
|
|
275
|
+
const armEscape = (): void => {
|
|
276
|
+
if (escapeTimer !== undefined) return;
|
|
277
|
+
escapeTimer = setTimeout(() => {
|
|
278
|
+
escapeTimer = undefined;
|
|
279
|
+
escapeState = "idle";
|
|
280
|
+
settled?.(undefined);
|
|
281
|
+
}, 30);
|
|
282
|
+
};
|
|
283
|
+
const onData = (chunk: Buffer): void => {
|
|
284
|
+
try {
|
|
285
|
+
for (const byte of chunk) {
|
|
286
|
+
switch (escapeState) {
|
|
287
|
+
case "esc": {
|
|
288
|
+
clearEscape();
|
|
289
|
+
if (byte === 91 || byte === 79) {
|
|
290
|
+
// ESC [ … or ESC O … — wait for the direction letter.
|
|
291
|
+
escapeState = "seq";
|
|
292
|
+
armEscape();
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// ESC followed by anything else: a plain Escape press.
|
|
296
|
+
escapeState = "idle";
|
|
297
|
+
settled?.(undefined);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
case "seq": {
|
|
301
|
+
clearEscape();
|
|
302
|
+
escapeState = "idle";
|
|
303
|
+
if (byte === 65) index = bound(index - 1);
|
|
304
|
+
else if (byte === 66) index = bound(index + 1);
|
|
305
|
+
else continue; // an unknown CSI sequence is not a key we know
|
|
306
|
+
paint();
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
case "idle": {
|
|
310
|
+
if (ENTERS.has(byte)) {
|
|
311
|
+
settled?.(options[index]?.label);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (byte === CTRL_C) {
|
|
315
|
+
settled?.(undefined);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (byte === ESC) {
|
|
319
|
+
escapeState = "esc";
|
|
320
|
+
armEscape();
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (byte === KEY_J || byte === KEY_J_UP) {
|
|
324
|
+
index = bound(index + 1);
|
|
325
|
+
paint();
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (byte === KEY_K || byte === KEY_K_UP) {
|
|
329
|
+
index = bound(index - 1);
|
|
330
|
+
paint();
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Any other byte (typos, mouse reports) is ignored.
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
failed?.(err);
|
|
176
340
|
}
|
|
177
|
-
|
|
341
|
+
};
|
|
342
|
+
const onSignal = (signal: NodeJS.Signals): void => {
|
|
343
|
+
// A real signal (not the Ctrl-C byte, which raw mode turns into the
|
|
344
|
+
// cancel above) still kills the process — but only after the terminal
|
|
345
|
+
// has been handed back, so the operator's shell is never left raw.
|
|
346
|
+
clearEscape();
|
|
347
|
+
input.removeListener("data", onData);
|
|
348
|
+
tty.setRawMode(wasRaw);
|
|
349
|
+
process.removeListener("SIGINT", onSignal);
|
|
350
|
+
process.removeListener("SIGTERM", onSignal);
|
|
351
|
+
process.removeListener("SIGHUP", onSignal);
|
|
352
|
+
process.kill(process.pid, signal);
|
|
353
|
+
};
|
|
354
|
+
let wasRaw = false;
|
|
355
|
+
try {
|
|
356
|
+
// Take the terminal over. The shared interface would echo every pressed
|
|
357
|
+
// key back into the row and fold Enter into a `line` the next prompt
|
|
358
|
+
// would swallow, so it is closed for the duration and re-opened below.
|
|
359
|
+
rl.close();
|
|
360
|
+
input.resume();
|
|
361
|
+
// Captured *after* the close: while the interface is alive it keeps the
|
|
362
|
+
// terminal raw (bun's readline does), and restoring to that state would
|
|
363
|
+
// leave the operator's shell raw after a signal.
|
|
364
|
+
wasRaw = tty.isRaw ?? false;
|
|
365
|
+
tty.setRawMode(true);
|
|
366
|
+
output.write(`${title}\n`);
|
|
367
|
+
output.write(" (↑/↓ or j/k to move · Enter accepts · Ctrl-C or Esc cancels)\n");
|
|
368
|
+
paint();
|
|
369
|
+
const done = new Promise<string | undefined>((resolve, reject) => {
|
|
370
|
+
settled = resolve;
|
|
371
|
+
failed = reject;
|
|
372
|
+
});
|
|
373
|
+
input.on("data", onData);
|
|
374
|
+
process.once("SIGINT", onSignal);
|
|
375
|
+
process.once("SIGTERM", onSignal);
|
|
376
|
+
process.once("SIGHUP", onSignal);
|
|
377
|
+
const picked = await done;
|
|
378
|
+
output.write("\n");
|
|
379
|
+
return picked;
|
|
380
|
+
} finally {
|
|
381
|
+
// Every exit path — accept, cancel, an exception in rendering, a signal
|
|
382
|
+
// that did not kill us — restores the terminal exactly as it was found
|
|
383
|
+
// and re-opens the line interface for the prompts that follow.
|
|
384
|
+
clearEscape();
|
|
385
|
+
input.removeListener("data", onData);
|
|
386
|
+
tty.setRawMode(wasRaw);
|
|
387
|
+
process.removeListener("SIGINT", onSignal);
|
|
388
|
+
process.removeListener("SIGTERM", onSignal);
|
|
389
|
+
process.removeListener("SIGHUP", onSignal);
|
|
390
|
+
reopen();
|
|
178
391
|
}
|
|
179
392
|
},
|
|
180
393
|
};
|
|
@@ -209,7 +422,9 @@ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): Te
|
|
|
209
422
|
export interface ScriptedAnswers {
|
|
210
423
|
input?: Record<string, string>;
|
|
211
424
|
confirm?: Record<string, boolean | boolean[] | null>;
|
|
212
|
-
|
|
425
|
+
/** The array variant is what lets one script drive a repeated menu (#417):
|
|
426
|
+
* the review loop's consent menu can be answered "decline, then apply". */
|
|
427
|
+
select?: Record<string, string | number | null | (string | number | null)[]>;
|
|
213
428
|
}
|
|
214
429
|
|
|
215
430
|
export function scriptedUi(script: ScriptedAnswers = {}): WizardUi {
|
package/src/worker.ts
CHANGED
|
@@ -61,6 +61,17 @@ export const RESUME_PROMPT =
|
|
|
61
61
|
"re-check the outcome of your last action before repeating it, then keep working your original " +
|
|
62
62
|
"brief to the same report contract.";
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* What an orphan-resumed worker is told instead of re-sending its brief. One
|
|
66
|
+
* literal so tests can pin it (#536): the original brief is already in the
|
|
67
|
+
* resumed transcript, and re-sending it is how a resumed worker ends up
|
|
68
|
+
* re-doing the work it just did.
|
|
69
|
+
*/
|
|
70
|
+
export const ORPHAN_RESUME_PROMPT =
|
|
71
|
+
"Your previous process was interrupted by a daemon restart. Review the transcript's final state before acting, " +
|
|
72
|
+
"then continue exactly where you left off: re-check the outcome of your last action before repeating it, and keep " +
|
|
73
|
+
"working your original brief to the same report contract.";
|
|
74
|
+
|
|
64
75
|
export interface WorkerOpts {
|
|
65
76
|
brief: string;
|
|
66
77
|
cwd: string;
|
|
@@ -74,6 +85,15 @@ export interface WorkerOpts {
|
|
|
74
85
|
* location; either way the real path comes back on {@link WorkerResult}.
|
|
75
86
|
*/
|
|
76
87
|
sessionDir?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Continue the most recent transcript in `sessionDir` instead of opening a
|
|
90
|
+
* blank session (#536). The daemon sets it only for an orphan-clean
|
|
91
|
+
* continuation, after verifying the prior transcript and worktree still
|
|
92
|
+
* exist — the harness's own `continueRecent` is the backstop, and a silent
|
|
93
|
+
* fallback to a fresh session is surfaced by the `sessionFile` lineage
|
|
94
|
+
* compare at the dispatch site, never left quiet.
|
|
95
|
+
*/
|
|
96
|
+
resume?: boolean;
|
|
77
97
|
/**
|
|
78
98
|
* Model pattern for this session, in omp's model/role syntax. Omitted leaves
|
|
79
99
|
* the harness to pick, which is what an unconfigured project wants.
|
|
@@ -266,6 +286,7 @@ export async function runWorker(
|
|
|
266
286
|
cwd: o.cwd,
|
|
267
287
|
...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
|
|
268
288
|
...(o.model === undefined ? {} : { model: o.model }),
|
|
289
|
+
...(o.resume === undefined ? {} : { resume: o.resume }),
|
|
269
290
|
// Prevention half of #24: as a worker, structured file tools cannot leave
|
|
270
291
|
// this worktree, and no release grant can ever reach this session (#122).
|
|
271
292
|
role: "worker",
|