@agent-compose/sdk 0.6.0 → 0.8.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 (126) hide show
  1. package/README.md +66 -39
  2. package/dist/agent/__tests__/runtime-json-schema.test.d.ts +10 -0
  3. package/dist/agent/agent-context.d.ts +21 -1
  4. package/dist/agent/agent-loop.d.ts +24 -1
  5. package/dist/client.d.ts +338 -534
  6. package/dist/directives.d.ts +112 -0
  7. package/dist/display.d.ts +242 -0
  8. package/dist/errors.d.ts +24 -1
  9. package/dist/index.d.ts +34 -13
  10. package/dist/index.js +2984 -861
  11. package/dist/pause/wrappers.d.ts +31 -9
  12. package/dist/processors/ask-human.d.ts +30 -0
  13. package/dist/processors/ask-human.test.d.ts +1 -0
  14. package/dist/processors/index.d.ts +1 -0
  15. package/dist/runtimes/_acp-client.d.ts +46 -1
  16. package/dist/runtimes/_cli-agent.d.ts +58 -4
  17. package/dist/runtimes/_jsonl-guard.d.ts +103 -0
  18. package/dist/runtimes/amp.d.ts +2 -2
  19. package/dist/runtimes/claude-code.d.ts +59 -0
  20. package/dist/runtimes/claude-code.test.d.ts +14 -0
  21. package/dist/runtimes/claude.d.ts +16 -0
  22. package/dist/runtimes/claude.test.d.ts +8 -0
  23. package/dist/runtimes/codex.d.ts +9 -3
  24. package/dist/runtimes/cursor.d.ts +9 -0
  25. package/dist/runtimes/droid.d.ts +9 -0
  26. package/dist/runtimes/jsonl-guard.test.d.ts +19 -0
  27. package/dist/runtimes/openai-desktop.js +2922 -861
  28. package/dist/runtimes/opencode.d.ts +25 -0
  29. package/dist/runtimes/vercel.js +22 -1
  30. package/dist/sandbox/devbox.d.ts +42 -0
  31. package/dist/sandbox/exec-stream.d.ts +14 -0
  32. package/dist/sandbox/network-policy.d.ts +100 -0
  33. package/dist/sandbox/provider-def.d.ts +79 -0
  34. package/dist/sandbox/providers/desktop.d.ts +10 -0
  35. package/dist/sandbox/providers/e2b.d.ts +17 -0
  36. package/dist/sandbox/providers/local.d.ts +11 -0
  37. package/dist/sandbox/providers/vercel.d.ts +18 -0
  38. package/dist/sandbox/registry.d.ts +45 -0
  39. package/dist/sandbox/sizes.d.ts +68 -0
  40. package/dist/sandbox.d.ts +24 -299
  41. package/dist/step-invocation/__tests__/foreground-recovery.test.d.ts +1 -0
  42. package/dist/step-invocation/invoker.d.ts +24 -1
  43. package/dist/step-invocation/protocol.d.ts +13 -0
  44. package/dist/types/api-compliance.d.ts +71 -0
  45. package/dist/types/api-conversations.d.ts +492 -0
  46. package/dist/types/api-factory.d.ts +309 -0
  47. package/dist/types/api-projects.d.ts +131 -0
  48. package/dist/types/api-runs.d.ts +377 -0
  49. package/dist/types/api-scopes.d.ts +102 -0
  50. package/dist/types/conversation-stream.d.ts +191 -0
  51. package/dist/types/execution-context.d.ts +12 -2
  52. package/dist/types/protocol.d.ts +30 -1
  53. package/dist/types/sandbox-environment.d.ts +8 -5
  54. package/dist/types/sandbox.d.ts +79 -0
  55. package/dist/types/workflow-metadata.d.ts +33 -8
  56. package/dist/types/workflow-plan.d.ts +10 -0
  57. package/dist/types/workflow.d.ts +18 -193
  58. package/dist/utils/bundler.d.ts +12 -1
  59. package/dist/utils/errors.d.ts +9 -1
  60. package/dist/workflow-steps/index.d.ts +1 -1
  61. package/dist/workflow-steps/observability.d.ts +8 -1
  62. package/dist/workflow-steps/runner.d.ts +3 -3
  63. package/dist/workflow-steps/step.d.ts +15 -1
  64. package/dist/workflow-steps/types.d.ts +19 -5
  65. package/dist/workflow-steps/workflow.d.ts +22 -1
  66. package/dist/workflows/engine.d.ts +3 -2
  67. package/dist/workflows/invoke-child.d.ts +2 -2
  68. package/package.json +1 -1
  69. package/src/agent/agent-context.ts +206 -16
  70. package/src/agent/agent-loop.ts +40 -4
  71. package/src/agent/run-agent.ts +9 -1
  72. package/src/client.ts +909 -621
  73. package/src/directives.ts +184 -0
  74. package/src/display.ts +788 -0
  75. package/src/errors.ts +39 -0
  76. package/src/index.ts +117 -10
  77. package/src/pause/wrappers.ts +44 -9
  78. package/src/processors/ask-human.ts +136 -0
  79. package/src/processors/index.ts +5 -0
  80. package/src/runtimes/_acp-client.ts +72 -3
  81. package/src/runtimes/_cli-agent.ts +171 -38
  82. package/src/runtimes/_jsonl-guard.ts +219 -0
  83. package/src/runtimes/claude-code.ts +246 -0
  84. package/src/runtimes/claude.ts +32 -2
  85. package/src/runtimes/codex.ts +55 -3
  86. package/src/runtimes/cursor.ts +59 -0
  87. package/src/runtimes/droid.ts +63 -0
  88. package/src/runtimes/openai-desktop.ts +59 -14
  89. package/src/runtimes/opencode.ts +61 -0
  90. package/src/sandbox/devbox.ts +48 -0
  91. package/src/sandbox/exec-stream.ts +48 -0
  92. package/src/sandbox/network-policy.ts +181 -0
  93. package/src/sandbox/provider-def.ts +94 -0
  94. package/src/sandbox/providers/desktop.ts +57 -0
  95. package/src/sandbox/providers/e2b.ts +354 -0
  96. package/src/sandbox/providers/local.ts +106 -0
  97. package/src/sandbox/providers/vercel.ts +331 -0
  98. package/src/sandbox/registry.ts +198 -0
  99. package/src/sandbox/sizes.ts +95 -0
  100. package/src/sandbox.ts +59 -1263
  101. package/src/step-invocation/invoker.ts +319 -34
  102. package/src/step-invocation/protocol.ts +19 -0
  103. package/src/types/api-compliance.ts +79 -0
  104. package/src/types/api-conversations.ts +522 -0
  105. package/src/types/api-factory.ts +336 -0
  106. package/src/types/api-projects.ts +140 -0
  107. package/src/types/api-runs.ts +412 -0
  108. package/src/types/api-scopes.ts +102 -0
  109. package/src/types/conversation-stream.ts +231 -0
  110. package/src/types/execution-context.ts +10 -2
  111. package/src/types/protocol.ts +33 -0
  112. package/src/types/sandbox-environment.ts +28 -9
  113. package/src/types/sandbox.ts +78 -0
  114. package/src/types/workflow-metadata.ts +35 -8
  115. package/src/types/workflow-plan.ts +11 -0
  116. package/src/types/workflow.ts +25 -280
  117. package/src/utils/bundler.ts +32 -5
  118. package/src/utils/errors.ts +34 -2
  119. package/src/workflow-steps/index.ts +1 -0
  120. package/src/workflow-steps/observability.ts +19 -8
  121. package/src/workflow-steps/runner.ts +4 -4
  122. package/src/workflow-steps/step.ts +49 -1
  123. package/src/workflow-steps/types.ts +20 -5
  124. package/src/workflow-steps/workflow.ts +22 -1
  125. package/src/workflows/engine.ts +3 -2
  126. package/src/workflows/invoke-child.ts +2 -2
