@ahpd/agent-claude 0.3.0 → 0.5.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.
package/src/kinds.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What kind of thing a tool call is, said where the reference client reads it.
3
+ *
4
+ * `_meta.toolKind` is not protocol. It is the one well-known key VS Code's
5
+ * agent window routes a tool call by: `terminal` goes to the command-and-output
6
+ * renderer, `subagent` to the subagent view, `search` and `read` to theirs, and
7
+ * a call with none is drawn as a generic tool - a name and a box. The reference
8
+ * host stamps it in its own adapters, and derives it for a "remote host" from
9
+ * a Copilot-internal permission payload this backend does not have; so it is
10
+ * stamped here, from the harness's own tool names, which are the one thing
11
+ * about a tool call this backend can be sure of.
12
+ *
13
+ * Names only. Nothing else about the call decides the kind, and a tool this
14
+ * table has not heard of is left unstamped rather than guessed at - the
15
+ * generic renderer is right for a tool nobody here knows.
16
+ */
17
+
18
+ export type ToolKind = 'terminal' | 'read' | 'search' | 'subagent';
19
+
20
+ const KINDS: Readonly<Record<string, ToolKind>> = {
21
+ Bash: 'terminal',
22
+ terminal: 'terminal',
23
+ Read: 'read',
24
+ Glob: 'search',
25
+ Grep: 'search',
26
+ WebSearch: 'search',
27
+ WebFetch: 'search',
28
+ Task: 'subagent',
29
+ Agent: 'subagent',
30
+ };
31
+
32
+ /** The kind of a tool by its harness name, or nothing for one the table lacks. */
33
+ export const toolKindOf = (name: string): ToolKind | undefined => KINDS[name];
34
+
35
+ /**
36
+ * The `_meta` a tool call carries from the moment it is announced.
37
+ *
38
+ * The reducer replaces a call's whole `_meta` whenever an action carries one,
39
+ * so anything added later - a progress line while it runs - has to be spread
40
+ * over this rather than sent alone. Absent for a tool with no kind, so a call
41
+ * that has nothing to say carries no empty bag.
42
+ */
43
+ export const toolMetaOf = (name: string): Record<string, unknown> | undefined => {
44
+ const toolKind = toolKindOf(name);
45
+ return toolKind === undefined ? undefined : { toolKind };
46
+ };
package/src/session.ts CHANGED
@@ -1,10 +1,14 @@
1
+ import { rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
1
4
  import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
2
5
  import { z } from 'zod';
3
- import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk';
6
+ import type { HookCallback, PermissionMode } from '@anthropic-ai/claude-agent-sdk';
4
7
  import { protectedResource, urlOf } from './mcp.js';
8
+ import { toolMetaOf } from './kinds.js';
5
9
  import type { ActiveTurn, McpServerState, ToolCallCompletedState, ToolCallRunningState, ToolResultContent, ToolResultTerminalContent, ToolResultTextContent } from '@microsoft/agent-host-protocol';
6
10
  import { Status, idOf, tail } from '@ahpd/sdk';
7
- import type { Bag, BoundTool, Chosen, OnWire, Session, SessionOptions, WireTurn } from '@ahpd/sdk';
11
+ import type { Bag, BoundTool, Chosen, MessageFrom, OnWire, Session, SessionOptions, WireTurn } from '@ahpd/sdk';
8
12
 
9
13
  /**
10
14
  * The effort levels this backend has, weakest first.
@@ -57,6 +61,43 @@ const bag = (value: unknown): Bag => (typeof value === 'object' && value !== nul
57
61
  const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
58
62
  const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
59
63
 
64
+ /** One client-generated script, sourced before every shell command. */
65
+ interface ShellInitScript { shell: 'bash' | 'powershell'; script: string }
66
+
67
+ /** A generated script is a few hundred bytes; anything near this is not one. */
68
+ const MAX_SHELL_INIT_SCRIPT = 64 * 1024;
69
+
70
+ /**
71
+ * The `shellInitScripts` value, checked to the reference host's rule.
72
+ *
73
+ * A list of `{ shell, script }`, each script non-empty and no longer than a
74
+ * generated one could be. `undefined` for anything else, which is what lets
75
+ * `setConfig` refuse it rather than write it to disk.
76
+ */
77
+ const shellInitScripts = (value: unknown): ShellInitScript[] | undefined => {
78
+ if (!Array.isArray(value)) return undefined;
79
+ const list: ShellInitScript[] = [];
80
+ for (const entry of value) {
81
+ const one = bag(entry);
82
+ if ((one.shell !== 'bash' && one.shell !== 'powershell') || typeof one.script !== 'string') return undefined;
83
+ if (one.script.length === 0 || one.script.length > MAX_SHELL_INIT_SCRIPT) return undefined;
84
+ list.push({ shell: one.shell, script: one.script });
85
+ }
86
+ return list;
87
+ };
88
+
89
+ /**
90
+ * What goes in front of a shell command while a script is in force.
91
+ *
92
+ * Sourced, so what it sets is there for the command; its stderr dropped, as
93
+ * the reference runtime drops it; and a nonzero status reported rather than
94
+ * hidden, since a profile that fails is something the model should hear.
95
+ */
96
+ const sourcing = (path: string): string => `{ . '${path.replaceAll("'", "'\\''")}'; } 2>/dev/null || printf 'shell init script exited %s\\n' "$?"`;
97
+
98
+ /** The CLI's sandbox setting for the reference host's three words; `null` clears it back to the settings files. */
99
+ const sandboxOf = (value: unknown): { enabled: boolean } | null => (value === 'on' ? { enabled: true } : value === 'off' ? { enabled: false } : null);
100
+
60
101
  interface PendingInput {
61
102
  id: string;
62
103
  entry: Bag;
@@ -536,6 +577,43 @@ export function createSession(options: SessionOptions): Session {
536
577
  */
537
578
  const settings: Record<string, unknown> = { permissionMode: 'default', ...options.settings };
538
579
 
580
+ /*
581
+ * The shell init script, on disk where a shell can source it.
582
+ *
583
+ * The reference client pushes `shellInitScripts` for the profile and the
584
+ * Python environment it has selected, and the SDK's shell tool has no
585
+ * setting for one - so a `PreToolUse` hook on `Bash` puts a `source` of
586
+ * this file in front of every command. One path for the session's life,
587
+ * rewritten on each change, because the hook is built once with the query
588
+ * and reads the file by name. The bash entry only: the CLI's shell tool is
589
+ * bash on every platform it runs on.
590
+ */
591
+ const initScript = join(tmpdir(), `ahpd-shell-init-${crypto.randomUUID()}.sh`);
592
+ let sourced = false;
593
+ const setShellInit = (value: unknown): true | string => {
594
+ const list = shellInitScripts(value);
595
+ if (list === undefined) return 'shellInitScripts takes a list of { shell, script }';
596
+ const bash = list.find((one) => one.shell === 'bash');
597
+ try {
598
+ if (bash === undefined) rmSync(initScript, { force: true });
599
+ else writeFileSync(initScript, bash.script, { mode: 0o600 });
600
+ }
601
+ catch (error) {
602
+ return `Could not write the shell init script: ${error instanceof Error ? error.message : String(error)}`;
603
+ }
604
+ sourced = bash !== undefined;
605
+ settings.shellInitScripts = list;
606
+ return true;
607
+ };
608
+ if (settings.shellInitScripts !== undefined) setShellInit(settings.shellInitScripts);
609
+ const sourceFirst: HookCallback = async (input) => {
610
+ if (!sourced || input.hook_event_name !== 'PreToolUse') return {};
611
+ const given = bag(input.tool_input);
612
+ const command = str(given.command);
613
+ if (command === undefined) return {};
614
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: { ...given, command: `${sourcing(initScript)}\n${command}` } } };
615
+ };
616
+
539
617
  /**
540
618
  * The MCP server a tool belongs to, out of its name.
541
619
  *
@@ -926,12 +1004,17 @@ export function createSession(options: SessionOptions): Session {
926
1004
  const contributor = from === undefined
927
1005
  ? undefined
928
1006
  : { kind: 'mcp' as const, customizationId: `mcp:${from}` };
1007
+ // What kind of row to draw, from the name and from the first frame:
1008
+ // a client that waited for the arguments to know it was a shell
1009
+ // command would draw a generic box and then redraw it.
1010
+ const meta = toolMetaOf(name);
929
1011
  const call: Bag = {
930
1012
  toolCallId: id,
931
1013
  toolName: name,
932
1014
  displayName: name,
933
1015
  status: 'streaming',
934
1016
  ...(contributor ? { contributor } : {}),
1017
+ ...(meta ? { _meta: meta } : {}),
935
1018
  };
936
1019
  const part: Bag = { id, kind: 'toolCall', toolCall: call };
937
1020
  parts.set(id, part);
@@ -943,6 +1026,7 @@ export function createSession(options: SessionOptions): Session {
943
1026
  toolName: name,
944
1027
  displayName: name,
945
1028
  ...(contributor ? { contributor } : {}),
1029
+ ...(meta ? { _meta: meta } : {}),
946
1030
  });
947
1031
  return;
948
1032
  }
@@ -1055,12 +1139,14 @@ export function createSession(options: SessionOptions): Session {
1055
1139
  // Running against somebody else's server, and so a call that can end
1056
1140
  // up waiting on a sign-in rather than on its own work.
1057
1141
  if (from !== undefined) onServer.set(id, { server: from, turnId: str(turn.id) ?? '', blocked: false });
1142
+ const meta = toolMetaOf(name);
1058
1143
  const call: Bag = open !== undefined ? bag(open.toolCall) : {
1059
1144
  toolCallId: id,
1060
1145
  toolName: name,
1061
1146
  displayName: name,
1062
1147
  status: 'running',
1063
1148
  ...(contributor ? { contributor } : {}),
1149
+ ...(meta ? { _meta: meta } : {}),
1064
1150
  /*
1065
1151
  * On the call, and not only on the action that announces it.
1066
1152
  *
@@ -1111,6 +1197,7 @@ export function createSession(options: SessionOptions): Session {
1111
1197
  toolName: name,
1112
1198
  displayName: name,
1113
1199
  ...(contributor ? { contributor } : {}),
1200
+ ...(meta ? { _meta: meta } : {}),
1114
1201
  });
1115
1202
  }
1116
1203
  emit('chat', {
@@ -1191,6 +1278,22 @@ export function createSession(options: SessionOptions): Session {
1191
1278
  * itself, and it is checked against the state it completes.
1192
1279
  */
1193
1280
  Object.assign(call, result);
1281
+ /*
1282
+ * The progress line goes with the running state it described.
1283
+ *
1284
+ * Meaningful only while the call runs, and a completed row that still
1285
+ * carries "Running Grep" is a row that says two things. Sent on the
1286
+ * completion only when there was one to take off, because an action
1287
+ * carrying `_meta` replaces the bag whole and an absent one leaves
1288
+ * the kind stamped at the start alone.
1289
+ */
1290
+ const meta = bag(call._meta);
1291
+ const progressed = meta.progressMessage !== undefined;
1292
+ if (progressed) {
1293
+ const { progressMessage: _gone, ...rest } = meta;
1294
+ if (Object.keys(rest).length > 0) call._meta = rest;
1295
+ else delete call._meta;
1296
+ }
1194
1297
  // And as it is now the tool has run. Paired with the `before` above by
1195
1298
  // the call's own id, which is the only thing that survives the gap.
1196
1299
  const changed = id === undefined ? undefined : editing.get(id);
@@ -1203,6 +1306,7 @@ export function createSession(options: SessionOptions): Session {
1203
1306
  turnId: active?.id,
1204
1307
  toolCallId: id,
1205
1308
  result,
1309
+ ...(progressed ? { _meta: call._meta ?? {} } : {}),
1206
1310
  });
