@webpresso/agent-kit 3.1.28 → 3.1.30

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 (98) hide show
  1. package/catalog/AGENTS.md.tpl +4 -4
  2. package/catalog/agent/rules/ci-cost-local-first.md +139 -0
  3. package/catalog/agent/rules/ci-test-perf.md +5 -1
  4. package/catalog/agent/rules/cmd-execution.md +9 -0
  5. package/catalog/agent/rules/pre-implementation.md +2 -1
  6. package/catalog/agent/rules/test-scan-perf.md +80 -0
  7. package/catalog/agent/rules/workflow-skills-routing.md +4 -2
  8. package/catalog/agent/skills/fix/SKILL.md +3 -0
  9. package/catalog/agent/skills/verify/SKILL.md +18 -0
  10. package/dist/esm/audit/blueprint-pr-coverage.js +68 -7
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +3 -0
  13. package/dist/esm/audit/roadmap-links.js +6 -0
  14. package/dist/esm/audit/test-scan-perf.d.ts +18 -0
  15. package/dist/esm/audit/test-scan-perf.js +275 -0
  16. package/dist/esm/blueprint/lifecycle/engine.d.ts +9 -0
  17. package/dist/esm/blueprint/lifecycle/engine.js +53 -6
  18. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +93 -12
  20. package/dist/esm/blueprint/markdown/helpers.js +6 -2
  21. package/dist/esm/ci/act-replay.js +110 -7
  22. package/dist/esm/ci/act-runner.d.ts +4 -0
  23. package/dist/esm/ci/act-runner.js +85 -26
  24. package/dist/esm/ci/act-worktree-git.d.ts +11 -0
  25. package/dist/esm/ci/act-worktree-git.js +219 -0
  26. package/dist/esm/cli/commands/audit.js +3 -0
  27. package/dist/esm/cli/commands/blueprint/mutations.js +35 -18
  28. package/dist/esm/cli/commands/blueprint/router.js +27 -2
  29. package/dist/esm/cli/commands/ci.d.ts +8 -1
  30. package/dist/esm/cli/commands/ci.js +80 -21
  31. package/dist/esm/cli/commands/dash/keymap.js +1 -0
  32. package/dist/esm/cli/commands/dash/launch-preferences.d.ts +15 -0
  33. package/dist/esm/cli/commands/dash/launch-preferences.js +46 -17
  34. package/dist/esm/cli/commands/dash/plan-once-label.d.ts +7 -0
  35. package/dist/esm/cli/commands/dash/plan-once-label.js +5 -0
  36. package/dist/esm/cli/commands/dash/plan-once-membership.d.ts +67 -0
  37. package/dist/esm/cli/commands/dash/plan-once-membership.js +151 -0
  38. package/dist/esm/cli/commands/dash/plan-once-merge.d.ts +143 -0
  39. package/dist/esm/cli/commands/dash/plan-once-merge.js +238 -0
  40. package/dist/esm/cli/commands/dash/plan-once.d.ts +22 -2
  41. package/dist/esm/cli/commands/dash/plan-once.js +41 -4
  42. package/dist/esm/cli/commands/dash/tui/attention-hud.d.ts +9 -0
  43. package/dist/esm/cli/commands/dash/tui/attention-hud.js +9 -0
  44. package/dist/esm/cli/commands/dash/tui/chat-bridge.d.ts +41 -0
  45. package/dist/esm/cli/commands/dash/tui/chat-bridge.js +73 -0
  46. package/dist/esm/cli/commands/dash/tui/chat-mode.d.ts +47 -0
  47. package/dist/esm/cli/commands/dash/tui/chat-mode.js +98 -0
  48. package/dist/esm/cli/commands/dash/tui/frame.d.ts +3 -0
  49. package/dist/esm/cli/commands/dash/tui/frame.js +3 -1
  50. package/dist/esm/cli/commands/dash/tui/palette.d.ts +1 -1
  51. package/dist/esm/cli/commands/dash/tui/palette.js +14 -0
  52. package/dist/esm/cli/commands/dash/tui/runtime.js +239 -4
  53. package/dist/esm/cli/commands/dash/tui/state.d.ts +41 -0
  54. package/dist/esm/cli/commands/dash/tui/state.js +257 -4
  55. package/dist/esm/cli/commands/format.js +0 -2
  56. package/dist/esm/cli/commands/init/config.d.ts +0 -1
  57. package/dist/esm/cli/commands/init/config.js +1 -4
  58. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.d.ts +5 -6
  59. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.js +6 -13
  60. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.d.ts +0 -2
  61. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.js +0 -2
  62. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.d.ts +6 -3
  63. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.js +39 -26
  64. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +61 -23
  65. package/dist/esm/cli/commands/logs.d.ts +15 -0
  66. package/dist/esm/cli/commands/logs.js +192 -3
  67. package/dist/esm/cli/commands/quality-log-store.d.ts +1 -0
  68. package/dist/esm/cli/commands/quality-log-store.js +187 -51
  69. package/dist/esm/cli/commands/review.js +5 -8
  70. package/dist/esm/cli/commands/secrets.d.ts +1 -0
  71. package/dist/esm/cli/commands/secrets.js +4 -1
  72. package/dist/esm/cli/direct-provider-launch.js +25 -1
  73. package/dist/esm/hooks/doctor.d.ts +15 -0
  74. package/dist/esm/hooks/doctor.js +56 -0
  75. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +4 -4
  76. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.d.ts +0 -1
  77. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.js +8 -5
  78. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.d.ts +9 -7
  79. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +126 -14
  80. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  81. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  82. package/dist/esm/mcp/tools/audit.js +10 -0
  83. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  84. package/dist/esm/mcp/tools/ci-act.js +9 -11
  85. package/dist/esm/paths/state-root.js +3 -0
  86. package/dist/esm/review/authority.js +17 -2
  87. package/dist/esm/review/availability.js +18 -5
  88. package/dist/esm/review/subject.d.ts +15 -0
  89. package/dist/esm/review/subject.js +105 -10
  90. package/dist/esm/runtime/executor.d.ts +1 -0
  91. package/dist/esm/runtime/executor.js +2 -1
  92. package/dist/esm/secret-gate/runner.d.ts +1 -0
  93. package/dist/esm/secret-gate/runner.js +4 -1
  94. package/dist/esm/worktrees/current.d.ts +1 -0
  95. package/dist/esm/worktrees/current.js +1 -0
  96. package/dist/esm/worktrees/project-agent-surfaces.d.ts +1 -0
  97. package/dist/esm/worktrees/project-agent-surfaces.js +2 -1
  98. package/package.json +14 -14
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Merge-set projection: membership (expected hosts) + registry runs → rollup.
3
+ * Pure given membership + run observations — no second run store.
4
+ *
5
+ * Operator surface: summary + per-host matrix only. Git/PR auto-merge is out of
6
+ * scope (no silent git merge of host work).
7
+ */
8
+ import { z } from "zod";
9
+ import { projectFanOutAttention, } from "#cli/commands/dash/plan-once-attention.js";
10
+ import { readPlanMembership, listPlanMemberships, } from "#cli/commands/dash/plan-once-membership.js";
11
+ import { readDashRuns } from "#cli/commands/dash/run-registry.js";
12
+ export const MERGE_ROLLUPS = ["all-ok", "partial", "all-failed", "incomplete"];
13
+ /** Rollups that should occupy the attention strip (all-ok does not). */
14
+ export const MERGE_ATTENTION_ROLLUPS = ["incomplete", "partial", "all-failed"];
15
+ export const mergeRollupSchema = z.enum(MERGE_ROLLUPS);
16
+ export const mergeHostStatusSchema = z.enum(["ok", "failed", "missing", "unknown"]);
17
+ export const mergeHostObservationSchema = z
18
+ .object({
19
+ host: z.string().min(1),
20
+ workdir: z.string().min(1),
21
+ expectedLabel: z.string().min(1),
22
+ runId: z.string().nullable(),
23
+ state: z.string().nullable(),
24
+ error: z.string().nullable(),
25
+ status: mergeHostStatusSchema,
26
+ })
27
+ .strict();
28
+ /** Serializable subset of merge projection (attention omitted). */
29
+ export const mergeSetCoreSchema = z
30
+ .object({
31
+ planId: z.string().uuid(),
32
+ expectedCount: z.number().int().nonnegative(),
33
+ observedCount: z.number().int().nonnegative(),
34
+ missingCount: z.number().int().nonnegative(),
35
+ failedCount: z.number().int().nonnegative(),
36
+ okCount: z.number().int().nonnegative(),
37
+ rollup: mergeRollupSchema,
38
+ hosts: z.array(mergeHostObservationSchema),
39
+ })
40
+ .strict();
41
+ function matchRun(expected, runs) {
42
+ // Exact label only — substring/prefix matching can join the wrong host when
43
+ // labels share prefixes and would undercount missing hosts as observed.
44
+ const matches = runs.filter((r) => r.label === expected.label);
45
+ if (matches.length === 0)
46
+ return undefined;
47
+ if (matches.length === 1)
48
+ return matches[0];
49
+ // Prefer newest when re-apply leaves multiple rows with the same label.
50
+ return matches.reduce((best, row) => {
51
+ const bestTs = best.updatedAt ?? "";
52
+ const rowTs = row.updatedAt ?? "";
53
+ return rowTs >= bestTs ? row : best;
54
+ });
55
+ }
56
+ function hostStatus(run) {
57
+ if (!run)
58
+ return "missing";
59
+ const state = run.state.toLowerCase();
60
+ if (state === "failed" || state === "error")
61
+ return "failed";
62
+ if (state === "unknown" || state === "stale")
63
+ return "unknown";
64
+ if (state === "running" ||
65
+ state === "starting" ||
66
+ state === "completed" ||
67
+ state === "idle" ||
68
+ state === "waiting" ||
69
+ state === "detached" ||
70
+ state === "stopped" ||
71
+ state === "archived") {
72
+ return "ok";
73
+ }
74
+ // Unrecognized durable state: fail-closed as unknown (not green)
75
+ return "unknown";
76
+ }
77
+ /**
78
+ * Project merge set from durable membership + registry-visible runs.
79
+ * Missing expected host → incomplete (never all-ok).
80
+ */
81
+ export function projectMergeSet(input) {
82
+ const hosts = input.membership.hosts.map((expected) => {
83
+ const run = matchRun(expected, input.runs);
84
+ const status = hostStatus(run);
85
+ return {
86
+ host: expected.host,
87
+ workdir: expected.workdir,
88
+ expectedLabel: expected.label,
89
+ runId: run?.id ?? null,
90
+ state: run?.state ?? null,
91
+ error: run?.error ?? null,
92
+ status,
93
+ };
94
+ });
95
+ const missingCount = hosts.filter((h) => h.status === "missing").length;
96
+ const failedCount = hosts.filter((h) => h.status === "failed" || h.status === "unknown").length;
97
+ const okCount = hosts.filter((h) => h.status === "ok").length;
98
+ const observedCount = hosts.filter((h) => h.runId !== null).length;
99
+ let rollup;
100
+ if (missingCount > 0) {
101
+ rollup = "incomplete";
102
+ }
103
+ else if (failedCount === hosts.length && hosts.length > 0) {
104
+ rollup = "all-failed";
105
+ }
106
+ else if (failedCount > 0) {
107
+ rollup = "partial";
108
+ }
109
+ else {
110
+ rollup = "all-ok";
111
+ }
112
+ const attentionRuns = hosts
113
+ .filter((h) => h.runId)
114
+ .map((h) => ({
115
+ runId: h.runId,
116
+ provider: h.host,
117
+ state: h.state ?? "unknown",
118
+ error: h.error,
119
+ }));
120
+ const attention = projectFanOutAttention({
121
+ planId: input.membership.planId,
122
+ runs: attentionRuns,
123
+ now: input.now,
124
+ });
125
+ return {
126
+ planId: input.membership.planId,
127
+ expectedCount: hosts.length,
128
+ observedCount,
129
+ missingCount,
130
+ failedCount,
131
+ okCount,
132
+ rollup,
133
+ hosts,
134
+ attention,
135
+ };
136
+ }
137
+ function toMergeRunRows(runs) {
138
+ return runs.map((r) => ({
139
+ id: r.id,
140
+ provider: r.provider,
141
+ state: r.state,
142
+ error: r.error,
143
+ label: r.label,
144
+ updatedAt: r.updatedAt,
145
+ }));
146
+ }
147
+ /**
148
+ * planId → load durable membership + readDashRuns → merge projection.
149
+ * Uses run-registry only (no second run store). Missing membership fails closed.
150
+ * Includes archived runs so archived hosts do not look missing after cleanup.
151
+ */
152
+ export async function projectMergeSetForPlan(planId, options = {}) {
153
+ const membership = await readPlanMembership(planId, { cwd: options.cwd });
154
+ if (!membership) {
155
+ return { ok: false, error: "membership-missing", planId };
156
+ }
157
+ const { runs } = await readDashRuns({ cwd: options.cwd, includeArchived: true });
158
+ const projection = projectMergeSet({
159
+ membership,
160
+ runs: toMergeRunRows(runs),
161
+ now: options.now,
162
+ });
163
+ return { ok: true, projection };
164
+ }
165
+ /** Project merge sets for every durable membership under the state root. */
166
+ export async function projectAllMergeSets(options = {}) {
167
+ const memberships = await listPlanMemberships({ cwd: options.cwd });
168
+ if (memberships.length === 0)
169
+ return [];
170
+ // includeArchived: archived plan-once hosts are still N-of-N observations.
171
+ const { runs } = await readDashRuns({ cwd: options.cwd, includeArchived: true });
172
+ const rows = toMergeRunRows(runs);
173
+ return memberships.map((membership) => projectMergeSet({ membership, runs: rows, now: options.now }));
174
+ }
175
+ /** One-line operator rollup for HUD/attention strip. */
176
+ export function formatMergeSetSummary(projection) {
177
+ const short = projection.planId.slice(0, 8);
178
+ const base = `merge ${short} · ${projection.rollup} · ${projection.okCount}/${projection.expectedCount} ok`;
179
+ // "all-ok" means every expected host is observed and non-failed — not that
180
+ // every host is terminal (running/starting still map to ok for N-of-N).
181
+ if (projection.rollup === "all-ok")
182
+ return `${base} · all observed · none failed`;
183
+ if (projection.missingCount > 0) {
184
+ const missing = projection.hosts
185
+ .filter((h) => h.status === "missing")
186
+ .map((h) => h.host)
187
+ .join(",");
188
+ return `${base} · missing ${missing}`;
189
+ }
190
+ if (projection.failedCount > 0) {
191
+ const failed = projection.hosts
192
+ .filter((h) => h.status === "failed" || h.status === "unknown")
193
+ .map((h) => `${h.host}:${h.error ?? h.state ?? h.status}`)
194
+ .join("; ");
195
+ return `${base} · ${failed}`;
196
+ }
197
+ return base;
198
+ }
199
+ /** Per-host matrix lines for operator surface. */
200
+ export function formatMergeSetMatrix(projection) {
201
+ return projection.hosts.map((h) => {
202
+ const state = h.state ?? "—";
203
+ const err = h.error ? ` · ${h.error}` : "";
204
+ return `${h.host} · ${h.status} · ${state}${err}`;
205
+ });
206
+ }
207
+ /** Operator-visible merge surface (HUD/browser). No silent git merge. */
208
+ export function formatMergeOperatorSurface(projection) {
209
+ return {
210
+ summary: formatMergeSetSummary(projection),
211
+ matrix: formatMergeSetMatrix(projection),
212
+ scopeNote: "git/PR auto-merge out of scope — status/summary only",
213
+ };
214
+ }
215
+ const ROLLUP_SEVERITY = {
216
+ incomplete: 4,
217
+ "all-failed": 3,
218
+ partial: 2,
219
+ "all-ok": 1,
220
+ };
221
+ /** Pick the highest-severity merge projection for a single attention line. */
222
+ export function pickPrimaryMergeProjection(projections) {
223
+ if (projections.length === 0)
224
+ return null;
225
+ return [...projections].sort((a, b) => ROLLUP_SEVERITY[b.rollup] - ROLLUP_SEVERITY[a.rollup])[0];
226
+ }
227
+ /**
228
+ * Attention-strip line for the worst non-ok merge set.
229
+ * all-ok is omitted so generic per-run attention remains visible.
230
+ */
231
+ export function formatPrimaryMergeAttentionLine(projections) {
232
+ const primary = pickPrimaryMergeProjection(projections);
233
+ if (!primary)
234
+ return null;
235
+ if (primary.rollup === "all-ok")
236
+ return null;
237
+ return formatMergeSetSummary(primary);
238
+ }
@@ -14,11 +14,12 @@
14
14
  * - Partial failure: continue remaining hosts; failed hosts reported honestly.
