@cjhyy/code-shell-core 0.6.0-rc.7 → 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 +22 -12
- package/dist/engine/engine.js +248 -80
- 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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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 +113 -67
- 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
|
|
@@ -997,17 +1006,53 @@ export class AgentServer {
|
|
|
997
1006
|
break;
|
|
998
1007
|
}
|
|
999
1008
|
case "compact": {
|
|
1000
|
-
const
|
|
1001
|
-
?
|
|
1002
|
-
:
|
|
1009
|
+
const compactSessionId = typeof params.sessionId === "string" && params.sessionId.length > 0
|
|
1010
|
+
? params.sessionId
|
|
1011
|
+
: undefined;
|
|
1012
|
+
let compactEngine = engine;
|
|
1013
|
+
if (this.chatManager) {
|
|
1014
|
+
if (compactSessionId) {
|
|
1015
|
+
let session = this.chatManager.get(compactSessionId);
|
|
1016
|
+
if (!session) {
|
|
1017
|
+
const probeEngine = this.anyEngine();
|
|
1018
|
+
if (!probeEngine?.sessionExistsOnDisk(compactSessionId)) {
|
|
1019
|
+
this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionNotFound, `Session not found: ${compactSessionId}`));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
session = this.chatManager.getOrCreate(compactSessionId, {
|
|
1024
|
+
cwd: probeEngine.getSessionManager().readCwd(compactSessionId),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
this.transport.send(createErrorResponse(req.id, err.code ?? ErrorCodes.InternalError, err.message));
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
compactEngine = session.engine;
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
compactEngine = this.anyEngine();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1003
1038
|
if (!compactEngine) {
|
|
1004
|
-
this.transport.send(createErrorResponse(req.id,
|
|
1005
|
-
? `No such live session: ${params.sessionId}`
|
|
1006
|
-
: "No engine available for compact query"));
|
|
1039
|
+
this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for compact query"));
|
|
1007
1040
|
return;
|
|
1008
1041
|
}
|
|
1009
1042
|
try {
|
|
1010
|
-
const result = compactEngine.forceCompact();
|
|
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
|
+
}
|
|
1011
1056
|
this.transport.send(createResponse(req.id, {
|
|
1012
1057
|
type: "compact",
|
|
1013
1058
|
data: result,
|
|
@@ -1335,8 +1380,8 @@ export class AgentServer {
|
|
|
1335
1380
|
return;
|
|
1336
1381
|
}
|
|
1337
1382
|
try {
|
|
1338
|
-
engine.enqueueSteer(params.sessionId, params.text, params.id);
|
|
1339
|
-
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 }));
|
|
1340
1385
|
}
|
|
1341
1386
|
catch (err) {
|
|
1342
1387
|
this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
|
|
@@ -1373,10 +1418,23 @@ export class AgentServer {
|
|
|
1373
1418
|
return new Promise((resolve) => {
|
|
1374
1419
|
const requestId = nanoid(12);
|
|
1375
1420
|
const sessionId = typeof request.sessionId === "string" ? request.sessionId : undefined;
|
|
1376
|
-
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
|
+
}
|
|
1377
1434
|
const timer = setTimeout(() => {
|
|
1378
|
-
|
|
1379
|
-
|
|
1435
|
+
const pending = session?.pendingApprovals ?? this.pendingApprovals;
|
|
1436
|
+
if (pending.has(requestId)) {
|
|
1437
|
+
pending.delete(requestId);
|
|
1380
1438
|
this.approvalTimers.delete(requestId);
|
|
1381
1439
|
resolve({ approved: false, reason: "approval timed out" });
|
|
1382
1440
|
}
|
|
@@ -1390,14 +1448,11 @@ export class AgentServer {
|
|
|
1390
1448
|
});
|
|
1391
1449
|
}
|
|
1392
1450
|
/**
|
|
1393
|
-
*
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
* (the chatManager approve handler looks there, keyed by sessionId+requestId)
|
|
1399
|
-
* and tags the notify with sessionId so the renderer routes the question to
|
|
1400
|
-
* 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.
|
|
1401
1456
|
*/
|
|
1402
1457
|
requestAskUserForSession(session, sessionId, question, opts) {
|
|
1403
1458
|
return new Promise((resolve) => {
|
|
@@ -1413,14 +1468,6 @@ export class AgentServer {
|
|
|
1413
1468
|
resolve(typeof decision === "string" ? decision : "");
|
|
1414
1469
|
}
|
|
1415
1470
|
});
|
|
1416
|
-
const timer = setTimeout(() => {
|
|
1417
|
-
if (session.pendingApprovals.has(requestId)) {
|
|
1418
|
-
session.pendingApprovals.delete(requestId);
|
|
1419
|
-
this.approvalTimers.delete(requestId);
|
|
1420
|
-
resolve("(approval timed out)");
|
|
1421
|
-
}
|
|
1422
|
-
}, AgentServer.APPROVAL_TIMEOUT_MS);
|
|
1423
|
-
this.approvalTimers.set(requestId, timer);
|
|
1424
1471
|
const args = { question };
|
|
1425
1472
|
if (opts?.header !== undefined)
|
|
1426
1473
|
args.header = opts.header;
|
|
@@ -1565,6 +1612,11 @@ export class AgentServer {
|
|
|
1565
1612
|
});
|
|
1566
1613
|
});
|
|
1567
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
|
+
*/
|
|
1568
1620
|
requestAskUserFromClient(question, opts) {
|
|
1569
1621
|
return new Promise((resolve) => {
|
|
1570
1622
|
const requestId = nanoid(12);
|
|
@@ -1577,14 +1629,6 @@ export class AgentServer {
|
|
|
1577
1629
|
resolve(result.reason ?? "(user declined to answer)");
|
|
1578
1630
|
}
|
|
1579
1631
|
});
|
|
1580
|
-
const timer = setTimeout(() => {
|
|
1581
|
-
if (this.pendingApprovals.has(requestId)) {
|
|
1582
|
-
this.pendingApprovals.delete(requestId);
|
|
1583
|
-
this.approvalTimers.delete(requestId);
|
|
1584
|
-
resolve("(approval timed out)");
|
|
1585
|
-
}
|
|
1586
|
-
}, AgentServer.APPROVAL_TIMEOUT_MS);
|
|
1587
|
-
this.approvalTimers.set(requestId, timer);
|
|
1588
1632
|
const args = { question };
|
|
1589
1633
|
if (opts?.header !== undefined)
|
|
1590
1634
|
args.header = opts.header;
|
|
@@ -1646,6 +1690,9 @@ export class AgentServer {
|
|
|
1646
1690
|
this.bgAgentBusUnsubscribe = null;
|
|
1647
1691
|
}
|
|
1648
1692
|
if (this.chatManager) {
|
|
1693
|
+
this.chatManager.forEachSession((session) => {
|
|
1694
|
+
this.cancelSessionApprovals(session, "server closing");
|
|
1695
|
+
});
|
|
1649
1696
|
this.chatManager.closeAll();
|
|
1650
1697
|
}
|
|
1651
1698
|
// Legacy path cleanup
|
|
@@ -1674,17 +1721,16 @@ export class AgentServer {
|
|
|
1674
1721
|
}
|
|
1675
1722
|
/**
|
|
1676
1723
|
* Resolve all of a chat session's pending approvals as cancelled and clear
|
|
1677
|
-
*
|
|
1724
|
+
* any matching server-side approval timers. Used by handleCancel's
|
|
1678
1725
|
* per-session path so a Stop while a tool is awaiting approval doesn't leave
|
|
1679
|
-
* the tool hanging
|
|
1680
|
-
*
|
|
1681
|
-
* (see requestAskUserForSession / makeBrowserBridge).
|
|
1726
|
+
* the tool hanging. Bounded request types have same-keyed timer entries;
|
|
1727
|
+
* AskUserQuestion does not.
|
|
1682
1728
|
*/
|
|
1683
|
-
cancelSessionApprovals(session) {
|
|
1729
|
+
cancelSessionApprovals(session, reason = "cancelled") {
|
|
1684
1730
|
for (const [requestId, resolve] of session.pendingApprovals) {
|
|
1685
1731
|
this.clearApprovalTimer(requestId);
|
|
1686
1732
|
try {
|
|
1687
|
-
resolve({ approved: false, reason
|
|
1733
|
+
resolve({ approved: false, reason });
|
|
1688
1734
|
}
|
|
1689
1735
|
catch {
|
|
1690
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;
|