1207
1311
  }
1208
1312
  };
@@ -1296,11 +1400,13 @@ export function createSession(options: SessionOptions): Session {
1296
1400
  // The call the assistant message opened, if it arrived first. Which of
1297
1401
  // the two comes first is the CLI's business; either order is one call.
1298
1402
  const held = parts.get(id);
1403
+ const meta = toolMetaOf(toolName);
1299
1404
  const call = held ? bag(held.toolCall) : {
1300
1405
  toolCallId: id,
1301
1406
  toolName,
1302
1407
  displayName,
1303
1408
  ...(command ? { toolInput: command } : {}),
1409
+ ...(meta ? { _meta: meta } : {}),
1304
1410
  } as Bag;
1305
1411
  call.status = 'pending-confirmation';
1306
1412
  call.confirmationTitle = confirmationTitle;
@@ -1312,7 +1418,10 @@ export function createSession(options: SessionOptions): Session {
1312
1418
  const part: Bag = { id, kind: 'toolCall', toolCall: call };
1313
1419
  parts.set(id, part);
1314
1420
  holdPart(turn, part);
1315
- emit('chat', { type: 'chat/toolCallStart', turnId: turn.id, toolCallId: id, toolName, displayName });
1421
+ emit('chat', {
1422
+ type: 'chat/toolCallStart', turnId: turn.id, toolCallId: id, toolName, displayName,
1423
+ ...(meta ? { _meta: meta } : {}),
1424
+ });
1316
1425
  }
1317
1426
  emit('chat', {
1318
1427
  type: 'chat/toolCallReady',
@@ -1359,6 +1468,17 @@ export function createSession(options: SessionOptions): Session {
1359
1468
  * `setMcpServers` does not touch servers that came from a settings file.
1360
1469
  */
1361
1470
  ...(Object.keys(declared).length > 0 ? { mcpServers: declared as never } : {}),
1471
+ /*
1472
+ * What the host wants said, after the CLI's own prompt.
1473
+ *
1474
+ * The preset with an `append`, not a prompt of this backend's own: the
1475
+ * CLI's prompt is what makes it the CLI. `snapshot`, so the prompt is
1476
+ * recorded once for the conversation and a resume does not rewrite it
1477
+ * under the model's reasoning.
1478
+ */
1479
+ ...(options.instructions && options.instructions.length > 0
1480
+ ? { systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const, append: options.instructions.join('\n\n'), snapshot: true } }
1481
+ : {}),
1362
1482
  includePartialMessages: true,
1363
1483
  /*
1364
1484
  * Over the daemon's own environment, never instead of it.
@@ -1374,6 +1494,11 @@ export function createSession(options: SessionOptions): Session {
1374
1494
  // From the settings, which is where it lives: it is a config key like
1375
1495
  // the others, and a second way in was a second thing to keep in step.
1376
1496
  ...(typeof settings.permissionMode === 'string' ? { permissionMode: settings.permissionMode } : {}),
1497
+ // The flag settings layer, which `applyFlagSettings` moves later: one
1498
+ // place for the sandbox, whether it was set at creation or since.
1499
+ ...(sandboxOf(settings.sandboxEnabled) !== null ? { settings: { sandbox: sandboxOf(settings.sandboxEnabled) } } : {}),
1500
+ // Before every shell command, while a client has a script in force.
1501
+ hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [sourceFirst] }] },
1377
1502
  /*
1378
1503
  * The lists, at the moment the query is built.
1379
1504
  *
@@ -1459,7 +1584,7 @@ export function createSession(options: SessionOptions): Session {
1459
1584
  */
1460
1585
  const ends = new Map<string, string>();
1461
1586
 
1462
- const beginTurn = (turnId: string, text: string, model?: Chosen, queuedMessageId?: string): void => {
1587
+ const beginTurn = (turnId: string, text: string, model?: Chosen, queuedMessageId?: string, from?: MessageFrom): void => {
1463
1588
  if (model !== undefined && model.id !== chosen) {
1464
1589
  chosen = model.id;
1465
1590
  void handle.setModel(model.id === 'default' ? undefined : model.id).catch(() => {});
@@ -1484,7 +1609,8 @@ export function createSession(options: SessionOptions): Session {
1484
1609
  startedAt: new Date().toISOString(),
1485
1610
  message: {
1486
1611
  text,
1487
- origin: { kind: 'user' },
1612
+ origin: from?.origin ?? { kind: 'user' },
1613
+ ...(from?._meta ? { _meta: from._meta } : {}),
1488
1614
  ...(chosen ? { model: { id: chosen, ...(model?.config ? { config: model.config } : {}) } } : {}),
1489
1615
  },
1490
1616
  responseParts: [],
@@ -1542,7 +1668,12 @@ export function createSession(options: SessionOptions): Session {
1542
1668
  let model: Chosen | undefined;
1543
1669
  if (id !== undefined)
1544
1670
  model = named.config ? { id, config: named.config as NonNullable<Chosen['config']> } : { id };
1545
- beginTurn(crypto.randomUUID(), str(message.text) ?? '', model, str(next.id));
1671
+ // With whose it was: a message an agent queued is still an agent's when
1672
+ // its turn comes.
1673
+ const from: MessageFrom = {};
1674
+ if (message.origin !== undefined) from.origin = bag(message.origin) as NonNullable<MessageFrom['origin']>;
1675
+ if (message._meta !== undefined) from._meta = bag(message._meta);
1676
+ beginTurn(crypto.randomUUID(), str(message.text) ?? '', model, str(next.id), from);
1546
1677
  };
1547
1678
 
1548
1679
  /**
@@ -1764,6 +1895,40 @@ export function createSession(options: SessionOptions): Session {
1764
1895
  if (entry !== undefined) ends.set(String(active.id), entry);
1765
1896
  }
1766
1897
 
1898
+ /*
1899
+ * A running subagent, saying how far it has got.
1900
+ *
1901
+ * `task_progress` is the harness's own status line for a `Task`
1902
+ * that is still running: a model-written summary when the option
1903
+ * is on, or the last tool it reached for. It goes on the call as
1904
+ * `_meta.progressMessage` - the reference client's word for a line
1905
+ * drawn on a running row and dropped when the row ends - and never
1906
+ * into the result, which is what the tool answered and not what it
1907
+ * was doing on the way. The reducer replaces a call's whole `_meta`
1908
+ * on any action that carries one, so the kind stamped at the start
1909
+ * is carried along rather than lost. The same line twice is said
1910
+ * once.
1911
+ */
1912
+ if (type === 'system' && str(message.subtype) === 'task_progress') {
1913
+ const id = str(message.tool_use_id);
1914
+ const part = id === undefined ? undefined : parts.get(id);
1915
+ const call = part === undefined ? undefined : bag(part.toolCall);
1916
+ const line = str(message.summary)
1917
+ ?? (str(message.last_tool_name) !== undefined ? `Running ${String(message.last_tool_name)}` : undefined);
1918
+ if (call !== undefined && line !== undefined && str(call.status) === 'running'
1919
+ && str(bag(call._meta).progressMessage) !== line) {
1920
+ call._meta = { ...bag(call._meta), progressMessage: line };
1921
+ emit('chat', {
1922
+ type: 'chat/toolCallContentChanged',
1923
+ turnId: active?.id,
1924
+ toolCallId: id,
1925
+ content: list(call.content),
1926
+ _meta: call._meta,
1927
+ });
1928
+ }
1929
+ continue;
1930
+ }
1931
+
1767
1932
  if (type === 'stream_event') { streamed(bag(message.event)); continue; }
1768
1933
  if (type === 'assistant') { assistant(bag(message.message)); continue; }
1769
1934
  if (type === 'user') {
@@ -1984,7 +2149,14 @@ export function createSession(options: SessionOptions): Session {
1984
2149
  settings.permissions = held;
1985
2150
  return true;
1986
2151
  }
2152
+ if (key === 'shellInitScripts') return setShellInit(value);
1987
2153
  const said = typeof value === 'string' ? value : '';
2154
+ if (key === 'sandboxEnabled') {
2155
+ if (said !== 'default' && said !== 'on' && said !== 'off') return `sandboxEnabled takes default, on or off, not ${said}`;
2156
+ settings.sandboxEnabled = said;
2157
+ void handle.applyFlagSettings({ sandbox: sandboxOf(said) }).catch(() => {});
2158
+ return true;
2159
+ }
1988
2160
  if (key === 'model') {
1989
2161
  try {
1990
2162
  await handle.setModel(said === 'default' ? undefined : said);
@@ -2171,7 +2343,8 @@ export function createSession(options: SessionOptions): Session {
2171
2343
  * that cannot, because the transcript would then credit a turn to a model
2172
2344
  * that never ran it.
2173
2345
  */
2174
- begin: (turnId, text, model) => beginTurn(turnId, text, model),
2346
+ begin: (turnId, text, model, from) => beginTurn(turnId, text, model, undefined, from),
2347
+ setTitle: (said) => { if (said !== '') title = said; },
2175
2348
 
2176
2349
  /**
2177
2350
  * A turn this host answered itself, with a shell rather than the agent.
@@ -2224,11 +2397,15 @@ export function createSession(options: SessionOptions): Session {
2224
2397
  // The person typed it themselves, so there is nobody left to ask.
2225
2398
  confirmed: 'not-needed',
2226
2399
  status: 'running',
2400
+ _meta: { toolKind: 'terminal' },
2227
2401
  } satisfies OnWire<ToolCallRunningState> as Bag;
2228
- holdPart(turn, call);
2402
+ // As a part, the way every other call is held: the bare call went
2403
+ // into the snapshot with no `kind`, so a client that subscribed after
2404
+ // the command ran had a row it could not draw.
2405
+ holdPart(turn, { id: toolCallId, kind: 'toolCall', toolCall: call });
2229
2406
  emit('chat', {
2230
2407
  type: 'chat/toolCallStart', turnId, toolCallId, toolName: 'terminal',
2231
- displayName: 'Terminal', intention: command,
2408
+ displayName: 'Terminal', intention: command, _meta: { toolKind: 'terminal' },
2232
2409
  });
2233
2410
  emit('chat', {
2234
2411
  type: 'chat/toolCallReady', turnId, toolCallId,
@@ -2320,12 +2497,13 @@ export function createSession(options: SessionOptions): Session {
2320
2497
  * immediately started, which is a queue entry a client sees appear and
2321
2498
  * leave rather than one that was never there.
2322
2499
  */
2323
- queue: (id, text, model) => {
2500
+ queue: (id, text, model, from) => {
2324
2501
  const entry: Bag = {
2325
2502
  id,
2326
2503
  message: {
2327
2504
  text,
2328
- origin: { kind: 'user' },
2505
+ origin: from?.origin ?? { kind: 'user' },
2506
+ ...(from?._meta ? { _meta: from._meta } : {}),
2329
2507
  ...(model ? { model: { id: model.id, ...(model.config ? { config: model.config } : {}) } } : {}),
2330
2508
  },
2331
2509
  };
@@ -2629,6 +2807,8 @@ export function createSession(options: SessionOptions): Session {
2629
2807
  one.settle({ behavior: 'deny', message: 'The session was disposed' });
2630
2808
  }
2631
2809
  releaseCalls('The session was disposed');
2810
+ try { rmSync(initScript, { force: true }); }
2811
+ catch { /* a script that was never written */ }
2632
2812
  handle.close();
2633
2813
  },
2634
2814
  };
package/src/transcript.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getSessionMessages } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { ResponsePart, ToolCallCompletedState, ToolResultContent, Turn } from '@microsoft/agent-host-protocol';
3
3
  import type { Bag, OnWire, WireTurn } from '@ahpd/sdk';
4
+ import { toolMetaOf } from './kinds.js';
4
5
 
5
6
  /**
6
7
  * Reads a session that already happened, as turns.
@@ -149,6 +150,7 @@ export async function turnsOf(sessionId: string, dir: string): Promise<WireTurn<
149
150
  } else if (kind === 'tool_use') {
150
151
  const name = str(block.name) ?? 'tool';
151
152
  const command = summarize(name, bag(block.input));
153
+ const meta = toolMetaOf(name);
152
154
  /*
153
155
  * Checked against the state it claims to be in, at the moment it is
154
156
  * built.
@@ -167,6 +169,9 @@ export async function turnsOf(sessionId: string, dir: string): Promise<WireTurn<
167
169
  // a call still reading `running` would be a spinner that never stops.
168
170
  status: 'completed',
169
171
  ...(command ? { toolInput: command } : {}),
172
+ // The same hint a live call carries, so a transcript read back off
173
+ // disk draws its shell commands as shell commands.
174
+ ...(meta ? { _meta: meta } : {}),
170
175
  /*
171
176
  * Required on a completed call, all four of them, and this builder
172
177
  * sent one of them sometimes.