15
15
  */
16
16
  import type { DashLaunchCoordinator, DashLaunchResult } from "#cli/commands/dash/launch-coordinator.js";
17
- import { type MultiHostPlanProposal, type PlanOnceDecision, type PlanOnceHostEntry, type PlanOnceValidationIssue } from "#cli/commands/dash/plan-once-schema.js";
17
+ import { type MultiHostPlanProposal, type PlanOnceDecision, type PlanOnceValidationIssue } from "#cli/commands/dash/plan-once-schema.js";
18
18
  import { type DashPermissionMode, type DashProvider } from "#cli/commands/dash/provider-capabilities.js";
19
19
  import { type CreateDashRunInput } from "#cli/commands/dash/run-registry.js";
20
20
  import type { DashRun, DashRunState } from "#cli/commands/dash/run-types.js";
21
21
  export { PLAN_ONCE_SCHEMA_VERSION, parseMultiHostPlanProposal, parsePlanOnceDecision, multiHostPlanProposalSchema, planOnceDecisionSchema, type MultiHostPlanProposal, type PlanOnceDecision, type PlanOnceHostEntry, type PlanOnceValidationIssue, } from "#cli/commands/dash/plan-once-schema.js";
22
+ export { planOnceRunLabel } from "#cli/commands/dash/plan-once-label.js";
22
23
  /** Documented fan-out concurrency — sequential because coordinator admits one pending launch. */
