@staix/agent-hub 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -6
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +17 -3
  5. package/docs/agent-notes/budget.md +4 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +14 -9
  8. package/docs/agent-notes/tasks.md +9 -4
  9. package/docs/agent-notes/tests.md +3 -3
  10. package/docs/dogfood.md +9 -4
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +90 -18
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +9 -3
  15. package/docs/sdlc.md +17 -0
  16. package/docs/security.md +37 -10
  17. package/docs/specs/2026-09-19-agent-hub-design.md +74 -10
  18. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  19. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  20. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  21. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  22. package/docs/verified.json +29 -17
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +992 -312
  26. package/src/adapters/acp.ts +43 -7
  27. package/src/adapters/claude-channel.ts +26 -4
  28. package/src/adapters/claude-implementer.ts +243 -0
  29. package/src/adapters/claude-print-child.ts +90 -0
  30. package/src/adapters/claude-task-sessions.ts +80 -0
  31. package/src/adapters/compat.ts +4 -0
  32. package/src/adapters/conformance/enrollment.ts +2 -1
  33. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  34. package/src/adapters/definitions/builtin-metadata.ts +14 -2
  35. package/src/adapters/definitions/native-channel-state.ts +1 -1
  36. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  37. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  38. package/src/adapters/definitions/native-factories.ts +8 -0
  39. package/src/adapters/kimi-wire-usage.ts +303 -0
  40. package/src/adapters/registry-types.ts +7 -3
  41. package/src/adapters/registry.ts +14 -2
  42. package/src/adapters/reviewer-print.ts +30 -53
  43. package/src/cli/console-config.ts +123 -0
  44. package/src/cli/console-state.ts +28 -1
  45. package/src/cli/console.ts +25 -1
  46. package/src/cli/facts-hook.ts +9 -5
  47. package/src/cli/help.ts +4 -3
  48. package/src/cli/launch.ts +10 -4
  49. package/src/cli/main.ts +47 -9
  50. package/src/cli/output.ts +28 -0
  51. package/src/cli/reset.ts +1 -1
  52. package/src/cli/settings-edit.ts +89 -0
  53. package/src/cli/terminal-recovery.ts +1 -6
  54. package/src/hub/action-enforce.ts +82 -0
  55. package/src/hub/action-policy.ts +22 -10
  56. package/src/hub/board.ts +26 -2
  57. package/src/hub/budget.ts +5 -0
  58. package/src/hub/bus.ts +5 -5
  59. package/src/hub/claude-launch-proof.ts +33 -0
  60. package/src/hub/config-apply.ts +5 -0
  61. package/src/hub/config-edit-host.ts +173 -0
  62. package/src/hub/config-edit.ts +282 -0
  63. package/src/hub/config-tier.ts +89 -0
  64. package/src/hub/control-client.ts +24 -3
  65. package/src/hub/daemon.ts +368 -59
  66. package/src/hub/deliverable-files.ts +131 -0
  67. package/src/hub/deliverables.ts +125 -0
  68. package/src/hub/delivery-journal.ts +18 -2
  69. package/src/hub/events.ts +3 -1
  70. package/src/hub/hub-tools.ts +19 -3
  71. package/src/hub/instances.ts +7 -4
  72. package/src/hub/kimi-usage.ts +144 -0
  73. package/src/hub/manager.ts +1 -1
  74. package/src/hub/peers.ts +7 -0
  75. package/src/hub/permission-payload.ts +15 -0
  76. package/src/hub/routing.ts +118 -3
  77. package/src/hub/settings.ts +1 -1
  78. package/src/hub/tasks.ts +202 -15
  79. package/src/hub/token-window.ts +33 -0
  80. package/src/hub/usage-rollup.ts +16 -6
  81. package/src/hub/workflow.ts +16 -0
  82. package/src/ui/index.html +115 -1
@@ -1,6 +1,6 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { OwnedPrintChild, PrintChildCleanupError } from "./claude-print-child.ts";
2
3
  import { randomUUID } from "node:crypto";
3
- import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
4
4
  import { HUB, frame } from "../hub/envelope.ts";
5
5
  import { BasePeer } from "../hub/peers.ts";
6
6
  import type { Envelope } from "../hub/envelope.ts";
@@ -107,9 +107,6 @@ export interface ReviewerPrintOptions {
107
107
  bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
108
108
  }
109
109
 
110
- // ponytail: 256 KiB per NDJSON frame and 4 MiB stdout per review child. Raise only with measured native receipts.
111
- const MAX_REVIEW_FRAME_BYTES = 256 * 1024, MAX_REVIEW_OUTPUT_BYTES = 4 * 1024 * 1024;
112
- class GroupCleanupError extends Error {}
113
110
  type ReviewTarget = Omit<ReviewerFailure, "cause">;
114
111
  interface ReviewBatch { targets: (ReviewTarget | undefined)[]; next: number; failures: ReviewerFailure[] }
115
112
  const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
