@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.
- package/dist/context/compaction.d.ts +30 -0
- package/dist/context/compaction.js +93 -0
- package/dist/context/manager.d.ts +18 -0
- package/dist/context/manager.js +156 -44
- package/dist/context/token-counter.js +13 -0
- package/dist/engine/engine.d.ts +21 -11
- package/dist/engine/engine.js +232 -76
- package/dist/engine/model-facade.js +2 -12
- package/dist/engine/query.js +2 -0
- package/dist/engine/session-usage.d.ts +12 -0
- package/dist/engine/session-usage.js +56 -0
- package/dist/engine/steer-queue.d.ts +2 -1
- package/dist/engine/steer-queue.js +2 -2
- package/dist/engine/turn-loop.d.ts +28 -2
- package/dist/engine/turn-loop.js +153 -26
- package/dist/protocol/chat-session.d.ts +2 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/client.d.ts +4 -1
- package/dist/protocol/client.js +8 -2
- package/dist/protocol/server.d.ts +13 -12
- package/dist/protocol/server.js +83 -61
- package/dist/protocol/types.d.ts +4 -0
- package/dist/runtime/safe-spawn.js +74 -11
- package/dist/session/session-manager.js +7 -1
- package/dist/session/transcript.d.ts +4 -0
- package/dist/session/transcript.js +21 -0
- package/dist/tool-system/mcp-manager.js +17 -0
- package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
- package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
- package/dist/types.d.ts +31 -1
- package/package.json +1 -1
package/dist/protocol/server.js
CHANGED
|
@@ -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
|
-
//
|
|
341
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
506
|
-
//
|
|
507
|
-
//
|
|
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
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
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.
|
|
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
|
-
|
|
1403
|
-
|
|
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
|
-
*
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
*
|
|
1421
|
-
*
|
|
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
|
-
*
|
|
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
|
|
1704
|
-
*
|
|
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
|
|
1733
|
+
resolve({ approved: false, reason });
|
|
1712
1734
|
}
|
|
1713
1735
|
catch {
|
|
1714
1736
|
/* a resolver must never break cancel cleanup */
|
package/dist/protocol/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
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
|
+
}
|