@rallycry/conveyor-agent 10.0.6 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-37ZIDX6S.js → chunk-EDEMOFUN.js} +848 -115
- package/dist/chunk-EDEMOFUN.js.map +1 -0
- package/dist/cli.js +192 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +59 -5
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/runtime/entrypoint.sh +22 -15
- package/dist/chunk-37ZIDX6S.js.map +0 -1
|
@@ -212,6 +212,20 @@ var AgentConnection = class _AgentConnection {
|
|
|
212
212
|
// the model to retry the tool, which is the safe place to decide idempotency.
|
|
213
213
|
static CALL_CONNECT_WAIT_MS = 2e4;
|
|
214
214
|
static CALL_ACK_TIMEOUT_MS = 3e4;
|
|
215
|
+
// ── Proactive socket recycle ───────────────────────────────────────────
|
|
216
|
+
// Cloud Run severs every WebSocket at its request timeout (3600s is the
|
|
217
|
+
// platform ceiling), so a socket that lives past ~60 minutes is killed at a
|
|
218
|
+
// random moment — historically mid-tool-call, which let the spawned CLI
|
|
219
|
+
// abandon its MCP session. Recycle the transport at a QUIET moment (no
|
|
220
|
+
// in-flight RPC) before the platform deadline instead: an engine-level close
|
|
221
|
+
// looks like a transport drop, so Socket.IO's auto-reconnect and the
|
|
222
|
+
// io "reconnect" → reconnectToSession() recovery path run unchanged. Jitter
|
|
223
|
+
// keeps a fleet of pods from recycling in one thundering herd.
|
|
224
|
+
static SOCKET_RECYCLE_BASE_MS = 52 * 60 * 1e3;
|
|
225
|
+
static SOCKET_RECYCLE_JITTER_MS = 4 * 60 * 1e3;
|
|
226
|
+
static SOCKET_RECYCLE_BUSY_POLL_MS = 15e3;
|
|
227
|
+
recycleTimer = null;
|
|
228
|
+
pendingCalls = 0;
|
|
215
229
|
async call(method, payload) {
|
|
216
230
|
const socket = this.socket;
|
|
217
231
|
if (!socket) {
|
|
@@ -219,10 +233,46 @@ var AgentConnection = class _AgentConnection {
|
|
|
219
233
|
`Not connected (method: ${String(method)}, session: ${this.config.sessionId})`
|
|
220
234
|
);
|
|
221
235
|
}
|
|
222
|
-
|
|
223
|
-
|
|
236
|
+
this.pendingCalls++;
|
|
237
|
+
try {
|
|
238
|
+
if (!socket.connected) {
|
|
239
|
+
await this.waitForConnected(socket, _AgentConnection.CALL_CONNECT_WAIT_MS, String(method));
|
|
240
|
+
}
|
|
241
|
+
return await this.emitWithAck(socket, method, payload);
|
|
242
|
+
} finally {
|
|
243
|
+
this.pendingCalls--;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** (Re)arm the recycle timer — called on every successful (re)connect. */
|
|
247
|
+
scheduleSocketRecycle() {
|
|
248
|
+
this.clearSocketRecycle();
|
|
249
|
+
const delay = _AgentConnection.SOCKET_RECYCLE_BASE_MS + Math.random() * _AgentConnection.SOCKET_RECYCLE_JITTER_MS;
|
|
250
|
+
this.armRecycleTimer(delay);
|
|
251
|
+
}
|
|
252
|
+
clearSocketRecycle() {
|
|
253
|
+
if (this.recycleTimer) {
|
|
254
|
+
clearTimeout(this.recycleTimer);
|
|
255
|
+
this.recycleTimer = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
armRecycleTimer(delay) {
|
|
259
|
+
this.recycleTimer = setTimeout(() => {
|
|
260
|
+
this.recycleTimer = null;
|
|
261
|
+
this.attemptSocketRecycle();
|
|
262
|
+
}, delay);
|
|
263
|
+
this.recycleTimer.unref?.();
|
|
264
|
+
}
|
|
265
|
+
attemptSocketRecycle() {
|
|
266
|
+
const socket = this.socket;
|
|
267
|
+
if (!socket?.connected) return;
|
|
268
|
+
if (this.pendingCalls > 0) {
|
|
269
|
+
this.armRecycleTimer(_AgentConnection.SOCKET_RECYCLE_BUSY_POLL_MS);
|
|
270
|
+
return;
|
|
224
271
|
}
|
|
225
|
-
|
|
272
|
+
process.stderr.write(
|
|
273
|
+
"[conveyor-agent] Recycling socket ahead of the platform request timeout\n"
|
|
274
|
+
);
|
|
275
|
+
socket.io.engine?.close?.();
|
|
226
276
|
}
|
|
227
277
|
/** Resolve once `socket` reports connected, or reject after `timeoutMs`. */
|
|
228
278
|
waitForConnected(socket, timeoutMs, method) {
|
|
@@ -357,6 +407,7 @@ var AgentConnection = class _AgentConnection {
|
|
|
357
407
|
});
|
|
358
408
|
this.socket.on("connect", () => {
|
|
359
409
|
process.stderr.write("[conveyor-agent] Socket connected\n");
|
|
410
|
+
this.scheduleSocketRecycle();
|
|
360
411
|
if (!settled) {
|
|
361
412
|
settled = true;
|
|
362
413
|
resolve();
|
|
@@ -404,6 +455,7 @@ var AgentConnection = class _AgentConnection {
|
|
|
404
455
|
}
|
|
405
456
|
disconnect() {
|
|
406
457
|
this.stopProactiveTokenRefresh();
|
|
458
|
+
this.clearSocketRecycle();
|
|
407
459
|
void this.flushEvents();
|
|
408
460
|
if (this.socket) {
|
|
409
461
|
this.socket.io.reconnection(false);
|
|
@@ -590,6 +642,17 @@ var AgentConnection = class _AgentConnection {
|
|
|
590
642
|
}).catch(() => {
|
|
591
643
|
});
|
|
592
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* Report that the interactive CLI process for this session has died and no
|
|
647
|
+
* respawn is imminent (fire-and-forget). The server clears the scrollback
|
|
648
|
+
* ring and broadcasts pty:ended so clients hide the Connected-TUI tab. Old
|
|
649
|
+
* servers that don't know the method reject harmlessly.
|
|
650
|
+
*/
|
|
651
|
+
sendPtyEnded() {
|
|
652
|
+
if (!this.socket) return;
|
|
653
|
+
void this.call("ptyEnded", { sessionId: this.config.sessionId }).catch(() => {
|
|
654
|
+
});
|
|
655
|
+
}
|
|
593
656
|
/** Subscribe to relayed keystrokes. Returns an unsubscribe fn. */
|
|
594
657
|
onPtyInput(handler) {
|
|
595
658
|
this.ptyInputCallback = handler;
|
|
@@ -2363,7 +2426,8 @@ var CreateSubtaskRequestSchema = z.object({
|
|
|
2363
2426
|
description: z.string().optional(),
|
|
2364
2427
|
plan: z.string().optional(),
|
|
2365
2428
|
storyPointValue: z.number().int().positive().optional(),
|
|
2366
|
-
ordinal: z.number().int().nonnegative().optional()
|
|
2429
|
+
ordinal: z.number().int().nonnegative().optional(),
|
|
2430
|
+
followParentStatus: z.boolean().optional()
|
|
2367
2431
|
});
|
|
2368
2432
|
var UpdateSubtaskRequestSchema = z.object({
|
|
2369
2433
|
sessionId: z.string(),
|
|
@@ -2372,7 +2436,8 @@ var UpdateSubtaskRequestSchema = z.object({
|
|
|
2372
2436
|
description: z.string().optional(),
|
|
2373
2437
|
plan: z.string().optional(),
|
|
2374
2438
|
status: z.string().optional(),
|
|
2375
|
-
storyPointValue: z.number().int().positive().optional()
|
|
2439
|
+
storyPointValue: z.number().int().positive().optional(),
|
|
2440
|
+
followParentStatus: z.boolean().optional()
|
|
2376
2441
|
});
|
|
2377
2442
|
var DeleteSubtaskRequestSchema = z.object({
|
|
2378
2443
|
sessionId: z.string(),
|
|
@@ -2493,6 +2558,9 @@ var GetUiCliHistoryRequestSchema = z.object({
|
|
|
2493
2558
|
var GetActivePtySessionRequestSchema = z.object({
|
|
2494
2559
|
taskId: z.string()
|
|
2495
2560
|
});
|
|
2561
|
+
var ListActivePtySessionsRequestSchema = z.object({
|
|
2562
|
+
taskId: z.string()
|
|
2563
|
+
});
|
|
2496
2564
|
var SendSoftStopRequestSchema = z.object({
|
|
2497
2565
|
taskId: z.string()
|
|
2498
2566
|
});
|
|
@@ -2555,6 +2623,9 @@ var PtyOutputRequestSchema = z.object({
|
|
|
2555
2623
|
cols: z.number().int().positive().max(PTY_MAX_DIMENSION).optional(),
|
|
2556
2624
|
rows: z.number().int().positive().max(PTY_MAX_DIMENSION).optional()
|
|
2557
2625
|
});
|
|
2626
|
+
var PtyEndedRequestSchema = z.object({
|
|
2627
|
+
sessionId: z.string()
|
|
2628
|
+
});
|
|
2558
2629
|
var PtyInputRequestSchema = z.object({
|
|
2559
2630
|
sessionId: z.string(),
|
|
2560
2631
|
data: z.string().max(PTY_FRAME_MAX_CHARS)
|
|
@@ -2699,6 +2770,25 @@ var StopProjectWorkspaceRequestSchema = z2.object({
|
|
|
2699
2770
|
destroy: z2.boolean().optional(),
|
|
2700
2771
|
requestingUserId: z2.string().optional()
|
|
2701
2772
|
});
|
|
2773
|
+
var ListMyLiveSessionsRequestSchema = z2.object({
|
|
2774
|
+
projectId: z2.string()
|
|
2775
|
+
});
|
|
2776
|
+
var StartAdhocSessionRequestSchema = z2.object({
|
|
2777
|
+
projectId: z2.string(),
|
|
2778
|
+
label: z2.string().max(200).optional(),
|
|
2779
|
+
requestingUserId: z2.string().optional()
|
|
2780
|
+
});
|
|
2781
|
+
var StopAdhocSessionRequestSchema = z2.object({
|
|
2782
|
+
projectId: z2.string(),
|
|
2783
|
+
workspaceId: z2.string(),
|
|
2784
|
+
destroy: z2.boolean().optional(),
|
|
2785
|
+
requestingUserId: z2.string().optional()
|
|
2786
|
+
});
|
|
2787
|
+
var ResumeAdhocSessionRequestSchema = z2.object({
|
|
2788
|
+
projectId: z2.string(),
|
|
2789
|
+
workspaceId: z2.string(),
|
|
2790
|
+
requestingUserId: z2.string().optional()
|
|
2791
|
+
});
|
|
2702
2792
|
var CreateProjectReleaseRequestSchema = z2.object({
|
|
2703
2793
|
projectId: z2.string(),
|
|
2704
2794
|
taskIds: z2.array(z2.string()).optional(),
|
|
@@ -2721,6 +2811,7 @@ var CreateProjectSubtaskRequestSchema = z2.object({
|
|
|
2721
2811
|
plan: z2.string().optional(),
|
|
2722
2812
|
ordinal: z2.number().int().nonnegative().optional(),
|
|
2723
2813
|
storyPointValue: z2.number().int().positive().optional(),
|
|
2814
|
+
followParentStatus: z2.boolean().optional(),
|
|
2724
2815
|
requestingUserId: z2.string().optional()
|
|
2725
2816
|
});
|
|
2726
2817
|
var UpdateProjectSubtaskRequestSchema = z2.object({
|
|
@@ -2732,6 +2823,7 @@ var UpdateProjectSubtaskRequestSchema = z2.object({
|
|
|
2732
2823
|
status: z2.string().optional(),
|
|
2733
2824
|
ordinal: z2.number().int().nonnegative().optional(),
|
|
2734
2825
|
storyPointValue: z2.number().int().positive().optional(),
|
|
2826
|
+
followParentStatus: z2.boolean().optional(),
|
|
2735
2827
|
requestingUserId: z2.string().optional()
|
|
2736
2828
|
});
|
|
2737
2829
|
var DeleteProjectSubtaskRequestSchema = z2.object({
|
|
@@ -2873,6 +2965,7 @@ var PM_CHAT_HISTORY_LIMIT = 40;
|
|
|
2873
2965
|
var AGENT_CHAT_HISTORY_FETCH_LIMIT = Math.max(TASK_CHAT_HISTORY_LIMIT, PM_CHAT_HISTORY_LIMIT) + 10;
|
|
2874
2966
|
|
|
2875
2967
|
// src/runner/mode-controller.ts
|
|
2968
|
+
var FRESH_AUTO_STATUSES = /* @__PURE__ */ new Set(["Planning", "Open"]);
|
|
2876
2969
|
var ModeController = class {
|
|
2877
2970
|
_mode;
|
|
2878
2971
|
_hasExitedPlanMode = false;
|
|
@@ -2956,12 +3049,25 @@ var ModeController = class {
|
|
|
2956
3049
|
}
|
|
2957
3050
|
return this._mode;
|
|
2958
3051
|
}
|
|
2959
|
-
/**
|
|
3052
|
+
/**
|
|
3053
|
+
* A FRESH auto task runs the read-only plan turn first (`--permission-mode plan`):
|
|
3054
|
+
* resolveInitialMode leaves it in `auto` with `_hasExitedPlanMode = false`, and
|
|
3055
|
+
* once the agent finishes it calls ExitPlanMode → building. Only tasks that have
|
|
3056
|
+
* already progressed past the fresh stage bypass planning and build immediately
|
|
3057
|
+
* with `--dangerously-skip-permissions`:
|
|
3058
|
+
*
|
|
3059
|
+
* - a build already underway (status past `Planning`/`Open`),
|
|
3060
|
+
* - a PR already open (safety net if status lags),
|
|
3061
|
+
* - a release (booted `InProgress`), or
|
|
3062
|
+
* - a pod restart mid-build (DB `agentMode` stays `"auto"` but status advanced).
|
|
3063
|
+
*
|
|
3064
|
+
* Runtime Build-after-Discovery never reaches this gate as `auto` — the server's
|
|
3065
|
+
* `resolveModeToSend` rewrites it to `"building"` for any non-`Planning` task.
|
|
3066
|
+
*/
|
|
2960
3067
|
canBypassPlanning(context) {
|
|
2961
3068
|
if (this._mode !== "auto") return false;
|
|
2962
|
-
if (context.status
|
|
2963
|
-
|
|
2964
|
-
return false;
|
|
3069
|
+
if (!FRESH_AUTO_STATUSES.has(context.status)) return true;
|
|
3070
|
+
return !!context.githubPRUrl;
|
|
2965
3071
|
}
|
|
2966
3072
|
// ── Mode transitions ───────────────────────────────────────────────
|
|
2967
3073
|
/** Handle mode change from server */
|
|
@@ -3024,7 +3130,8 @@ var DEFAULT_LIFECYCLE_CONFIG = {
|
|
|
3024
3130
|
dormantTimeoutMs: 60 * 60 * 1e3,
|
|
3025
3131
|
heartbeatIntervalMs: 3e4,
|
|
3026
3132
|
tokenRefreshIntervalMs: 45 * 60 * 1e3,
|
|
3027
|
-
gitFlushIntervalMs: 2 * 60 * 1e3
|
|
3133
|
+
gitFlushIntervalMs: 2 * 60 * 1e3,
|
|
3134
|
+
usageSampleIntervalMs: 5 * 60 * 1e3
|
|
3028
3135
|
};
|
|
3029
3136
|
var Lifecycle = class {
|
|
3030
3137
|
config;
|
|
@@ -3035,6 +3142,7 @@ var Lifecycle = class {
|
|
|
3035
3142
|
idleCheckInterval = null;
|
|
3036
3143
|
dormantTimer = null;
|
|
3037
3144
|
gitFlushTimer = null;
|
|
3145
|
+
usageSampleTimer = null;
|
|
3038
3146
|
constructor(config, callbacks) {
|
|
3039
3147
|
this.config = config;
|
|
3040
3148
|
this.callbacks = callbacks;
|
|
@@ -3080,6 +3188,21 @@ var Lifecycle = class {
|
|
|
3080
3188
|
this.gitFlushTimer = null;
|
|
3081
3189
|
}
|
|
3082
3190
|
}
|
|
3191
|
+
// ── Claude key usage sampling ─────────────────────────────────────
|
|
3192
|
+
startUsageSample() {
|
|
3193
|
+
this.stopUsageSample();
|
|
3194
|
+
if (this.config.usageSampleIntervalMs <= 0) return;
|
|
3195
|
+
this.callbacks.onUsageSample();
|
|
3196
|
+
this.usageSampleTimer = setInterval(() => {
|
|
3197
|
+
this.callbacks.onUsageSample();
|
|
3198
|
+
}, this.config.usageSampleIntervalMs);
|
|
3199
|
+
}
|
|
3200
|
+
stopUsageSample() {
|
|
3201
|
+
if (this.usageSampleTimer) {
|
|
3202
|
+
clearInterval(this.usageSampleTimer);
|
|
3203
|
+
this.usageSampleTimer = null;
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3083
3206
|
// ── Idle timer ─────────────────────────────────────────────────────
|
|
3084
3207
|
startIdleTimer() {
|
|
3085
3208
|
this.clearIdleTimers();
|
|
@@ -3116,6 +3239,7 @@ var Lifecycle = class {
|
|
|
3116
3239
|
this.stopHeartbeat();
|
|
3117
3240
|
this.stopTokenRefresh();
|
|
3118
3241
|
this.stopGitFlush();
|
|
3242
|
+
this.stopUsageSample();
|
|
3119
3243
|
this.clearIdleTimers();
|
|
3120
3244
|
this.cancelDormantTimer();
|
|
3121
3245
|
}
|
|
@@ -3175,7 +3299,7 @@ var ClaudeCodeHarness = class {
|
|
|
3175
3299
|
};
|
|
3176
3300
|
|
|
3177
3301
|
// src/harness/pty/session.ts
|
|
3178
|
-
import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4
|
|
3302
|
+
import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4 } from "fs/promises";
|
|
3179
3303
|
import { tmpdir as tmpdir4 } from "os";
|
|
3180
3304
|
import { join as join4, dirname } from "path";
|
|
3181
3305
|
|
|
@@ -3349,15 +3473,28 @@ function numberField(record, ...keys) {
|
|
|
3349
3473
|
return void 0;
|
|
3350
3474
|
}
|
|
3351
3475
|
function mapSystem(record) {
|
|
3352
|
-
if (record.subtype
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3476
|
+
if (record.subtype === "init") {
|
|
3477
|
+
const event = {
|
|
3478
|
+
type: "system",
|
|
3479
|
+
subtype: "init",
|
|
3480
|
+
model: stringField(record, "model") ?? ""
|
|
3481
|
+
};
|
|
3482
|
+
const sessionId = stringField(record, "session_id", "sessionId");
|
|
3483
|
+
if (sessionId !== void 0) event.session_id = sessionId;
|
|
3484
|
+
return event;
|
|
3485
|
+
}
|
|
3486
|
+
if (record.subtype === "turn_duration") {
|
|
3487
|
+
const event = {
|
|
3488
|
+
type: "result",
|
|
3489
|
+
subtype: "success",
|
|
3490
|
+
result: "",
|
|
3491
|
+
total_cost_usd: 0
|
|
3492
|
+
};
|
|
3493
|
+
const sessionId = stringField(record, "session_id", "sessionId");
|
|
3494
|
+
if (sessionId !== void 0) event.sessionId = sessionId;
|
|
3495
|
+
return event;
|
|
3496
|
+
}
|
|
3497
|
+
return null;
|
|
3361
3498
|
}
|
|
3362
3499
|
function mapUsage(message) {
|
|
3363
3500
|
const usage = message.usage;
|
|
@@ -3556,6 +3693,14 @@ function buildSpawnArgs(input) {
|
|
|
3556
3693
|
}
|
|
3557
3694
|
return args;
|
|
3558
3695
|
}
|
|
3696
|
+
function spawnOptionsFingerprint(input) {
|
|
3697
|
+
return JSON.stringify([
|
|
3698
|
+
input.model,
|
|
3699
|
+
input.permissionMode,
|
|
3700
|
+
input.appendSystemPrompt ?? "",
|
|
3701
|
+
input.cwd
|
|
3702
|
+
]);
|
|
3703
|
+
}
|
|
3559
3704
|
var ANSI_CSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
3560
3705
|
function cleanTerminalOutput(raw, maxChars = 1200) {
|
|
3561
3706
|
const noAnsi = raw.replace(ANSI_CSI, "");
|
|
@@ -3648,6 +3793,7 @@ import { join as join3 } from "path";
|
|
|
3648
3793
|
import { randomBytes } from "crypto";
|
|
3649
3794
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3650
3795
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3796
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
3651
3797
|
|
|
3652
3798
|
// src/harness/pty/mcp-server.ts
|
|
3653
3799
|
var PtyMcpServer = class {
|
|
@@ -3669,6 +3815,7 @@ var PtyMcpServer = class {
|
|
|
3669
3815
|
|
|
3670
3816
|
// src/harness/pty/tool-server.ts
|
|
3671
3817
|
var LOOPBACK = "127.0.0.1";
|
|
3818
|
+
var MAX_SESSIONS = 16;
|
|
3672
3819
|
var PtyToolServer = class {
|
|
3673
3820
|
constructor(name, tools) {
|
|
3674
3821
|
this.name = name;
|
|
@@ -3677,22 +3824,11 @@ var PtyToolServer = class {
|
|
|
3677
3824
|
name;
|
|
3678
3825
|
tools;
|
|
3679
3826
|
http = null;
|
|
3680
|
-
|
|
3681
|
-
mcp = null;
|
|
3827
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3682
3828
|
token = randomBytes(24).toString("base64url");
|
|
3683
3829
|
async start() {
|
|
3684
|
-
const mcp = new McpServer({ name: this.name, version: "1.0.0" });
|
|
3685
|
-
const register = mcp.tool.bind(mcp);
|
|
3686
|
-
for (const tool2 of this.tools) {
|
|
3687
|
-
register(tool2.name, tool2.description, tool2.schema, (args) => tool2.handler(args));
|
|
3688
|
-
}
|
|
3689
|
-
const transport = new StreamableHTTPServerTransport({
|
|
3690
|
-
sessionIdGenerator: () => randomBytes(16).toString("hex"),
|
|
3691
|
-
enableJsonResponse: true
|
|
3692
|
-
});
|
|
3693
|
-
await mcp.connect(transport);
|
|
3694
3830
|
const server = createServer2((req, res) => {
|
|
3695
|
-
void this.handle(req, res
|
|
3831
|
+
void this.handle(req, res);
|
|
3696
3832
|
});
|
|
3697
3833
|
await new Promise((resolve, reject) => {
|
|
3698
3834
|
server.once("error", reject);
|
|
@@ -3701,33 +3837,84 @@ var PtyToolServer = class {
|
|
|
3701
3837
|
const address = server.address();
|
|
3702
3838
|
const port = address && typeof address === "object" ? address.port : 0;
|
|
3703
3839
|
this.http = server;
|
|
3704
|
-
this.transport = transport;
|
|
3705
|
-
this.mcp = mcp;
|
|
3706
3840
|
return { url: `http://${LOOPBACK}:${port}/mcp`, token: this.token };
|
|
3707
3841
|
}
|
|
3708
|
-
|
|
3842
|
+
/** Fresh McpServer with every tool registered — one per MCP session. */
|
|
3843
|
+
buildMcpServer() {
|
|
3844
|
+
const mcp = new McpServer({ name: this.name, version: "1.0.0" });
|
|
3845
|
+
const register = mcp.tool.bind(mcp);
|
|
3846
|
+
for (const tool2 of this.tools) {
|
|
3847
|
+
register(tool2.name, tool2.description, tool2.schema, (args) => tool2.handler(args));
|
|
3848
|
+
}
|
|
3849
|
+
return mcp;
|
|
3850
|
+
}
|
|
3851
|
+
async handle(req, res) {
|
|
3709
3852
|
if (req.headers.authorization !== `Bearer ${this.token}`) {
|
|
3710
3853
|
res.writeHead(401).end();
|
|
3711
3854
|
return;
|
|
3712
3855
|
}
|
|
3713
3856
|
try {
|
|
3714
|
-
|
|
3857
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
3858
|
+
if (typeof sessionId === "string") {
|
|
3859
|
+
const session = this.sessions.get(sessionId);
|
|
3860
|
+
if (!session) {
|
|
3861
|
+
jsonRpcError(res, 404, -32001, "Session not found");
|
|
3862
|
+
return;
|
|
3863
|
+
}
|
|
3864
|
+
await session.transport.handleRequest(req, res);
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
if (req.method !== "POST") {
|
|
3868
|
+
jsonRpcError(res, 400, -32600, "Bad Request: Mcp-Session-Id header is required");
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
const body = await readJsonBody(req);
|
|
3872
|
+
const messages = Array.isArray(body) ? body : [body];
|
|
3873
|
+
if (!messages.some((m) => isInitializeRequest(m))) {
|
|
3874
|
+
jsonRpcError(res, 400, -32600, "Bad Request: Mcp-Session-Id header is required");
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
await this.openSession(req, res, body);
|
|
3715
3878
|
} catch {
|
|
3716
3879
|
if (res.headersSent) res.end();
|
|
3717
3880
|
else res.writeHead(500).end();
|
|
3718
3881
|
}
|
|
3719
3882
|
}
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3883
|
+
/** Connect a fresh transport + McpServer pair and serve the initialize. */
|
|
3884
|
+
async openSession(req, res, parsedBody) {
|
|
3885
|
+
const mcp = this.buildMcpServer();
|
|
3886
|
+
const transport = new StreamableHTTPServerTransport({
|
|
3887
|
+
sessionIdGenerator: () => randomBytes(16).toString("hex"),
|
|
3888
|
+
enableJsonResponse: true,
|
|
3889
|
+
onsessioninitialized: (sid) => {
|
|
3890
|
+
this.sessions.set(sid, { transport, mcp });
|
|
3891
|
+
this.evictOverflow(sid);
|
|
3892
|
+
}
|
|
3893
|
+
});
|
|
3894
|
+
transport.onclose = () => {
|
|
3895
|
+
const sid = transport.sessionId;
|
|
3896
|
+
if (sid && this.sessions.get(sid)?.transport === transport) {
|
|
3897
|
+
this.sessions.delete(sid);
|
|
3898
|
+
}
|
|
3899
|
+
};
|
|
3900
|
+
await mcp.connect(transport);
|
|
3901
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
3902
|
+
}
|
|
3903
|
+
/** Drop oldest abandoned sessions past MAX_SESSIONS (never the newest). */
|
|
3904
|
+
evictOverflow(currentSid) {
|
|
3905
|
+
for (const [sid, session] of this.sessions) {
|
|
3906
|
+
if (this.sessions.size <= MAX_SESSIONS) break;
|
|
3907
|
+
if (sid === currentSid) continue;
|
|
3908
|
+
this.sessions.delete(sid);
|
|
3909
|
+
void closeSession(session);
|
|
3724
3910
|
}
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3911
|
+
}
|
|
3912
|
+
async close() {
|
|
3913
|
+
const sessions = [...this.sessions.values()];
|
|
3914
|
+
this.sessions.clear();
|
|
3915
|
+
for (const session of sessions) {
|
|
3916
|
+
await closeSession(session);
|
|
3729
3917
|
}
|
|
3730
|
-
this.mcp = null;
|
|
3731
3918
|
const http = this.http;
|
|
3732
3919
|
this.http = null;
|
|
3733
3920
|
if (http) {
|
|
@@ -3737,6 +3924,27 @@ var PtyToolServer = class {
|
|
|
3737
3924
|
}
|
|
3738
3925
|
}
|
|
3739
3926
|
};
|
|
3927
|
+
async function closeSession(session) {
|
|
3928
|
+
try {
|
|
3929
|
+
await session.transport.close();
|
|
3930
|
+
} catch {
|
|
3931
|
+
}
|
|
3932
|
+
try {
|
|
3933
|
+
await session.mcp.close();
|
|
3934
|
+
} catch {
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
function jsonRpcError(res, status, code, message) {
|
|
3938
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
3939
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }));
|
|
3940
|
+
}
|
|
3941
|
+
async function readJsonBody(req) {
|
|
3942
|
+
const chunks = [];
|
|
3943
|
+
for await (const chunk of req) {
|
|
3944
|
+
chunks.push(chunk);
|
|
3945
|
+
}
|
|
3946
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
3947
|
+
}
|
|
3740
3948
|
async function startToolServers(mcpServers, tempDir) {
|
|
3741
3949
|
const servers = [];
|
|
3742
3950
|
const config = {};
|
|
@@ -3754,8 +3962,28 @@ async function startToolServers(mcpServers, tempDir) {
|
|
|
3754
3962
|
return { servers, mcpConfigPath };
|
|
3755
3963
|
}
|
|
3756
3964
|
|
|
3757
|
-
// src/harness/pty/
|
|
3965
|
+
// src/harness/pty/pty-support.ts
|
|
3966
|
+
import { stat as stat3 } from "fs/promises";
|
|
3758
3967
|
var MAX_DIAGNOSTIC_OUTPUT = 4e3;
|
|
3968
|
+
var MAX_BETWEEN_TURN_BUFFER = 500;
|
|
3969
|
+
var SUBMIT_SETTLE_MS = 300;
|
|
3970
|
+
var SUBMIT_NUDGE_INTERVAL_MS = 2e3;
|
|
3971
|
+
var SUBMIT_NUDGE_MAX_PRESSES = 5;
|
|
3972
|
+
var SUBMIT_NUDGE_SLOW_INTERVAL_MS = 5e3;
|
|
3973
|
+
var SUBMIT_NUDGE_WINDOW_MS = 9e4;
|
|
3974
|
+
var PLAN_DIALOG_FIRST_PRESS_MS = 700;
|
|
3975
|
+
var PLAN_DIALOG_INTERVAL_MS = 1500;
|
|
3976
|
+
var PLAN_DIALOG_SLOW_INTERVAL_MS = 5e3;
|
|
3977
|
+
var PLAN_DIALOG_FAST_WINDOW_MS = 1e4;
|
|
3978
|
+
var PLAN_DIALOG_WINDOW_MS = 9e4;
|
|
3979
|
+
function turnOptionsFrom(options) {
|
|
3980
|
+
return {
|
|
3981
|
+
canUseTool: options.canUseTool,
|
|
3982
|
+
promptDelivery: options.promptDelivery,
|
|
3983
|
+
planDialogAutoAccept: options.planDialogAutoAccept,
|
|
3984
|
+
abortController: options.abortController
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3759
3987
|
function isRecord2(value) {
|
|
3760
3988
|
return typeof value === "object" && value !== null;
|
|
3761
3989
|
}
|
|
@@ -3778,16 +4006,13 @@ function inheritedEnv(socketPath) {
|
|
|
3778
4006
|
if (typeof value === "string") env[key] = value;
|
|
3779
4007
|
}
|
|
3780
4008
|
env.CONVEYOR_HOOK_SOCKET = socketPath;
|
|
4009
|
+
env.MCP_TIMEOUT ??= "60000";
|
|
4010
|
+
env.MCP_TOOL_TIMEOUT ??= "180000";
|
|
3781
4011
|
return env;
|
|
3782
4012
|
}
|
|
3783
4013
|
function buildPromptBytes(text) {
|
|
3784
4014
|
return `\x1B[200~${text}\x1B[201~`;
|
|
3785
4015
|
}
|
|
3786
|
-
var SUBMIT_SETTLE_MS = 300;
|
|
3787
|
-
var SUBMIT_NUDGE_INTERVAL_MS = 2e3;
|
|
3788
|
-
var SUBMIT_NUDGE_MAX_PRESSES = 5;
|
|
3789
|
-
var SUBMIT_NUDGE_SLOW_INTERVAL_MS = 5e3;
|
|
3790
|
-
var SUBMIT_NUDGE_WINDOW_MS = 9e4;
|
|
3791
4016
|
function sleep3(ms) {
|
|
3792
4017
|
return new Promise((resolve) => {
|
|
3793
4018
|
setTimeout(resolve, ms);
|
|
@@ -3819,18 +4044,41 @@ function parseUserQuestions(input) {
|
|
|
3819
4044
|
}
|
|
3820
4045
|
return questions;
|
|
3821
4046
|
}
|
|
4047
|
+
|
|
4048
|
+
// src/harness/pty/session.ts
|
|
3822
4049
|
var PtySession = class {
|
|
3823
4050
|
constructor(prompt, options, resume, bridge) {
|
|
3824
|
-
this.prompt = prompt;
|
|
3825
4051
|
this.options = options;
|
|
3826
4052
|
this.resume = resume;
|
|
3827
4053
|
this.bridge = bridge;
|
|
4054
|
+
this.turnPrompt = prompt;
|
|
4055
|
+
this.turn = turnOptionsFrom(options);
|
|
3828
4056
|
}
|
|
3829
|
-
prompt;
|
|
3830
4057
|
options;
|
|
3831
4058
|
resume;
|
|
3832
4059
|
bridge;
|
|
3833
|
-
|
|
4060
|
+
// The current turn's event stream. Null between turns (process parked) — the
|
|
4061
|
+
// process outlives any single turn, so the queue is per-turn, not per-process.
|
|
4062
|
+
activeQueue = null;
|
|
4063
|
+
// The drain generator for the current turn, captured at turn-start and bound
|
|
4064
|
+
// to that turn's queue instance. events() returns THIS, not a fresh drain of
|
|
4065
|
+
// activeQueue — a turn can end (closing + nulling activeQueue) synchronously
|
|
4066
|
+
// within beginPassiveTurn (a buffered result) before the consumer calls
|
|
4067
|
+
// events(), and the consumer must still drain the already-buffered records.
|
|
4068
|
+
turnStream = null;
|
|
4069
|
+
// Transcript events that arrive with no turn draining (parked window). A
|
|
4070
|
+
// passive turn replays these; beginTurn drops them.
|
|
4071
|
+
betweenTurnBuffer = [];
|
|
4072
|
+
// Leading-edge guard so a burst of parked activity fires onPassiveActivity
|
|
4073
|
+
// once, not per event. Reset when a turn (re)starts.
|
|
4074
|
+
passiveSignaled = false;
|
|
4075
|
+
passiveListener = null;
|
|
4076
|
+
// Set once the CLI process actually exits (distinct from _toreDown, which is
|
|
4077
|
+
// our own teardown). Blocks reuse of a dead process.
|
|
4078
|
+
exited = false;
|
|
4079
|
+
// Whether the turn that just ended did so via a clean transcript `result`
|
|
4080
|
+
// (eligible for parking) vs. an abort/error (must teardown).
|
|
4081
|
+
lastTurnCleanResult = false;
|
|
3834
4082
|
socket = null;
|
|
3835
4083
|
tailer = null;
|
|
3836
4084
|
pty = null;
|
|
@@ -3859,27 +4107,173 @@ var PtySession = class {
|
|
|
3859
4107
|
// Submit-nudge state (see armSubmitNudge).
|
|
3860
4108
|
pendingSubmitNudge = false;
|
|
3861
4109
|
submitNudgeTimer = null;
|
|
4110
|
+
// Per-turn state: the prompt to feed and the per-turn options subset. Both
|
|
4111
|
+
// start from the constructor args (turn 1) and are replaced by beginTurn.
|
|
4112
|
+
turnPrompt;
|
|
4113
|
+
turn;
|
|
3862
4114
|
onIdle(listener) {
|
|
3863
4115
|
this.idleListeners.push(listener);
|
|
3864
4116
|
}
|
|
3865
4117
|
onExit(listener) {
|
|
3866
4118
|
this.exitListeners.push(listener);
|
|
3867
4119
|
}
|
|
4120
|
+
/** Subscribe to parked-window transcript activity (human typed into the idle
|
|
4121
|
+
* TUI). Returns unsubscribe. Only one listener is retained. */
|
|
4122
|
+
onPassiveActivity(listener) {
|
|
4123
|
+
this.passiveListener = listener;
|
|
4124
|
+
return () => {
|
|
4125
|
+
if (this.passiveListener === listener) this.passiveListener = null;
|
|
4126
|
+
};
|
|
4127
|
+
}
|
|
3868
4128
|
get isToreDown() {
|
|
3869
4129
|
return this._toreDown;
|
|
3870
4130
|
}
|
|
4131
|
+
/** True once the underlying CLI process has exited. */
|
|
4132
|
+
get hasExited() {
|
|
4133
|
+
return this.exited;
|
|
4134
|
+
}
|
|
4135
|
+
/** True when the last turn ended via a clean transcript result (parkable). */
|
|
4136
|
+
get endedCleanly() {
|
|
4137
|
+
return this.lastTurnCleanResult;
|
|
4138
|
+
}
|
|
4139
|
+
/** The deterministic session UUID this process is bound to. */
|
|
4140
|
+
get sessionUuid() {
|
|
4141
|
+
return this.resume ?? this.options.sessionId;
|
|
4142
|
+
}
|
|
4143
|
+
/** Fingerprint of the spawn-time options a reused process cannot change. */
|
|
4144
|
+
get spawnFingerprint() {
|
|
4145
|
+
return spawnOptionsFingerprint({
|
|
4146
|
+
model: this.options.model,
|
|
4147
|
+
permissionMode: this.options.permissionMode,
|
|
4148
|
+
...this.options.appendSystemPrompt ? { appendSystemPrompt: this.options.appendSystemPrompt } : {},
|
|
4149
|
+
cwd: this.options.cwd
|
|
4150
|
+
});
|
|
4151
|
+
}
|
|
4152
|
+
/**
|
|
4153
|
+
* Whether this parked process can serve a turn wanting `resume`/`fingerprint`.
|
|
4154
|
+
* Requires: live (not torn down / not exited), idle (no turn draining), a
|
|
4155
|
+
* matching resume target, and matching spawn options.
|
|
4156
|
+
*/
|
|
4157
|
+
canReuse(resume, fingerprint) {
|
|
4158
|
+
return !this._toreDown && !this.exited && this.activeQueue === null && resume !== void 0 && resume === this.sessionUuid && fingerprint === this.spawnFingerprint;
|
|
4159
|
+
}
|
|
3871
4160
|
get hookSocketPath() {
|
|
3872
4161
|
return this.socket ? this.socket.socketPath : null;
|
|
3873
4162
|
}
|
|
3874
4163
|
events() {
|
|
3875
|
-
|
|
4164
|
+
if (!this.turnStream) {
|
|
4165
|
+
const empty = new AsyncEventQueue();
|
|
4166
|
+
empty.close();
|
|
4167
|
+
return empty.drain();
|
|
4168
|
+
}
|
|
4169
|
+
return this.turnStream;
|
|
4170
|
+
}
|
|
4171
|
+
/**
|
|
4172
|
+
* Route a harness event to the current turn's queue, or — when no turn is
|
|
4173
|
+
* draining (the process is parked) — into the between-turn buffer, firing the
|
|
4174
|
+
* passive-activity signal once per parked window.
|
|
4175
|
+
*/
|
|
4176
|
+
pushEvent(event) {
|
|
4177
|
+
if (this.activeQueue) {
|
|
4178
|
+
this.activeQueue.push(event);
|
|
4179
|
+
return;
|
|
4180
|
+
}
|
|
4181
|
+
this.betweenTurnBuffer.push(event);
|
|
4182
|
+
if (this.betweenTurnBuffer.length > MAX_BETWEEN_TURN_BUFFER) {
|
|
4183
|
+
this.betweenTurnBuffer.shift();
|
|
4184
|
+
}
|
|
4185
|
+
if (!this.passiveSignaled) {
|
|
4186
|
+
this.passiveSignaled = true;
|
|
4187
|
+
this.passiveListener?.();
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
/**
|
|
4191
|
+
* Begin a follow-up turn on the ALREADY-RUNNING process: fresh per-turn state
|
|
4192
|
+
* and queue, rewire the abort listener to this turn's controller, then feed
|
|
4193
|
+
* the prompt into the live pty. The caller must have verified canReuse().
|
|
4194
|
+
*/
|
|
4195
|
+
async beginTurn(prompt, options) {
|
|
4196
|
+
this.turnPrompt = prompt;
|
|
4197
|
+
this.turn = turnOptionsFrom(options);
|
|
4198
|
+
this.resetForTurn();
|
|
4199
|
+
this.betweenTurnBuffer = [];
|
|
4200
|
+
this.activeQueue = new AsyncEventQueue();
|
|
4201
|
+
this.turnStream = this.activeQueue.drain();
|
|
4202
|
+
if (!this.rearmAbort()) return;
|
|
4203
|
+
await this.feedPrompt();
|
|
4204
|
+
}
|
|
4205
|
+
/**
|
|
4206
|
+
* Begin a PASSIVE turn: a human typed into the parked TUI and the CLI is
|
|
4207
|
+
* producing transcript records with no prompt from us. Replay the buffered
|
|
4208
|
+
* records into a fresh queue (a buffered `result` ends the turn at once) and
|
|
4209
|
+
* let live records continue to flow.
|
|
4210
|
+
*/
|
|
4211
|
+
beginPassiveTurn(turn) {
|
|
4212
|
+
this.turn = turn;
|
|
4213
|
+
this.resetForTurn();
|
|
4214
|
+
const buffered = this.betweenTurnBuffer;
|
|
4215
|
+
this.betweenTurnBuffer = [];
|
|
4216
|
+
this.activeQueue = new AsyncEventQueue();
|
|
4217
|
+
this.turnStream = this.activeQueue.drain();
|
|
4218
|
+
if (!this.rearmAbort()) return;
|
|
4219
|
+
for (const event of buffered) {
|
|
4220
|
+
this.activeQueue?.push(event);
|
|
4221
|
+
if (event.type === "result") this.endTurn(true);
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
/** Reset per-turn state shared by beginTurn/beginPassiveTurn. */
|
|
4225
|
+
resetForTurn() {
|
|
4226
|
+
this.sawResult = false;
|
|
4227
|
+
this.lastTurnCleanResult = false;
|
|
4228
|
+
this.passiveSignaled = false;
|
|
4229
|
+
this.disarmSubmitNudge();
|
|
4230
|
+
this.disarmPlanDialogAutoAccept();
|
|
4231
|
+
}
|
|
4232
|
+
/**
|
|
4233
|
+
* (Re)register the abort→teardown listener on the current turn's controller,
|
|
4234
|
+
* removing any prior one. Returns false (after tearing down) when the signal
|
|
4235
|
+
* is already aborted, so callers can bail.
|
|
4236
|
+
*/
|
|
4237
|
+
rearmAbort() {
|
|
4238
|
+
if (this.abortHandler && this.turn.abortController) {
|
|
4239
|
+
this.turn.abortController.signal.removeEventListener("abort", this.abortHandler);
|
|
4240
|
+
}
|
|
4241
|
+
this.abortHandler = null;
|
|
4242
|
+
const signal = this.turn.abortController?.signal;
|
|
4243
|
+
if (!signal) return true;
|
|
4244
|
+
if (signal.aborted) {
|
|
4245
|
+
void this.teardown();
|
|
4246
|
+
return false;
|
|
4247
|
+
}
|
|
4248
|
+
this.abortHandler = () => {
|
|
4249
|
+
void this.teardown();
|
|
4250
|
+
};
|
|
4251
|
+
signal.addEventListener("abort", this.abortHandler, { once: true });
|
|
4252
|
+
return true;
|
|
4253
|
+
}
|
|
4254
|
+
/**
|
|
4255
|
+
* End the current turn WITHOUT killing the process: close+null the queue so
|
|
4256
|
+
* the consumer's generator completes, and unhook the (now-finished) turn's
|
|
4257
|
+
* abort listener so a later abort of that controller can't teardown a process
|
|
4258
|
+
* we intend to reuse. The process stays alive (parked) for the next turn.
|
|
4259
|
+
*/
|
|
4260
|
+
endTurn(clean) {
|
|
4261
|
+
this.lastTurnCleanResult = clean;
|
|
4262
|
+
this.activeQueue?.close();
|
|
4263
|
+
this.activeQueue = null;
|
|
4264
|
+
if (this.abortHandler && this.turn.abortController) {
|
|
4265
|
+
this.turn.abortController.signal.removeEventListener("abort", this.abortHandler);
|
|
4266
|
+
this.abortHandler = null;
|
|
4267
|
+
}
|
|
3876
4268
|
}
|
|
3877
4269
|
async start() {
|
|
3878
4270
|
const sessionId = this.resume ?? this.options.sessionId;
|
|
3879
4271
|
if (!sessionId) {
|
|
3880
4272
|
throw new Error("PtySession requires options.sessionId or a resume target");
|
|
3881
4273
|
}
|
|
3882
|
-
|
|
4274
|
+
this.activeQueue = new AsyncEventQueue();
|
|
4275
|
+
this.turnStream = this.activeQueue.drain();
|
|
4276
|
+
const signal = this.turn.abortController?.signal;
|
|
3883
4277
|
if (signal?.aborted) {
|
|
3884
4278
|
await this.teardown();
|
|
3885
4279
|
return;
|
|
@@ -3959,7 +4353,7 @@ var PtySession = class {
|
|
|
3959
4353
|
this.unsubResize?.();
|
|
3960
4354
|
this.unsubResize = null;
|
|
3961
4355
|
if (this.abortHandler) {
|
|
3962
|
-
this.
|
|
4356
|
+
this.turn.abortController?.signal.removeEventListener("abort", this.abortHandler);
|
|
3963
4357
|
this.abortHandler = null;
|
|
3964
4358
|
}
|
|
3965
4359
|
try {
|
|
@@ -3983,7 +4377,8 @@ var PtySession = class {
|
|
|
3983
4377
|
}
|
|
3984
4378
|
this.toolServers = [];
|
|
3985
4379
|
this.mcpConfigPath = null;
|
|
3986
|
-
this.
|
|
4380
|
+
this.activeQueue?.close();
|
|
4381
|
+
this.activeQueue = null;
|
|
3987
4382
|
if (this.tempDir) {
|
|
3988
4383
|
await rm4(this.tempDir, { recursive: true, force: true });
|
|
3989
4384
|
this.tempDir = "";
|
|
@@ -4044,18 +4439,18 @@ var PtySession = class {
|
|
|
4044
4439
|
}
|
|
4045
4440
|
async deliverPrompt(text) {
|
|
4046
4441
|
this.writeStdin(buildPromptBytes(text));
|
|
4047
|
-
if (this.
|
|
4442
|
+
if (this.turn.promptDelivery === "prefill") return;
|
|
4048
4443
|
await sleep3(SUBMIT_SETTLE_MS);
|
|
4049
4444
|
if (this._toreDown) return;
|
|
4050
4445
|
this.writeStdin("\r");
|
|
4051
4446
|
this.armSubmitNudge();
|
|
4052
4447
|
}
|
|
4053
4448
|
async feedPrompt() {
|
|
4054
|
-
if (typeof this.
|
|
4055
|
-
await this.deliverPrompt(this.
|
|
4449
|
+
if (typeof this.turnPrompt === "string") {
|
|
4450
|
+
await this.deliverPrompt(this.turnPrompt);
|
|
4056
4451
|
return;
|
|
4057
4452
|
}
|
|
4058
|
-
for await (const message of this.
|
|
4453
|
+
for await (const message of this.turnPrompt) {
|
|
4059
4454
|
const content = message.message.content;
|
|
4060
4455
|
const text = typeof content === "string" ? content : JSON.stringify(content);
|
|
4061
4456
|
await this.deliverPrompt(text);
|
|
@@ -4103,7 +4498,10 @@ var PtySession = class {
|
|
|
4103
4498
|
}
|
|
4104
4499
|
}
|
|
4105
4500
|
handleProgress(progress) {
|
|
4106
|
-
this.
|
|
4501
|
+
if (this.pendingPlanApproval && progress.tool_name === "ExitPlanMode") {
|
|
4502
|
+
this.disarmPlanDialogAutoAccept();
|
|
4503
|
+
}
|
|
4504
|
+
this.pushEvent({
|
|
4107
4505
|
type: "tool_progress",
|
|
4108
4506
|
...progress.tool_name === void 0 ? {} : { tool_name: progress.tool_name },
|
|
4109
4507
|
...progress.elapsed_time_seconds === void 0 ? {} : { elapsed_time_seconds: progress.elapsed_time_seconds }
|
|
@@ -4119,10 +4517,14 @@ var PtySession = class {
|
|
|
4119
4517
|
async handlePreToolUse(request) {
|
|
4120
4518
|
if (request.tool_name === "AskUserQuestion") {
|
|
4121
4519
|
this.disarmSubmitNudge();
|
|
4122
|
-
this.
|
|
4520
|
+
this.disarmPlanDialogAutoAccept();
|
|
4521
|
+
this.pushEvent({
|
|
4522
|
+
type: "user_question",
|
|
4523
|
+
questions: parseUserQuestions(request.tool_input)
|
|
4524
|
+
});
|
|
4123
4525
|
return { decision: "allow" };
|
|
4124
4526
|
}
|
|
4125
|
-
const canUseTool = this.
|
|
4527
|
+
const canUseTool = this.turn.canUseTool;
|
|
4126
4528
|
let verdict;
|
|
4127
4529
|
if (canUseTool) {
|
|
4128
4530
|
const result = await canUseTool(request.tool_name, request.tool_input);
|
|
@@ -4130,65 +4532,83 @@ var PtySession = class {
|
|
|
4130
4532
|
} else {
|
|
4131
4533
|
verdict = { decision: "allow" };
|
|
4132
4534
|
}
|
|
4133
|
-
if (verdict.decision === "allow" && request.tool_name === "ExitPlanMode" && this.
|
|
4535
|
+
if (verdict.decision === "allow" && request.tool_name === "ExitPlanMode" && this.turn.planDialogAutoAccept) {
|
|
4134
4536
|
this.armPlanDialogAutoAccept();
|
|
4135
4537
|
}
|
|
4136
4538
|
return verdict;
|
|
4137
4539
|
}
|
|
4138
4540
|
/**
|
|
4139
4541
|
* A hook "allow" does not bypass the CLI's plan-approval dialog — it renders
|
|
4140
|
-
* right after the verdict
|
|
4141
|
-
*
|
|
4142
|
-
* dialog
|
|
4542
|
+
* right after the verdict and parks the turn until answered. Press Enter
|
|
4543
|
+
* (default option approves) until the ExitPlanMode PostToolUse envelope
|
|
4544
|
+
* proves the dialog was answered (handleProgress), or the turn ends, over a
|
|
4545
|
+
* two-phase fast/slow window.
|
|
4546
|
+
*
|
|
4547
|
+
* Transcript records are deliberately NOT a stop signal: the CLI flushes the
|
|
4548
|
+
* assistant record holding the ExitPlanMode tool_use ~0.5s AFTER the
|
|
4549
|
+
* PreToolUse hook fires (verified live on 2.1.202), so when the Conveyor
|
|
4550
|
+
* verdict resolves quickly that record lands right after arming — a
|
|
4551
|
+
* record-based disarm then cancels the presses before the first one fires,
|
|
4552
|
+
* which is exactly the failure that parked auto cards on the approval
|
|
4553
|
+
* dialog until a human pressed Enter.
|
|
4143
4554
|
*/
|
|
4144
4555
|
armPlanDialogAutoAccept() {
|
|
4145
|
-
if (this.
|
|
4556
|
+
if (this.pendingPlanApproval) return;
|
|
4146
4557
|
this.pendingPlanApproval = true;
|
|
4147
|
-
|
|
4558
|
+
const startedAt = Date.now();
|
|
4148
4559
|
const press = () => {
|
|
4149
|
-
if (this._toreDown || !this.pendingPlanApproval
|
|
4560
|
+
if (this._toreDown || !this.pendingPlanApproval) return;
|
|
4561
|
+
const elapsed = Date.now() - startedAt;
|
|
4562
|
+
if (elapsed >= PLAN_DIALOG_WINDOW_MS) {
|
|
4563
|
+
process.stderr.write(
|
|
4564
|
+
"[PtySession] plan-dialog auto-accept window expired without ExitPlanMode executing \u2014 the approval dialog may still be parked\n"
|
|
4565
|
+
);
|
|
4150
4566
|
this.disarmPlanDialogAutoAccept();
|
|
4151
4567
|
return;
|
|
4152
4568
|
}
|
|
4153
|
-
presses++;
|
|
4154
4569
|
this.writeStdin("\r");
|
|
4570
|
+
const interval = elapsed < PLAN_DIALOG_FAST_WINDOW_MS ? PLAN_DIALOG_INTERVAL_MS : PLAN_DIALOG_SLOW_INTERVAL_MS;
|
|
4571
|
+
this.planApprovalTimer = setTimeout(press, interval);
|
|
4155
4572
|
};
|
|
4156
|
-
this.planApprovalTimer =
|
|
4157
|
-
setTimeout(press, 700);
|
|
4573
|
+
this.planApprovalTimer = setTimeout(press, PLAN_DIALOG_FIRST_PRESS_MS);
|
|
4158
4574
|
}
|
|
4159
4575
|
disarmPlanDialogAutoAccept() {
|
|
4160
4576
|
this.pendingPlanApproval = false;
|
|
4161
4577
|
if (this.planApprovalTimer) {
|
|
4162
|
-
|
|
4578
|
+
clearTimeout(this.planApprovalTimer);
|
|
4163
4579
|
this.planApprovalTimer = null;
|
|
4164
4580
|
}
|
|
4165
4581
|
}
|
|
4166
4582
|
handleTranscriptEvent(event) {
|
|
4167
|
-
if (this.pendingPlanApproval
|
|
4583
|
+
if (this.pendingPlanApproval && event.type === "result") {
|
|
4584
|
+
this.disarmPlanDialogAutoAccept();
|
|
4585
|
+
}
|
|
4168
4586
|
if (this.pendingSubmitNudge) this.disarmSubmitNudge();
|
|
4169
|
-
this.
|
|
4587
|
+
this.pushEvent(event);
|
|
4170
4588
|
if (event.type === "result") {
|
|
4171
4589
|
this.sawResult = true;
|
|
4172
4590
|
for (const listener of this.idleListeners) listener();
|
|
4173
|
-
this.
|
|
4591
|
+
this.endTurn(true);
|
|
4174
4592
|
}
|
|
4175
4593
|
}
|
|
4176
4594
|
async finalizeOnExit(exitCode) {
|
|
4177
4595
|
this.coalescer?.flush();
|
|
4596
|
+
this.exited = true;
|
|
4178
4597
|
if (this._toreDown) return;
|
|
4179
4598
|
if (this.tailer) {
|
|
4180
4599
|
this.tailer.close();
|
|
4181
4600
|
await this.tailer.flush();
|
|
4182
4601
|
}
|
|
4183
|
-
if (!this.sawResult) {
|
|
4184
|
-
this.
|
|
4602
|
+
if (!this.sawResult && this.activeQueue) {
|
|
4603
|
+
this.activeQueue.push({
|
|
4185
4604
|
type: "result",
|
|
4186
4605
|
subtype: "error",
|
|
4187
4606
|
errors: buildExitErrors(exitCode, this.recentOutput, resolveClaudeBinary())
|
|
4188
4607
|
});
|
|
4189
4608
|
}
|
|
4190
4609
|
for (const listener of this.exitListeners) listener(exitCode);
|
|
4191
|
-
this.
|
|
4610
|
+
this.activeQueue?.close();
|
|
4611
|
+
this.activeQueue = null;
|
|
4192
4612
|
}
|
|
4193
4613
|
};
|
|
4194
4614
|
|
|
@@ -4358,6 +4778,15 @@ async function removeConveyorCredentials(env = process.env) {
|
|
|
4358
4778
|
}
|
|
4359
4779
|
|
|
4360
4780
|
// src/harness/pty/index.ts
|
|
4781
|
+
var ENDED_GRACE_MS = 4e3;
|
|
4782
|
+
function fingerprintOf(options) {
|
|
4783
|
+
return spawnOptionsFingerprint({
|
|
4784
|
+
model: options.model,
|
|
4785
|
+
permissionMode: options.permissionMode,
|
|
4786
|
+
...options.appendSystemPrompt ? { appendSystemPrompt: options.appendSystemPrompt } : {},
|
|
4787
|
+
cwd: options.cwd
|
|
4788
|
+
});
|
|
4789
|
+
}
|
|
4361
4790
|
var PtyHarness = class {
|
|
4362
4791
|
/**
|
|
4363
4792
|
* `bridge` relays raw terminal I/O to/from the S2 server (and on to the S5
|
|
@@ -4370,35 +4799,144 @@ var PtyHarness = class {
|
|
|
4370
4799
|
bridge;
|
|
4371
4800
|
/** The session currently streaming events, if any — repaint target. */
|
|
4372
4801
|
activeSession = null;
|
|
4802
|
+
/** A completed-but-alive session kept for the next turn (keep-alive). */
|
|
4803
|
+
parked = null;
|
|
4804
|
+
/** Pending "process died" → `pty:ended` timer (grace window). */
|
|
4805
|
+
endedTimer = null;
|
|
4806
|
+
/** Passive-activity subscriber, re-attached to whichever session is parked. */
|
|
4807
|
+
passiveHandler = null;
|
|
4373
4808
|
/**
|
|
4374
|
-
* Wiggle the live pty's size so the CLI repaints its whole screen. No-op
|
|
4375
|
-
*
|
|
4376
|
-
* the
|
|
4377
|
-
* and the Connected-TUI terminal reappears.
|
|
4809
|
+
* Wiggle the live pty's size so the CLI repaints its whole screen. No-op on
|
|
4810
|
+
* the SDK harness. Falls back to the parked session so an API reconnect while
|
|
4811
|
+
* the CLI is idle still re-seeds the server's scrollback ring.
|
|
4378
4812
|
*/
|
|
4379
4813
|
forceRepaint() {
|
|
4380
|
-
this.activeSession?.forceRepaint();
|
|
4814
|
+
(this.activeSession ?? this.parked)?.forceRepaint();
|
|
4381
4815
|
}
|
|
4382
4816
|
async *executeQuery(opts) {
|
|
4383
|
-
const
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
this.
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4817
|
+
const want = opts.resume ?? opts.options.resume;
|
|
4818
|
+
const fingerprint = fingerprintOf(opts.options);
|
|
4819
|
+
let session;
|
|
4820
|
+
if (this.parked?.canReuse(want, fingerprint)) {
|
|
4821
|
+
session = this.parked;
|
|
4822
|
+
this.parked = null;
|
|
4823
|
+
this.cancelEndedTimer();
|
|
4824
|
+
await session.beginTurn(opts.prompt, opts.options);
|
|
4825
|
+
} else {
|
|
4826
|
+
if (this.parked) {
|
|
4827
|
+
const stale = this.parked;
|
|
4828
|
+
this.parked = null;
|
|
4829
|
+
this.cancelEndedTimer();
|
|
4830
|
+
await stale.teardown();
|
|
4831
|
+
}
|
|
4832
|
+
session = new PtySession(opts.prompt, opts.options, want, this.bridge);
|
|
4833
|
+
await ensureClaudeCredentials();
|
|
4834
|
+
await ensureClaudeOnboarding(process.env, opts.options.cwd);
|
|
4835
|
+
session.onExit(() => this.handleSessionExit(session));
|
|
4836
|
+
await session.start();
|
|
4837
|
+
}
|
|
4838
|
+
yield* this.drain(session);
|
|
4839
|
+
}
|
|
4840
|
+
/**
|
|
4841
|
+
* PTY-only: subscribe to "the parked CLI produced transcript activity with no
|
|
4842
|
+
* query running" (a human typed into the idle Connected-TUI). Attaches to the
|
|
4843
|
+
* currently-parked session and to any session parked later.
|
|
4844
|
+
*/
|
|
4845
|
+
onPassiveActivity(handler) {
|
|
4846
|
+
this.passiveHandler = handler;
|
|
4847
|
+
const unsubParked = this.parked?.onPassiveActivity(handler);
|
|
4848
|
+
return () => {
|
|
4849
|
+
if (this.passiveHandler === handler) this.passiveHandler = null;
|
|
4850
|
+
unsubParked?.();
|
|
4851
|
+
};
|
|
4852
|
+
}
|
|
4853
|
+
/**
|
|
4854
|
+
* PTY-only: drain a passive turn against the parked process — the human
|
|
4855
|
+
* already submitted in the TUI, so no prompt is fed. Empty stream when nothing
|
|
4856
|
+
* is parked.
|
|
4857
|
+
*/
|
|
4858
|
+
async *executePassiveTurn(options) {
|
|
4859
|
+
const session = this.parked;
|
|
4860
|
+
if (!session) return;
|
|
4861
|
+
this.parked = null;
|
|
4862
|
+
this.cancelEndedTimer();
|
|
4863
|
+
session.beginPassiveTurn(options);
|
|
4864
|
+
yield* this.drain(session);
|
|
4865
|
+
}
|
|
4866
|
+
/**
|
|
4867
|
+
* Shared query/passive-turn drain: stream the session's events, then either
|
|
4868
|
+
* park it (clean result → keep the process for the next turn) or tear it down
|
|
4869
|
+
* (abort/error/early-return → real death, schedule the ended signal).
|
|
4870
|
+
*/
|
|
4871
|
+
async *drain(session) {
|
|
4392
4872
|
this.activeSession = session;
|
|
4873
|
+
let clean = false;
|
|
4393
4874
|
try {
|
|
4394
4875
|
for await (const event of session.events()) {
|
|
4395
4876
|
yield event;
|
|
4396
4877
|
}
|
|
4878
|
+
clean = session.endedCleanly && !session.isToreDown && !session.hasExited;
|
|
4397
4879
|
} finally {
|
|
4398
4880
|
if (this.activeSession === session) this.activeSession = null;
|
|
4399
|
-
|
|
4881
|
+
if (clean) {
|
|
4882
|
+
this.park(session);
|
|
4883
|
+
} else {
|
|
4884
|
+
await session.teardown();
|
|
4885
|
+
this.scheduleEnded();
|
|
4886
|
+
}
|
|
4400
4887
|
}
|
|
4401
4888
|
}
|
|
4889
|
+
/** Park a live, idle session for reuse and (re)wire the passive listener. */
|
|
4890
|
+
park(session) {
|
|
4891
|
+
this.parked = session;
|
|
4892
|
+
if (this.passiveHandler) session.onPassiveActivity(this.passiveHandler);
|
|
4893
|
+
}
|
|
4894
|
+
/**
|
|
4895
|
+
* Parked/active process exited on its own (crash). A mid-turn death is
|
|
4896
|
+
* handled by drain()'s finally; here we only act on a death while PARKED —
|
|
4897
|
+
* tear down the husk and report ended immediately (no respawn is coming).
|
|
4898
|
+
*/
|
|
4899
|
+
handleSessionExit(session) {
|
|
4900
|
+
if (this.parked !== session) return;
|
|
4901
|
+
this.parked = null;
|
|
4902
|
+
void session.teardown();
|
|
4903
|
+
this.notifyEnded();
|
|
4904
|
+
}
|
|
4905
|
+
scheduleEnded() {
|
|
4906
|
+
if (this.endedTimer) return;
|
|
4907
|
+
this.endedTimer = setTimeout(() => {
|
|
4908
|
+
this.endedTimer = null;
|
|
4909
|
+
this.notifyEnded();
|
|
4910
|
+
}, ENDED_GRACE_MS);
|
|
4911
|
+
}
|
|
4912
|
+
cancelEndedTimer() {
|
|
4913
|
+
if (this.endedTimer) {
|
|
4914
|
+
clearTimeout(this.endedTimer);
|
|
4915
|
+
this.endedTimer = null;
|
|
4916
|
+
}
|
|
4917
|
+
}
|
|
4918
|
+
notifyEnded() {
|
|
4919
|
+
this.cancelEndedTimer();
|
|
4920
|
+
this.bridge?.sendEnded?.();
|
|
4921
|
+
}
|
|
4922
|
+
/**
|
|
4923
|
+
* Tear down every live process (parked + active) and signal ended. Called by
|
|
4924
|
+
* the runner on stop/shutdown so the parked CLI never outlives the session and
|
|
4925
|
+
* the Connected-TUI tab hides promptly.
|
|
4926
|
+
*/
|
|
4927
|
+
async dispose() {
|
|
4928
|
+
this.cancelEndedTimer();
|
|
4929
|
+
const toKill = [this.parked, this.activeSession].filter((s) => s !== null);
|
|
4930
|
+
this.parked = null;
|
|
4931
|
+
this.activeSession = null;
|
|
4932
|
+
for (const session of toKill) {
|
|
4933
|
+
try {
|
|
4934
|
+
await session.teardown();
|
|
4935
|
+
} catch {
|
|
4936
|
+
}
|
|
4937
|
+
}
|
|
4938
|
+
if (toKill.length > 0) this.notifyEnded();
|
|
4939
|
+
}
|
|
4402
4940
|
createMcpServer(config) {
|
|
4403
4941
|
return new PtyMcpServer(config.name, config.tools);
|
|
4404
4942
|
}
|
|
@@ -6630,6 +7168,7 @@ function buildCommonTools(connection, config) {
|
|
|
6630
7168
|
// src/tools/pm-tools.ts
|
|
6631
7169
|
import { z as z8 } from "zod";
|
|
6632
7170
|
var SP_DESCRIPTION = "Story point value (1=Common, 2=Magic, 3=Rare, 5=Unique)";
|
|
7171
|
+
var FOLLOW_PARENT_STATUS_DESCRIPTION = "Child mirrors the parent task's status automatically \u2014 for subtasks that ship on the parent's branch/PR with no build or PR of their own. Manual status writes on a follower stick only until the parent's next transition.";
|
|
6633
7172
|
function buildUpdateTaskTool(connection) {
|
|
6634
7173
|
return defineTool(
|
|
6635
7174
|
"update_task_plan",
|
|
@@ -6661,9 +7200,10 @@ function buildCreateSubtaskTool(connection) {
|
|
|
6661
7200
|
description: z8.string().optional().describe("Brief description"),
|
|
6662
7201
|
plan: z8.string().optional().describe("Implementation plan in markdown"),
|
|
6663
7202
|
ordinal: z8.number().optional().describe("Step/order number (0-based)"),
|
|
6664
|
-
storyPointValue: z8.number().optional().describe(SP_DESCRIPTION)
|
|
7203
|
+
storyPointValue: z8.number().optional().describe(SP_DESCRIPTION),
|
|
7204
|
+
followParentStatus: z8.boolean().optional().describe(FOLLOW_PARENT_STATUS_DESCRIPTION)
|
|
6665
7205
|
},
|
|
6666
|
-
async ({ title, description, plan, ordinal, storyPointValue }) => {
|
|
7206
|
+
async ({ title, description, plan, ordinal, storyPointValue, followParentStatus }) => {
|
|
6667
7207
|
try {
|
|
6668
7208
|
const result = await connection.call("createSubtask", {
|
|
6669
7209
|
sessionId: connection.sessionId,
|
|
@@ -6671,7 +7211,8 @@ function buildCreateSubtaskTool(connection) {
|
|
|
6671
7211
|
...description !== void 0 && { description },
|
|
6672
7212
|
...plan !== void 0 && { plan },
|
|
6673
7213
|
...storyPointValue !== void 0 && { storyPointValue },
|
|
6674
|
-
...ordinal !== void 0 && { ordinal }
|
|
7214
|
+
...ordinal !== void 0 && { ordinal },
|
|
7215
|
+
...followParentStatus !== void 0 && { followParentStatus }
|
|
6675
7216
|
});
|
|
6676
7217
|
return textResult(`Subtask created with ID: ${result.id}`);
|
|
6677
7218
|
} catch (error) {
|
|
@@ -6692,9 +7233,10 @@ function buildUpdateSubtaskTool(connection) {
|
|
|
6692
7233
|
description: z8.string().optional(),
|
|
6693
7234
|
plan: z8.string().optional(),
|
|
6694
7235
|
ordinal: z8.number().optional(),
|
|
6695
|
-
storyPointValue: z8.number().optional().describe(SP_DESCRIPTION)
|
|
7236
|
+
storyPointValue: z8.number().optional().describe(SP_DESCRIPTION),
|
|
7237
|
+
followParentStatus: z8.boolean().optional().describe(FOLLOW_PARENT_STATUS_DESCRIPTION)
|
|
6696
7238
|
},
|
|
6697
|
-
async ({ subtaskId, title, description, plan, storyPointValue }) => {
|
|
7239
|
+
async ({ subtaskId, title, description, plan, storyPointValue, followParentStatus }) => {
|
|
6698
7240
|
try {
|
|
6699
7241
|
await connection.call("updateSubtask", {
|
|
6700
7242
|
sessionId: connection.sessionId,
|
|
@@ -6702,7 +7244,8 @@ function buildUpdateSubtaskTool(connection) {
|
|
|
6702
7244
|
...title !== void 0 && { title },
|
|
6703
7245
|
...description !== void 0 && { description },
|
|
6704
7246
|
...plan !== void 0 && { plan },
|
|
6705
|
-
...storyPointValue !== void 0 && { storyPointValue }
|
|
7247
|
+
...storyPointValue !== void 0 && { storyPointValue },
|
|
7248
|
+
...followParentStatus !== void 0 && { followParentStatus }
|
|
6706
7249
|
});
|
|
6707
7250
|
return textResult("Subtask updated.");
|
|
6708
7251
|
} catch (error) {
|
|
@@ -8049,6 +8592,18 @@ async function runPrefilledFollowUp(host, context, options, resume, followUpCont
|
|
|
8049
8592
|
});
|
|
8050
8593
|
await trackAndRun(host, context, queryOptions, agentQuery);
|
|
8051
8594
|
}
|
|
8595
|
+
async function runPassiveTurn(host, context) {
|
|
8596
|
+
if (host.isStopped()) return;
|
|
8597
|
+
if (!host.harness.executePassiveTurn) return;
|
|
8598
|
+
const options = buildQueryOptions(host, context);
|
|
8599
|
+
const passiveQuery = host.harness.executePassiveTurn(options);
|
|
8600
|
+
host.activeQuery = passiveQuery;
|
|
8601
|
+
try {
|
|
8602
|
+
await processEvents(passiveQuery, context, host);
|
|
8603
|
+
} finally {
|
|
8604
|
+
host.activeQuery = null;
|
|
8605
|
+
}
|
|
8606
|
+
}
|
|
8052
8607
|
async function trackAndRun(host, context, options, agentQuery) {
|
|
8053
8608
|
if (host.harnessKind === "pty" && options.promptDelivery !== "prefill") {
|
|
8054
8609
|
agentQuery = watchForParkedTui(agentQuery, host);
|
|
@@ -8384,6 +8939,7 @@ function resolveHarnessKind() {
|
|
|
8384
8939
|
function buildPtyBridge(connection) {
|
|
8385
8940
|
return {
|
|
8386
8941
|
sendOutput: (data, dims) => connection.sendPtyOutput(data, dims),
|
|
8942
|
+
sendEnded: () => connection.sendPtyEnded(),
|
|
8387
8943
|
onInput: (handler) => connection.onPtyInput(handler),
|
|
8388
8944
|
onResize: (handler) => connection.onPtyResize(handler)
|
|
8389
8945
|
};
|
|
@@ -8455,6 +9011,24 @@ var QueryBridge = class {
|
|
|
8455
9011
|
resume() {
|
|
8456
9012
|
this._stopped = false;
|
|
8457
9013
|
}
|
|
9014
|
+
/**
|
|
9015
|
+
* Tear down any parked/active CLI process the harness is keeping alive between
|
|
9016
|
+
* turns (PTY keep-alive). Called by SessionRunner on stop/shutdown so the
|
|
9017
|
+
* process never outlives the session and the Connected-TUI tab hides. No-op on
|
|
9018
|
+
* the SDK harness.
|
|
9019
|
+
*/
|
|
9020
|
+
async dispose() {
|
|
9021
|
+
await this.harness.dispose?.();
|
|
9022
|
+
}
|
|
9023
|
+
/**
|
|
9024
|
+
* Subscribe to "a human typed into the parked, idle TUI" (PTY keep-alive).
|
|
9025
|
+
* SessionRunner uses this to wake a passive turn. Returns unsubscribe; no-op
|
|
9026
|
+
* (returns a noop unsubscribe) on the SDK harness.
|
|
9027
|
+
*/
|
|
9028
|
+
onPassiveActivity(handler) {
|
|
9029
|
+
return this.harness.onPassiveActivity?.(handler) ?? (() => {
|
|
9030
|
+
});
|
|
9031
|
+
}
|
|
8458
9032
|
/**
|
|
8459
9033
|
* How the INITIAL instructions for this task would be delivered — "prefill"
|
|
8460
9034
|
* means the TUI input is pre-filled but unsubmitted, awaiting the human.
|
|
@@ -8503,6 +9077,34 @@ var QueryBridge = class {
|
|
|
8503
9077
|
this._abortController = null;
|
|
8504
9078
|
}
|
|
8505
9079
|
}
|
|
9080
|
+
/**
|
|
9081
|
+
* Drive a passive turn: the parked CLI is producing transcript records because
|
|
9082
|
+
* a human typed into the idle Connected-TUI. Mirrors execute()'s setup (fresh
|
|
9083
|
+
* abort controller so a superseding chat message can abort, host construction,
|
|
9084
|
+
* error classification) but runs the promptless passive drain instead of a
|
|
9085
|
+
* full query.
|
|
9086
|
+
*/
|
|
9087
|
+
async executePassive(context) {
|
|
9088
|
+
this._stopped = false;
|
|
9089
|
+
this._wasRateLimited = false;
|
|
9090
|
+
this._abortController = new AbortController();
|
|
9091
|
+
const host = this.buildHost();
|
|
9092
|
+
try {
|
|
9093
|
+
await runPassiveTurn(host, context);
|
|
9094
|
+
} catch (err) {
|
|
9095
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9096
|
+
const isAbort = this._stopped || /abort/i.test(msg);
|
|
9097
|
+
if (isAbort) {
|
|
9098
|
+
logger3.info("Passive turn stopped", { error: msg });
|
|
9099
|
+
} else {
|
|
9100
|
+
logger3.error("Passive turn failed", { error: msg });
|
|
9101
|
+
this.connection.sendEvent({ type: "error", message: msg });
|
|
9102
|
+
}
|
|
9103
|
+
} finally {
|
|
9104
|
+
this.mode.pendingModeRestart = false;
|
|
9105
|
+
this._abortController = null;
|
|
9106
|
+
}
|
|
9107
|
+
}
|
|
8506
9108
|
// ── QueryHost construction ──────────────────────────────────────────
|
|
8507
9109
|
buildHost() {
|
|
8508
9110
|
const bridge = this;
|
|
@@ -8622,6 +9224,80 @@ function readAgentVersion() {
|
|
|
8622
9224
|
return null;
|
|
8623
9225
|
}
|
|
8624
9226
|
|
|
9227
|
+
// src/execution/usage-sampler.ts
|
|
9228
|
+
var logger4 = createServiceLogger("usage-sampler");
|
|
9229
|
+
var OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
9230
|
+
var OAUTH_USAGE_BETA = "oauth-2025-04-20";
|
|
9231
|
+
var KNOWN_RATE_LIMIT_TYPES = [
|
|
9232
|
+
"five_hour",
|
|
9233
|
+
"seven_day",
|
|
9234
|
+
"seven_day_opus",
|
|
9235
|
+
"seven_day_sonnet"
|
|
9236
|
+
];
|
|
9237
|
+
function normalizeUtilization(raw) {
|
|
9238
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
|
|
9239
|
+
const fraction = raw > 1 ? raw / 100 : raw;
|
|
9240
|
+
return Math.max(0, Math.min(1, fraction));
|
|
9241
|
+
}
|
|
9242
|
+
function extractBucket(value) {
|
|
9243
|
+
if (typeof value === "number") {
|
|
9244
|
+
const utilization = normalizeUtilization(value);
|
|
9245
|
+
return utilization === null ? null : { utilization, status: "allowed" };
|
|
9246
|
+
}
|
|
9247
|
+
if (value && typeof value === "object") {
|
|
9248
|
+
const obj = value;
|
|
9249
|
+
const utilization = normalizeUtilization(
|
|
9250
|
+
obj.utilization ?? obj.used ?? obj.percent ?? obj.percentage
|
|
9251
|
+
);
|
|
9252
|
+
if (utilization === null) return null;
|
|
9253
|
+
const status = typeof obj.status === "string" ? obj.status : "allowed";
|
|
9254
|
+
return { utilization, status };
|
|
9255
|
+
}
|
|
9256
|
+
return null;
|
|
9257
|
+
}
|
|
9258
|
+
function mapUsageResponse(data) {
|
|
9259
|
+
if (!data || typeof data !== "object") return [];
|
|
9260
|
+
const root = data;
|
|
9261
|
+
const candidates = [root];
|
|
9262
|
+
for (const key of ["usage", "rate_limits", "rateLimits"]) {
|
|
9263
|
+
const nested = root[key];
|
|
9264
|
+
if (nested && typeof nested === "object") {
|
|
9265
|
+
candidates.push(nested);
|
|
9266
|
+
}
|
|
9267
|
+
}
|
|
9268
|
+
for (const container of candidates) {
|
|
9269
|
+
const samples = [];
|
|
9270
|
+
for (const type of KNOWN_RATE_LIMIT_TYPES) {
|
|
9271
|
+
const bucket = extractBucket(container[type]);
|
|
9272
|
+
if (bucket) samples.push({ rateLimitType: type, ...bucket });
|
|
9273
|
+
}
|
|
9274
|
+
if (samples.length > 0) return samples;
|
|
9275
|
+
}
|
|
9276
|
+
return [];
|
|
9277
|
+
}
|
|
9278
|
+
async function sampleKeyUsage(token) {
|
|
9279
|
+
if (!token) return [];
|
|
9280
|
+
try {
|
|
9281
|
+
const res = await fetch(OAUTH_USAGE_URL, {
|
|
9282
|
+
headers: {
|
|
9283
|
+
Authorization: `Bearer ${token}`,
|
|
9284
|
+
"anthropic-beta": OAUTH_USAGE_BETA
|
|
9285
|
+
}
|
|
9286
|
+
});
|
|
9287
|
+
if (!res.ok) {
|
|
9288
|
+
logger4.info("OAuth usage endpoint returned non-200", { status: res.status });
|
|
9289
|
+
return [];
|
|
9290
|
+
}
|
|
9291
|
+
const data = await res.json();
|
|
9292
|
+
return mapUsageResponse(data);
|
|
9293
|
+
} catch (error) {
|
|
9294
|
+
logger4.info("OAuth usage sample failed", {
|
|
9295
|
+
error: error instanceof Error ? error.message : String(error)
|
|
9296
|
+
});
|
|
9297
|
+
return [];
|
|
9298
|
+
}
|
|
9299
|
+
}
|
|
9300
|
+
|
|
8625
9301
|
// src/runner/port-discovery.ts
|
|
8626
9302
|
import { readFile as readFile4 } from "fs/promises";
|
|
8627
9303
|
import { execFile } from "child_process";
|
|
@@ -8920,7 +9596,8 @@ var SessionRunner = class _SessionRunner {
|
|
|
8920
9596
|
}
|
|
8921
9597
|
},
|
|
8922
9598
|
onTokenRefresh: () => void this.refreshGithubToken(),
|
|
8923
|
-
onGitFlush: () => void this.periodicGitFlush()
|
|
9599
|
+
onGitFlush: () => void this.periodicGitFlush(),
|
|
9600
|
+
onUsageSample: () => void this.sampleAndReportKeyUsage()
|
|
8924
9601
|
});
|
|
8925
9602
|
}
|
|
8926
9603
|
get state() {
|
|
@@ -8946,6 +9623,7 @@ var SessionRunner = class _SessionRunner {
|
|
|
8946
9623
|
this.wireConnectionCallbacks();
|
|
8947
9624
|
this.lifecycle.startHeartbeat();
|
|
8948
9625
|
this.lifecycle.startTokenRefresh();
|
|
9626
|
+
this.lifecycle.startUsageSample();
|
|
8949
9627
|
const { pendingMessages: serverMessages } = await this.connection.call("connectAgent", {
|
|
8950
9628
|
sessionId: this.sessionId
|
|
8951
9629
|
});
|
|
@@ -9102,16 +9780,21 @@ var SessionRunner = class _SessionRunner {
|
|
|
9102
9780
|
break;
|
|
9103
9781
|
}
|
|
9104
9782
|
this.interrupted = false;
|
|
9105
|
-
|
|
9106
|
-
type: "user_message",
|
|
9107
|
-
content: msg.content,
|
|
9108
|
-
userId: msg.userId
|
|
9109
|
-
});
|
|
9110
|
-
if (this.prefillEligible(msg)) {
|
|
9111
|
-
await this.runPrefilledMessage(msg, this.mode.effectiveMode);
|
|
9112
|
-
} else {
|
|
9783
|
+
if (msg.source === "pty_passive") {
|
|
9113
9784
|
await this.setState("running");
|
|
9114
|
-
await this.
|
|
9785
|
+
await this.executePassive();
|
|
9786
|
+
} else {
|
|
9787
|
+
await this.callbacks.onEvent({
|
|
9788
|
+
type: "user_message",
|
|
9789
|
+
content: msg.content,
|
|
9790
|
+
userId: msg.userId
|
|
9791
|
+
});
|
|
9792
|
+
if (this.prefillEligible(msg)) {
|
|
9793
|
+
await this.runPrefilledMessage(msg, this.mode.effectiveMode);
|
|
9794
|
+
} else {
|
|
9795
|
+
await this.setState("running");
|
|
9796
|
+
await this.executeQuery(msg.content);
|
|
9797
|
+
}
|
|
9115
9798
|
}
|
|
9116
9799
|
if (this.queryBridge?.isDiscoveryCompleted) {
|
|
9117
9800
|
process.stderr.write(
|
|
@@ -9338,6 +10021,28 @@ var SessionRunner = class _SessionRunner {
|
|
|
9338
10021
|
throw err;
|
|
9339
10022
|
}
|
|
9340
10023
|
}
|
|
10024
|
+
/**
|
|
10025
|
+
* A human typed into the parked, idle Connected-TUI (keep-alive PTY). Wake the
|
|
10026
|
+
* core loop with a sentinel so it drains a passive turn. No-op once stopped.
|
|
10027
|
+
*/
|
|
10028
|
+
onPassiveTuiActivity() {
|
|
10029
|
+
if (this.stopped) return;
|
|
10030
|
+
this.injectMessage({ content: "", userId: "human", source: "pty_passive" });
|
|
10031
|
+
}
|
|
10032
|
+
/** Run queryBridge.executePassive, swallowing abort errors from stop/softStop. */
|
|
10033
|
+
async executePassive() {
|
|
10034
|
+
if (!this.fullContext || !this.queryBridge) return;
|
|
10035
|
+
this.agentSpokeThisTurn = false;
|
|
10036
|
+
try {
|
|
10037
|
+
await this.queryBridge.executePassive(this.fullContext);
|
|
10038
|
+
} catch (err) {
|
|
10039
|
+
if (this.interrupted || this.stopped) {
|
|
10040
|
+
process.stderr.write("[conveyor-agent] Passive turn aborted by stop/softStop signal\n");
|
|
10041
|
+
return;
|
|
10042
|
+
}
|
|
10043
|
+
throw err;
|
|
10044
|
+
}
|
|
10045
|
+
}
|
|
9341
10046
|
// ── Stop / soft-stop ───────────────────────────────────────────────
|
|
9342
10047
|
/** Periodic best-effort WIP commit + push during normal agent execution.
|
|
9343
10048
|
* Covers ungraceful pod termination (OOMKilled, node crash/eviction) where
|
|
@@ -9372,6 +10077,24 @@ var SessionRunner = class _SessionRunner {
|
|
|
9372
10077
|
this.periodicFlushInFlight = false;
|
|
9373
10078
|
}
|
|
9374
10079
|
}
|
|
10080
|
+
/** Sample the running Claude subscription key's rate-limit utilization from the
|
|
10081
|
+
* Anthropic OAuth usage endpoint and report it as `rate_limit_update` events.
|
|
10082
|
+
* The API (`persistRateLimitSnapshot`) attributes them to the key this session
|
|
10083
|
+
* launched under, keeping User Settings + the PtY-tab usage widget fresh and
|
|
10084
|
+
* `selectBestKey` rotation honest. Best-effort — never throws, no-op when the
|
|
10085
|
+
* pod has no OAuth token (e.g. API-key projects). */
|
|
10086
|
+
async sampleAndReportKeyUsage() {
|
|
10087
|
+
if (this.stopped) return;
|
|
10088
|
+
const samples = await sampleKeyUsage(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
10089
|
+
for (const sample of samples) {
|
|
10090
|
+
this.connection.sendEvent({
|
|
10091
|
+
type: "rate_limit_update",
|
|
10092
|
+
rateLimitType: sample.rateLimitType,
|
|
10093
|
+
utilization: sample.utilization,
|
|
10094
|
+
status: sample.status
|
|
10095
|
+
});
|
|
10096
|
+
}
|
|
10097
|
+
}
|
|
9375
10098
|
/** PUT the WorkPreservation snapshot tar to the bundle's capability URL
|
|
9376
10099
|
* when this pod is v3 (CONVEYOR_SNAPSHOT_UPLOAD_URL set). Never throws. */
|
|
9377
10100
|
async uploadGcsSnapshotIfConfigured() {
|
|
@@ -9422,6 +10145,8 @@ var SessionRunner = class _SessionRunner {
|
|
|
9422
10145
|
stop() {
|
|
9423
10146
|
this.stopped = true;
|
|
9424
10147
|
this.queryBridge?.stop();
|
|
10148
|
+
void this.queryBridge?.dispose().catch(() => {
|
|
10149
|
+
});
|
|
9425
10150
|
this.portDiscovery?.stop();
|
|
9426
10151
|
this.lifecycle.destroy();
|
|
9427
10152
|
this.connection.disconnect();
|
|
@@ -9609,6 +10334,7 @@ var SessionRunner = class _SessionRunner {
|
|
|
9609
10334
|
this.connection.emitModeChanged(newMode);
|
|
9610
10335
|
this.softStop();
|
|
9611
10336
|
};
|
|
10337
|
+
bridge.onPassiveActivity(() => this.onPassiveTuiActivity());
|
|
9612
10338
|
return bridge;
|
|
9613
10339
|
}
|
|
9614
10340
|
// ── Private helpers ────────────────────────────────────────────────
|
|
@@ -9725,6 +10451,10 @@ var SessionRunner = class _SessionRunner {
|
|
|
9725
10451
|
this.connection.sendEvent({ type: "shutdown", reason: finalState });
|
|
9726
10452
|
this.portDiscovery?.stop();
|
|
9727
10453
|
this.lifecycle.destroy();
|
|
10454
|
+
try {
|
|
10455
|
+
await this.queryBridge?.dispose();
|
|
10456
|
+
} catch {
|
|
10457
|
+
}
|
|
9728
10458
|
try {
|
|
9729
10459
|
await this.setState(finalState);
|
|
9730
10460
|
} catch {
|
|
@@ -9890,11 +10620,13 @@ function unshallowRepo(workspaceDir) {
|
|
|
9890
10620
|
}
|
|
9891
10621
|
|
|
9892
10622
|
export {
|
|
10623
|
+
DEFAULT_SONNET_MODEL,
|
|
9893
10624
|
fetchBootstrap,
|
|
9894
10625
|
applyBootstrapToEnv,
|
|
9895
10626
|
AgentConnection,
|
|
9896
10627
|
DEFAULT_LIFECYCLE_CONFIG,
|
|
9897
10628
|
Lifecycle,
|
|
10629
|
+
PtyHarness,
|
|
9898
10630
|
createServiceLogger,
|
|
9899
10631
|
hasUncommittedChanges,
|
|
9900
10632
|
getCurrentBranch,
|
|
@@ -9904,6 +10636,7 @@ export {
|
|
|
9904
10636
|
flushPendingChanges,
|
|
9905
10637
|
pushToOrigin,
|
|
9906
10638
|
PlanSync,
|
|
10639
|
+
sampleKeyUsage,
|
|
9907
10640
|
awaitGitReady,
|
|
9908
10641
|
uploadSnapshotToGcs,
|
|
9909
10642
|
captureSnapshot,
|
|
@@ -9920,4 +10653,4 @@ export {
|
|
|
9920
10653
|
runStartCommand,
|
|
9921
10654
|
unshallowRepo
|
|
9922
10655
|
};
|
|
9923
|
-
//# sourceMappingURL=chunk-
|
|
10656
|
+
//# sourceMappingURL=chunk-EDEMOFUN.js.map
|