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/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
- /** Numbered single-choice list. Resolves the chosen option's label, or
40
- * `undefined` when dismissed. */
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
- const rl: Interface = createInterface({ input, output });
93
+ let rl: Interface = createInterface({ input, output });
82
94
  let closed = false;
83
- const close = (): void => {
84
- if (closed) return;
85
- closed = true;
86
- rl.close();
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 buffered: string[] = [];
101
- let waiting: ((value: string | undefined) => void) | undefined;
102
- let ended = false;
103
- const deliver = (value: string | undefined): boolean => {
104
- const resolve = waiting;
105
- if (resolve === undefined) return false;
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
- resolve(value);
108
- return true;
141
+ ended = false;
142
+ rl = createInterface({ input, output });
143
+ attach(rl);
109
144
  };
110
- rl.on("line", (text: string) => {
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
- output.write(`${title}\n`);
160
- options.forEach((o, i) => {
161
- output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` ${o.description}` : ""}\n`);
162
- });
163
- const initial = dialogOptions?.initialIndex ?? 0;
164
- const defaultLabel = options[initial]?.label ?? "";
165
- for (;;) {
166
- const answer = await line(
167
- `Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `,
168
- );
169
- if (answer === undefined) return undefined;
170
- const a = answer.trim();
171
- if (a === "") return defaultLabel; // a bare Enter re-affirms the current row
172
- const n = Number(a);
173
- if (Number.isInteger(n)) {
174
- const picked = options[n - 1];
175
- if (picked !== undefined) return picked.label;
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
- output.write(`Please enter a number between 1 and ${options.length}.\n`);
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
- select?: Record<string, string | number | null>;
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",