package/src/display.ts ADDED
@@ -0,0 +1,788 @@
1
+ /**
2
+ * The display palette (ADR-0040 §7, CLI-first amendment) — the typed marker
3
+ * contract between `agentc display` and the session executors.
4
+ *
5
+ * A native-harness session (Claude Code / codex running in-sandbox, or the
6
+ * local bridge daemon) has no typed platform tools — its platform surface is
7
+ * the `agentc` CLI. To keep RENDERING parity with platform sessions, the
8
+ * model is given an explicit palette of display commands
9
+ * (`agentc display run|changes|document|plan|image|table|chart|ask …`) whose stdout is a one-line
10
+ * TYPED MARKER. The executors (server cloud-executor, CLI bridge daemon)
11
+ * detect these markers in tool results and persist the SAME `MessagePart`
12
+ * shapes the platform harness's tools produce — so the dashboard renders the
13
+ * same rich cards (run card, reviewed-changes diff, document tile, plan
14
+ * checklist) with NO new renderer vocabulary beyond the additive
15
+ * `display_*` tool names.
16
+ *
17
+ * Known agentc invocations are ALSO auto-promoted without an explicit
18
+ * display call: `agentc run dispatch` → the dispatched-run card (the exact
19
+ * `dispatch_workflow` part shapes turn.ts persists), `agentc run get` → the
20
+ * run card, `agentc files read` → the document tile. Claude Code's own
21
+ * TodoWrite tool additionally forwards as a durable `plan` part (the same
22
+ * shape the ACP daemon posts), so converged sessions get the plan checklist
23
+ * for free.
24
+ *
25
+ * Everything here is pure and executor-agnostic: the promoter takes the
26
+ * runtime's tool_use / tool_result events and returns plain part-shaped
27
+ * objects that are structurally identical to the server's `MessagePart`
28
+ * union (pinned by tests on both sides). Promotion is strictly a REWRITE of
29
+ * the pair's shape — it never suppresses data: an unrecognized command,
30
+ * unparseable arguments, or a missing marker all fall back to the raw pair.
31
+ */
32
+
33
+ import { REVISION_SELECTOR_RE } from "./directives.js";
34
+
35
+ // ── Marker contract ──────────────────────────────────────────────────────────
36
+
37
+ export const DISPLAY_MARKER_KEY = "acDisplay";
38
+ export const DISPLAY_MARKER_VERSION = "v1";
39
+
40
+ export type DisplayPlanStatus = "pending" | "in_progress" | "completed";
41
+ export interface DisplayPlanEntry {
42
+ content: string;
43
+ status: DisplayPlanStatus;
44
+ }
45
+
46
+ /** Plan bounds — mirror the server's BridgePartSchema (entries ≤ 64,
47
+ * content ≤ 1000) so a marker-built plan part is always postable. */
48
+ export const PLAN_MAX_ENTRIES = 64;
49
+ export const PLAN_ENTRY_MAX_CHARS = 1_000;
50
+
51
+ export type DisplayTableCell = string | number | boolean | null;
52
+ export interface DisplayTableColumn { key: string; label: string }
53
+ export interface DisplayTableData {
54
+ columns: DisplayTableColumn[];
55
+ rows: Array<Record<string, DisplayTableCell>>;
56
+ truncated: boolean;
57
+ }
58
+
59
+ export type DisplayChartKind = "bar" | "line";
60
+ export interface DisplayChartPoint { x: string | number; y: number }
61
+ export interface DisplayChartSeries { label: string; points: DisplayChartPoint[] }
62
+
63
+ export interface DisplayAskOption { id: string; label: string }
64
+
65
+ export type DisplayMarker =
66
+ | { kind: "run"; runId: string }
67
+ | { kind: "changes"; runId: string; factorySlug?: string; endedAt?: string | null }
68
+ | { kind: "document"; path: string; factorySlug?: string; preview?: string | null }
69
+ | { kind: "plan"; entries: DisplayPlanEntry[] }
70
+ | { kind: "image"; path: string; factorySlug?: string }
71
+ | { kind: "table"; columns: DisplayTableColumn[]; rows: Array<Record<string, DisplayTableCell>>; truncated: boolean }
72
+ | { kind: "chart"; chartKind: DisplayChartKind; series: DisplayChartSeries[]; xLabel?: string; yLabel?: string }
73
+ | { kind: "ask"; askId: string; prompt: string; options?: DisplayAskOption[]; freeText: boolean };
74
+
75
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
76
+
77
+ const PLAN_STATUSES: ReadonlySet<string> = new Set(["pending", "in_progress", "completed"]);
78
+
79
+ /** Clamp raw plan entries to the marker/part bounds; invalid rows drop. */
80
+ export function clampPlanEntries(raw: unknown): DisplayPlanEntry[] {
81
+ if (!Array.isArray(raw)) return [];
82
+ const entries: DisplayPlanEntry[] = [];
83
+ for (const e of raw) {
84
+ if (typeof e !== "object" || e === null) continue;
85
+ const { content, status } = e as { content?: unknown; status?: unknown };
86
+ if (typeof content !== "string" || content.trim().length === 0) continue;
87
+ if (typeof status !== "string" || !PLAN_STATUSES.has(status)) continue;
88
+ entries.push({ content: content.slice(0, PLAN_ENTRY_MAX_CHARS), status: status as DisplayPlanStatus });
89
+ if (entries.length >= PLAN_MAX_ENTRIES) break;
90
+ }
91
+ return entries;
92
+ }
93
+
94
+ // ── Table / chart / ask clamps ───────────────────────────────────────────────
95
+ // Shared by the CLI (before it serializes the marker) and the marker parser
96
+ // (so a hand-forged oversized marker can never bloat a persisted part) —
97
+ // the `clampPlanEntries` pattern.
98
+
99
+ /** Table bounds — mirror the pinned `display_table` output shape. */
100
+ export const TABLE_MAX_COLUMNS = 12;
101
+ export const TABLE_MAX_ROWS = 100;
102
+ export const TABLE_CELL_MAX_CHARS = 200;
103
+
104
+ /** Drive-path bound for image markers (mirrors the directive path cap). */
105
+ const IMAGE_PATH_MAX_CHARS = 512;
106
+
107
+ function clampCellString(s: string): string {
108
+ return s.length > TABLE_CELL_MAX_CHARS ? `${s.slice(0, TABLE_CELL_MAX_CHARS - 1)}…` : s;
109
+ }
110
+
111
+ function clampCell(value: unknown): DisplayTableCell {
112
+ if (typeof value === "string") return clampCellString(value);
113
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
114
+ if (typeof value === "boolean") return value;
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Clamp raw table input to the marker/part bounds. Accepts an array of flat
120
+ * objects (columns inferred from the key union, first-seen order) or a
121
+ * `{ columns, rows }` object. Null when nothing tabular survives — the CLI
122
+ * hard-errors, the parser treats the marker as not-a-marker.
123
+ */
124
+ export function clampTableData(raw: unknown): DisplayTableData | null {
125
+ let rawColumns: unknown;
126
+ let rawRows: unknown;
127
+ if (Array.isArray(raw)) {
128
+ rawRows = raw;
129
+ } else if (typeof raw === "object" && raw !== null) {
130
+ rawColumns = (raw as { columns?: unknown }).columns;
131
+ rawRows = (raw as { rows?: unknown }).rows;
132
+ } else {
133
+ return null;
134
+ }
135
+ if (!Array.isArray(rawRows)) return null;
136
+
137
+ // Columns: explicit `{ key, label }` list, or the key union of the rows
138
+ // in first-seen order. `sourceKey` (unclamped) reads the raw rows; the
139
+ // clamped `key` is what the part carries and what rows are re-keyed to.
140
+ let truncated = false;
141
+ const columns: Array<{ sourceKey: string; key: string; label: string }> = [];
142
+ const seenKeys = new Set<string>();
143
+ const pushColumn = (key: string, label: string): void => {
144
+ if (seenKeys.has(key)) return;
145
+ seenKeys.add(key);
146
+ if (columns.length >= TABLE_MAX_COLUMNS) { truncated = true; return; }
147
+ columns.push({ sourceKey: key, key: clampCellString(key), label: clampCellString(label) });
148
+ };
149
+
150
+ if (rawColumns !== undefined) {
151
+ if (!Array.isArray(rawColumns)) return null;
152
+ for (const c of rawColumns) {
153
+ if (typeof c !== "object" || c === null) continue;
154
+ const { key, label } = c as { key?: unknown; label?: unknown };
155
+ if (typeof key !== "string" || key.length === 0) continue;
156
+ pushColumn(key, typeof label === "string" && label.length > 0 ? label : key);
157
+ }
158
+ } else {
159
+ for (const row of rawRows) {
160
+ if (typeof row !== "object" || row === null || Array.isArray(row)) continue;
161
+ for (const key of Object.keys(row)) pushColumn(key, key);
162
+ }
163
+ }
164
+ if (columns.length === 0) return null;
165
+
166
+ const rows: Array<Record<string, DisplayTableCell>> = [];
167
+ for (const rawRow of rawRows) {
168
+ if (typeof rawRow !== "object" || rawRow === null || Array.isArray(rawRow)) continue;
169
+ if (rows.length >= TABLE_MAX_ROWS) { truncated = true; break; }
170
+ const row: Record<string, DisplayTableCell> = {};
171
+ for (const c of columns) row[c.key] = clampCell((rawRow as Record<string, unknown>)[c.sourceKey]);
172
+ rows.push(row);
173
+ }
174
+ if (rows.length === 0) return null;
175
+ return { columns: columns.map(({ key, label }) => ({ key, label })), rows, truncated };
176
+ }
177
+
178
+ /** Chart bounds — mirror the pinned `display_chart` output shape. */
179
+ export const CHART_MAX_SERIES = 4;
180
+ export const CHART_MAX_POINTS_PER_SERIES = 60;
181
+ export const CHART_LABEL_MAX_CHARS = 40;
182
+
183
+ /** Clamp raw chart series to the marker/part bounds; invalid series and
184
+ * non-finite points drop. Empty result → the CLI hard-errors / the parser
185
+ * rejects the marker. */
186
+ export function clampChartSeries(raw: unknown): DisplayChartSeries[] {
187
+ if (!Array.isArray(raw)) return [];
188
+ const series: DisplayChartSeries[] = [];
189
+ for (const s of raw) {
190
+ if (series.length >= CHART_MAX_SERIES) break;
191
+ if (typeof s !== "object" || s === null) continue;
192
+ const { label, points } = s as { label?: unknown; points?: unknown };
193
+ if (typeof label !== "string" || label.trim().length === 0) continue;
194
+ if (!Array.isArray(points)) continue;
195
+ const clamped: DisplayChartPoint[] = [];
196
+ for (const p of points) {
197
+ if (clamped.length >= CHART_MAX_POINTS_PER_SERIES) break;
198
+ if (typeof p !== "object" || p === null) continue;
199
+ const { x, y } = p as { x?: unknown; y?: unknown };
200
+ if (typeof y !== "number" || !Number.isFinite(y)) continue;
201
+ if (typeof x === "number" && Number.isFinite(x)) clamped.push({ x, y });
202
+ else if (typeof x === "string" && x.length > 0) clamped.push({ x: x.slice(0, CHART_LABEL_MAX_CHARS), y });
203
+ }
204
+ if (clamped.length === 0) continue;
205
+ series.push({ label: label.slice(0, CHART_LABEL_MAX_CHARS), points: clamped });
206
+ }
207
+ return series;
208
+ }
209
+
210
+ /** Clamp an axis label to the chart bound; undefined when unusable. */
211
+ export function clampChartAxisLabel(raw: unknown): string | undefined {
212
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.slice(0, CHART_LABEL_MAX_CHARS) : undefined;
213
+ }
214
+
215
+ /** Ask bounds — mirror the pinned `display_ask` output shape (the
216
+ * promoter path; the approver directive has its own tighter caps in
217
+ * directives.ts). */
218
+ export const ASK_PROMPT_MAX_CHARS = 1_000;
219
+ export const ASK_MAX_OPTIONS = 8;
220
+ export const ASK_OPTION_ID_MAX_CHARS = 100;
221
+ export const ASK_OPTION_LABEL_MAX_CHARS = 200;
222
+
223
+ /** Normalize + clamp ask options. Accepts `["a","b"]` (id = label) or
224
+ * `[{ id, label }]`; invalid entries drop, duplicate ids drop. */
225
+ export function clampAskOptions(raw: unknown): DisplayAskOption[] {
226
+ if (!Array.isArray(raw)) return [];
227
+ const options: DisplayAskOption[] = [];
228
+ const seen = new Set<string>();
229
+ for (const o of raw) {
230
+ if (options.length >= ASK_MAX_OPTIONS) break;
231
+ let id: string | null = null;
232
+ let label: string | null = null;
233
+ if (typeof o === "string" && o.trim().length > 0) {
234
+ id = o;
235
+ label = o;
236
+ } else if (typeof o === "object" && o !== null) {
237
+ const cand = o as { id?: unknown; label?: unknown };
238
+ if (typeof cand.id === "string" && cand.id.length > 0 && typeof cand.label === "string" && cand.label.length > 0) {
239
+ id = cand.id;
240
+ label = cand.label;
241
+ }
242
+ }
243
+ if (id === null || label === null) continue;
244
+ const clampedId = id.slice(0, ASK_OPTION_ID_MAX_CHARS);
245
+ if (seen.has(clampedId)) continue;
246
+ seen.add(clampedId);
247
+ options.push({ id: clampedId, label: label.slice(0, ASK_OPTION_LABEL_MAX_CHARS) });
248
+ }
249
+ return options;
250
+ }
251
+
252
+ /** One-line JSON marker — exactly what `agentc display` prints. */
253
+ export function serializeDisplayMarker(marker: DisplayMarker): string {
254
+ return JSON.stringify({ [DISPLAY_MARKER_KEY]: DISPLAY_MARKER_VERSION, ...marker });
255
+ }
256
+
257
+ /** Parse one line as a display marker; null when it isn't one (or fails
258
+ * validation — a malformed marker is not a marker). */
259
+ export function parseDisplayMarker(line: string): DisplayMarker | null {
260
+ const trimmed = line.trim();
261
+ if (!trimmed.startsWith("{") || !trimmed.includes(DISPLAY_MARKER_KEY)) return null;
262
+ let obj: Record<string, unknown>;
263
+ try {
264
+ const parsed: unknown = JSON.parse(trimmed);
265
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
266
+ obj = parsed as Record<string, unknown>;
267
+ } catch {
268
+ return null;
269
+ }
270
+ if (obj[DISPLAY_MARKER_KEY] !== DISPLAY_MARKER_VERSION) return null;
271
+ const str = (key: string): string | undefined => {
272
+ const v = obj[key];
273
+ return typeof v === "string" && v.length > 0 ? v : undefined;
274
+ };
275
+ switch (obj.kind) {
276
+ case "run": {
277
+ const runId = str("runId");
278
+ return runId && UUID_RE.test(runId) ? { kind: "run", runId } : null;
279
+ }
280
+ case "changes": {
281
+ const runId = str("runId");
282
+ if (!runId || !UUID_RE.test(runId)) return null;
283
+ return {
284
+ kind: "changes", runId,
285
+ ...(str("factorySlug") ? { factorySlug: str("factorySlug") } : {}),
286
+ endedAt: str("endedAt") ?? null,
287
+ };
288
+ }
289
+ case "document": {
290
+ const path = str("path");
291
+ if (!path) return null;
292
+ return {
293
+ kind: "document", path,
294
+ ...(str("factorySlug") ? { factorySlug: str("factorySlug") } : {}),
295
+ preview: str("preview") ?? null,
296
+ };
297
+ }
298
+ case "plan": {
299
+ const entries = clampPlanEntries(obj.entries);
300
+ return entries.length > 0 ? { kind: "plan", entries } : null;
301
+ }
302
+ case "image": {
303
+ const path = str("path");
304
+ if (!path || path.length > IMAGE_PATH_MAX_CHARS) return null;
305
+ return {
306
+ kind: "image", path,
307
+ ...(str("factorySlug") ? { factorySlug: str("factorySlug") } : {}),
308
+ };
309
+ }
310
+ case "table": {
311
+ // Re-clamp through the shared helper — a hand-forged oversized
312
+ // marker cannot bloat the part beyond the pinned bounds.
313
+ const data = clampTableData({ columns: obj.columns, rows: obj.rows });
314
+ if (!data) return null;
315
+ return { kind: "table", ...data, truncated: data.truncated || obj.truncated === true };
316
+ }
317
+ case "chart": {
318
+ if (obj.chartKind !== "bar" && obj.chartKind !== "line") return null;
319
+ const series = clampChartSeries(obj.series);
320
+ if (series.length === 0) return null;
321
+ const xLabel = clampChartAxisLabel(obj.xLabel);
322
+ const yLabel = clampChartAxisLabel(obj.yLabel);
323
+ return {
324
+ kind: "chart", chartKind: obj.chartKind, series,
325
+ ...(xLabel !== undefined ? { xLabel } : {}),
326
+ ...(yLabel !== undefined ? { yLabel } : {}),
327
+ };
328
+ }
329
+ case "ask": {
330
+ const askId = str("askId");
331
+ const rawPrompt = str("prompt");
332
+ if (!askId || !UUID_RE.test(askId) || !rawPrompt) return null;
333
+ const options = clampAskOptions(obj.options);
334
+ return {
335
+ kind: "ask", askId, prompt: rawPrompt.slice(0, ASK_PROMPT_MAX_CHARS),
336
+ ...(options.length > 0 ? { options } : {}),
337
+ freeText: true, // always true in v1
338
+ };
339
+ }
340
+ default:
341
+ return null;
342
+ }
343
+ }
344
+
345
+ /** First display marker in a tool result's text output (the CLI prints
346
+ * exactly one; scanning lines keeps it robust to surrounding stderr/echo). */
347
+ export function findDisplayMarker(text: string): DisplayMarker | null {
348
+ for (const line of text.split("\n")) {
349
+ const marker = parseDisplayMarker(line);
350
+ if (marker) return marker;
351
+ }
352
+ return null;
353
+ }
354
+
355
+ // ── agentc invocation detection ──────────────────────────────────────────────
356
+
357
+ export type AgentcInvocation =
358
+ | { verb: "display-run"; runId: string }
359
+ | { verb: "display-changes"; runId: string; factorySlug?: string }
360
+ | { verb: "display-document"; path: string; factorySlug?: string }
361
+ | { verb: "display-plan" }
362
+ | { verb: "display-image"; path: string; factorySlug?: string }
363
+ /** Directive verb — detection only arms the tool_call promotion; the
364
+ * result keeps its raw output so the directive marker reaches the cloud
365
+ * executor. Same for table `source:"file"`, diff, and ask-with-approver. */
366
+ | { verb: "display-preview"; path: string }
367
+ | { verb: "display-table"; source: "inline" | "file"; path?: string }
368
+ | { verb: "display-chart"; chartKind: DisplayChartKind }
369
+ | { verb: "display-diff"; path: string; from: string; to: string }
370
+ | { verb: "display-ask"; prompt: string; approver?: string }
371
+ | { verb: "run-dispatch"; templateName: string; input?: unknown }
372
+ | { verb: "run-get"; runId: string }
373
+ | { verb: "files-read"; path: string; factorySlug?: string };
374
+
375
+ /** Shell operators that end the agentc invocation's own argument list. */
376
+ const SHELL_STOPPERS = new Set(["|", "||", "&&", ";", ">", ">>", "<", "2>", "2>>", "&"]);
377
+
378
+ /** Minimal shell-word split: whitespace-separated, single/double quotes
379
+ * group. Unclosed quotes swallow the rest (matching sh's prompt-for-more
380
+ * by simply taking what's there — promotion is best-effort). */
381
+ export function shellWords(command: string): string[] {
382
+ const words: string[] = [];
383
+ let current = "";
384
+ let started = false;
385
+ let quote: '"' | "'" | null = null;
386
+ for (let i = 0; i < command.length; i += 1) {
387
+ const ch = command[i];
388
+ if (quote) {
389
+ if (ch === quote) quote = null;
390
+ else current += ch;
391
+ continue;
392
+ }
393
+ if (ch === "'" || ch === '"') { quote = ch; started = true; continue; }
394
+ if (ch === " " || ch === "\t" || ch === "\n") {
395
+ if (started) { words.push(current); current = ""; started = false; }
396
+ continue;
397
+ }
398
+ current += ch;
399
+ started = true;
400
+ }
401
+ if (started) words.push(current);
402
+ return words;
403
+ }
404
+
405
+ /** Flags that take a value — consumed silently when not of interest so
406
+ * positional collection stays correct. */
407
+ const VALUE_FLAGS = new Set([
408
+ "--factory", "--input", "--entries", "--url", "--api-key", "--limit",
409
+ "--thread", "--content-type", "--file", "--out", "--revision", "--ended-at",
410
+ "--data", "--kind", "--from", "--to", "--prompt", "--options", "--approver",
411
+ ]);
412
+
413
+ /** Detect a known agentc invocation inside a shell command string. Returns
414
+ * null for anything unrecognized or with unparseable arguments (a `$VAR`
415
+ * run id, a missing positional) — the pair then persists raw. */
416
+ /** A word that chains, redirects, or substitutes — its presence means the
417
+ * command is a PIPELINE/COMPOUND, where agentc is a data source (e.g.
418
+ * `agentc run get … --json | python3 …`), not a display action. Promoting
419
+ * those turned parse-helper failures into errored "Showed run" cards. */
420
+ function isShellControlWord(w: string): boolean {
421
+ // `2>&1` (stderr merged INTO stdout) is the one harmless redirect: the
422
+ // marker still lands in captured output. Agents append it habitually —
423
+ // live failure: a valid `display chart … 2>&1` stayed a raw text row.
424
+ if (/^\d*>&\d+$/.test(w)) return false;
425
+ return SHELL_STOPPERS.has(w) || /^\d*>>?(&\d+)?$/.test(w) || w === "<" || w.startsWith("$(") || w.startsWith("`");
426
+ }
427
+
428
+ export function detectAgentcInvocation(command: string): AgentcInvocation | null {
429
+ const words = shellWords(command);
430
+ const start = words.findIndex((w) => w === "agentc" || w.endsWith("/agentc"));
431
+ if (start < 0) return null;
432
+ // Pure invocations only: any control word ANYWHERE (before or after)
433
+ // makes this a compound command — persist the pair raw.
434
+ if (words.some(isShellControlWord)) return null;
435
+ // Words before `agentc` must be env assignments (FOO=bar agentc …) or a
436
+ // shell-invoker wrapper (codex runs commands as `bash -lc "agentc …"`,
437
+ // whose argv joins to `bash -lc agentc …`).
438
+ for (let i = 0; i < start; i += 1) {
439
+ const w = words[i];
440
+ const isEnvAssign = /^[A-Za-z_][A-Za-z0-9_]*=/.test(w);
441
+ const isShellInvoker = /^(?:[\w./-]*\/)?(?:sh|bash|zsh|dash)$/.test(w);
442
+ if (!isEnvAssign && !isShellInvoker && !w.startsWith("-")) return null;
443
+ }
444
+ const args: string[] = [];
445
+ const flags = new Map<string, string>();
446
+ for (let i = start + 1; i < words.length; i += 1) {
447
+ const w = words[i];
448
+ if (SHELL_STOPPERS.has(w)) break;
449
+ if (w.startsWith("--")) {
450
+ if (VALUE_FLAGS.has(w) && i + 1 < words.length && !SHELL_STOPPERS.has(words[i + 1])) {
451
+ flags.set(w, words[i + 1]);
452
+ i += 1;
453
+ }
454
+ continue;
455
+ }
456
+ args.push(w);
457
+ }
458
+ const factorySlug = flags.get("--factory");
459
+ const withSlug = factorySlug ? { factorySlug } : {};
460
+ const uuid = (v: string | undefined): string | null => (v && UUID_RE.test(v) ? v : null);
461
+
462
+ if (args[0] === "display") {
463
+ switch (args[1]) {
464
+ case "run": {
465
+ const runId = uuid(args[2]);
466
+ return runId ? { verb: "display-run", runId } : null;
467
+ }
468
+ case "changes": {
469
+ const runId = uuid(args[2]);
470
+ return runId ? { verb: "display-changes", runId, ...withSlug } : null;
471
+ }
472
+ case "document":
473
+ return args[2] ? { verb: "display-document", path: args[2], ...withSlug } : null;
474
+ case "plan":
475
+ return { verb: "display-plan" };
476
+ case "image":
477
+ return args[2] ? { verb: "display-image", path: args[2], ...withSlug } : null;
478
+ case "preview":
479
+ return args[2] ? { verb: "display-preview", path: args[2] } : null;
480
+ case "table": {
481
+ const file = flags.get("--file");
482
+ const hasData = flags.has("--data");
483
+ // Exactly one source; both or neither is an unpromotable combination.
484
+ if (file && !hasData) return { verb: "display-table", source: "file", path: file };
485
+ if (hasData && !file) return { verb: "display-table", source: "inline" };
486
+ return null;
487
+ }
488
+ case "chart": {
489
+ const chartKind = flags.get("--kind");
490
+ return chartKind === "bar" || chartKind === "line"
491
+ ? { verb: "display-chart", chartKind }
492
+ : null;
493
+ }
494
+ case "diff": {
495
+ const from = flags.get("--from");
496
+ const to = flags.get("--to");
497
+ if (!args[2] || !from || !to) return null;
498
+ if (!REVISION_SELECTOR_RE.test(from) || !REVISION_SELECTOR_RE.test(to)) return null;
499
+ return { verb: "display-diff", path: args[2], from, to };
500
+ }
501
+ case "ask": {
502
+ const prompt = flags.get("--prompt");
503
+ if (!prompt || prompt.trim().length === 0) return null;
504
+ const approver = flags.get("--approver");
505
+ return {
506
+ verb: "display-ask",
507
+ prompt: prompt.slice(0, ASK_PROMPT_MAX_CHARS),
508
+ ...(approver ? { approver } : {}),
509
+ };
510
+ }
511
+ default:
512
+ return null;
513
+ }
514
+ }
515
+ if (args[0] === "run" && args[1] === "dispatch" && args[2]) {
516
+ let input: unknown;
517
+ const rawInput = flags.get("--input");
518
+ if (rawInput !== undefined) {
519
+ try { input = JSON.parse(rawInput); } catch { /* raw string stays out */ }
520
+ }
521
+ return { verb: "run-dispatch", templateName: args[2], ...(input !== undefined ? { input } : {}) };
522
+ }
523
+ if (args[0] === "run" && args[1] === "get") {
524
+ const runId = uuid(args[2]);
525
+ return runId ? { verb: "run-get", runId } : null;
526
+ }
527
+ if (args[0] === "files" && args[1] === "read" && args[2]) {
528
+ return { verb: "files-read", path: args[2], ...withSlug };
529
+ }
530
+ return null;
531
+ }
532
+
533
+ // ── The promoter ─────────────────────────────────────────────────────────────
534
+
535
+ /** Plain part shapes, structurally identical to the server's `MessagePart`
536
+ * union (and the bridge POST schema) — pinned by shape-fidelity tests on
537
+ * the server side. */
538
+ export type PromotedPart =
539
+ | { type: "tool_call"; toolName: string; input: Record<string, unknown>; toolCallId: string }
540
+ | { type: "tool_result"; toolCallId: string; output: unknown; isError: boolean }
541
+ | { type: "plan"; entries: DisplayPlanEntry[] };
542
+
543
+ /** Preview / content clamps for promoted document results. */
544
+ const DOCUMENT_PREVIEW_CHARS = 240;
545
+ const DOCUMENT_CONTENT_CHARS = 4_000;
546
+
547
+ interface PendingPromotion {
548
+ invocation: AgentcInvocation;
549
+ }
550
+
551
+ export interface DisplayPromoterContext {
552
+ /** The session's factory id (cloud sessions know it from the session row)
553
+ * — stamped onto document/changes parts when the command names no
554
+ * `--factory` slug, so the renderer can resolve the drive link. */
555
+ factoryId?: string;
556
+ }
557
+
558
+ export interface DisplayPromoter {
559
+ /** Promote a tool_use event. Non-null → persist these parts INSTEAD of
560
+ * the default mapping (the promoted tool_call reuses the toolUseId, so
561
+ * the eventual result still pairs). Null → not a promotable call. */
562
+ promoteToolUse(msg: { toolName: string; toolInput: unknown; toolUseId: string }): PromotedPart[] | null;
563
+ /** Promote the matching tool_result. Non-null → persist these parts
564
+ * instead of the default mapping. Null → no promotion pending. */
565
+ promoteToolResult(msg: { toolUseId: string; output: unknown; isError?: boolean }): PromotedPart[] | null;
566
+ }
567
+
568
+ /** The command string of a shell-ish tool input — Claude Code's Bash tool
569
+ * ({ command: "…" }) and codex's shell tool ({ command: [argv…] }). */
570
+ function commandOf(toolInput: unknown): string | null {
571
+ if (typeof toolInput !== "object" || toolInput === null) return null;
572
+ const cmd = (toolInput as { command?: unknown }).command;
573
+ if (typeof cmd === "string") return cmd;
574
+ if (Array.isArray(cmd) && cmd.every((c) => typeof c === "string")) return cmd.join(" ");
575
+ return null;
576
+ }
577
+
578
+ /** Flatten a tool result's output to text, however the runtime wrapped it. */
579
+ function textOf(output: unknown): string | null {
580
+ if (typeof output === "string") return output;
581
+ if (typeof output !== "object" || output === null) return null;
582
+ const o = output as Record<string, unknown>;
583
+ if (typeof o.output === "string") return o.output;
584
+ if (typeof o.text === "string") return o.text;
585
+ if (typeof o.content === "string") return o.content;
586
+ return null;
587
+ }
588
+
589
+ /** Last JSON object found on a line of `text` carrying a string `runId` —
590
+ * how a dispatch result names its run with or without `--json`. */
591
+ function runIdIn(text: string): { runId: string; parsed: Record<string, unknown> | null } | null {
592
+ for (const line of [...text.split("\n")].reverse()) {
593
+ const trimmed = line.trim();
594
+ if (trimmed.startsWith("{")) {
595
+ try {
596
+ const parsed: unknown = JSON.parse(trimmed);
597
+ if (typeof parsed === "object" && parsed !== null) {
598
+ const runId = (parsed as { runId?: unknown }).runId;
599
+ if (typeof runId === "string" && UUID_RE.test(runId)) {
600
+ return { runId, parsed: parsed as Record<string, unknown> };
601
+ }
602
+ }
603
+ } catch { /* not JSON — keep scanning */ }
604
+ }
605
+ }
606
+ const m = /run ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(text);
607
+ return m ? { runId: m[1], parsed: null } : null;
608
+ }
609
+
610
+ export function createDisplayPromoter(ctx: DisplayPromoterContext = {}): DisplayPromoter {
611
+ const pending = new Map<string, PendingPromotion>();
612
+
613
+ /** factorySlug from the command when present, else the session's factory
614
+ * id — the renderer accepts either (slug direct, or id via its cached
615
+ * factories lookup). */
616
+ const factoryRef = (slug: string | undefined): Record<string, string> => {
617
+ if (slug) return { factorySlug: slug };
618
+ if (ctx.factoryId) return { factoryId: ctx.factoryId };
619
+ return {};
620
+ };
621
+
622
+ return {
623
+ promoteToolUse(msg) {
624
+ // Claude Code's native TodoWrite → ADDITIONALLY forward the durable
625
+ // `plan` part (the ACP daemon's shape) so the checklist renders; the
626
+ // raw tool pair still persists unchanged (strictly additive).
627
+ if (msg.toolName === "TodoWrite") {
628
+ const todos = (msg.toolInput as { todos?: unknown } | null)?.todos;
629
+ const entries = clampPlanEntries(todos);
630
+ if (entries.length === 0) return null;
631
+ return [
632
+ { type: "tool_call", toolName: msg.toolName, input: msg.toolInput as Record<string, unknown>, toolCallId: msg.toolUseId },
633
+ { type: "plan", entries },
634
+ ];
635
+ }
636
+ const command = commandOf(msg.toolInput);
637
+ if (!command || !command.includes("agentc")) return null;
638
+ const invocation = detectAgentcInvocation(command);
639
+ if (!invocation) return null;
640
+ pending.set(msg.toolUseId, { invocation });
641
+ const call = (toolName: string, input: Record<string, unknown>): PromotedPart[] => [
642
+ { type: "tool_call", toolName, input: { ...input, command }, toolCallId: msg.toolUseId },
643
+ ];
644
+ switch (invocation.verb) {
645
+ case "display-run":
646
+ case "run-get":
647
+ return call("display_run", { runId: invocation.runId });
648
+ case "display-changes":
649
+ return call("display_changes", { runId: invocation.runId, ...factoryRef(invocation.factorySlug) });
650
+ case "display-document":
651
+ case "files-read":
652
+ return call("display_document", { path: invocation.path, ...factoryRef(invocation.factorySlug) });
653
+ case "display-plan":
654
+ return call("display_plan", {});
655
+ case "display-image":
656
+ return call("display_image", { path: invocation.path, ...factoryRef(invocation.factorySlug) });
657
+ case "display-preview":
658
+ return call("display_preview", { path: invocation.path, ...factoryRef(undefined) });
659
+ case "display-table":
660
+ return call("display_table", {
661
+ source: invocation.source,
662
+ ...(invocation.path !== undefined ? { path: invocation.path } : {}),
663
+ ...(invocation.source === "file" ? factoryRef(undefined) : {}),
664
+ });
665
+ case "display-chart":
666
+ return call("display_chart", { chartKind: invocation.chartKind });
667
+ case "display-diff":
668
+ return call("display_diff", {
669
+ path: invocation.path, from: invocation.from, to: invocation.to,
670
+ ...factoryRef(undefined),
671
+ });
672
+ case "display-ask":
673
+ return call("display_ask", { prompt: invocation.prompt });
674
+ case "run-dispatch":
675
+ return call("dispatch_workflow", {
676
+ templateName: invocation.templateName,
677
+ ...(invocation.input !== undefined ? { input: invocation.input } : {}),
678
+ });
679
+ }
680
+ },
681
+
682
+ promoteToolResult(msg) {
683
+ const entry = pending.get(msg.toolUseId);
684
+ if (!entry) return null;
685
+ pending.delete(msg.toolUseId);
686
+ const isError = msg.isError === true;
687
+ const result = (output: unknown): PromotedPart => ({
688
+ type: "tool_result", toolCallId: msg.toolUseId, output, isError,
689
+ });
690
+ // An errored command keeps its raw output — failures are never dressed
691
+ // up as cards (the renderer shows the promoted row with its error).
692
+ const text = textOf(msg.output);
693
+ if (isError || text === null) return [result(msg.output)];
694
+
695
+ const inv = entry.invocation;
696
+ switch (inv.verb) {
697
+ case "display-run": {
698
+ const marker = findDisplayMarker(text);
699
+ return [result(marker?.kind === "run" ? { runId: marker.runId } : { runId: inv.runId })];
700
+ }
701
+ case "run-get": {
702
+ const found = runIdIn(text);
703
+ return [result(found?.parsed ?? { runId: found?.runId ?? inv.runId })];
704
+ }
705
+ case "display-changes": {
706
+ const marker = findDisplayMarker(text);
707
+ const m = marker?.kind === "changes" ? marker : null;
708
+ return [result({
709
+ runId: m?.runId ?? inv.runId,
710
+ ...factoryRef(m?.factorySlug ?? inv.factorySlug),
711
+ endedAt: m?.endedAt ?? null,
712
+ })];
713
+ }
714
+ case "display-document": {
715
+ const marker = findDisplayMarker(text);
716
+ const m = marker?.kind === "document" ? marker : null;
717
+ return [result({
718
+ path: m?.path ?? inv.path,
719
+ ...factoryRef(m?.factorySlug ?? inv.factorySlug),
720
+ preview: m?.preview ?? null,
721
+ })];
722
+ }
723
+ case "files-read":
724
+ return [result({
725
+ path: inv.path,
726
+ ...factoryRef(inv.factorySlug),
727
+ preview: text.slice(0, DOCUMENT_PREVIEW_CHARS) || null,
728
+ content: text.slice(0, DOCUMENT_CONTENT_CHARS),
729
+ })];
730
+ case "display-plan": {
731
+ const marker = findDisplayMarker(text);
732
+ if (marker?.kind !== "plan") return [result(msg.output)];
733
+ return [
734
+ result({ entries: marker.entries.length }),
735
+ { type: "plan", entries: marker.entries },
736
+ ];
737
+ }
738
+ case "display-image": {
739
+ const marker = findDisplayMarker(text);
740
+ const m = marker?.kind === "image" ? marker : null;
741
+ return [result({
742
+ path: m?.path ?? inv.path,
743
+ ...factoryRef(m?.factorySlug ?? inv.factorySlug),
744
+ })];
745
+ }
746
+ case "display-table": {
747
+ // File-sourced tables are a DIRECTIVE: keep the raw output so the
748
+ // marker inside it reaches the cloud executor (the real card pair).
749
+ if (inv.source === "file") return [result(msg.output)];
750
+ const marker = findDisplayMarker(text);
751
+ if (marker?.kind !== "table") return [result(msg.output)];
752
+ return [result({ columns: marker.columns, rows: marker.rows, truncated: marker.truncated })];
753
+ }
754
+ case "display-chart": {
755
+ const marker = findDisplayMarker(text);
756
+ if (marker?.kind !== "chart") return [result(msg.output)];
757
+ return [result({
758
+ chartKind: marker.chartKind, series: marker.series,
759
+ ...(marker.xLabel !== undefined ? { xLabel: marker.xLabel } : {}),
760
+ ...(marker.yLabel !== undefined ? { yLabel: marker.yLabel } : {}),
761
+ })];
762
+ }
763
+ case "display-ask": {
764
+ // Approver asks are a DIRECTIVE (server-authoritative resolution
765
+ // + ping) — raw output through, marker intact.
766
+ if (inv.approver !== undefined) return [result(msg.output)];
767
+ const marker = findDisplayMarker(text);
768
+ if (marker?.kind !== "ask") return [result(msg.output)];
769
+ return [result({
770
+ askId: marker.askId, prompt: marker.prompt,
771
+ ...(marker.options !== undefined ? { options: marker.options } : {}),
772
+ freeText: marker.freeText,
773
+ })];
774
+ }
775
+ case "display-preview":
776
+ case "display-diff":
777
+ // Directive verbs: the promoted tool_call is armed above; the raw
778
+ // result keeps the directive marker for the cloud executor.
779
+ return [result(msg.output)];
780
+ case "run-dispatch": {
781
+ const found = runIdIn(text);
782
+ if (!found) return [result(msg.output)];
783
+ return [result(found.parsed ?? { runId: found.runId })];
784
+ }
785
+ }
786
+ },
787
+ };
788
+ }