@cjhyy/code-shell-core 0.6.0-rc.8 → 0.6.0-rc.9

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.
@@ -26,6 +26,19 @@ import { logger } from "../logging/logger.js";
26
26
  import { nanoid } from "nanoid";
27
27
  import { redactLlmConfig, maskSecretValue } from "./redact.js";
28
28
  import { redactSecrets } from "../logging/sanitize-messages.js";
29
+ const COMPACT_STREAM_STRATEGIES = new Set([
30
+ "micro",
31
+ "summary",
32
+ "window",
33
+ "snip",
34
+ "emergency",
35
+ "compacted",
36
+ ]);
37
+ function toCompactStreamStrategy(strategy) {
38
+ return COMPACT_STREAM_STRATEGIES.has(strategy)
39
+ ? strategy
40
+ : "compacted";
41
+ }
29
42
  export class AgentServer {
30
43
  chatManager;
31
44
  legacyEngine;
@@ -336,9 +349,9 @@ export class AgentServer {
336
349
  session.engine.setAskUser((question, opts) => this.requestAskUserForSession(session, sid, question, opts));
337
350
  // Browser automation bridge: each method routes a browser action to the
338
351
  // client (Electron main drives the webview via CDP) over the SAME
339
- // request/response channel as askUser (pendingApprovals + requestId),
340
- // reusing its proven resolve/timeout/cleanup. The main-side handler
341
- // replies with the action's JSON result.
352
+ // request/response channel as askUser (pendingApprovals + requestId).
353
+ // Browser actions still keep their own bounded timeout; AskUserQuestion
354
+ // waits for a real answer or Stop/cancel.
342
355
  session.engine.setBrowserBridge(this.makeBrowserBridge(session, sid));
343
356
  // Cookie→browser injection (InjectCredential tool): same cross-process
344
357
  // channel; main restores the cookie jar into the built-in browser.
@@ -352,6 +365,7 @@ export class AgentServer {
352
365
  ? params.goal
353
366
  : undefined,
354
367
  onStream: (event) => this.notify(Methods.StreamEvent, { sessionId: sid, event }),
368
+ clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
355
369
  });
356
370
  const runResult = {
357
371
  text: result.text,
@@ -431,6 +445,7 @@ export class AgentServer {
431
445
  sessionId: params.sessionId,
432
446
  signal: runController.signal,
433
447
  onStream: streamToClient,
448
+ clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
434
449
  goal: typeof params.goal === "string" ||
435
450
  (params.goal != null && typeof params.goal === "object")
436
451
  ? params.goal
@@ -478,7 +493,10 @@ export class AgentServer {
478
493
  // ─── Approve ────────────────────────────────────────────────────
479
494
  handleApprove(req) {
480
495
  const params = (req.params ?? {});
481
- // ChatSessionManager path: look up per-session pendingApprovals
496
+ // ChatSessionManager path: approvals are scoped by (sessionId, requestId).
497
+ // Never fall back from a session-tagged response to the legacy global map:
498
+ // a stale/misrouted UI response must fail closed instead of resolving a
499
+ // pending prompt from another session.
482
500
  if (this.chatManager && typeof params.sessionId === "string") {
483
501
  const s = this.chatManager.get(params.sessionId);
484
502
  if (!s) {
@@ -487,25 +505,13 @@ export class AgentServer {
487
505
  }
488
506
  const resolve = s.pendingApprovals.get(params.requestId);
489
507
  if (!resolve) {
490
- // Tool approvals still resolve through the interactive backend's
491
- // legacy pending map; the sessionId on their envelope is UI routing
492
- // metadata. Accept a session-tagged response for those requests too.
493
- const legacyResolve = this.pendingApprovals.get(params.requestId);
494
- if (!legacyResolve) {
495
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `No pending approval: ${params.requestId}`));
496
- return;
497
- }
498
- this.pendingApprovals.delete(params.requestId);
499
- this.clearApprovalTimer(params.requestId);
500
- legacyResolve(params.decision);
501
- this.transport.send(createResponse(req.id, { ok: true }));
508
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `No pending approval for session ${params.sessionId}: ${params.requestId}`));
502
509
  return;
503
510
  }
504
511
  s.pendingApprovals.delete(params.requestId);
505
- // Cancel the pending timeout for THIS request (the ask path armed one).
506
- // The legacy single-engine branch below already does this; without it
507
- // here a decided request leaves a live timer that fires at
508
- // APPROVAL_TIMEOUT_MS and churns a dead map entry.
512
+ // Cancel the pending timeout for requests that arm one (browser actions,
513
+ // credential injection, and tool approvals). AskUserQuestion does not use
514
+ // a timeout; this is harmless for those request ids.
509
515
  this.clearApprovalTimer(params.requestId);
510
516
  resolve(params.decision);
511
517
  this.transport.send(createResponse(req.id, { ok: true }));
@@ -539,11 +545,10 @@ export class AgentServer {
539
545
  s.cancel();
540
546
  // s.cancel() only aborts the engine controller + drains queued turns. The
541
547
  // session's pendingApprovals (askUser / browser_action / tool approvals)
542
- // are NOT driven by the abort signal they only settle on a client reply
543
- // or the 5-minute APPROVAL_TIMEOUT_MS. Left alone, the awaiting tool call
544
- // hangs until that timeout (delaying run wind-down) and the server-side
545
- // approvalTimers entry leaks. Resolve them as cancelled now and clear the
546
- // matching timers, mirroring the legacy path below.
548
+ // are NOT driven directly by the abort signal. Left alone, an awaiting
549
+ // AskUserQuestion would now wait forever, while bounded request types
550
+ // would wait until APPROVAL_TIMEOUT_MS. Resolve them as cancelled now and
551
+ // clear any matching timers, mirroring the legacy path below.
547
552
  this.cancelSessionApprovals(s);
548
553
  this.transport.send(createResponse(req.id, { ok: true }));
549
554
  return;
@@ -726,6 +731,10 @@ export class AgentServer {
726
731
  return;
727
732
  }
728
733
  if (this.chatManager) {
734
+ const session = this.chatManager.get(params.sessionId);
735
+ if (session) {
736
+ this.cancelSessionApprovals(session, "session closed");
737
+ }
729
738
  this.chatManager.close(params.sessionId);
730
739
  }
731
740
  // Explicit session teardown — reap that session's background shells
@@ -1031,7 +1040,19 @@ export class AgentServer {
1031
1040
  return;
1032
1041
  }
1033
1042
  try {
1034
- const result = compactEngine.forceCompact(compactSessionId);
1043
+ const result = await compactEngine.forceCompact(compactSessionId);
1044
+ if (result.before > result.after) {
1045
+ const event = {
1046
+ type: "context_compact",
1047
+ strategy: toCompactStreamStrategy(result.strategy),
1048
+ before: result.before,
1049
+ after: result.after,
1050
+ };
1051
+ this.notify(Methods.StreamEvent, {
1052
+ sessionId: compactSessionId ?? "",
1053
+ event,
1054
+ });
1055
+ }
1035
1056
  this.transport.send(createResponse(req.id, {
1036
1057
  type: "compact",
1037
1058
  data: result,
@@ -1359,8 +1380,8 @@ export class AgentServer {
1359
1380
  return;
1360
1381
  }
1361
1382
  try {
1362
- engine.enqueueSteer(params.sessionId, params.text, params.id);
1363
- this.transport.send(createResponse(req.id, { ok: true }));
1383
+ const result = engine.enqueueSteer(params.sessionId, params.text, params.id, params.clientMessageId);
1384
+ this.transport.send(createResponse(req.id, { ok: true, ...result }));
1364
1385
  }
1365
1386
  catch (err) {
1366
1387
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
@@ -1397,10 +1418,23 @@ export class AgentServer {
1397
1418
  return new Promise((resolve) => {
1398
1419
  const requestId = nanoid(12);
1399
1420
  const sessionId = typeof request.sessionId === "string" ? request.sessionId : undefined;
1400
- this.pendingApprovals.set(requestId, resolve);
1421
+ const session = this.chatManager && sessionId ? this.chatManager.get(sessionId) : undefined;
1422
+ if (this.chatManager && sessionId && !session) {
1423
+ resolve({ approved: false, reason: `session closed: ${sessionId}` });
1424
+ return;
1425
+ }
1426
+ if (session) {
1427
+ session.pendingApprovals.set(requestId, (decision) => {
1428
+ resolve(decision);
1429
+ });
1430
+ }
1431
+ else {
1432
+ this.pendingApprovals.set(requestId, resolve);
1433
+ }
1401
1434
  const timer = setTimeout(() => {
1402
- if (this.pendingApprovals.has(requestId)) {
1403
- this.pendingApprovals.delete(requestId);
1435
+ const pending = session?.pendingApprovals ?? this.pendingApprovals;
1436
+ if (pending.has(requestId)) {
1437
+ pending.delete(requestId);
1404
1438
  this.approvalTimers.delete(requestId);
1405
1439
  resolve({ approved: false, reason: "approval timed out" });
1406
1440
  }
@@ -1414,14 +1448,11 @@ export class AgentServer {
1414
1448
  });
1415
1449
  }
1416
1450
  /**
1417
- * Ask the client to answer a question from the agent (legacy single-engine path).
1418
- */
1419
- /**
1420
- * Per-session AskUserQuestion for the chatManager path. Mirrors
1421
- * requestAskUserFromClient but resolves via the SESSION's pendingApprovals
1422
- * (the chatManager approve handler looks there, keyed by sessionId+requestId)
1423
- * and tags the notify with sessionId so the renderer routes the question to
1424
- * the right chat tab.
1451
+ * Per-session AskUserQuestion for the chatManager path. Resolves via the
1452
+ * SESSION's pendingApprovals (the chatManager approve handler looks there,
1453
+ * keyed by sessionId+requestId) and tags the notify with sessionId so the
1454
+ * renderer routes the question to the right chat tab. This intentionally has
1455
+ * no wall-clock timeout; Stop/cancel drains the pending ask.
1425
1456
  */
1426
1457
  requestAskUserForSession(session, sessionId, question, opts) {
1427
1458
  return new Promise((resolve) => {
@@ -1437,14 +1468,6 @@ export class AgentServer {
1437
1468
  resolve(typeof decision === "string" ? decision : "");
1438
1469
  }
1439
1470
  });
1440
- const timer = setTimeout(() => {
1441
- if (session.pendingApprovals.has(requestId)) {
1442
- session.pendingApprovals.delete(requestId);
1443
- this.approvalTimers.delete(requestId);
1444
- resolve("(approval timed out)");
1445
- }
1446
- }, AgentServer.APPROVAL_TIMEOUT_MS);
1447
- this.approvalTimers.set(requestId, timer);
1448
1471
  const args = { question };
1449
1472
  if (opts?.header !== undefined)
1450
1473
  args.header = opts.header;
@@ -1589,6 +1612,11 @@ export class AgentServer {
1589
1612
  });
1590
1613
  });
1591
1614
  }
1615
+ /**
1616
+ * Ask the client to answer a question from the agent (legacy single-engine
1617
+ * path). This intentionally has no wall-clock timeout; Stop/cancel drains
1618
+ * the pending ask.
1619
+ */
1592
1620
  requestAskUserFromClient(question, opts) {
1593
1621
  return new Promise((resolve) => {
1594
1622
  const requestId = nanoid(12);
@@ -1601,14 +1629,6 @@ export class AgentServer {
1601
1629
  resolve(result.reason ?? "(user declined to answer)");
1602
1630
  }
1603
1631
  });
1604
- const timer = setTimeout(() => {
1605
- if (this.pendingApprovals.has(requestId)) {
1606
- this.pendingApprovals.delete(requestId);
1607
- this.approvalTimers.delete(requestId);
1608
- resolve("(approval timed out)");
1609
- }
1610
- }, AgentServer.APPROVAL_TIMEOUT_MS);
1611
- this.approvalTimers.set(requestId, timer);
1612
1632
  const args = { question };
1613
1633
  if (opts?.header !== undefined)
1614
1634
  args.header = opts.header;
@@ -1670,6 +1690,9 @@ export class AgentServer {
1670
1690
  this.bgAgentBusUnsubscribe = null;
1671
1691
  }
1672
1692
  if (this.chatManager) {
1693
+ this.chatManager.forEachSession((session) => {
1694
+ this.cancelSessionApprovals(session, "server closing");
1695
+ });
1673
1696
  this.chatManager.closeAll();
1674
1697
  }
1675
1698
  // Legacy path cleanup
@@ -1698,17 +1721,16 @@ export class AgentServer {
1698
1721
  }
1699
1722
  /**
1700
1723
  * Resolve all of a chat session's pending approvals as cancelled and clear
1701
- * the matching server-side approval timers. Used by handleCancel's
1724
+ * any matching server-side approval timers. Used by handleCancel's
1702
1725
  * per-session path so a Stop while a tool is awaiting approval doesn't leave
1703
- * the tool hanging until APPROVAL_TIMEOUT_MS (and leak the timer). Each
1704
- * requestId in the session map has a same-keyed entry in this.approvalTimers
1705
- * (see requestAskUserForSession / makeBrowserBridge).
1726
+ * the tool hanging. Bounded request types have same-keyed timer entries;
1727
+ * AskUserQuestion does not.
1706
1728
  */
1707
- cancelSessionApprovals(session) {
1729
+ cancelSessionApprovals(session, reason = "cancelled") {
1708
1730
  for (const [requestId, resolve] of session.pendingApprovals) {
1709
1731
  this.clearApprovalTimer(requestId);
1710
1732
  try {
1711
- resolve({ approved: false, reason: "cancelled" });
1733
+ resolve({ approved: false, reason });
1712
1734
  }
1713
1735
  catch {
1714
1736
  /* a resolver must never break cancel cleanup */
@@ -51,6 +51,8 @@ export declare const ErrorCodes: {
51
51
  export interface RunParams {
52
52
  sessionId: string;
53
53
  task: string;
54
+ /** Stable id for the user's submit intent; duplicate ids are idempotent. */
55
+ clientMessageId?: string;
54
56
  /**
55
57
  * Working directory for this run. When omitted, the Engine uses its
56
58
  * configured cwd.
@@ -133,6 +135,8 @@ export interface SteerParams {
133
135
  /** Stable host-side id for this queued draft. Rides through to the
134
136
  * steer_injected event and is the handle Unsteer uses to revoke it. */
135
137
  id?: string;
138
+ /** Stable submit-intent id, distinct from the queued steer id. */
139
+ clientMessageId?: string;
136
140
  }
137
141
  /** Revoke a still-pending steer entry by id (before the loop consumes it). */
138
142
  export interface UnsteerParams {
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { spawn } from "node:child_process";
37
37
  import { StringDecoder } from "node:string_decoder";
38
- import { resolveSpawnTarget, defaultShellBinary, killChildTree } from "./spawn-common.js";
38
+ import { resolveSpawnTarget, defaultShellBinary, killChildTree, killProcessGroup } from "./spawn-common.js";
39
39
  export const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
40
40
  export const DEFAULT_IO_DRAIN_GRACE_MS = 100;
41
41
  const TIMEOUT_SIGKILL_GRACE_MS = 2000;
@@ -71,9 +71,21 @@ export function safeSpawnShell(command, opts) {
71
71
  shell,
72
72
  sandbox: opts.sandbox,
73
73
  });
74
- return runLifecycle({ file, args, opts, cleanup, resolveMs: elapsedMs(resolveStartedAt) });
74
+ // Shell commands run free-form LLM strings that routinely background
75
+ // grandchildren (`pytest &`, `sh → npm → node`). Spawn as a process-group
76
+ // leader (detached) so a timeout/abort kill reaches the WHOLE subtree, not
77
+ // just the direct shell — otherwise an orphaned grandchild keeps the
78
+ // inherited stdout pipe open and Node's `close` never fires (the hang).
79
+ return runLifecycle({
80
+ file,
81
+ args,
82
+ opts,
83
+ cleanup,
84
+ resolveMs: elapsedMs(resolveStartedAt),
85
+ detached: true,
86
+ });
75
87
  }
76
- function runLifecycle({ file, args, opts, cleanup, resolveMs }) {
88
+ function runLifecycle({ file, args, opts, cleanup, resolveMs, detached }) {
77
89
  const maxBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
78
90
  const abortGrace = opts.ioDrainGraceMs ?? DEFAULT_IO_DRAIN_GRACE_MS;
79
91
  const lifecycleStartedAt = spawnProfileEnabled() ? performance.now() : 0;
@@ -84,10 +96,19 @@ function runLifecycle({ file, args, opts, cleanup, resolveMs }) {
84
96
  }
85
97
  return new Promise((resolve) => {
86
98
  let settled = false;
99
+ // Declared before finish() so the spawn-failed early-return (which calls
100
+ // finish before these are assigned) doesn't hit a TDZ reference.
101
+ let timer;
102
+ let settleTimer;
103
+ let onAbort;
87
104
  const finish = (result) => {
88
105
  if (settled)
89
106
  return;
90
107
  settled = true;
108
+ if (timer)
109
+ clearTimeout(timer);
110
+ if (settleTimer)
111
+ clearTimeout(settleTimer);
91
112
  logSpawnProfile(file, args, resolveMs, elapsedMs(lifecycleStartedAt), result.reason);
92
113
  // Always release backend-allocated resources, regardless of exit path.
93
114
  // cleanup is best-effort — see seatbelt backend for rationale.
@@ -101,7 +122,7 @@ function runLifecycle({ file, args, opts, cleanup, resolveMs }) {
101
122
  };
102
123
  let child;
103
124
  try {
104
- child = spawn(file, args, { cwd: opts.cwd, env: opts.env });
125
+ child = spawn(file, args, { cwd: opts.cwd, env: opts.env, detached });
105
126
  }
106
127
  catch (err) {
107
128
  finish(emptyResult({ reason: "spawn_failed", spawnFailed: true, error: err.message }));
@@ -115,21 +136,63 @@ function runLifecycle({ file, args, opts, cleanup, resolveMs }) {
115
136
  let stderrTruncated = false;
116
137
  let timedOut = false;
117
138
  let aborted = false;
118
- const timer = setTimeout(() => {
139
+ let lastExitCode = null;
140
+ let lastExitSignal = null;
141
+ // Terminate the child on timeout/abort. When detached (shell mode) the
142
+ // child leads its own process group, so kill the WHOLE group — this reaps
143
+ // backgrounded grandchildren (`pytest &`, `sh → npm → node`) that a bare
144
+ // child.kill() would orphan. After the kill, arm a settle deadline:
145
+ // Node's `close` waits for every inherited stdio pipe to close, and an
146
+ // orphaned grandchild can hold stdout open forever, so `close` may never
147
+ // fire. Force a resolve via the last-seen `exit` code once the kill grace
148
+ // has elapsed — the promise must never hang past terminate().
149
+ const terminate = (graceMs) => {
150
+ if (detached && typeof child.pid === "number") {
151
+ void killProcessGroup(child.pid, { graceMs });
152
+ }
153
+ else {
154
+ killChildTree(child, graceMs);
155
+ }
156
+ if (settleTimer)
157
+ return;
158
+ settleTimer = setTimeout(() => {
159
+ finishFromExit();
160
+ }, graceMs + 500);
161
+ settleTimer.unref?.();
162
+ };
163
+ timer = setTimeout(() => {
119
164
  timedOut = true;
120
- // win32: taskkill /T reaps the whole tree (a foreground `npm test`
121
- // spawns node children that child.kill() alone would orphan). POSIX:
122
- // SIGTERM → grace → SIGKILL. See killChildTree.
123
- killChildTree(child, TIMEOUT_SIGKILL_GRACE_MS);
165
+ terminate(TIMEOUT_SIGKILL_GRACE_MS);
124
166
  }, opts.timeoutMs);
125
- let onAbort;
126
167
  if (opts.signal) {
127
168
  onAbort = () => {
128
169
  aborted = true;
129
- killChildTree(child, abortGrace);
170
+ terminate(abortGrace);
130
171
  };
131
172
  opts.signal.addEventListener("abort", onAbort, { once: true });
132
173
  }
174
+ // Fallback finish when `close` never arrives because a killed process's
175
+ // orphaned grandchild still holds a stdio pipe. Uses whatever `exit` code
176
+ // we saw (exit fires when the direct child dies, independent of stdio).
177
+ const finishFromExit = () => {
178
+ const reason = timedOut ? "timeout" : aborted ? "aborted" : "ok";
179
+ finish({
180
+ reason,
181
+ stdout,
182
+ stderr,
183
+ exitCode: lastExitCode,
184
+ signal: lastExitSignal,
185
+ stdoutTruncated,
186
+ stderrTruncated,
187
+ timedOut,
188
+ aborted,
189
+ spawnFailed: false,
190
+ });
191
+ };
192
+ child.on("exit", (code, sig) => {
193
+ lastExitCode = code;
194
+ lastExitSignal = sig;
195
+ });
133
196
  child.stdout?.on("data", (chunk) => {
134
197
  if (stdoutTruncated)
135
198
  return;
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
7
7
  import { nanoid } from "nanoid";
8
8
  import { Transcript } from "./transcript.js";
9
9
  import { SessionError } from "../exceptions.js";
10
+ import { normalizeCumulativeUsageCounters } from "../engine/session-usage.js";
10
11
  /**
11
12
  * Validate a session ID before it is joined into a filesystem path.
12
13
  *
@@ -83,6 +84,9 @@ export class SessionManager {
83
84
  model,
84
85
  provider,
85
86
  tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
87
+ cumulativePromptTokens: 0,
88
+ cumulativeCacheReadTokens: 0,
89
+ cumulativeCacheCreationTokens: 0,
86
90
  turnCount: 0,
87
91
  invokedSkills: [],
88
92
  status: "active",
@@ -245,6 +249,7 @@ export class SessionManager {
245
249
  const transcriptFile = join(sessionDir, "transcript.jsonl");
246
250
  const transcript = Transcript.loadFromFile(transcriptFile);
247
251
  state.status = "active";
252
+ Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
248
253
  return { state, transcript };
249
254
  }
250
255
  saveState(state) {
@@ -275,7 +280,8 @@ export class SessionManager {
275
280
  newBundle.state.parentSessionId = sourceSessionId;
276
281
  // Copy events up to the fork point
277
282
  for (const event of events) {
278
- if (event.type === "turn_boundary" && (event.data.turnNumber ?? -1) > forkTurn) {
283
+ if (event.type === "turn_boundary" &&
284
+ (event.data.turnNumber ?? -1) > forkTurn) {
279
285
  break;
280
286
  }
281
287
  newBundle.transcript.append(event.type, event.data);
@@ -22,7 +22,10 @@ export declare class Transcript {
22
22
  */
23
23
  appendMessage(role: string, content: string | ContentBlock[], opts?: {
24
24
  injected?: boolean;
25
+ steerId?: string;
26
+ clientMessageId?: string;
25
27
  }): TranscriptEvent;
28
+ hasClientMessageId(clientMessageId: string): boolean;
26
29
  appendToolUse(toolName: string, toolCallId: string, args: Record<string, unknown>): TranscriptEvent;
27
30
  appendToolResult(toolCallId: string, toolName: string, result?: string, error?: string): TranscriptEvent;
28
31
  /** Anchor for a spawned sub-agent (see TranscriptEventType "subagent").
@@ -52,6 +55,7 @@ export declare class Transcript {
52
55
  getEvents(type?: TranscriptEventType): TranscriptEvent[];
53
56
  get turnNumber(): number;
54
57
  get eventCount(): number;
58
+ private findMessageByClientId;
55
59
  private flush;
56
60
  /**
57
61
  * Repair tool_result pairing issues:
@@ -5,6 +5,7 @@
5
5
  import { appendFileSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  import { nanoid } from "nanoid";
8
+ import { logger } from "../logging/logger.js";
8
9
  export class Transcript {
9
10
  events = [];
10
11
  filePath;
@@ -40,12 +41,28 @@ export class Transcript {
40
41
  * step-gap steering messages are left unmarked so they render normally.
41
42
  */
42
43
  appendMessage(role, content, opts) {
44
+ if (opts?.clientMessageId) {
45
+ const existing = this.findMessageByClientId(opts.clientMessageId);
46
+ if (existing) {
47
+ logger.info("steer.submit.duplicate_ignored", {
48
+ clientMessageId: opts.clientMessageId,
49
+ role,
50
+ transcript: this.filePath,
51
+ });
52
+ return existing;
53
+ }
54
+ }
43
55
  return this.append("message", {
44
56
  role,
45
57
  content,
46
58
  ...(opts?.injected ? { injected: true } : {}),
59
+ ...(opts?.steerId ? { steerId: opts.steerId } : {}),
60
+ ...(opts?.clientMessageId ? { clientMessageId: opts.clientMessageId } : {}),
47
61
  });
48
62
  }
63
+ hasClientMessageId(clientMessageId) {
64
+ return this.findMessageByClientId(clientMessageId) !== undefined;
65
+ }
49
66
  appendToolUse(toolName, toolCallId, args) {
50
67
  return this.append("tool_use", { toolName, toolCallId, args });
51
68
  }
@@ -150,6 +167,10 @@ export class Transcript {
150
167
  get eventCount() {
151
168
  return this.events.length;
152
169
  }
170
+ findMessageByClientId(clientMessageId) {
171
+ return this.events.find((event) => event.type === "message" &&
172
+ event.data.clientMessageId === clientMessageId);
173
+ }
153
174
  flush(event) {
154
175
  try {
155
176
  appendFileSync(this.filePath, JSON.stringify(event) + "\n", "utf-8");
@@ -11,6 +11,7 @@ import { CredentialStore } from "../credentials/index.js";
11
11
  import { ENV_ALLOWLIST } from "../runtime/spawn-common.js";
12
12
  import { writeFile, mkdir } from "node:fs/promises";
13
13
  import { join } from "node:path";
14
+ import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
14
15
  /**
15
16
  * Read a required secret from `process.env` by NAME (Codex-style env-secret
16
17
  * handling — the value is never persisted in MCP config). A referenced env var
@@ -393,6 +394,22 @@ export class MCPManager {
393
394
  catch {
394
395
  // ignore cleanup errors
395
396
  }
397
+ if (transportType === "stdio" && config.command) {
398
+ const diagnostic = await diagnoseMcpStdioMissingCommand(config.command, err);
399
+ if (diagnostic) {
400
+ logger.warn("mcp.stdio_command_missing", {
401
+ server: name,
402
+ command: config.command,
403
+ foundPaths: diagnostic.foundPaths,
404
+ path: previewPath(),
405
+ message: diagnostic.message,
406
+ });
407
+ const original = err instanceof Error ? err.message : String(err);
408
+ const enhanced = new Error(`${original}\n${diagnostic.message}`);
409
+ enhanced.cause = err;
410
+ throw enhanced;
411
+ }
412
+ }
396
413
  throw err;
397
414
  }
398
415
  finally {
@@ -0,0 +1,9 @@
1
+ export declare function isBareCommand(command: string): boolean;
2
+ export declare function isMissingCommandError(error: unknown): boolean;
3
+ export declare function probeCommonExecutableLocations(command: string, env?: NodeJS.ProcessEnv): Promise<string[]>;
4
+ export declare function classifyMcpStdioMissingCommand(command: string, foundPaths: readonly string[]): string;
5
+ export declare function diagnoseMcpStdioMissingCommand(command: string, error: unknown, env?: NodeJS.ProcessEnv): Promise<{
6
+ message: string;
7
+ foundPaths: string[];
8
+ } | null>;
9
+ export declare function previewPath(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,93 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { delimiter, isAbsolute, join } from "node:path";
4
+ const COMMON_POSIX_BIN_DIRS = [
5
+ "/opt/homebrew/bin",
6
+ "/usr/local/bin",
7
+ "/home/linuxbrew/.linuxbrew/bin",
8
+ "/usr/bin",
9
+ "/bin",
10
+ ];
11
+ function unique(entries) {
12
+ const seen = new Set();
13
+ const out = [];
14
+ for (const entry of entries) {
15
+ const trimmed = entry.trim();
16
+ if (!trimmed || seen.has(trimmed))
17
+ continue;
18
+ seen.add(trimmed);
19
+ out.push(trimmed);
20
+ }
21
+ return out;
22
+ }
23
+ function commonExecutableDirs(env = process.env) {
24
+ const home = env.HOME?.trim();
25
+ return unique([
26
+ ...COMMON_POSIX_BIN_DIRS,
27
+ ...(home
28
+ ? [join(home, ".bun", "bin"), join(home, ".local", "bin"), join(home, ".npm-global", "bin")]
29
+ : []),
30
+ ]);
31
+ }
32
+ export function isBareCommand(command) {
33
+ const trimmed = command.trim();
34
+ return !!trimmed && !trimmed.includes("/") && !trimmed.includes("\\") && !isAbsolute(trimmed);
35
+ }
36
+ export function isMissingCommandError(error) {
37
+ const err = error;
38
+ const message = typeof err.message === "string" ? err.message : String(error);
39
+ return err.code === "ENOENT" || /\bENOENT\b/i.test(message) || /command not found/i.test(message);
40
+ }
41
+ async function isExecutable(filePath) {
42
+ try {
43
+ await access(filePath, constants.X_OK);
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ export async function probeCommonExecutableLocations(command, env = process.env) {
51
+ if (!isBareCommand(command) || process.platform === "win32")
52
+ return [];
53
+ const found = [];
54
+ for (const dir of commonExecutableDirs(env)) {
55
+ const candidate = join(dir, command);
56
+ if (await isExecutable(candidate))
57
+ found.push(candidate);
58
+ }
59
+ return found;
60
+ }
61
+ function installGuidance(command) {
62
+ if (command === "node" || command === "npx")
63
+ return "Please install Node.js and restart CodeShell.";
64
+ if (command === "bun" || command === "bunx")
65
+ return "Please install Bun and restart CodeShell.";
66
+ return `Please install "${command}" and restart CodeShell.`;
67
+ }
68
+ export function classifyMcpStdioMissingCommand(command, foundPaths) {
69
+ const trimmed = command.trim();
70
+ if (foundPaths.length > 0) {
71
+ return [
72
+ `MCP stdio command "${trimmed}" failed to start: detected ${trimmed} at ${foundPaths[0]},`,
73
+ "but that directory was not available on PATH.",
74
+ "Login-shell PATH injection may have failed; restart CodeShell or configure this MCP command as an absolute path.",
75
+ ].join(" ");
76
+ }
77
+ return [
78
+ `MCP stdio command "${trimmed}" failed to start: ${trimmed} was not found on PATH or in common install locations.`,
79
+ installGuidance(trimmed),
80
+ ].join(" ");
81
+ }
82
+ export async function diagnoseMcpStdioMissingCommand(command, error, env = process.env) {
83
+ if (!isBareCommand(command) || !isMissingCommandError(error))
84
+ return null;
85
+ const foundPaths = await probeCommonExecutableLocations(command, env);
86
+ return {
87
+ message: classifyMcpStdioMissingCommand(command, foundPaths),
88
+ foundPaths,
89
+ };
90
+ }
91
+ export function previewPath(env = process.env) {
92
+ return (env.PATH ?? "").split(delimiter).filter(Boolean).join(delimiter);
93
+ }