23
24
  export declare const PLAN_ONCE_FANOUT_CONCURRENCY: "sequential";
24
25
  export type PlanOnceFanOutConcurrency = typeof PLAN_ONCE_FANOUT_CONCURRENCY;
@@ -39,6 +40,26 @@ export declare function createPlanOnceSession(plan: MultiHostPlanProposal): Plan
39
40
  export declare function reducePlanOnceDecision(session: PlanOnceSession, decision: PlanOnceDecision): PlanOnceReduceResult;
40
41
  /** Parse decision then reduce — convenience for IPC/menu handlers. */
41
42
  export declare function applyPlanOnceDecision(session: PlanOnceSession, rawDecision: unknown): PlanOnceReduceResult;
43
+ export type ConfirmPlanOnceWithMembershipResult = {
44
+ readonly ok: true;
45
+ readonly session: PlanOnceSession;
46
+ /** Set when confirm succeeds and membership was written. */
47
+ readonly membershipWritten: boolean;
48
+ } | {
49
+ readonly ok: false;
50
+ readonly error: string;
51
+ };
52
+ /**
53
+ * Confirm-once with durable membership write. Cancel never writes membership.
54
+ * Lazy-imports membership IO so pure reducers stay free of FS side effects.
55
+ */
56
+ export declare function confirmPlanOnceWithMembership(input: {
57
+ readonly session: PlanOnceSession;
58
+ readonly decision: PlanOnceDecision | unknown;
59
+ readonly cwd?: string;
60
+ /** Injected for tests; default writes real membership store. */
61
+ readonly writeMembership?: (plan: MultiHostPlanProposal, cwd?: string) => Promise<unknown>;
62
+ }): Promise<ConfirmPlanOnceWithMembershipResult>;
42
63
  export type FanOutHostResult = {
43
64
  readonly host: DashProvider;
44
65
  readonly workdir: string;
@@ -91,7 +112,6 @@ export type PlanOnceApplyDeps = {
91
112
  /** Registry file scope (dash instance cwd). Host workdirs may differ. */
92
113
  readonly registryCwd: string;
93
114
  };
94
- export declare function planOnceRunLabel(planId: string, host: PlanOnceHostEntry): string;
95
115
  /**
96
116
  * Apply a confirmed plan: sequential fan-out through launch coordinator.
97
117
  *
@@ -1,8 +1,10 @@
1
1
  import { resolveLaunchPermissionMode } from "#cli/commands/dash/launch-preferences.js";
2
+ import { planOnceRunLabel } from "#cli/commands/dash/plan-once-label.js";
2
3
  import { parseMultiHostPlanProposal, parsePlanOnceDecision, } from "#cli/commands/dash/plan-once-schema.js";
3
4
  import { buildDashLaunchCommand, } from "#cli/commands/dash/provider-capabilities.js";
4
5
  import { updateDashRun } from "#cli/commands/dash/run-registry.js";
5
6
  export { PLAN_ONCE_SCHEMA_VERSION, parseMultiHostPlanProposal, parsePlanOnceDecision, multiHostPlanProposalSchema, planOnceDecisionSchema, } from "#cli/commands/dash/plan-once-schema.js";
7
+ export { planOnceRunLabel } from "#cli/commands/dash/plan-once-label.js";
6
8
  /** Documented fan-out concurrency — sequential because coordinator admits one pending launch. */
7
9
  export const PLAN_ONCE_FANOUT_CONCURRENCY = "sequential";
8
10
  /** Pure confirm-once / cancel reducer — no registry side effects. */
@@ -35,6 +37,37 @@ export function applyPlanOnceDecision(session, rawDecision) {
35
37
  }
36
38
  return reducePlanOnceDecision(session, parsed.decision);
37
39
  }
