chainlesschain 0.45.64 → 0.45.65

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.
@@ -18,6 +18,8 @@ import fs from "fs";
18
18
  import path from "path";
19
19
  import { execSync } from "child_process";
20
20
  import os from "os";
21
+ import sharedCodingAgentPolicy from "../runtime/coding-agent-policy.cjs";
22
+ import sharedShellPolicy from "../runtime/coding-agent-shell-policy.cjs";
21
23
  import { getPlanModeManager } from "./plan-mode.js";
22
24
  import { CLISkillLoader } from "./skill-loader.js";
23
25
  import { executeHooks, HookEvents } from "./hook-manager.js";
@@ -32,6 +34,9 @@ import { createToolContext } from "../tools/tool-context.js";
32
34
  import { createToolTelemetryRecord } from "../tools/tool-telemetry.js";
33
35
  import { DEFAULT_TOOL_DESCRIPTORS } from "../tools/registry.js";
34
36
 
37
+ const { isReadOnlyGitCommand, normalizeGitCommand } = sharedCodingAgentPolicy;
38
+ const { evaluateShellCommandPolicy } = sharedShellPolicy;
39
+
35
40
  // ─── Tool definitions ────────────────────────────────────────────────────
36
41
 
37
42
  export const AGENT_TOOLS = [
@@ -91,7 +96,7 @@ export const AGENT_TOOLS = [
91
96
  function: {
92
97
  name: "run_shell",
93
98
  description:
94
- "Execute a shell command and return the output. Use for running tests, installing packages, git operations, etc.",
99
+ "Execute a shell command and return the output. Use for running tests, linting, builds, and other non-git workspace commands.",
95
100
  parameters: {
96
101
  type: "object",
97
102
  properties: {
@@ -105,6 +110,29 @@ export const AGENT_TOOLS = [
105
110
  },
106
111
  },
107
112
  },
113
+ {
114
+ type: "function",
115
+ function: {
116
+ name: "git",
117
+ description:
118
+ "Run a git command inside the workspace. Use this instead of run_shell for git status, diff, log, commit, branch, and related repository operations.",
119
+ parameters: {
120
+ type: "object",
121
+ properties: {
122
+ command: {
123
+ type: "string",
124
+ description:
125
+ 'Git subcommand to execute, for example "status", "diff --stat", or "log --oneline -5"',
126
+ },
127
+ cwd: {
128
+ type: "string",
129
+ description: "Working directory (optional)",
130
+ },
131
+ },
132
+ required: ["command"],
133
+ },
134
+ },
135
+ },
108
136
  {
109
137
  type: "function",
110
138
  function: {
@@ -287,6 +315,11 @@ export function getAgentToolDefinitions({
287
315
  const disabledNames = new Set(
288
316
  Array.isArray(disabledTools) ? disabledTools : [],
289
317
  );
318
+ const extraToolNames = new Set(
319
+ (Array.isArray(extraTools) ? extraTools : [])
320
+ .map((tool) => tool?.function?.name)
321
+ .filter(Boolean),
322
+ );
290
323
  const allTools = mergeToolDefinitions(
291
324
  AGENT_TOOLS,
292
325
  Array.isArray(extraTools) ? extraTools : [],
@@ -295,7 +328,9 @@ export function getAgentToolDefinitions({
295
328
  return allTools.filter((tool) => {
296
329
  const name = tool?.function?.name;
297
330
  if (!name) return false;
298
- if (allowedNames && !allowedNames.has(name)) return false;
331
+ if (allowedNames && !allowedNames.has(name) && !extraToolNames.has(name)) {
332
+ return false;
333
+ }
299
334
  if (disabledNames.has(name)) return false;
300
335
  return true;
301
336
  });
@@ -407,6 +442,7 @@ Key behaviors:
407
442
  - When asked to modify code, read the file first, then edit it
408
443
  - When asked to create something, use write_file to create it
409
444
  - When asked to run/test something, use run_shell to execute it
445
+ - When asked about git status, diff, log, or other repository operations, use the git tool instead of run_shell
410
446
  - When asked about files or code, use read_file and search_files to find information
411
447
  - You have multi-layer skills (built-in, marketplace, global, project-level) — use list_skills to discover them and run_skill to execute them
412
448
  - Always explain what you're doing and show results
@@ -575,7 +611,14 @@ export async function executeTool(name, args, context = {}) {
575
611
  const hookDb = context.hookDb || null;
576
612
  const skillLoader = context.skillLoader || _defaultSkillLoader;
577
613
  const cwd = context.cwd || process.cwd();
578
- const runtimeDescriptor = getRuntimeToolDescriptor(name);
614
+ const planManager = context.planManager || getPlanModeManager();
615
+ const localToolDescriptor =
616
+ context.externalToolDescriptors &&
617
+ typeof context.externalToolDescriptors === "object"
618
+ ? context.externalToolDescriptors[name] || null
619
+ : null;
620
+ const runtimeDescriptor =
621
+ getRuntimeToolDescriptor(name) || localToolDescriptor;
579
622
  const toolContext = createToolContext({
580
623
  toolName: runtimeDescriptor?.name || name,
581
624
  cwd,
@@ -600,7 +643,21 @@ export async function executeTool(name, args, context = {}) {
600
643
  : null;
601
644
  const isExternalHostTool =
602
645
  hostToolPolicy && !STATIC_AGENT_TOOL_NAMES.has(name);
603
- if (hostToolPolicy && hostToolPolicy.allowed === false) {
646
+ const isExternalLocalTool =
647
+ localToolDescriptor && !STATIC_AGENT_TOOL_NAMES.has(name);
648
+ const hostPolicyAllowsReadOnlyGit =
649
+ name === "git" &&
650
+ hostToolPolicy?.planModeBehavior === "readonly-conditional" &&
651
+ isReadOnlyGitCommand(args.command);
652
+ const localReadOnlyAllowedInPlanMode =
653
+ isExternalLocalTool &&
654
+ planManager.isActive() &&
655
+ localToolDescriptor?.isReadOnly === true;
656
+ if (
657
+ hostToolPolicy &&
658
+ hostToolPolicy.allowed === false &&
659
+ !hostPolicyAllowsReadOnlyGit
660
+ ) {
604
661
  return {
605
662
  error: `[Host Policy] Tool "${name}" is blocked by desktop host policy. ${hostToolPolicy.reason || "Desktop approval has not been synchronized yet."}`,
606
663
  policy: {
@@ -613,20 +670,24 @@ export async function executeTool(name, args, context = {}) {
613
670
  }
614
671
 
615
672
  // Plan mode: check if tool is allowed
616
- const planManager = getPlanModeManager();
617
673
  if (
618
674
  planManager.isActive() &&
675
+ !(name === "git" && isReadOnlyGitCommand(args.command)) &&
619
676
  !planManager.isToolAllowed(name) &&
620
- !(isExternalHostTool && hostToolPolicy?.allowed === true)
677
+ !(isExternalHostTool && hostToolPolicy?.allowed === true) &&
678
+ !localReadOnlyAllowedInPlanMode
621
679
  ) {
622
680
  planManager.addPlanItem({
623
681
  title: `${name}: ${formatToolArgs(name, args)}`,
624
682
  tool: name,
625
683
  params: args,
626
684
  estimatedImpact:
627
- name === "run_shell" || name === "run_code"
685
+ name === "run_shell" ||
686
+ name === "run_code" ||
687
+ name === "git" ||
688
+ localToolDescriptor?.riskLevel === "high"
628
689
  ? "high"
629
- : name === "write_file"
690
+ : name === "write_file" || localToolDescriptor?.riskLevel === "medium"
630
691
  ? "medium"
631
692
  : "low",
632
693
  });
@@ -659,6 +720,9 @@ export async function executeTool(name, args, context = {}) {
659
720
  parentMessages: context.parentMessages,
660
721
  interaction: context.interaction,
661
722
  hostManagedToolPolicy: context.hostManagedToolPolicy || null,
723
+ externalToolDescriptors: context.externalToolDescriptors || null,
724
+ externalToolExecutors: context.externalToolExecutors || null,
725
+ mcpClient: context.mcpClient || null,
662
726
  });
663
727
  } catch (err) {
664
728
  if (hookDb) {
@@ -715,9 +779,23 @@ export async function executeTool(name, args, context = {}) {
715
779
  async function executeToolInner(
716
780
  name,
717
781
  args,
718
- { skillLoader, cwd, parentMessages, interaction, hostManagedToolPolicy },
782
+ {
783
+ skillLoader,
784
+ cwd,
785
+ parentMessages,
786
+ interaction,
787
+ hostManagedToolPolicy,
788
+ externalToolDescriptors,
789
+ externalToolExecutors,
790
+ mcpClient,
791
+ },
719
792
  ) {
720
- const runtimeDescriptor = getRuntimeToolDescriptor(name);
793
+ const localToolDescriptor =
794
+ externalToolDescriptors && typeof externalToolDescriptors === "object"
795
+ ? externalToolDescriptors[name] || null
796
+ : null;
797
+ const runtimeDescriptor =
798
+ getRuntimeToolDescriptor(name) || localToolDescriptor;
721
799
  const hostToolPolicies =
722
800
  hostManagedToolPolicy?.tools || hostManagedToolPolicy?.toolPolicies || null;
723
801
  const hostToolPolicy =
@@ -754,6 +832,10 @@ async function executeToolInner(
754
832
  }
755
833
  return DEFAULT_TOOL_DESCRIPTOR_MAP.get("shell");
756
834
  };
835
+ const localToolExecutor =
836
+ externalToolExecutors && typeof externalToolExecutors === "object"
837
+ ? externalToolExecutors[name] || null
838
+ : null;
757
839
  switch (name) {
758
840
  case "read_file": {
759
841
  const filePath = path.resolve(cwd, args.path);
@@ -799,6 +881,18 @@ async function executeToolInner(
799
881
  }
800
882
 
801
883
  case "run_shell": {
884
+ const shellPolicy = evaluateShellCommandPolicy(args.command);
885
+ const override = resolveShellDescriptor(args.command);
886
+ if (!shellPolicy.allowed) {
887
+ return attachDescriptor(
888
+ {
889
+ error: `[Shell Policy] ${shellPolicy.reason}`,
890
+ shellCommandPolicy: shellPolicy,
891
+ },
892
+ override || runtimeDescriptor,
893
+ );
894
+ }
895
+
802
896
  try {
803
897
  const output = execSync(args.command, {
804
898
  cwd: args.cwd || cwd,
@@ -806,26 +900,57 @@ async function executeToolInner(
806
900
  timeout: 60000,
807
901
  maxBuffer: 1024 * 1024,
808
902
  });
809
- const override = resolveShellDescriptor(args.command);
810
903
  return attachDescriptor(
811
904
  {
812
905
  stdout: output.substring(0, 30000),
906
+ shellCommandPolicy: shellPolicy,
813
907
  },
814
908
  override || runtimeDescriptor,
815
909
  );
816
910
  } catch (err) {
817
- const override = resolveShellDescriptor(args.command);
818
911
  return attachDescriptor(
819
912
  {
820
913
  error: err.message.substring(0, 2000),
821
914
  stderr: (err.stderr || "").substring(0, 2000),
822
915
  exitCode: err.status,
916
+ shellCommandPolicy: shellPolicy,
823
917
  },
824
918
  override || runtimeDescriptor,
825
919
  );
826
920
  }
827
921
  }
828
922
 
923
+ case "git": {
924
+ const normalizedCommand = normalizeGitCommand(args.command);
925
+ if (!normalizedCommand) {
926
+ return attachDescriptor({
927
+ error: "Git command is required.",
928
+ });
929
+ }
930
+
931
+ try {
932
+ const output = execSync(`git ${normalizedCommand}`, {
933
+ cwd: args.cwd || cwd,
934
+ encoding: "utf8",
935
+ timeout: 60000,
936
+ maxBuffer: 1024 * 1024,
937
+ });
938
+ return attachDescriptor({
939
+ stdout: output.substring(0, 30000),
940
+ command: normalizedCommand,
941
+ readOnly: isReadOnlyGitCommand(normalizedCommand),
942
+ });
943
+ } catch (err) {
944
+ return attachDescriptor({
945
+ error: err.message.substring(0, 2000),
946
+ stderr: (err.stderr || "").substring(0, 2000),
947
+ exitCode: err.status,
948
+ command: normalizedCommand,
949
+ readOnly: isReadOnlyGitCommand(normalizedCommand),
950
+ });
951
+ }
952
+ }
953
+
829
954
  case "run_code": {
830
955
  return attachDescriptor(await _executeRunCode(args, cwd));
831
956
  }
@@ -988,6 +1113,30 @@ async function executeToolInner(
988
1113
  }
989
1114
 
990
1115
  default:
1116
+ if (localToolExecutor?.kind === "mcp") {
1117
+ if (!mcpClient || typeof mcpClient.callTool !== "function") {
1118
+ return attachDescriptor({
1119
+ error: `MCP client is unavailable for tool: ${name}`,
1120
+ });
1121
+ }
1122
+
1123
+ try {
1124
+ const result = await mcpClient.callTool(
1125
+ localToolExecutor.serverName,
1126
+ localToolExecutor.toolName,
1127
+ args || {},
1128
+ );
1129
+ if (result && typeof result === "object") {
1130
+ return attachDescriptor(result);
1131
+ }
1132
+ return attachDescriptor({ result });
1133
+ } catch (err) {
1134
+ return attachDescriptor({
1135
+ error: `MCP tool execution failed: ${err.message}`,
1136
+ });
1137
+ }
1138
+ }
1139
+
991
1140
  if (
992
1141
  hostToolDefinition &&
993
1142
  interaction &&
@@ -1332,8 +1481,12 @@ export async function chatWithTools(rawMessages, options) {
1332
1481
 
1333
1482
  const persona = _loadProjectPersona(options.cwd);
1334
1483
  const tools = getAgentToolDefinitions({
1484
+ names: options.enabledToolNames,
1335
1485
  disabledTools: persona?.toolsDisabled,
1336
- extraTools: options.hostManagedToolPolicy?.toolDefinitions || [],
1486
+ extraTools: [
1487
+ ...(options.hostManagedToolPolicy?.toolDefinitions || []),
1488
+ ...(options.extraToolDefinitions || []),
1489
+ ],
1337
1490
  });
1338
1491
 
1339
1492
  const lastUserMsg = [...rawMessages].reverse().find((m) => m.role === "user");
@@ -1518,7 +1671,12 @@ export async function* agentLoop(messages, options) {
1518
1671
  hookDb: options.hookDb || null,
1519
1672
  skillLoader: options.skillLoader || _defaultSkillLoader,
1520
1673
  cwd: options.cwd || process.cwd(),
1674
+ planManager: options.planManager || null,
1675
+ sessionId: options.sessionId || null,
1521
1676
  hostManagedToolPolicy: options.hostManagedToolPolicy || null,
1677
+ externalToolDescriptors: options.externalToolDescriptors || null,
1678
+ externalToolExecutors: options.externalToolExecutors || null,
1679
+ mcpClient: options.mcpClient || null,
1522
1680
  parentMessages: messages, // pass parent messages for sub-agent auto-condensation
1523
1681
  interaction: options.interaction || null,
1524
1682
  };
@@ -1646,6 +1804,8 @@ export function formatToolArgs(name, args) {
1646
1804
  return args.path;
1647
1805
  case "run_shell":
1648
1806
  return args.command;
1807
+ case "git":
1808
+ return args.command;
1649
1809
  case "search_files":
1650
1810
  return args.pattern;
1651
1811
  case "list_dir":
@@ -7,6 +7,25 @@
7
7
  */
8
8
 
9
9
  import { createHash } from "crypto";
10
+ import {
11
+ createCodingAgentEvent,
12
+ CodingAgentSequenceTracker,
13
+ CODING_AGENT_EVENT_TYPES,
14
+ LEGACY_TO_UNIFIED_TYPE,
15
+ } from "../runtime/runtime-events.js";
16
+
17
+ // Whitelist of event types the CLI runtime should emit as unified envelopes
18
+ // (with source: "cli-runtime"). Anything not in this set keeps the legacy
19
+ // raw shape so non-coding-agent transports (host-tool callbacks, generic
20
+ // progress events, etc.) are unaffected.
21
+ const CODING_AGENT_EVENT_TYPE_SET = new Set([
22
+ ...Object.values(CODING_AGENT_EVENT_TYPES),
23
+ ...Object.keys(LEGACY_TO_UNIFIED_TYPE),
24
+ ]);
25
+
26
+ function isCodingAgentEventType(type) {
27
+ return typeof type === "string" && CODING_AGENT_EVENT_TYPE_SET.has(type);
28
+ }
10
29
 
11
30
  /**
12
31
  * Base class — subclasses must implement askInput, askSelect, askConfirm, emit.
@@ -87,6 +106,10 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
87
106
  this.sessionId = sessionId;
88
107
  /** @type {Map<string, {resolve: Function, reject: Function}>} */
89
108
  this._pending = new Map();
109
+ // Per-instance sequence tracker so monotonic sequences are scoped to
110
+ // this WS session instead of leaking across sessions via the process-
111
+ // global default tracker.
112
+ this._sequenceTracker = new CodingAgentSequenceTracker();
90
113
  }
91
114
 
92
115
  /** Generate a unique request id */
@@ -184,6 +207,28 @@ export class WebSocketInteractionAdapter extends InteractionAdapter {
184
207
  }
185
208
 
186
209
  emit(eventType, data) {
210
+ // Coding-agent events flow as the unified envelope so the Desktop bridge
211
+ // (and any other consumer) sees a single canonical shape with
212
+ // source: "cli-runtime" baked in — no Bridge-layer translation needed.
213
+ if (isCodingAgentEventType(eventType)) {
214
+ const payload = data && typeof data === "object" ? { ...data } : {};
215
+ const requestId = payload.requestId || null;
216
+ const sessionId = payload.sessionId || this.sessionId || null;
217
+ delete payload.requestId;
218
+ delete payload.sessionId;
219
+
220
+ const envelope = createCodingAgentEvent(eventType, payload, {
221
+ sessionId,
222
+ requestId,
223
+ source: "cli-runtime",
224
+ tracker: this._sequenceTracker,
225
+ });
226
+ this._sendWs(envelope);
227
+ return;
228
+ }
229
+
230
+ // Non-coding-agent events (host-tool callbacks, generic progress, etc.)
231
+ // keep the legacy raw shape — they are not part of the v1.0 protocol.
187
232
  this._sendWs({
188
233
  type: eventType,
189
234
  sessionId: this.sessionId,
@@ -2,7 +2,7 @@
2
2
  * Plan Mode for CLI Agent REPL
3
3
  *
4
4
  * During plan mode, the AI can only use read-only tools (read_file, search_files, list_dir, list_skills).
5
- * Write/execute tools (write_file, edit_file, run_shell, run_skill) are blocked until the plan is approved.
5
+ * Write/execute tools (write_file, edit_file, run_shell, git, run_skill) are blocked until the plan is approved.
6
6
  *
7
7
  * Lightweight port of desktop-app-vue/src/main/ai-engine/plan-mode/index.js
8
8
  */
@@ -48,6 +48,7 @@ const WRITE_TOOLS = new Set([
48
48
  "write_file",
49
49
  "edit_file",
50
50
  "run_shell",
51
+ "git",
51
52
  "run_skill",
52
53
  ]);
53
54
 
@@ -66,6 +67,7 @@ const TOOL_RISK_WEIGHTS = {
66
67
  edit_file: 2,
67
68
  run_skill: 2,
68
69
  run_shell: 3,
70
+ git: 3,
69
71
  };
70
72
 
71
73
  const IMPACT_MULTIPLIERS = {
@@ -16,11 +16,18 @@ function ensureSessionsTable(db) {
16
16
  model TEXT DEFAULT '',
17
17
  message_count INTEGER DEFAULT 0,
18
18
  messages TEXT DEFAULT '[]',
19
+ metadata TEXT DEFAULT '{}',
19
20
  summary TEXT DEFAULT '',
20
21
  created_at TEXT DEFAULT (datetime('now')),
21
22
  updated_at TEXT DEFAULT (datetime('now'))
22
23
  )
23
24
  `);
25
+
26
+ try {
27
+ db.exec("ALTER TABLE llm_sessions ADD COLUMN metadata TEXT DEFAULT '{}'");
28
+ } catch (_error) {
29
+ // Column already exists or ALTER TABLE is unsupported by the mock DB.
30
+ }
24
31
  }
25
32
 
26
33
  /**
@@ -34,13 +41,14 @@ export function createSession(db, options = {}) {
34
41
  `session-${Date.now()}-${createHash("sha256").update(Math.random().toString()).digest("hex").slice(0, 6)}`;
35
42
 
36
43
  db.prepare(
37
- `INSERT INTO llm_sessions (id, title, provider, model, messages) VALUES (?, ?, ?, ?, ?)`,
44
+ `INSERT INTO llm_sessions (id, title, provider, model, messages, metadata) VALUES (?, ?, ?, ?, ?, ?)`,
38
45
  ).run(
39
46
  id,
40
47
  options.title || "Untitled",
41
48
  options.provider || "",
42
49
  options.model || "",
43
50
  JSON.stringify(options.messages || []),
51
+ JSON.stringify(options.metadata || {}),
44
52
  );
45
53
 
46
54
  return { id, title: options.title || "Untitled" };
@@ -71,14 +79,26 @@ export function addMessage(db, sessionId, role, content) {
71
79
  /**
72
80
  * Save all messages at once (batch update)
73
81
  */
74
- export function saveMessages(db, sessionId, messages) {
82
+ export function saveMessages(db, sessionId, messages, metadata) {
75
83
  ensureSessionsTable(db);
76
84
 
77
- const result = db
78
- .prepare(
79
- `UPDATE llm_sessions SET messages = ?, message_count = ?, updated_at = datetime('now') WHERE id = ?`,
80
- )
81
- .run(JSON.stringify(messages), messages.length, sessionId);
85
+ const hasMetadata = metadata !== undefined;
86
+ const result = hasMetadata
87
+ ? db
88
+ .prepare(
89
+ `UPDATE llm_sessions SET messages = ?, metadata = ?, message_count = ?, updated_at = datetime('now') WHERE id = ?`,
90
+ )
91
+ .run(
92
+ JSON.stringify(messages),
93
+ JSON.stringify(metadata || {}),
94
+ messages.length,
95
+ sessionId,
96
+ )
97
+ : db
98
+ .prepare(
99
+ `UPDATE llm_sessions SET messages = ?, message_count = ?, updated_at = datetime('now') WHERE id = ?`,
100
+ )
101
+ .run(JSON.stringify(messages), messages.length, sessionId);
82
102
 
83
103
  return { messageCount: messages.length, updated: result.changes > 0 };
84
104
  }
@@ -105,6 +125,10 @@ export function getSession(db, sessionId) {
105
125
  return {
106
126
  ...session,
107
127
  messages: JSON.parse(session.messages || "[]"),
128
+ metadata:
129
+ typeof session.metadata === "string"
130
+ ? JSON.parse(session.metadata || "{}")
131
+ : session.metadata || {},
108
132
  };
109
133
  }
110
134
 
@@ -119,11 +143,19 @@ export function listSessions(db, options = {}) {
119
143
  return db
120
144
  .prepare(
121
145
  `SELECT id, title, provider, model, message_count, summary, created_at, updated_at
146
+ , metadata
122
147
  FROM llm_sessions
123
148
  ORDER BY updated_at DESC
124
149
  LIMIT ?`,
125
150
  )
126
- .all(limit);
151
+ .all(limit)
152
+ .map((session) => ({
153
+ ...session,
154
+ metadata:
155
+ typeof session.metadata === "string"
156
+ ? JSON.parse(session.metadata || "{}")
157
+ : session.metadata || {},
158
+ }));
127
159
  }
128
160
 
129
161
  /**
@@ -142,6 +174,11 @@ export function updateSession(db, sessionId, updates) {
142
174
  "UPDATE llm_sessions SET summary = ?, updated_at = datetime('now') WHERE id = ?",
143
175
  ).run(updates.summary, sessionId);
144
176
  }
177
+ if (updates.metadata !== undefined) {
178
+ db.prepare(
179
+ "UPDATE llm_sessions SET metadata = ?, updated_at = datetime('now') WHERE id = ?",
180
+ ).run(JSON.stringify(updates.metadata || {}), sessionId);
181
+ }
145
182
  }
146
183
 
147
184
  /**
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Web UI envelope unwrap helper.
3
+ *
4
+ * Single source of truth for the unified Coding Agent envelope → legacy
5
+ * kebab-case adapter consumed by the browser bundle in `web-ui-server.js`.
6
+ *
7
+ * The same code runs in two contexts:
8
+ * 1. Node.js unit tests — imported as ESM/CJS and executed directly.
9
+ * 2. Browser — inlined into the HTML payload as a `<script>` block via
10
+ * `getInlineSource()`. The function body must therefore stay
11
+ * ES5-friendly (no spread, no const) so it parses in older runtimes.
12
+ */
13
+
14
+ export const UNIFIED_TO_LEGACY = Object.freeze({
15
+ "session.started": "session-created",
16
+ "session.resumed": "session-resumed",
17
+ "session.list": "session-list-result",
18
+ "session.closed": "session-closed",
19
+ "assistant.delta": "response-token",
20
+ "assistant.final": "response-complete",
21
+ "assistant.message": "response-message",
22
+ "assistant.thought-summary": "thought-summary",
23
+ "tool.call.started": "tool-executing",
24
+ "tool.call.completed": "tool-result",
25
+ "tool.call.failed": "tool-error",
26
+ "tool.call.skipped": "tool-skipped",
27
+ "plan.started": "plan-started",
28
+ "plan.updated": "plan-updated",
29
+ "plan.approval_required": "plan-ready",
30
+ "plan.approved": "plan-approved",
31
+ "plan.rejected": "plan-rejected",
32
+ "request.accepted": "request-accepted",
33
+ "request.rejected": "request-rejected",
34
+ "approval.requested": "approval-requested",
35
+ "approval.granted": "approval-granted",
36
+ "approval.denied": "approval-denied",
37
+ "model.switch": "model-switch",
38
+ "command.response": "command-response",
39
+ "slot.filling": "slot-filling",
40
+ "context.compaction.completed": "compression-applied",
41
+ "worktree.list": "worktree-list",
42
+ "worktree.diff": "worktree-diff",
43
+ "worktree.merged": "worktree-merged",
44
+ "worktree.merge-preview": "worktree-merge-preview",
45
+ "worktree.automation-applied": "worktree-automation-applied",
46
+ });
47
+
48
+ /**
49
+ * Detect a unified envelope and unwrap its payload into a flat shape that
50
+ * matches the legacy kebab-case message structure.
51
+ *
52
+ * Returns the message unchanged if it is not a recognised envelope so that
53
+ * non-envelope traffic (auth-result, server pings, etc.) keeps working.
54
+ */
55
+ export function unwrapEnvelope(msg) {
56
+ if (!msg || typeof msg !== "object") return msg;
57
+ if (msg.version !== "1.0") return msg;
58
+ if (typeof msg.eventId !== "string") return msg;
59
+ if (!msg.payload || typeof msg.payload !== "object") return msg;
60
+ const legacyType = UNIFIED_TO_LEGACY[msg.type] || msg.type;
61
+ const flat = Object.assign({}, msg.payload);
62
+ flat.type = legacyType;
63
+ if (msg.sessionId != null) flat.sessionId = msg.sessionId;
64
+ if (msg.requestId != null) flat.requestId = msg.requestId;
65
+ return flat;
66
+ }
67
+
68
+ /**
69
+ * Render the helper as a `var` + `function` declaration string suitable
70
+ * for inlining inside the browser bundle returned by `buildHtml`.
71
+ *
72
+ * The output is intentionally ES5-compatible: it uses `var` rather than
73
+ * `const`, and inlines the map literal so the browser does not need to
74
+ * import any module.
75
+ */
76
+ export function getInlineSource() {
77
+ return (
78
+ "var UNIFIED_TO_LEGACY = " +
79
+ JSON.stringify(UNIFIED_TO_LEGACY, null, 2) +
80
+ ";\n" +
81
+ "function unwrapEnvelope(msg) {\n" +
82
+ " if (!msg || typeof msg !== 'object') return msg;\n" +
83
+ " if (msg.version !== '1.0') return msg;\n" +
84
+ " if (typeof msg.eventId !== 'string') return msg;\n" +
85
+ " if (!msg.payload || typeof msg.payload !== 'object') return msg;\n" +
86
+ " var legacyType = UNIFIED_TO_LEGACY[msg.type] || msg.type;\n" +
87
+ " var flat = Object.assign({}, msg.payload);\n" +
88
+ " flat.type = legacyType;\n" +
89
+ " if (msg.sessionId != null) flat.sessionId = msg.sessionId;\n" +
90
+ " if (msg.requestId != null) flat.requestId = msg.requestId;\n" +
91
+ " return flat;\n" +
92
+ " }"
93
+ );
94
+ }
@@ -12,6 +12,7 @@ import http from "http";
12
12
  import fs from "fs";
13
13
  import path from "path";
14
14
  import { fileURLToPath } from "url";
15
+ import { getInlineSource as getEnvelopeInlineSource } from "./web-ui-envelope.js";
15
16
 
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
 
@@ -654,7 +655,16 @@ function buildHtml({
654
655
  send({ type: 'session-list' });
655
656
  }
656
657
 
657
- function handleMessage(msg) {
658
+ // Map unified envelope dot-case types back to legacy kebab-case so the
659
+ // existing switch table below keeps working without per-case rewrites.
660
+ // The CLI runtime wraps every coding-agent event in a v1.0 envelope.
661
+ // Source of truth lives in lib/web-ui-envelope.js — inlined here at
662
+ // build time so the browser bundle stays self-contained and the same
663
+ // unwrap logic is unit-testable in Node.
664
+ ${getEnvelopeInlineSource()}
665
+
666
+ function handleMessage(rawMsg) {
667
+ var msg = unwrapEnvelope(rawMsg);
658
668
  switch (msg.type) {
659
669
  case 'auth-result':
660
670
  if (msg.success) {
@@ -798,6 +808,8 @@ function buildHtml({
798
808
  ws.onmessage = ev => {
799
809
  let msg;
800
810
  try { msg = JSON.parse(ev.data); } catch { return; }
811
+ // Unwrap unified envelopes so the type compare below still matches.
812
+ msg = unwrapEnvelope(msg);
801
813
  if (msg.type === 'session-created' && msg.sessionId) {
802
814
  // Replace temp id
803
815
  sessions.delete(tempId);