@@ -129,8 +126,7 @@ const reviewTask = (env: Envelope): number | undefined => {
129
126
  export class ReviewerPrintPeer extends BasePeer {
130
127
  readonly hubNative = true;
131
128
  private generation = 0;
132
- private child: ChildProcess | undefined;
133
- private childStop: ((child: ChildProcess) => Promise<void>) | undefined;
129
+ private child: OwnedPrintChild | undefined;
134
130
  private childCleanup: (() => Promise<void>) | undefined;
135
131
  private childCurrent: (() => boolean) | undefined;
136
132
  private reviewMcpClose: (() => void) | undefined;
@@ -242,7 +238,7 @@ export class ReviewerPrintPeer extends BasePeer {
242
238
  try { result = await this.review(env, task, generation); }
243
239
  catch (error) {
244
240
  if (generation !== this.generation) return;
245
- if (error instanceof GroupCleanupError) {
241
+ if (error instanceof PrintChildCleanupError) {
246
242
  // Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
247
243
  this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
248
244
  this.settle("needs_review", `review process group still running: ${error.message}`);
@@ -329,60 +325,41 @@ export class ReviewerPrintPeer extends BasePeer {
329
325
  let mcpClosed=false;
330
326
  const closeMcp=()=>{ if(mcpClosed)return; mcpClosed=true; mcp?.close(); if(this.reviewMcpClose===closeMcp)this.reviewMcpClose=undefined; };
331
327
  this.reviewMcpClose=closeMcp;
332
- let ownedChild: ChildProcess | undefined, releasing: Promise<void> | undefined;
328
+ let ownedChild: OwnedPrintChild | undefined, releasing: Promise<void> | undefined;
333
329
  let policyTimer: ReturnType<typeof setInterval> | undefined;
334
330
  const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
335
331
  try {
336
332
  launch = await this.prepare(randomUUID(), input, task, mcp?.config);
337
333
  if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation or admission changed before child spawn", launch.current?.() === false ? "binary" : "unknown");
338
334
  this.receipt = launch.isolation;
339
- const [cmd, ...prefix] = launch.command;
340
335
  let quota = false;
341
- return await new Promise<{ code: number | null; quota: boolean }>((resolve, reject) => {
342
- const child = spawn(cmd!, [...prefix, ...launch!.args], { cwd: launch!.cwd ?? this.opts.cwd, detached: true,
343
- env: peerChildEnv(this.id, launch!.envMode === "replace" ? launch!.env : { ...process.env, ...launch!.env }), stdio: ["pipe", "pipe", "ignore"] });
344
- // Owned before any await, so a stop that lands before "spawn" still finds and kills it.
345
- this.child = child;
346
- ownedChild = child;
347
- this.childStop = launch!.stop;
348
- this.childCleanup = release;
349
- this.childCurrent = launch!.watchCurrent ? launch!.current : undefined;
350
- child.once("error", error => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } reject(error); });
351
- child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
352
- child.once("spawn", () => {
353
- trackGroup(child);
354
- if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
336
+ const child = new OwnedPrintChild({ peer: this.id, command: launch.command, args: launch.args,
337
+ cwd: launch.cwd ?? this.opts.cwd, env: launch.env, envMode: launch.envMode, input,
338
+ current: () => generation === this.generation, stop: launch.stop,
339
+ onError: () => { if (this.child === child) this.child = undefined; },
340
+ onSpawn: () => {
355
341
  this.runningBinding={task,binding};
356
342
  this.reviewing = task; this.reviewSeq++;
357
343
  if (this.childCurrent) policyTimer = setInterval(() => { this.reviewToken(task); }, 100);
358
- child.stdin!.end(input);
359
- });
360
- let buffer = "", outputBytes = 0;
361
- child.stdout!.on("data", (chunk: Buffer) => {
362
- if (generation !== this.generation) return;
363
- outputBytes += chunk.length;
364
- if (outputBytes > MAX_REVIEW_OUTPUT_BYTES) { this.abortReview("review output limit exceeded"); return; }
365
- buffer += chunk.toString("utf8");
366
- for (let i; (i = buffer.indexOf("\n")) >= 0;) {
367
- const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
368
- if (Buffer.byteLength(line) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
369
- try {
370
- const event: unknown = JSON.parse(line);
371
- const usage = this.opts.backend.usage?.(event);
372
- if (usage) this.opts.onUsage?.(usage, task);
373
- if (this.opts.backend.failure?.(event) === "quota") quota = true;
374
- } catch { /* not a stream-json line */ }
375
- }
376
- if (Buffer.byteLength(buffer) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
377
- this.touch();
378
- });
379
- child.once("exit", code => {
380
- if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; } // the verdict authority ends with the leader
381
- // The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
382
- (launch!.stop ? launch!.stop(child) : stopOwnedProcess(child, { group: true })).then(() => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } resolve({ code, quota }); },
383
- (error: Error) => reject(new GroupCleanupError(error.message)));
384
- });
344
+ },
345
+ onFrame: event => {
346
+ const usage = this.opts.backend.usage?.(event);
347
+ if (usage) this.opts.onUsage?.(usage, task);
348
+ if (this.opts.backend.failure?.(event) === "quota") quota = true;
349
+ },
350
+ onActivity: () => this.touch(),
351
+ onLimit: () => this.abortReview("review output limit exceeded"),
352
+ onLeaderExit: () => {
353
+ if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; }
354
+ },
385
355
  });
356
+ // Owned before any await, so a stop that lands before "spawn" still finds and kills it.
357
+ this.child = ownedChild = child;
358
+ this.childCleanup = release;
359
+ this.childCurrent = launch.watchCurrent ? launch.current : undefined;
360
+ const code = await child.done;
361
+ if (this.child === child) this.child = undefined;
362
+ return { code, quota };
386
363
  } finally {
387
364
  closeMcp();
388
365
  if (policyTimer) clearInterval(policyTimer);
@@ -403,8 +380,8 @@ export class ReviewerPrintPeer extends BasePeer {
403
380
  this.runningBinding=undefined;
404
381
  const child = this.child;
405
382
  if (!child) return;
406
- await (this.childStop ? this.childStop(child) : stopOwnedProcess(child, { group: true }));
407
- if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
383
+ await child.stop();
384
+ if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
408
385
  }
409
386
  }
410
387
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * #689: the console's structural config form (Settings panel, key `c`). A pure reducer: it collects answers, builds the one request the
3
+ * hub validates (`config_edit_preview`, then `config_edit_apply`) and never judges a value itself. The loaders' reasons come back from
4
+ * the hub and are shown as they are. A tier 3 key is confirmed by typing its name, never by `y`.
5
+ */
6
+ export interface ConfigEditBody { file: "config.local.json" | "sdlc.toml"; revision: string; set: { path: string[]; value: unknown }[] }
7
+ export type ConfigFormKind = "instance" | "sdlc" | "other";
8
+ export interface ConfigForm {
9
+ kind: ConfigFormKind; token: number;
10
+ /** The instance ids the hub reports: an id in it changes leaf by leaf, a new id is added as one object. */
11
+ known?: readonly string[];
12
+ /** True until the person has chosen what to edit (1, 2 or 3). */
13
+ choosing?: boolean;
14
+ phase: "fields" | "preview" | "reason" | "confirm" | "typed";
15
+ step: number; answers: string[]; text: string;
16
+ edit?: ConfigEditBody; lines?: string[]; reason?: string;
17
+ /** The key a tier 3 edit must be confirmed with, from the hub's preview. */
18
+ typed?: string; warning?: string;
19
+ }
20
+ export type ConfigFormEffect =
21
+ | { type: "config_preview"; token: number; edit: ConfigEditBody }
22
+ | { type: "config_write"; edit: ConfigEditBody; reason: string; confirm?: string };
23
+ export interface ConfigFormStep { form?: ConfigForm; effect?: ConfigFormEffect; note?: string }
24
+
25
+ const FIELDS: Record<ConfigFormKind, readonly { name: string; ask: string }[]> = {
26
+ instance: [
27
+ { name: "id", ask: "instance id, <kind>-<name> (e.g. codex-review)" }, { name: "kind", ask: "kind (claude, codex, kimi, jcode, ...; empty for an instance that exists already)" },
28
+ { name: "roles", ask: "roles, comma list (planner, implementer, verifier, reviewer; empty leaves it)" },
29
+ { name: "start_mode", ask: "start_mode (headless; empty leaves it, clear removes it)" }, { name: "review_mode", ask: "review_mode (fresh; empty leaves it unset)" },
30
+ { name: "worktree", ask: "worktree path (empty leaves it unset)" }, { name: "permission_mode", ask: "permission_mode (ask, ask-when-needed, never-ask; empty leaves it unset)" },
31
+ { name: "readOnly", ask: "readOnly (true or false; empty leaves it unset)" },
32
+ ],
33
+ sdlc: [{ name: "mode", ask: "sdlc mode (off, advise, enforce; empty leaves it)" }, { name: "required_checks", ask: "required checks, comma list (empty leaves them)" }],
34
+ other: [{ name: "path", ask: "key path, dot separated (e.g. review.pool)" }, { name: "json", ask: "JSON value (empty removes the key)" }],
35
+ };
36
+ const a1x = (text: string | undefined): text is string => !!text;
37
+ const list = (text: string): string[] => text.split(",").map(item => item.trim()).filter(Boolean);
38
+
39
+ /** `1` an instance, `2` the lifecycle, `3` another structural key; anything else opens nothing. */
40
+ export function openConfigForm(choice: string, token: number, known: readonly string[] = []): ConfigForm | undefined {
41
+ const kind = ({ "1": "instance", "2": "sdlc", "3": "other" } as const)[choice as "1"];
42
+ return kind ? { kind, token, phase: "fields", step: 0, answers: [], text: "", ...(known.length ? { known } : {}) } : undefined;
43
+ }
44
+
45
+ export function configFormPrompt(form: ConfigForm): string {
46
+ if (form.choosing) return "edit: 1 instance 2 sdlc lifecycle 3 another key Esc cancel";
47
+ if (form.phase === "fields") return `${FIELDS[form.kind][form.step]!.ask}: ${form.text}`;
48
+ if (form.phase === "preview") return "checking the change with the hub's loaders; Esc cancels";
49
+ if (form.phase === "reason") return `reason: ${form.text}`;
50
+ if (form.phase === "typed") return `${form.warning ?? "this runs code with the daemon's rights"}; type ${form.typed} to confirm: ${form.text}`;
51
+ return `write ${form.edit?.file} (${form.reason})? y/N`;
52
+ }
53
+
54
+ /** The request the answers describe, or the first reason they cannot. */
55
+ function build(form: ConfigForm, revisions: Record<string, string>): { edit: ConfigEditBody } | { note: string } {
56
+ const [a0 = "", a1 = ""] = form.answers;
57
+ if (form.kind === "instance") {
58
+ const names = ["kind", "roles", "start_mode", "review_mode", "worktree", "permission_mode", "readOnly"] as const;
59
+ const typed = names.flatMap((name, index) => a1x(form.answers[index + 1]) ? [[name, form.answers[index + 1]!] as const] : []);
60
+ const ro = typed.find(([name]) => name === "readOnly");
61
+ if (ro && !["true", "false", "clear"].includes(ro[1])) return { note: "readOnly is true or false (or clear)" };
62
+ const convert = (name: string, text: string): unknown => name === "roles" ? list(text) : name === "readOnly" ? text === "true" : text;
63
+ const file = revisions["config.local.json"] ?? "absent";
64
+ if (form.known?.includes(a0)) {
65
+ // An existing instance changes leaf by leaf: a blank field keeps its value, and a tier 3 field it holds is never part of the request.
66
+ if (!typed.length) return { note: "type a field to change, or clear to remove one" };
67
+ return { edit: { file: "config.local.json", revision: file, set: typed.map(([name, text]) => ({ path: ["instances", a0, name], value: text === "clear" ? null : convert(name, text) })) } };
68
+ }
69
+ if (!a1) return { note: "the kind is required for a new instance" };
70
+ const value: Record<string, unknown> = {};
71
+ for (const [name, text] of typed) if (text !== "clear") value[name] = convert(name, text);
72
+ return { edit: { file: "config.local.json", revision: file, set: [{ path: ["instances", a0], value }] } };
73
+ }
74
+ if (form.kind === "sdlc") {
75
+ const set: ConfigEditBody["set"] = [];
76
+ if (a0) set.push({ path: ["mode"], value: a0 });
77
+ if (a1) set.push({ path: ["required_checks"], value: list(a1) });
78
+ if (!set.length) return { note: "give a mode or the required checks" };
79
+ return { edit: { file: "sdlc.toml", revision: revisions["sdlc.toml"] ?? "absent", set } };
80
+ }
81
+ let value: unknown = null;
82
+ if (a1.trim()) { try { value = JSON.parse(a1); } catch { return { note: "the value is not valid JSON (strings need their quotes)" }; } }
83
+ return { edit: { file: "config.local.json", revision: revisions["config.local.json"] ?? "absent", set: [{ path: a0.split("."), value }] } };
84
+ }
85
+
86
+ /** One key. A chunk longer than one key is pasted text: it is text in a text phase and never an answer to y/N. */
87
+ export function reduceConfigForm(form: ConfigForm, key: string, revisions: Record<string, string>): ConfigFormStep {
88
+ if (key === "\x1b") return { form: undefined };
89
+ if (form.choosing) {
90
+ const chosen = key.length === 1 ? openConfigForm(key, form.token, form.known) : undefined;
91
+ return { form: chosen ?? form };
92
+ }
93
+ if (form.phase === "preview") return { form };
94
+ if (form.phase === "confirm") {
95
+ if (key === "n") return { form: undefined };
96
+ if (key !== "y") return { form };
97
+ return { form: undefined, effect: { type: "config_write", edit: form.edit!, reason: form.reason! } };
98
+ }
99
+ const text = (current: ConfigForm): ConfigFormStep => {
100
+ if (key === "\x7f" || key === "\b") return { form: { ...current, text: Array.from(current.text).slice(0, -1).join("") } };
101
+ if (key === "\x15") return { form: { ...current, text: "" } };
102
+ if (key.startsWith("\x1b") || key === "\r" || key === "\n") return { form: current };
103
+ return { form: { ...current, text: (current.text + key.replace(/[\x00-\x1f\x7f]/g, "")).slice(0, 4000) } };
104
+ };
105
+ if (key !== "\r" && key !== "\n") return text(form);
106
+ const answer = form.text.trim();
107
+ if (form.phase === "reason") {
108
+ if (!answer || Buffer.byteLength(answer) > 400) return { form, note: "give a reason from 1 to 400 UTF-8 bytes" };
109
+ return { form: { ...form, reason: answer, text: "", phase: form.typed ? "typed" : "confirm" } };
110
+ }
111
+ if (form.phase === "typed") {
112
+ if (answer !== form.typed) return { form, note: `type exactly ${form.typed}` };
113
+ return { form: undefined, effect: { type: "config_write", edit: form.edit!, reason: form.reason!, confirm: form.typed } };
114
+ }
115
+ // phase "fields"
116
+ if (form.kind === "instance" && form.step === 0 && !answer) return { form, note: "an instance id is required" };
117
+ if (form.kind === "other" && form.step === 0 && (!answer || answer.startsWith(".") || answer.endsWith(".") || answer.includes(".."))) return { form, note: "give a key path such as review.pool" };
118
+ const answers = [...form.answers, answer];
119
+ if (form.step + 1 < FIELDS[form.kind].length) return { form: { ...form, answers, step: form.step + 1, text: "" } };
120
+ const built = build({ ...form, answers }, revisions);
121
+ if ("note" in built) return { form: { ...form, answers: answers.slice(0, -1), text: answer }, note: built.note };
122
+ return { form: { ...form, answers, text: "", phase: "preview", edit: built.edit }, effect: { type: "config_preview", token: form.token, edit: built.edit } };
123
+ }
@@ -9,6 +9,7 @@ import { sanitize } from "../hub/envelope.ts";
9
9
  import type { SettingRow, SettingValue } from "../hub/settings.ts";
10
10
  import { symbolFields, overlapFields } from "./console-symbols.ts";
11
11
  import { choicePrompt, reviewChoiceFor, reviewHint, reviewRefusal, verdictGuide, verdictInput, type ReviewChoice, type Verdict } from "./console-review.ts";
12
+ import { configFormPrompt, reduceConfigForm, type ConfigEditBody, type ConfigForm } from "./console-config.ts";
12
13
  import { USAGE_DROP, USAGE_HEAD, usageCells, usageDetail, usagePeers, usageTaskLines, type UsageState } from "./console-usage.ts";
13
14
 
14
15
  export interface Approval {
@@ -24,6 +25,8 @@ export interface SettingsEdit {
24
25
  text: string; value?: SettingValue; reason?: string; lines?: string[]; confirm?: string; undoDigest?: string;
25
26
  }
26
27
  export interface ConsoleState {
28
+ /** #689: the structural config form (Settings panel, key c) and the file revisions the hub last reported. */
29
+ configForm?: ConfigForm; configRevisions?: Record<string, string>; configInstances?: string[];
27
30
  settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
28
31
  settingsEdit?: SettingsEdit; settingsSequence?: number;
29
32
  pendingRestart?: { key: string; reason: string }[];
@@ -65,6 +68,7 @@ export interface ConsoleState {
65
68
  export type Detail = string | Record<string, unknown>;
66
69
  export type ConsoleEffect = { type: "exit" } | { type: "permit"; id: string; option?: string } |
67
70
  { type: "settings_preview"; token: number; key?: string; value?: SettingValue; undo: boolean } |
71
+ { type: "config_preview"; token: number; edit: ConfigEditBody } | { type: "config_write"; edit: ConfigEditBody; reason: string; confirm?: string } |
68
72
  { type: "settings_write"; key?: string; value?: SettingValue; undo: boolean; reason: string; confirm?: string; undoDigest?: string } |
69
73
  { type: "permission_default"; peer: string; confirmed: boolean } | { type: "command"; args: string[] } | { type: "show"; panel: number; id: string } | { type: "print"; text: string; kind: string; tone?: Tone } | { type: "keys" };
70
74
  export function initialConsoleState(panels = false): ConsoleState {
@@ -490,6 +494,13 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
490
494
  const done = () => ({ state: bound(s), effects });
491
495
  const answered = (id: string) => { s.approvals = s.approvals.filter(a => a.id !== id); if (s.approvalId === id) s.approvalId = undefined; };
492
496
  if (key === "\x03") { effects.push({ type: "exit" }); return done(); }
497
+ if (s.configForm) {
498
+ const step = reduceConfigForm(s.configForm, key, s.configRevisions ?? {});
499
+ s.configForm = step.form;
500
+ if (step.note) notify(s, step.note, now);
501
+ if (step.effect) effects.push(step.effect);
502
+ return done();
503
+ }
493
504
  if (s.settingsEdit) {
494
505
  const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
495
506
  if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
@@ -684,6 +695,14 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
684
695
  s.settingsEdit = { token, panel: 1, key: row.key, undo: false, phase: "value", text: String(wip.wipLimit) };
685
696
  return done();
686
697
  }
698
+ if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && key === "c") {
699
+ const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
700
+ s.configForm = { kind: "other", token, phase: "fields", step: 0, answers: [], text: "", choosing: true, ...(s.configInstances?.length ? { known: s.configInstances } : {}) };
701
+ return done();
702
+ }
703
+ if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && key === "R" && s.pendingRestart?.length) {
704
+ s.input = "restart"; s.editing = true; return done();
705
+ }
687
706
  if (s.mode === "panels" && s.panel === 6 && s.detail === undefined && (key === "e" || key === "u")) {
688
707
  const row = selectedPanelRow(s);
689
708
  if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
@@ -962,8 +981,9 @@ function taskHints(row: any): string[] {
962
981
  const settingValueText = (value: unknown): string => value === null || value === undefined ? "inherit" : Array.isArray(value) ? value.join(",") : String(value);
963
982
  function panelContext(s: ConsoleState, row: any): string[] {
964
983
  if (s.panel === 3) return [...(row ? taskHints(row) : []), ...(row && s.features?.includes("task_cancel") ? ["c cancel"] : []), s.backlogView ? "b open" : "b backlog", "n add"];
984
+ // The structural config form and the restart prefill need no selected row (#689).
985
+ if (s.panel === 6) return [...(row ? ["e edit"] : []), "c config", ...(s.settingsUndo ? ["u undo"] : []), ...(s.pendingRestart?.length ? ["R restart"] : [])];
965
986
  if (!row) return [];
966
- if (s.panel === 6) return ["e edit", ...(s.settingsUndo ? ["u undo"] : [])];
967
987
  if (s.panel === 1) return ["p pause", "r resume", "m permission mode", "w WIP limit"];
968
988
  if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"]; // D (the peer's whole backlog) is in the ? key table: it must not push j/k off an 80-column footer
969
989
  return [];
@@ -1044,6 +1064,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1044
1064
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
1045
1065
  if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
1046
1066
  if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
1067
+ if (s.configForm) prompt = configFormPrompt(s.configForm);
1047
1068
  if (s.settingsEdit) {
1048
1069
  const edit = s.settingsEdit;
1049
1070
  prompt = edit.phase === "value" ? `value for ${edit.key} (inherit removes): ${edit.text}` : edit.phase === "preview" ? "checking setting preview; Esc cancels" : edit.phase === "reason" ? `reason: ${edit.text}` : `${edit.undo ? "undo last setting" : `set ${edit.key}`} (${edit.reason})? y/N`;
@@ -1094,6 +1115,12 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
1094
1115
  const height = bodyHeight - noticeLines.length;
1095
1116
  const lines: Span[][] = [];
1096
1117
  if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer, undefined, createProjectAdapterCatalog())], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
1118
+ else if (s.configForm && !s.configForm.choosing && (s.configForm.lines?.length || s.configForm.phase !== "fields")) {
1119
+ // #689: what the hub's loaders said about the request, as the person will confirm it: the diff, then the decision.
1120
+ const form = s.configForm;
1121
+ lines.push(...fieldLines([["file", form.edit?.file ?? "-"], ["diff", (form.lines ?? ["checking with the hub's loaders..."]).join("\n")], ...(form.warning ? [["warning", form.warning] as [string, unknown]] : []),
1122
+ ["decision", form.typed ? `Esc cancels; type ${form.typed} to confirm` : "Esc cancels; saving needs a reason and explicit y"]], columns, now).flat());
1123
+ }
1097
1124
  else if (s.settingsEdit) {
1098
1125
  const edit = s.settingsEdit;
1099
1126
  const row = s.settingsRows?.find(row => row.key === edit.key);
@@ -102,7 +102,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
102
102
  let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
103
103
  let priorStreamFooterStart: number | undefined;
104
104
  let lastExpandedNotice = "";
105
- const notice = (text: string) => notify(state, text, Date.now());
105
+ const notice = (text: string, tone: Tone = "failure") => notify(state, text, Date.now(), tone);
106
106
  const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
107
107
  const streamLines = (event: ConsoleEvent) => terminalText(event.text).replace(/\t/g, " ").split("\n").flatMap((text, index) => {
108
108
  // Tabs are flattened before measuring, as wrap() does; commands already wrap cells at their own offsets.
@@ -243,6 +243,10 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
243
243
  if (settings.ok && Array.isArray(settings.rows)) {
244
244
  state.settingsRows = settings.rows; state.settingsKnown = true; state.settingsProblem = undefined;
245
245
  state.settingsUndo = typeof settings.undo === "string" ? settings.undo : undefined;
246
+ const revisions = (settings.configEdit as { revisions?: unknown } | undefined)?.revisions;
247
+ const known = (settings.configEdit as { known?: unknown } | undefined)?.known;
248
+ state.configInstances = Array.isArray(known) ? known.map(String).filter(Boolean) : [];
249
+ if (revisions && typeof revisions === "object") state.configRevisions = Object.fromEntries(Object.entries(revisions).filter(([, value]) => typeof value === "string")) as Record<string, string>;
246
250
  state.pendingRestart = Array.isArray(settings.pendingRestart) ? settings.pendingRestart : [];
247
251
  state.retainedConfig = Array.isArray(settings.retainedConfig) ? settings.retainedConfig : [];
248
252
  } else state.settingsProblem = String(settings.error ?? "settings are unavailable");
@@ -282,6 +286,26 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
282
286
  } else state.settingsEdit = { ...edit, ...(action.undo ? { key: response.key, undoDigest: response.undoDigest } : {}), phase: "reason", text: "", lines: [...(Array.isArray(response.lines) ? response.lines.map(String) : []), ...(Array.isArray(response.tasks) ? response.tasks.flatMap((task: any) => [String(task.before?.join("\n") ?? ""), String(task.after?.join("\n") ?? "")]) : []), ...(response.hidden ? [`${response.hidden} more open tasks are not shown`] : [])], ...(typeof response.confirm === "string" ? { confirm: response.confirm } : {}) };
283
287
  draw(); return;
284
288
  }
289
+ if (action.type === "config_preview") {
290
+ const response = await client.request({ t: "config_edit_preview", edit: action.edit }, 5000);
291
+ const form = state.configForm;
292
+ // A reply for a form that was cancelled or replaced is dropped: it never opens a confirmation of its own.
293
+ if (!active || state.mode !== "panels" || !form || form.token !== action.token) return; // every form has its own token and one outstanding preview
294
+ if (response.ok === false) { state.configForm = undefined; notice(String(response.error ?? "config preview refused")); }
295
+ else state.configForm = { ...form, phase: "reason", text: "", lines: (Array.isArray(response.lines) ? response.lines : []).map(String).slice(0, 40),
296
+ ...(typeof response.confirm === "string" ? { typed: response.confirm, warning: typeof response.warning === "string" ? response.warning : undefined } : {}) };
297
+ draw(); return;
298
+ }
299
+ if (action.type === "config_write") {
300
+ const result = await client.request({ t: "config_edit_apply", edit: action.edit, reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
301
+ if (!active) return;
302
+ if (result.ok === false) notice(String(result.error ?? "config write refused"));
303
+ else {
304
+ const text = [String(result.text ?? "config saved"), ...(Array.isArray(result.restartPending) && result.restartPending.length ? [`restart pending: ${result.restartPending.join(", ")}`] : [])].join("; ");
305
+ stream({ text, kind: "settings" }); notice(text, "info");
306
+ }
307
+ await refresh(); draw(); return;
308
+ }
285
309
  if (action.type === "settings_write") {
286
310
  const result = await client.request({ t: action.undo ? "settings_undo" : "settings_set", key: action.key, ...(!action.undo ? { value: action.value } : { undoDigest: action.undoDigest }), reason: action.reason, ...(action.confirm ? { confirm: action.confirm } : {}) }, 35_000);
287
311
  if (!active) return;
@@ -65,7 +65,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
65
65
  const port = definitionOf(peer)?.hooks?.factsHook;
66
66
  const payload: unknown = JSON.parse(stdin);
67
67
  if (!port?.decode(nativeEvent, payload)) return undefined;
68
- const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
68
+ const hub = await ControlClient.connect(stateDir, { role: "tools", peer, ...(nativeHookIdentity(stateDir, peer).nativeLaunchId ? { launchId: nativeHookIdentity(stateDir, peer).nativeLaunchId } : {}) }, timeoutMs);
69
69
  try {
70
70
  const reply = await hub.request({ t: "native_facts", event: nativeEvent, payload, ...nativeHookIdentity(stateDir, peer) }, timeoutMs);
71
71
  const output = reply.ok ? port.encode(nativeEvent, typeof reply.text === "string" ? reply.text : undefined) : undefined;
@@ -74,7 +74,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
74
74
  }
75
75
  const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
76
76
  const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
77
- const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
77
+ const hub = await ControlClient.connect(stateDir, { role: "tools", peer, ...(nativeHookIdentity(stateDir, peer).nativeLaunchId ? { launchId: nativeHookIdentity(stateDir, peer).nativeLaunchId } : {}) }, timeoutMs);
78
78
  try {
79
79
  const res = await hub.request({
80
80
  t: "facts",
@@ -90,10 +90,14 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
90
90
  if (phase !== "pre" || !res?.ok) return undefined;
91
91
  const permission = isPermissionMode(res.permission) ? res.permission : "ask";
92
92
  const fileTool = projectFileTool(input.tool_name, input.tool_input, res.projectRoot, res.protectInstructionFiles === true);
93
- const allow = input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
93
+ // Always-ask (#636): the hub classed this call (publish, external-write, destroy, install). `ask` wins over every allow, never-ask included.
94
+ // A present value that is not an empty list asks, even when malformed: the hook never turns an unreadable classification into an allow.
95
+ const classed = res.actionClasses !== undefined && !(Array.isArray(res.actionClasses) && res.actionClasses.length === 0);
96
+ const classNames = Array.isArray(res.actionClasses) ? res.actionClasses.filter((key: unknown): key is string => typeof key === "string" && /^[a-z-]{1,32}$/.test(key)) : [];
97
+ const allow = !classed && input.hook_event_name === "PreToolUse" && (permission === "never-ask" || permission === "ask-when-needed" && fileTool);
94
98
  const text = typeof res.text === "string" && res.text ? res.text : undefined;
95
- if (!allow && !text) return undefined;
96
- return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(allow ? { permissionDecision: "allow" } : {}) } });
99
+ if (!allow && !classed && !text) return undefined;
100
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", ...(text ? { additionalContext: text } : {}), ...(classed ? { permissionDecision: "ask", permissionDecisionReason: `agent-hub always-ask${classNames.length ? `: ${classNames.join(", ")}` : ""}` } : allow ? { permissionDecision: "allow" } : {}) } });
97
101
  } finally {
98
102
  hub.close();
99
103
  }
package/src/cli/help.ts CHANGED
@@ -73,6 +73,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
73
73
  ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "human only: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
74
74
  ["ahub settings [list] [--json] | get <key> [--json]", "human only: the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
75
75
  ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "human only: change or reload validated settings, written to .agenthub/config.local.json or routing.local.toml (never a tracked file); --preview shows the effect and saves nothing; undo puts back the last write"],
76
+ ["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
76
77
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
77
78
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
78
79
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
@@ -83,7 +84,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
83
84
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
84
85
  ["ahub board [state | --ready] [--columns] [--release <name> | --release-id <id>] [--json] [--full] [--color=auto|always|never]", "the task board, one line per task at a terminal width; --full: whole titles and SDLC advice; --ready: proposed tasks with nothing left to wait for"],
85
86
  ["ahub task propose [--class <c> | <class>] <title...> [--owner <peer>] [--path <p>]... [--after <id>]... [--urgent] [--backlog] [--detail <text>]", "put a task on the board; a first word that names a class is taken as the class; while a task named by --after is not approved yet, --owner reserves the task for that peer, offered to it first once it is ready; otherwise --owner assigns it now"],
86
- ["ahub backlog list | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits"],
87
+ ["ahub backlog list [--json] [--full] [--color=auto|always|never] | add <class> <title...> | plan <id> <revision> <fields-json> <reason...> | pin <id> <revision> true|false <reason...> | undo <id> <revision>", "person only: stage and plan work, preserve pins/frozen scope and refuse stale edits; list is a table by rank (--json: the raw listing)"],
87
88
  ["ahub release new <name> <goal> <order> | list | show <name> | plan <name> <revision> <dates-json> | activate|freeze|ship|drop <name> <revision>", "person only: planned UTC millisecond dates and phased scope; ship records a time after approval, never tags or publishes"],
88
89
  ["ahub backlog split <id> <revision> <parts-json> <reason...>", "person only: stage2..8 child prerequisites; original task retains integration, undo refuses used or changed children"],
89
90
  ["ahub backlog pull-ready <id> <revision> <reason...>", "person only: reserve an eligible WIP slot before routing; pending unowned work can be undone"],
@@ -96,7 +97,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
96
97
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned [--created <time>]", "your later verdict on an approved task, kept in the research records (research must be on)"],
97
98
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]... [--head <sha>] [--findings <json>]", "give a review verdict on a task; the person can do the same in ahub console: Tasks, select the task, r, then a (approve) or c (request changes, with a note)"],
98
99
  ["ahub review brief <id>", "print the review brief of a task's commit: reviewable and excluded files, rules, coverage to account for"],
99
- ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],
100
+ ["ahub route explain <id>", "why a task went where it went, its reserved owner and the planner walk included"],
100
101
  ["ahub route explain --class <c> <title...>", "what would happen to such a task now"],
101
102
  ["ahub ask [--remember] <question...>", "answer from the task board, shared memory and this run's log, with the ids it rests on"],
102
103
  ["ahub remember <text...>", "save a note to the memory all agents share"],
@@ -143,7 +144,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
143
144
  ]],
144
145
  ["Dashboard", [
145
146
  ["ahub ui [--no-open]", "open the local dashboard with configured attention character (or print its one-time link)"],
146
- ["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing and start settings"],
147
+ ["ahub ui --settings [--no-open]", "human only: open it with a settings session, which for 15 minutes may change permission modes, routing, start settings and structural config"],
147
148
  ["ahub ui --all [--no-open]", "open the unified project dashboard"],
148
149
  ["ahub ui --all --stop", "stop only the dashboard manager"],
149
150
  ["ahub manager", "internal: the dashboard manager process that ahub ui --all starts"],
package/src/cli/launch.ts CHANGED
@@ -30,10 +30,14 @@ export function instanceLaunchArguments(command: string, raw: readonly string[],
30
30
  const declaration = instances && Object.hasOwn(instances, id) ? instances[id] : undefined;
31
31
  if (!declaration || declaration.kind !== command || !definitionOf(id, instances)) throw new Error("--as must name a declared instance of this command's native kind");
32
32
  // #223: a headless reviewer is started by the hub, never from this terminal.
33
- hubStarted = definitionOf(id, instances) === definitionOf(command)?.reviewerVariant;
33
+ const runtime = definitionOf(id, instances), base = definitionOf(command);
34
+ hubStarted = runtime === base?.reviewerVariant || runtime === base?.headlessVariant;
34
35
  instance = id;
35
36
  }
36
- if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run")) throw new Error(`${instance} is a headless reviewer the hub runs per review: ahub ${command} --as ${instance} takes no launch arguments`);
37
+ if (hubStarted && args.some(arg => arg !== "--print-command" && arg !== "--dry-run")) {
38
+ const writer = definitionOf(instance!, instances) === definitionOf(command)?.headlessVariant;
39
+ throw new Error(`${instance} is a headless ${writer ? "implementer the hub runs per task" : "reviewer the hub runs per review"}: ahub ${command} --as ${instance} takes no launch arguments`);
40
+ }
37
41
  return { ...(instance ? { instance } : {}), args, ...(hubStarted ? { hubStarted: true as const } : {}) };
38
42
  }
39
43
 
@@ -108,6 +112,8 @@ export interface ClaudeLaunchRecord extends Record<string, unknown> {
108
112
  instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
109
113
  /** A claude-<name> instance (#450 I3); absent for the builtin claude peer, whose file keeps its original name. */
110
114
  peer?: string;
115
+ /** Validated terminal instance root; absent for legacy/canonical launches. */
116
+ executionCwd?: string;
111
117
  }
112
118
 
113
119
  export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
@@ -172,10 +178,10 @@ export function recordClaudeLaunch(stateDir: string, record: ClaudeLaunchRecord)
172
178
 
173
179
  function sameClaudeLaunch(left: ClaudeLaunchRecord, right: ClaudeLaunchRecord): boolean {
174
180
  return left.instanceId === right.instanceId && left.launchId === right.launchId && left.settingsFile === right.settingsFile
175
- && left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature;
181
+ && left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature && left.executionCwd === right.executionCwd;
176
182
  }
177
183
  function nativeAttestationPath(stateDir: string, launch: ClaudeLaunchRecord): string {
178
- const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature])).digest("hex");
184
+ const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature, ...(launch.executionCwd ? [launch.executionCwd] : [])])).digest("hex");
179
185
  return join(stateDir, `claude-native-${hash}.json`);
180
186
  }
181
187
  function privateClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): { file: string; stat: ReturnType<typeof lstatSync>; record: ClaudeLaunchRecord } | undefined {