40
+ /**
41
+ * Confirm-once with durable membership write. Cancel never writes membership.
42
+ * Lazy-imports membership IO so pure reducers stay free of FS side effects.
43
+ */
44
+ export async function confirmPlanOnceWithMembership(input) {
45
+ // Always schema-parse the public entrypoint (no cast-on-kind shortcut).
46
+ const parsed = parsePlanOnceDecision(input.decision);
47
+ if (!parsed.ok) {
48
+ return {
49
+ ok: false,
50
+ error: parsed.issues.map((i) => i.message).join("; ") || "Invalid plan decision",
51
+ };
52
+ }
53
+ const reduced = reducePlanOnceDecision(input.session, parsed.decision);
54
+ if (!reduced.ok)
55
+ return { ok: false, error: reduced.error };
56
+ if (reduced.session.status === "cancelled") {
57
+ return { ok: true, session: reduced.session, membershipWritten: false };
58
+ }
59
+ if (reduced.session.status !== "confirmed") {
60
+ return { ok: false, error: `Unexpected status after decision: ${reduced.session.status}` };
61
+ }
62
+ if (input.writeMembership) {
63
+ await input.writeMembership(reduced.session.plan, input.cwd);
64
+ }
65
+ else {
66
+ const { buildPlanMembership, writePlanMembership } = await import("#cli/commands/dash/plan-once-membership.js");
67
+ await writePlanMembership(buildPlanMembership(reduced.session.plan), { cwd: input.cwd });
68
+ }
69
+ return { ok: true, session: reduced.session, membershipWritten: true };
70
+ }
38
71
  function defaultRegistryPort() {
39
72
  // createRun is intentionally not used by apply — only updateRun for plan labels.
40
73
  // Import createDashRun only when tests inject a full port; production update path:
@@ -53,10 +86,6 @@ function defaultDeps(input) {
53
86
  registryCwd: input.registryCwd,
54
87
  };
55
88
  }
56
- export function planOnceRunLabel(planId, host) {
57
- const base = host.label?.trim() || host.host;
58
- return `plan-once:${planId}:${base}`;
59
- }
60
89
  function outcomeRunId(result) {
61
90
  const outcome = result.outcome;
62
91
  if ("runId" in outcome && typeof outcome.runId === "string" && outcome.runId.length > 0) {
@@ -80,6 +109,14 @@ export async function applyConfirmedMultiHostPlan(input) {
80
109
  const deps = defaultDeps(input);
81
110
  const plan = input.session.plan;
82
111
  const hostResults = [];
112
+ // Ensure durable membership exists before fan-out (idempotent). Cancel never reaches here.
113
+ try {
114
+ const { buildPlanMembership, writePlanMembership } = await import("#cli/commands/dash/plan-once-membership.js");
115
+ await writePlanMembership(buildPlanMembership(plan), { cwd: deps.registryCwd });
116
+ }
117
+ catch {
118
+ // Membership write failure does not block launch; merge may report incomplete.
119
+ }
83
120
  for (const entry of plan.hosts) {
84
121
  const permissionMode = deps.resolvePermissionMode(entry.extraArgs ?? [], entry.host);
85
122
  const command = buildDashLaunchCommand(entry.host, permissionMode, entry.extraArgs ?? []);
@@ -2,6 +2,15 @@ import { type AttentionItem } from "#cli/commands/dash/attention.js";
2
2
  import type { AgentRunStatus } from "#cli/commands/dash/pane-controller.js";
3
3
  export declare function attentionItemsFromRunStatuses(statuses: readonly AgentRunStatus[], now?: string): readonly AttentionItem[];
4
4
  export declare function formatAttentionLine(items: readonly AttentionItem[]): string | null;
5
+ /**
6
+ * Prefer multi-host merge rollup when the merge line is non-null.
7
+ * Callers should pass a merge line only for non-all-ok rollups
8
+ * (`formatPrimaryMergeAttentionLine`) so all-ok does not hide live failures.
9
+ */
10
+ export declare function selectAttentionLine(input: {
11
+ readonly mergeLine: string | null;
12
+ readonly genericLine: string | null;
13
+ }): string | null;
5
14
  /** Dash-process RSS + active run count (owned tree probe is best-effort elsewhere). */
6
15
  export declare function formatResourceLine(input: {
7
16
  readonly statuses: readonly AgentRunStatus[];
@@ -1,5 +1,6 @@
1
1
  // Project session attention + process resource HUD lines for the pure TUI.
2
2
  // Uses the same normalizeSessionState / projectAttention domain as desktop.
3
+ // Multi-host merge rollup (plan-once membership) can override the generic line.
3
4
  import { normalizeSessionState, projectAttention, } from "#cli/commands/dash/attention.js";
4
5
  import { resourceView } from "#cli/commands/dash/resources.js";
5
6
  export function attentionItemsFromRunStatuses(statuses, now = new Date().toISOString()) {
@@ -35,6 +36,14 @@ export function formatAttentionLine(items) {
35
36
  return null;
36
37
  return `attention · ${top.provider}:${top.state} · ${top.reason} · ${top.action}`;
37
38
  }
39
+ /**
40
+ * Prefer multi-host merge rollup when the merge line is non-null.
41
+ * Callers should pass a merge line only for non-all-ok rollups
42
+ * (`formatPrimaryMergeAttentionLine`) so all-ok does not hide live failures.
43
+ */
44
+ export function selectAttentionLine(input) {
45
+ return input.mergeLine ?? input.genericLine;
46
+ }
38
47
  /** Dash-process RSS + active run count (owned tree probe is best-effort elsewhere). */
39
48
  export function formatResourceLine(input) {
40
49
  const active = input.statuses.filter((status) => status.running || status.active);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Integrated-chat bridge port: hermetic fake for CI + real adapter seam.
3
+ * Does not create dash runs — uses existing run identity only (no second registry).
4
+ */
5
+ import type { DashProvider } from "#cli/commands/dash/provider-capabilities.js";
6
+ export type ChatRole = "user" | "assistant" | "system";
7
+ export type ChatMessage = {
8
+ readonly role: ChatRole;
9
+ readonly text: string;
10
+ readonly at: string;
11
+ };
12
+ export type ChatSession = {
13
+ readonly runId: string;
14
+ readonly host: DashProvider;
15
+ readonly label: string | null;
16
+ readonly messages: readonly ChatMessage[];
17
+ };
18
+ export type ChatBridgeResult = {
19
+ readonly ok: true;
20
+ readonly session: ChatSession;
21
+ } | {
22
+ readonly ok: false;
23
+ readonly error: string;
24
+ };
25
+ export type ChatBridge = {
26
+ open(input: {
27
+ readonly host: DashProvider;
28
+ readonly runId: string;
29
+ readonly label?: string | null;
30
+ }): Promise<ChatBridgeResult>;
31
+ send(session: ChatSession, text: string): Promise<ChatBridgeResult>;
32
+ };
33
+ /**
34
+ * Hermetic bridge: drives real send/receive entry points without live ACP.
35
+ * Codex is the MVP host (capability matrix); others fail with matrix reasons.
36
+ */
37
+ export declare function createHermeticChatBridge(options?: {
38
+ readonly now?: () => string;
39
+ }): ChatBridge;
40
+ /** Format transcript lines for browser overlay. */
41
+ export declare function formatChatTranscript(session: ChatSession): readonly string[];
@@ -0,0 +1,73 @@
1
+ import { hostAcpCapability } from "#cli/commands/dash/tui/chat-mode.js";
2
+ function nowIso() {
3
+ return new Date().toISOString();
4
+ }
5
+ /**
6
+ * Hermetic bridge: drives real send/receive entry points without live ACP.
7
+ * Codex is the MVP host (capability matrix); others fail with matrix reasons.
8
+ */
9
+ export function createHermeticChatBridge(options = {}) {
10
+ const stamp = options.now ?? nowIso;
11
+ return {
12
+ async open(input) {
13
+ if (!input.runId.trim()) {
14
+ return { ok: false, error: "runId is required (use existing dash run identity)" };
15
+ }
16
+ const cap = hostAcpCapability(input.host);
17
+ if (!cap.acpAvailable) {
18
+ return { ok: false, error: cap.reason };
19
+ }
20
+ const session = {
21
+ runId: input.runId,
22
+ host: input.host,
23
+ label: input.label ?? null,
24
+ messages: [
25
+ {
26
+ role: "system",
27
+ text: `Integrated chat open for ${input.host} (hermetic). Host-pane remains available via mode switch.`,
28
+ at: stamp(),
29
+ },
30
+ ],
31
+ };
32
+ return { ok: true, session };
33
+ },
34
+ async send(session, text) {
35
+ const trimmed = text.trim();
36
+ if (!trimmed) {
37
+ return { ok: false, error: "message text is required" };
38
+ }
39
+ const cap = hostAcpCapability(session.host);
40
+ if (!cap.acpAvailable) {
41
+ return { ok: false, error: cap.reason };
42
+ }
43
+ const userMsg = { role: "user", text: trimmed, at: stamp() };
44
+ // Hermetic assistant echo proves receive path without live LLM.
45
+ const assistantMsg = {
46
+ role: "assistant",
47
+ text: `[hermetic:${session.host}] ${trimmed}`,
48
+ at: stamp(),
49
+ };
50
+ return {
51
+ ok: true,
52
+ session: {
53
+ ...session,
54
+ messages: [...session.messages, userMsg, assistantMsg],
55
+ },
56
+ };
57
+ },
58
+ };
59
+ }
60
+ /** Format transcript lines for browser overlay. */
61
+ export function formatChatTranscript(session) {
62
+ const header = [
63
+ `chat · ${session.host} · run ${session.runId.slice(0, 8)}`,
64
+ session.label ? `label · ${session.label}` : null,
65
+ "scope · integrated-chat (host-pane available via mode switch)",
66
+ "─".repeat(40),
67
+ ].filter((line) => line !== null);
68
+ const body = session.messages.map((msg) => {
69
+ const tag = msg.role === "user" ? "you" : msg.role === "assistant" ? "host" : "sys";
70
+ return `${tag} · ${msg.text}`;
71
+ });
72
+ return [...header, ...body];
73
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Control-plane interaction mode + per-host ACP capability matrix.
3
+ * Pure — no I/O. Missing ACP never silently omits a host.
4
+ */
5
+ import { type DashProvider } from "#cli/commands/dash/provider-capabilities.js";
6
+ export declare const CONTROL_PLANE_CHAT_MODES: readonly ["host-pane", "integrated-chat"];
7
+ export type ControlPlaneChatMode = (typeof CONTROL_PLANE_CHAT_MODES)[number];
8
+ export declare const DEFAULT_CONTROL_PLANE_CHAT_MODE: ControlPlaneChatMode;
9
+ export type HostAcpCapability = {
10
+ readonly host: DashProvider;
11
+ /** True when integrated-chat transport can open for this host (hermetic or real). */
12
+ readonly acpAvailable: boolean;
13
+ /**
14
+ * When acpAvailable is false, honest degrade reason (required).
15
+ * When true, optional note about transport class.
16
+ */
17
+ readonly reason: string;
18
+ /** Transport class for docs/tests — hermetic fake, headless, or acp. */
19
+ readonly transport: "hermetic" | "headless" | "acp" | "unavailable";
20
+ };
21
+ /**
22
+ * Explicit matrix for every control-plane host. Never omit a host.
23
+ * MVP: Codex is hermetic-chat capable for CI; others degrade with reasons.
24
+ */
25
+ export declare const HOST_ACP_CAPABILITIES: readonly HostAcpCapability[];
26
+ export declare function hostAcpCapability(host: DashProvider): HostAcpCapability;
27
+ /** Every host has an entry — used by tests to lock non-silent-omit. */
28
+ export declare function projectHostAcpMatrix(): readonly HostAcpCapability[];
29
+ export declare function isControlPlaneChatMode(value: string): value is ControlPlaneChatMode;
30
+ export declare function formatChatModePill(mode: ControlPlaneChatMode): string;
31
+ export declare function toggleControlPlaneChatMode(mode: ControlPlaneChatMode): ControlPlaneChatMode;
32
+ /**
33
+ * Pure mode transition. Integrated-chat requires an explicit host that is ACP-capable
34
+ * when opening chat; switching back to host-pane always succeeds.
35
+ */
36
+ export declare function reduceChatModeSwitch(input: {
37
+ readonly mode: ControlPlaneChatMode;
38
+ readonly target: ControlPlaneChatMode;
39
+ readonly host?: DashProvider | null;
40
+ }): {
41
+ readonly ok: true;
42
+ readonly mode: ControlPlaneChatMode;
43
+ } | {
44
+ readonly ok: false;
45
+ readonly error: string;
46
+ readonly mode: ControlPlaneChatMode;
47
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Control-plane interaction mode + per-host ACP capability matrix.
3
+ * Pure — no I/O. Missing ACP never silently omits a host.
4
+ */
5
+ import { DASH_PROVIDERS } from "#cli/commands/dash/provider-capabilities.js";
6
+ export const CONTROL_PLANE_CHAT_MODES = ["host-pane", "integrated-chat"];
7
+ export const DEFAULT_CONTROL_PLANE_CHAT_MODE = "host-pane";
8
+ /**
9
+ * Explicit matrix for every control-plane host. Never omit a host.
10
+ * MVP: Codex is hermetic-chat capable for CI; others degrade with reasons.
11
+ */
12
+ export const HOST_ACP_CAPABILITIES = DASH_PROVIDERS.map((host) => {
13
+ if (host === "codex") {
14
+ return {
15
+ host,
16
+ acpAvailable: true,
17
+ reason: "Hermetic integrated-chat adapter (MVP); real ACP optional later",
18
+ transport: "hermetic",
19
+ };
20
+ }
21
+ if (host === "claude") {
22
+ return {
23
+ host,
24
+ acpAvailable: false,
25
+ reason: "Claude ACP not wired in dash yet — use host-pane",
26
+ transport: "unavailable",
27
+ };
28
+ }
29
+ if (host === "grok") {
30
+ return {
31
+ host,
32
+ acpAvailable: false,
33
+ reason: "Grok ACP/headless chat not wired in dash yet — use host-pane",
34
+ transport: "unavailable",
35
+ };
36
+ }
37
+ return {
38
+ host,
39
+ acpAvailable: false,
40
+ reason: "OpenCode ACP not wired in dash yet — use host-pane",
41
+ transport: "unavailable",
42
+ };
43
+ });
44
+ export function hostAcpCapability(host) {
45
+ const row = HOST_ACP_CAPABILITIES.find((item) => item.host === host);
46
+ if (!row) {
47
+ // Should be unreachable — matrix covers DASH_PROVIDERS SSOT.
48
+ return {
49
+ host,
50
+ acpAvailable: false,
51
+ reason: `No ACP capability entry for ${host}`,
52
+ transport: "unavailable",
53
+ };
54
+ }
55
+ return row;
56
+ }
57
+ /** Every host has an entry — used by tests to lock non-silent-omit. */
58
+ export function projectHostAcpMatrix() {
59
+ return HOST_ACP_CAPABILITIES;
60
+ }
61
+ export function isControlPlaneChatMode(value) {
62
+ return CONTROL_PLANE_CHAT_MODES.includes(value);
63
+ }
64
+ export function formatChatModePill(mode) {
65
+ return mode === "integrated-chat" ? "[chat]" : "[pane]";
66
+ }
67
+ export function toggleControlPlaneChatMode(mode) {
68
+ return mode === "host-pane" ? "integrated-chat" : "host-pane";
69
+ }
70
+ /**
71
+ * Pure mode transition. Integrated-chat requires an explicit host that is ACP-capable
72
+ * when opening chat; switching back to host-pane always succeeds.
73
+ */
74
+ export function reduceChatModeSwitch(input) {
75
+ if (input.target === input.mode) {
76
+ return { ok: true, mode: input.mode };
77
+ }
78
+ if (input.target === "host-pane") {
79
+ return { ok: true, mode: "host-pane" };
80
+ }
81
+ // target integrated-chat
82
+ if (!input.host) {
83
+ return {
84
+ ok: false,
85
+ error: "Select a host session before switching to integrated chat",
86
+ mode: input.mode,
87
+ };
88
+ }
89
+ const cap = hostAcpCapability(input.host);
90
+ if (!cap.acpAvailable) {
91
+ return {
92
+ ok: false,
93
+ error: cap.reason,
94
+ mode: input.mode,
95
+ };
96
+ }
97
+ return { ok: true, mode: "integrated-chat" };
98
+ }
@@ -3,6 +3,7 @@ import { type MenuState } from "#cli/commands/dash/menu-state.js";
3
3
  import { type PaletteCommand } from "#cli/commands/dash/tui/palette.js";
4
4
  import { type HostAvailability } from "#cli/commands/dash/tui/hosts.js";
5
5
  import type { DashPermissionMode } from "#cli/commands/dash/provider-capabilities.js";
6
+ import { type ControlPlaneChatMode } from "#cli/commands/dash/tui/chat-mode.js";
6
7
  export type TuiOverlay = {
7
8
  readonly kind: "none";
8
9
  } | {
@@ -28,6 +29,8 @@ export type AppViewModel = {
28
29
  readonly width: number;
29
30
  readonly height: number;
30
31
  readonly permissionMode: DashPermissionMode;
32
+ /** host-pane (default) vs integrated-chat ACP surface */
33
+ readonly chatMode: ControlPlaneChatMode;
31
34
  readonly agents: readonly AgentEntry[];
32
35
  readonly menu: MenuState;
33
36
  readonly hostAvailability: readonly HostAvailability[];