@episoda/cli 0.2.153 → 0.2.154

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.
@@ -2913,7 +2913,7 @@ var require_package = __commonJS({
2913
2913
  "package.json"(exports2, module2) {
2914
2914
  module2.exports = {
2915
2915
  name: "@episoda/cli",
2916
- version: "0.2.153",
2916
+ version: "0.2.154",
2917
2917
  description: "CLI tool for Episoda local development workflow orchestration",
2918
2918
  main: "dist/index.js",
2919
2919
  types: "dist/index.d.ts",
@@ -8535,7 +8535,7 @@ function generateCodexMcpConfigToml(servers, projectPath) {
8535
8535
  }
8536
8536
 
8537
8537
  // src/agent/agent-manager.ts
8538
- var import_child_process12 = require("child_process");
8538
+ var import_child_process14 = require("child_process");
8539
8539
  var path20 = __toESM(require("path"));
8540
8540
  var fs19 = __toESM(require("fs"));
8541
8541
  var os7 = __toESM(require("os"));
@@ -8842,6 +8842,1057 @@ function generateClaudeConfig(options = {}) {
8842
8842
 
8843
8843
  // src/agent/agent-manager.ts
8844
8844
  var import_core11 = __toESM(require_dist());
8845
+
8846
+ // src/agent/claude-persistent-runtime.ts
8847
+ var import_child_process12 = require("child_process");
8848
+ var ECHO_TIMEOUT_MS = parseInt(process.env.AGENT_ECHO_TIMEOUT_MS || "10000", 10);
8849
+ var INACTIVITY_TIMEOUT_MS = parseInt(process.env.AGENT_STREAM_INACTIVITY_TIMEOUT_MS || "180000", 10);
8850
+ var SHUTDOWN_SIGTERM_WAIT_MS = 2e3;
8851
+ var SHUTDOWN_SIGKILL_WAIT_MS = 2e3;
8852
+ var STDIN_DRAIN_TIMEOUT_MS = 1e4;
8853
+ var RUNTIME_DEBUG = process.env.AGENT_RUNTIME_DEBUG === "1";
8854
+ var ClaudePersistentRuntime = class {
8855
+ constructor(options) {
8856
+ this.options = options;
8857
+ this.provider = "claude";
8858
+ this.process = null;
8859
+ this._turnState = "idle";
8860
+ this.alive = false;
8861
+ this.stuck = false;
8862
+ // Set when SIGKILL doesn't work (macOS kernel wait)
8863
+ // Current turn's callbacks — set on each sendMessage(), cleared on turn end
8864
+ this.callbacks = null;
8865
+ // Keep a small stderr tail so we can print it on failure without spamming logs.
8866
+ this.stderrTail = "";
8867
+ this.maxStderrTailBytes = 8192;
8868
+ // Stdout JSONL parsing state
8869
+ this.stdoutBuffer = "";
8870
+ this.parsedLineCount = 0;
8871
+ this.chunksSent = 0;
8872
+ this.hasContent = false;
8873
+ // For paragraph breaks between messages
8874
+ // EP1360: Per-turn TTFT tracking
8875
+ this.turnTtftLogged = false;
8876
+ // EP1360: tool_use_id dedup (#20583 — Claude Code can emit duplicate tool_use IDs)
8877
+ this.seenToolUseIds = /* @__PURE__ */ new Set();
8878
+ // Timers
8879
+ this.echoTimer = null;
8880
+ this.inactivityTimer = null;
8881
+ this.turnStartTime = 0;
8882
+ this.sawAnyStdoutThisTurn = false;
8883
+ // EP1360: Instrumentation
8884
+ this.spawnTimestamp = 0;
8885
+ this.sessionId = options.sessionId;
8886
+ }
8887
+ // -------------------------------------------------------------------------
8888
+ // Public interface
8889
+ // -------------------------------------------------------------------------
8890
+ get turnState() {
8891
+ return this._turnState;
8892
+ }
8893
+ get agentSessionId() {
8894
+ return this._agentSessionId;
8895
+ }
8896
+ get pid() {
8897
+ return this._pid;
8898
+ }
8899
+ /**
8900
+ * Spawn the persistent CLI process.
8901
+ * Must be called once before sendMessage(). Separated from constructor
8902
+ * because spawn is synchronous but we need to set up handlers.
8903
+ */
8904
+ spawn() {
8905
+ if (this.process) {
8906
+ throw new Error(`[ClaudePersistentRuntime] Process already spawned for session ${this.sessionId}`);
8907
+ }
8908
+ const { binaryPath, args, env, cwd } = this.options;
8909
+ this.spawnTimestamp = Date.now();
8910
+ const spawnRssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
8911
+ console.log(`[ClaudePersistentRuntime] Spawning persistent Claude process for session ${this.sessionId}, RSS=${spawnRssMb}MB`);
8912
+ this.process = (0, import_child_process12.spawn)(binaryPath, args, {
8913
+ cwd,
8914
+ env,
8915
+ // CLAUDE_CODE_DISABLE_PLUGIN_CACHE=1 is set by agent-manager in envVars
8916
+ stdio: ["pipe", "pipe", "pipe"]
8917
+ });
8918
+ this._pid = this.process.pid;
8919
+ this.alive = true;
8920
+ this.process.stderr?.on("data", (data) => {
8921
+ const chunk = data.toString();
8922
+ this.stderrTail = (this.stderrTail + chunk).slice(-this.maxStderrTailBytes);
8923
+ if (RUNTIME_DEBUG) {
8924
+ console.error(`[ClaudePersistentRuntime] stderr (session=${this.sessionId}): ${chunk.trimEnd()}`);
8925
+ }
8926
+ });
8927
+ this.process.stdout?.on("data", (data) => {
8928
+ this.resetInactivityTimer();
8929
+ this.stdoutBuffer += data.toString();
8930
+ const lines = this.stdoutBuffer.split("\n");
8931
+ this.stdoutBuffer = lines.pop() || "";
8932
+ for (const line of lines) {
8933
+ if (!line.trim()) continue;
8934
+ try {
8935
+ const parsed = JSON.parse(line);
8936
+ this.parsedLineCount++;
8937
+ if (this.parsedLineCount === 1) {
8938
+ const ttftMs = Date.now() - this.spawnTimestamp;
8939
+ console.log(`[ClaudePersistentRuntime] EP1360: TTFT (spawn to first JSON): ${ttftMs}ms, session=${this.sessionId}`);
8940
+ }
8941
+ this.handleParsedEvent(parsed);
8942
+ } catch {
8943
+ if (line.trim() && this.callbacks) {
8944
+ this.callbacks.onChunk(line + "\n");
8945
+ }
8946
+ }
8947
+ }
8948
+ });
8949
+ this.process.on("exit", (code, signal) => {
8950
+ const exitRssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
8951
+ console.log(`[ClaudePersistentRuntime] Process exited: code=${code}, signal=${signal}, parsedLines=${this.parsedLineCount}, session=${this.sessionId}, RSS=${exitRssMb}MB`);
8952
+ this.alive = false;
8953
+ this.process = null;
8954
+ if (this._turnState !== "idle" && this.callbacks) {
8955
+ const msg = signal ? `Claude process killed by ${signal}` : `Claude process exited with code ${code}`;
8956
+ if (this.stderrTail.trim()) {
8957
+ console.error(`[ClaudePersistentRuntime] stderr tail (session=${this.sessionId}): ${this.stderrTail.trimEnd()}`);
8958
+ }
8959
+ this.callbacks.onError(`PROCESS_EXIT: ${msg}`);
8960
+ this.endTurn();
8961
+ }
8962
+ });
8963
+ this.process.on("error", (error) => {
8964
+ console.error(`[ClaudePersistentRuntime] Spawn error: ${error.message}, session=${this.sessionId}`);
8965
+ this.alive = false;
8966
+ if (this.callbacks) {
8967
+ this.callbacks.onError(`SPAWN_ERROR: ${error.message}`);
8968
+ this.endTurn();
8969
+ }
8970
+ });
8971
+ }
8972
+ async sendMessage(message, callbacks) {
8973
+ if (this._turnState !== "idle") {
8974
+ throw new Error(
8975
+ `[ClaudePersistentRuntime] Cannot send message: turn state is '${this._turnState}', expected 'idle'. Turn serialization requires the previous turn to complete before sending a new message. session=${this.sessionId}`
8976
+ );
8977
+ }
8978
+ if (!this.alive || !this.process?.stdin) {
8979
+ throw new Error(
8980
+ `[ClaudePersistentRuntime] Cannot send message: process is not alive. session=${this.sessionId}`
8981
+ );
8982
+ }
8983
+ this.callbacks = callbacks;
8984
+ this.resultModel = void 0;
8985
+ this.resultCostUsd = void 0;
8986
+ this.resultUsage = void 0;
8987
+ this.resultNumTurns = void 0;
8988
+ this.turnStartTime = Date.now();
8989
+ this.turnTtftLogged = false;
8990
+ this.seenToolUseIds.clear();
8991
+ this.sawAnyStdoutThisTurn = false;
8992
+ this._turnState = "waiting_for_echo";
8993
+ const payload = JSON.stringify({ type: "user", message: { role: "user", content: message } }) + "\n";
8994
+ try {
8995
+ await this.writeToStdin(payload);
8996
+ } catch (err) {
8997
+ const msg = err instanceof Error ? err.message : String(err);
8998
+ this.callbacks.onError(`SYNC_RUNTIME_ERROR: stdin write failed: ${msg}`);
8999
+ this.endTurn();
9000
+ throw err;
9001
+ }
9002
+ this.echoTimer = setTimeout(() => {
9003
+ if (this._turnState === "waiting_for_echo") {
9004
+ console.warn(`[ClaudePersistentRuntime] Echo timeout after ${ECHO_TIMEOUT_MS}ms \u2014 auto-degrading. session=${this.sessionId}`);
9005
+ if (this.callbacks) {
9006
+ this.callbacks.onError(`ECHO_TIMEOUT: No stdout event within ${ECHO_TIMEOUT_MS}ms after send`);
9007
+ this.endTurn();
9008
+ }
9009
+ }
9010
+ }, ECHO_TIMEOUT_MS);
9011
+ this.resetInactivityTimer();
9012
+ }
9013
+ isAlive() {
9014
+ return this.alive && !this.stuck;
9015
+ }
9016
+ async shutdown() {
9017
+ this.clearTimers();
9018
+ if (!this.process || !this.alive) {
9019
+ return;
9020
+ }
9021
+ console.log(`[ClaudePersistentRuntime] Shutting down session ${this.sessionId} (3-stage)`);
9022
+ try {
9023
+ this.process.stdin?.end();
9024
+ } catch {
9025
+ }
9026
+ const exited = await this.waitForExit(SHUTDOWN_SIGTERM_WAIT_MS);
9027
+ if (exited) return;
9028
+ console.log(`[ClaudePersistentRuntime] SIGTERM for session ${this.sessionId}`);
9029
+ try {
9030
+ this.process.kill("SIGTERM");
9031
+ } catch {
9032
+ return;
9033
+ }
9034
+ const exitedAfterTerm = await this.waitForExit(SHUTDOWN_SIGKILL_WAIT_MS);
9035
+ if (exitedAfterTerm) return;
9036
+ console.log(`[ClaudePersistentRuntime] SIGKILL for session ${this.sessionId}`);
9037
+ try {
9038
+ this.process.kill("SIGKILL");
9039
+ } catch {
9040
+ return;
9041
+ }
9042
+ const exitedAfterKill = await this.waitForExit(2e3);
9043
+ if (!exitedAfterKill) {
9044
+ console.error(`[ClaudePersistentRuntime] Process did not exit after SIGKILL \u2014 marking as stuck. session=${this.sessionId}`);
9045
+ this.stuck = true;
9046
+ this.alive = false;
9047
+ }
9048
+ }
9049
+ // -------------------------------------------------------------------------
9050
+ // Event handling
9051
+ // -------------------------------------------------------------------------
9052
+ handleParsedEvent(parsed) {
9053
+ const type = parsed.type;
9054
+ if (this._turnState === "waiting_for_echo") {
9055
+ this.sawAnyStdoutThisTurn = true;
9056
+ this.clearEchoTimer();
9057
+ this._turnState = "streaming";
9058
+ if (RUNTIME_DEBUG) {
9059
+ console.log(`[ClaudePersistentRuntime] First stdout for turn \u2014 streaming started. session=${this.sessionId}`);
9060
+ }
9061
+ }
9062
+ if (type === "user") {
9063
+ return;
9064
+ }
9065
+ if (!this.callbacks) return;
9066
+ switch (type) {
9067
+ case "stream_event": {
9068
+ const ev = parsed.event;
9069
+ const evType = ev?.type;
9070
+ if (!evType) break;
9071
+ if (evType === "content_block_delta") {
9072
+ const delta = ev?.delta;
9073
+ const text = delta?.text ?? "";
9074
+ if (text) {
9075
+ this.chunksSent++;
9076
+ if (!this.turnTtftLogged) {
9077
+ this.turnTtftLogged = true;
9078
+ const turnTtft = Date.now() - this.turnStartTime;
9079
+ console.log(`[ClaudePersistentRuntime] EP1360: Turn TTFT: ${turnTtft}ms, session=${this.sessionId}`);
9080
+ }
9081
+ this.callbacks.onChunk(text);
9082
+ }
9083
+ }
9084
+ if (evType === "content_block_start" && this.callbacks.onToolUse) {
9085
+ const block = ev?.content_block;
9086
+ if (block?.type === "tool_use") {
9087
+ const toolId = block.id;
9088
+ if (toolId && this.seenToolUseIds.has(toolId)) break;
9089
+ if (toolId) this.seenToolUseIds.add(toolId);
9090
+ const toolEvent = {
9091
+ id: toolId || `tool-${Date.now()}`,
9092
+ name: block.name,
9093
+ input: block.input || {}
9094
+ };
9095
+ this.callbacks.onToolUse(toolEvent);
9096
+ }
9097
+ }
9098
+ break;
9099
+ }
9100
+ case "assistant": {
9101
+ const content = parsed.message?.content;
9102
+ if (content) {
9103
+ const shouldEmitText = this.chunksSent === 0;
9104
+ if (shouldEmitText && this.hasContent) {
9105
+ this.callbacks.onChunk("\n\n");
9106
+ }
9107
+ for (const block of content) {
9108
+ if (block.type === "text" && block.text && shouldEmitText) {
9109
+ this.hasContent = true;
9110
+ this.chunksSent++;
9111
+ if (!this.turnTtftLogged) {
9112
+ this.turnTtftLogged = true;
9113
+ const turnTtft = Date.now() - this.turnStartTime;
9114
+ console.log(`[ClaudePersistentRuntime] EP1360: Turn TTFT: ${turnTtft}ms, session=${this.sessionId}`);
9115
+ }
9116
+ this.callbacks.onChunk(block.text);
9117
+ } else if (block.type === "tool_use" && this.callbacks.onToolUse) {
9118
+ const toolId = block.id;
9119
+ if (toolId && this.seenToolUseIds.has(toolId)) {
9120
+ continue;
9121
+ }
9122
+ if (toolId) {
9123
+ this.seenToolUseIds.add(toolId);
9124
+ }
9125
+ const toolEvent = {
9126
+ id: toolId,
9127
+ name: block.name,
9128
+ input: block.input || {}
9129
+ };
9130
+ this.callbacks.onToolUse(toolEvent);
9131
+ } else if (block.type === "tool_result" && this.callbacks.onToolResult) {
9132
+ const toolResult = {
9133
+ id: block.tool_use_id || block.id || `tool-result-${Date.now()}`,
9134
+ name: block.name,
9135
+ output: block.output ?? block.result ?? block.content ?? block
9136
+ };
9137
+ this.callbacks.onToolResult(toolResult);
9138
+ }
9139
+ }
9140
+ }
9141
+ break;
9142
+ }
9143
+ case "content_block_delta": {
9144
+ const delta = parsed.delta;
9145
+ if (delta?.text) {
9146
+ this.chunksSent++;
9147
+ if (!this.turnTtftLogged) {
9148
+ this.turnTtftLogged = true;
9149
+ const turnTtft = Date.now() - this.turnStartTime;
9150
+ console.log(`[ClaudePersistentRuntime] EP1360: Turn TTFT: ${turnTtft}ms, session=${this.sessionId}`);
9151
+ }
9152
+ this.callbacks.onChunk(delta.text);
9153
+ }
9154
+ break;
9155
+ }
9156
+ case "tool_result": {
9157
+ if (this.callbacks.onToolResult) {
9158
+ const toolResult = {
9159
+ id: parsed.tool_use_id || parsed.toolUseId || parsed.id || `tool-result-${Date.now()}`,
9160
+ name: parsed.name || parsed.tool_name,
9161
+ output: parsed.output ?? parsed.result ?? parsed.content ?? parsed.message ?? parsed
9162
+ };
9163
+ this.callbacks.onToolResult(toolResult);
9164
+ }
9165
+ break;
9166
+ }
9167
+ case "result": {
9168
+ if (parsed.session_id) {
9169
+ this._agentSessionId = parsed.session_id;
9170
+ }
9171
+ const resultObj = parsed.result;
9172
+ if (resultObj?.session_id) {
9173
+ this._agentSessionId = resultObj.session_id;
9174
+ }
9175
+ this.resultModel = parsed.model;
9176
+ if (typeof parsed.total_cost_usd === "number") {
9177
+ this.resultCostUsd = parsed.total_cost_usd;
9178
+ } else if (typeof parsed.cost_usd === "number") {
9179
+ this.resultCostUsd = parsed.cost_usd;
9180
+ }
9181
+ this.resultNumTurns = typeof parsed.num_turns === "number" ? parsed.num_turns : void 0;
9182
+ if (parsed.usage) {
9183
+ const u = parsed.usage;
9184
+ const inputTokens = u.input_tokens ?? u.prompt_tokens ?? 0;
9185
+ const outputTokens = u.output_tokens ?? u.completion_tokens ?? 0;
9186
+ this.resultUsage = {
9187
+ inputTokens,
9188
+ outputTokens,
9189
+ cacheReadTokens: u.cache_read_input_tokens ?? u.cache_read_tokens,
9190
+ cacheWriteTokens: u.cache_creation_input_tokens ?? u.cache_write_tokens,
9191
+ totalTokens: inputTokens + outputTokens
9192
+ };
9193
+ }
9194
+ this.completeTurn();
9195
+ break;
9196
+ }
9197
+ case "system": {
9198
+ if (parsed.session_id) {
9199
+ this._agentSessionId = parsed.session_id;
9200
+ }
9201
+ if (parsed.subtype === "compact_boundary" && this.callbacks.onCompaction) {
9202
+ const metadata = parsed.compactMetadata;
9203
+ const compactionEvent = {
9204
+ seq: this.parsedLineCount,
9205
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9206
+ provider: "claude",
9207
+ type: "session.compaction",
9208
+ raw: parsed,
9209
+ trigger: metadata?.trigger ?? "auto",
9210
+ preCompactionTokens: metadata?.preCompactTokenCount
9211
+ };
9212
+ console.log(`[ClaudePersistentRuntime] Compaction detected \u2014 trigger: ${compactionEvent.trigger}, session=${this.sessionId}`);
9213
+ this.callbacks.onCompaction(compactionEvent);
9214
+ }
9215
+ break;
9216
+ }
9217
+ case "error": {
9218
+ const errorMsg = parsed.error?.message || parsed.message || "Unknown error from Claude Code";
9219
+ this.callbacks.onError(errorMsg);
9220
+ this.endTurn();
9221
+ break;
9222
+ }
9223
+ default:
9224
+ if (type) {
9225
+ console.log(`[ClaudePersistentRuntime] Unhandled event: ${type}, session=${this.sessionId}`);
9226
+ }
9227
+ }
9228
+ }
9229
+ // -------------------------------------------------------------------------
9230
+ // Turn lifecycle
9231
+ // -------------------------------------------------------------------------
9232
+ completeTurn() {
9233
+ if (!this.callbacks) return;
9234
+ const durationMs = Date.now() - this.turnStartTime;
9235
+ const resultMeta = this.resultModel || this.resultCostUsd !== void 0 || this.resultUsage ? {
9236
+ model: this.resultModel,
9237
+ costUsd: this.resultCostUsd,
9238
+ durationMs,
9239
+ numTurns: this.resultNumTurns,
9240
+ usage: this.resultUsage
9241
+ } : void 0;
9242
+ console.log(`[ClaudePersistentRuntime] Turn completed: duration=${durationMs}ms, chunks=${this.chunksSent}, session=${this.sessionId}`);
9243
+ const cb = this.callbacks;
9244
+ this.endTurn();
9245
+ cb.onComplete(this._agentSessionId, resultMeta);
9246
+ }
9247
+ endTurn() {
9248
+ this.clearTimers();
9249
+ this.callbacks = null;
9250
+ this._turnState = "idle";
9251
+ this.chunksSent = 0;
9252
+ }
9253
+ // -------------------------------------------------------------------------
9254
+ // stdin
9255
+ // -------------------------------------------------------------------------
9256
+ writeToStdin(data) {
9257
+ if (!this.process?.stdin || this.process.stdin.destroyed) {
9258
+ throw new Error(`[ClaudePersistentRuntime] stdin not available. session=${this.sessionId}`);
9259
+ }
9260
+ return new Promise((resolve4, reject) => {
9261
+ const stdin = this.process.stdin;
9262
+ const ok = stdin.write(data);
9263
+ if (ok) {
9264
+ resolve4();
9265
+ return;
9266
+ }
9267
+ const onDrain = () => {
9268
+ cleanup();
9269
+ resolve4();
9270
+ };
9271
+ const onError = (err) => {
9272
+ cleanup();
9273
+ reject(err);
9274
+ };
9275
+ const onTimeout = () => {
9276
+ cleanup();
9277
+ reject(new Error(`STDIN_DRAIN_TIMEOUT after ${STDIN_DRAIN_TIMEOUT_MS}ms`));
9278
+ };
9279
+ const cleanup = () => {
9280
+ clearTimeout(timer);
9281
+ stdin.off("drain", onDrain);
9282
+ stdin.off("error", onError);
9283
+ };
9284
+ const timer = setTimeout(onTimeout, STDIN_DRAIN_TIMEOUT_MS);
9285
+ stdin.once("drain", onDrain);
9286
+ stdin.once("error", onError);
9287
+ });
9288
+ }
9289
+ // -------------------------------------------------------------------------
9290
+ // Timers
9291
+ // -------------------------------------------------------------------------
9292
+ clearEchoTimer() {
9293
+ if (this.echoTimer) {
9294
+ clearTimeout(this.echoTimer);
9295
+ this.echoTimer = null;
9296
+ }
9297
+ }
9298
+ resetInactivityTimer() {
9299
+ if (this.inactivityTimer) {
9300
+ clearTimeout(this.inactivityTimer);
9301
+ }
9302
+ if (this._turnState === "idle") return;
9303
+ this.inactivityTimer = setTimeout(() => {
9304
+ if (this._turnState !== "idle") {
9305
+ console.warn(`[ClaudePersistentRuntime] Inactivity timeout (${INACTIVITY_TIMEOUT_MS}ms) \u2014 no stdout during streaming. session=${this.sessionId}`);
9306
+ if (this.callbacks) {
9307
+ this.callbacks.onError(`INACTIVITY_TIMEOUT: No stdout for ${INACTIVITY_TIMEOUT_MS}ms during streaming`);
9308
+ this.endTurn();
9309
+ }
9310
+ }
9311
+ }, INACTIVITY_TIMEOUT_MS);
9312
+ }
9313
+ clearTimers() {
9314
+ this.clearEchoTimer();
9315
+ if (this.inactivityTimer) {
9316
+ clearTimeout(this.inactivityTimer);
9317
+ this.inactivityTimer = null;
9318
+ }
9319
+ }
9320
+ // -------------------------------------------------------------------------
9321
+ // Helpers
9322
+ // -------------------------------------------------------------------------
9323
+ waitForExit(timeoutMs) {
9324
+ return new Promise((resolve4) => {
9325
+ if (!this.process || !this.alive) {
9326
+ resolve4(true);
9327
+ return;
9328
+ }
9329
+ const onExit = () => {
9330
+ clearTimeout(timer);
9331
+ resolve4(true);
9332
+ };
9333
+ const timer = setTimeout(() => {
9334
+ this.process?.removeListener("exit", onExit);
9335
+ resolve4(false);
9336
+ }, timeoutMs);
9337
+ this.process.once("exit", onExit);
9338
+ });
9339
+ }
9340
+ };
9341
+
9342
+ // src/agent/codex-persistent-runtime.ts
9343
+ var import_child_process13 = require("child_process");
9344
+ var INIT_TIMEOUT_MS = 3e4;
9345
+ var INACTIVITY_TIMEOUT_MS2 = parseInt(process.env.AGENT_STREAM_INACTIVITY_TIMEOUT_MS || "180000", 10);
9346
+ var SHUTDOWN_SIGTERM_WAIT_MS2 = 2e3;
9347
+ var SHUTDOWN_SIGKILL_WAIT_MS2 = 2e3;
9348
+ var STDIN_DRAIN_TIMEOUT_MS2 = 1e4;
9349
+ var RUNTIME_DEBUG2 = process.env.AGENT_RUNTIME_DEBUG === "1";
9350
+ var CodexPersistentRuntime = class {
9351
+ constructor(options) {
9352
+ this.options = options;
9353
+ this.provider = "codex";
9354
+ this.process = null;
9355
+ this._turnState = "idle";
9356
+ this.alive = false;
9357
+ this.stuck = false;
9358
+ this.callbacks = null;
9359
+ // Keep a small stderr tail so we can print it on failure without spamming logs.
9360
+ this.stderrTail = "";
9361
+ this.maxStderrTailBytes = 8192;
9362
+ this.stdoutBuffer = "";
9363
+ this.parsedLineCount = 0;
9364
+ // Per-turn accounting
9365
+ this.turnStartTime = 0;
9366
+ this.chunksSent = 0;
9367
+ this.turnTtftLogged = false;
9368
+ // Timers
9369
+ this.inactivityTimer = null;
9370
+ // Spawn instrumentation
9371
+ this.spawnTimestamp = 0;
9372
+ // JSON-RPC state
9373
+ this.nextId = 1;
9374
+ this.pending = /* @__PURE__ */ new Map();
9375
+ this.initializing = null;
9376
+ this.initialized = false;
9377
+ this.threadIdWaiters = [];
9378
+ this.debugTraceCount = 0;
9379
+ this.sessionId = options.sessionId;
9380
+ }
9381
+ get turnState() {
9382
+ return this._turnState;
9383
+ }
9384
+ get agentSessionId() {
9385
+ return this._agentSessionId;
9386
+ }
9387
+ get pid() {
9388
+ return this._pid;
9389
+ }
9390
+ spawn() {
9391
+ if (this.process) {
9392
+ throw new Error(`[CodexPersistentRuntime] Process already spawned for session ${this.sessionId}`);
9393
+ }
9394
+ const { binaryPath, args, env, cwd } = this.options;
9395
+ this.spawnTimestamp = Date.now();
9396
+ const spawnRssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
9397
+ console.log(`[CodexPersistentRuntime] Spawning persistent Codex app-server for session ${this.sessionId}, RSS=${spawnRssMb}MB`);
9398
+ this.process = (0, import_child_process13.spawn)(binaryPath, args, {
9399
+ cwd,
9400
+ env,
9401
+ stdio: ["pipe", "pipe", "pipe"]
9402
+ });
9403
+ this._pid = this.process.pid;
9404
+ this.alive = true;
9405
+ this.process.stderr?.on("data", (data) => {
9406
+ const chunk = data.toString();
9407
+ this.stderrTail = (this.stderrTail + chunk).slice(-this.maxStderrTailBytes);
9408
+ if (RUNTIME_DEBUG2) {
9409
+ console.error(`[CodexPersistentRuntime] stderr (session=${this.sessionId}): ${chunk.trimEnd()}`);
9410
+ }
9411
+ });
9412
+ this.process.stdout?.on("data", (data) => {
9413
+ this.resetInactivityTimer();
9414
+ this.stdoutBuffer += data.toString();
9415
+ const lines = this.stdoutBuffer.split("\n");
9416
+ this.stdoutBuffer = lines.pop() || "";
9417
+ for (const line of lines) {
9418
+ if (!line.trim()) continue;
9419
+ try {
9420
+ const parsed = JSON.parse(line);
9421
+ this.parsedLineCount++;
9422
+ if (this.parsedLineCount === 1) {
9423
+ const ttftMs = Date.now() - this.spawnTimestamp;
9424
+ console.log(`[CodexPersistentRuntime] EP1360: TTFT (spawn to first JSON): ${ttftMs}ms, session=${this.sessionId}`);
9425
+ }
9426
+ this.handleJsonRpcMessage(parsed);
9427
+ } catch {
9428
+ if (line.trim() && this.callbacks) {
9429
+ this.callbacks.onChunk(line + "\n");
9430
+ }
9431
+ }
9432
+ }
9433
+ });
9434
+ this.process.on("exit", (code, signal) => {
9435
+ const exitRssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
9436
+ console.log(`[CodexPersistentRuntime] Process exited: code=${code}, signal=${signal}, parsedLines=${this.parsedLineCount}, session=${this.sessionId}, RSS=${exitRssMb}MB`);
9437
+ this.alive = false;
9438
+ this.process = null;
9439
+ for (const [id, p] of this.pending) {
9440
+ clearTimeout(p.timeout);
9441
+ p.reject(new Error(`Codex app-server exited (id=${id})`));
9442
+ }
9443
+ this.pending.clear();
9444
+ if (this._turnState !== "idle" && this.callbacks) {
9445
+ const msg = signal ? `Codex process killed by ${signal}` : `Codex process exited with code ${code}`;
9446
+ if (this.stderrTail.trim()) {
9447
+ console.error(`[CodexPersistentRuntime] stderr tail (session=${this.sessionId}): ${this.stderrTail.trimEnd()}`);
9448
+ }
9449
+ this.callbacks.onError(`PROCESS_EXIT: ${msg}`);
9450
+ this.endTurn();
9451
+ }
9452
+ });
9453
+ this.process.on("error", (error) => {
9454
+ console.error(`[CodexPersistentRuntime] Spawn error: ${error.message}, session=${this.sessionId}`);
9455
+ this.alive = false;
9456
+ for (const [id, p] of this.pending) {
9457
+ clearTimeout(p.timeout);
9458
+ p.reject(error);
9459
+ }
9460
+ this.pending.clear();
9461
+ if (this.callbacks) {
9462
+ this.callbacks.onError(`SPAWN_ERROR: ${error.message}`);
9463
+ this.endTurn();
9464
+ }
9465
+ });
9466
+ }
9467
+ async sendMessage(message, callbacks) {
9468
+ if (this._turnState !== "idle") {
9469
+ throw new Error(
9470
+ `[CodexPersistentRuntime] Cannot send message: turn state is '${this._turnState}', expected 'idle'. session=${this.sessionId}`
9471
+ );
9472
+ }
9473
+ if (!this.alive || !this.process?.stdin) {
9474
+ throw new Error(`[CodexPersistentRuntime] Cannot send message: process is not alive. session=${this.sessionId}`);
9475
+ }
9476
+ this.callbacks = callbacks;
9477
+ this.turnStartTime = Date.now();
9478
+ this.chunksSent = 0;
9479
+ this.turnTtftLogged = false;
9480
+ this.resultModel = void 0;
9481
+ this.resultUsage = void 0;
9482
+ this.resultNumTurns = void 0;
9483
+ this._turnState = "streaming";
9484
+ void (async () => {
9485
+ try {
9486
+ await this.ensureInitialized();
9487
+ const threadId = this._agentSessionId;
9488
+ if (!threadId) {
9489
+ throw new Error("Missing threadId after initialization");
9490
+ }
9491
+ await this.sendRequest("turn/start", {
9492
+ threadId,
9493
+ input: [{ type: "text", text: message }]
9494
+ }, INIT_TIMEOUT_MS);
9495
+ } catch (err) {
9496
+ const msg = err instanceof Error ? err.message : String(err);
9497
+ if (this.callbacks) {
9498
+ this.callbacks.onError(`SYNC_RUNTIME_ERROR: ${msg}`);
9499
+ this.endTurn();
9500
+ }
9501
+ }
9502
+ })();
9503
+ this.resetInactivityTimer();
9504
+ }
9505
+ isAlive() {
9506
+ return this.alive && !this.stuck;
9507
+ }
9508
+ async shutdown() {
9509
+ this.clearTimers();
9510
+ if (!this.process || !this.alive) return;
9511
+ console.log(`[CodexPersistentRuntime] Shutting down session ${this.sessionId} (3-stage)`);
9512
+ try {
9513
+ this.process.stdin?.end();
9514
+ } catch {
9515
+ }
9516
+ const exited = await this.waitForExit(SHUTDOWN_SIGTERM_WAIT_MS2);
9517
+ if (exited) return;
9518
+ console.log(`[CodexPersistentRuntime] SIGTERM for session ${this.sessionId}`);
9519
+ try {
9520
+ this.process.kill("SIGTERM");
9521
+ } catch {
9522
+ return;
9523
+ }
9524
+ const exitedAfterTerm = await this.waitForExit(SHUTDOWN_SIGKILL_WAIT_MS2);
9525
+ if (exitedAfterTerm) return;
9526
+ console.log(`[CodexPersistentRuntime] SIGKILL for session ${this.sessionId}`);
9527
+ try {
9528
+ this.process.kill("SIGKILL");
9529
+ } catch {
9530
+ return;
9531
+ }
9532
+ const exitedAfterKill = await this.waitForExit(2e3);
9533
+ if (!exitedAfterKill) {
9534
+ console.error(`[CodexPersistentRuntime] Process did not exit after SIGKILL \u2014 marking as stuck. session=${this.sessionId}`);
9535
+ this.stuck = true;
9536
+ this.alive = false;
9537
+ }
9538
+ }
9539
+ // -------------------------------------------------------------------------
9540
+ // JSON-RPC plumbing
9541
+ // -------------------------------------------------------------------------
9542
+ handleJsonRpcMessage(msg) {
9543
+ if (RUNTIME_DEBUG2 && this.debugTraceCount < 20) {
9544
+ this.debugTraceCount++;
9545
+ const kind = typeof msg.id === "number" ? "response" : typeof msg.method === "string" ? `notif:${msg.method}` : "unknown";
9546
+ console.log(`[CodexPersistentRuntime] debug json-rpc (${kind}) session=${this.sessionId}: ${JSON.stringify(msg).slice(0, 2e3)}`);
9547
+ }
9548
+ const isResponse = typeof msg.id === "number" && (msg.result !== void 0 || msg.error !== void 0);
9549
+ if (isResponse) {
9550
+ const id = msg.id;
9551
+ const pending = this.pending.get(id);
9552
+ if (!pending) return;
9553
+ clearTimeout(pending.timeout);
9554
+ this.pending.delete(id);
9555
+ if (msg.error) {
9556
+ const e = msg.error;
9557
+ pending.reject(new Error(e?.message || "JSON-RPC error"));
9558
+ } else {
9559
+ pending.resolve(msg.result);
9560
+ }
9561
+ return;
9562
+ }
9563
+ const method = msg.method;
9564
+ if (!method) return;
9565
+ if (method === "thread/started" || method === "thread.started") {
9566
+ const params2 = msg.params ?? {};
9567
+ const threadId = this.extractThreadId(params2);
9568
+ if (threadId) {
9569
+ this.setThreadId(threadId, "notification");
9570
+ console.log(`[CodexPersistentRuntime] Thread started: ${threadId}, session=${this.sessionId}`);
9571
+ }
9572
+ return;
9573
+ }
9574
+ if (!this.callbacks) return;
9575
+ const params = msg.params ?? {};
9576
+ switch (method) {
9577
+ case "item/agentMessage/delta":
9578
+ case "item.agentMessage.delta": {
9579
+ const delta = params.delta || params.text || "";
9580
+ if (delta) {
9581
+ this.chunksSent++;
9582
+ if (!this.turnTtftLogged) {
9583
+ this.turnTtftLogged = true;
9584
+ const turnTtft = Date.now() - this.turnStartTime;
9585
+ console.log(`[CodexPersistentRuntime] EP1360: Turn TTFT: ${turnTtft}ms, session=${this.sessionId}`);
9586
+ }
9587
+ this.callbacks.onChunk(delta);
9588
+ }
9589
+ break;
9590
+ }
9591
+ case "item/started":
9592
+ case "item.started": {
9593
+ const item = params.item;
9594
+ if (!item) break;
9595
+ if (item.type === "command_execution" && this.callbacks.onToolUse) {
9596
+ const toolEvent = {
9597
+ id: item.id || `codex-cmd-${Date.now()}`,
9598
+ name: "Bash",
9599
+ input: { command: item.command || item.input || "" }
9600
+ };
9601
+ this.callbacks.onToolUse(toolEvent);
9602
+ }
9603
+ break;
9604
+ }
9605
+ case "item/completed":
9606
+ case "item.completed": {
9607
+ const item = params.item;
9608
+ if (!item) break;
9609
+ if (item.type === "agent_message" && item.text) {
9610
+ this.chunksSent++;
9611
+ if (!this.turnTtftLogged) {
9612
+ this.turnTtftLogged = true;
9613
+ const turnTtft = Date.now() - this.turnStartTime;
9614
+ console.log(`[CodexPersistentRuntime] EP1360: Turn TTFT: ${turnTtft}ms, session=${this.sessionId}`);
9615
+ }
9616
+ this.callbacks.onChunk(item.text);
9617
+ }
9618
+ if (item.type === "command_execution" && this.callbacks.onToolResult) {
9619
+ const toolResult = {
9620
+ id: item.id || `codex-cmd-${Date.now()}`,
9621
+ name: "Bash",
9622
+ output: item.output ?? item.result ?? item
9623
+ };
9624
+ this.callbacks.onToolResult(toolResult);
9625
+ }
9626
+ if (item.type === "contextCompaction" && this.callbacks.onCompaction) {
9627
+ const compactionEvent = {
9628
+ seq: this.parsedLineCount,
9629
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9630
+ provider: "codex",
9631
+ type: "session.compaction",
9632
+ raw: msg,
9633
+ trigger: "auto",
9634
+ summary: item.summary
9635
+ };
9636
+ this.callbacks.onCompaction(compactionEvent);
9637
+ }
9638
+ break;
9639
+ }
9640
+ case "turn/completed":
9641
+ case "turn.completed": {
9642
+ const usage = params.usage;
9643
+ if (usage) {
9644
+ const inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0;
9645
+ const outputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0;
9646
+ this.resultUsage = { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
9647
+ }
9648
+ this.resultModel = params.model ?? this.resultModel;
9649
+ this.resultNumTurns = typeof params.num_turns === "number" ? params.num_turns : this.resultNumTurns;
9650
+ this.completeTurn();
9651
+ break;
9652
+ }
9653
+ case "turn/failed":
9654
+ case "turn.failed":
9655
+ case "error": {
9656
+ const errMsg = params.message || params.error?.message || "Codex operation failed";
9657
+ this.callbacks.onError(errMsg);
9658
+ this.endTurn();
9659
+ break;
9660
+ }
9661
+ default:
9662
+ break;
9663
+ }
9664
+ }
9665
+ async ensureInitialized() {
9666
+ if (this.initialized) return;
9667
+ if (this.initializing) return this.initializing;
9668
+ this.initializing = (async () => {
9669
+ await this.sendRequest("initialize", {
9670
+ clientInfo: { name: "episoda", version: "1.0" }
9671
+ }, INIT_TIMEOUT_MS);
9672
+ await this.sendNotification("initialized", {});
9673
+ const resumeThreadId = this.options.resumeThreadId;
9674
+ const model = this.options.model;
9675
+ if (resumeThreadId) {
9676
+ const resumeMethods = ["thread/resume", "thread.resume"];
9677
+ let resumed = false;
9678
+ for (const method of resumeMethods) {
9679
+ try {
9680
+ const res = await this.sendRequest(method, { threadId: resumeThreadId }, INIT_TIMEOUT_MS);
9681
+ const threadId = this.extractThreadId(res) || resumeThreadId;
9682
+ if (threadId) {
9683
+ this.setThreadId(threadId, `response:${method}`);
9684
+ console.log(`[CodexPersistentRuntime] Thread resumed (${method}): ${threadId}, session=${this.sessionId}`);
9685
+ resumed = true;
9686
+ break;
9687
+ }
9688
+ } catch (err) {
9689
+ const msg = err instanceof Error ? err.message : String(err);
9690
+ console.warn(`[CodexPersistentRuntime] Thread resume failed (${method}): ${msg}, session=${this.sessionId}`);
9691
+ }
9692
+ }
9693
+ if (!resumed) {
9694
+ console.warn(`[CodexPersistentRuntime] Falling back to thread/start (resume unsupported or failed). session=${this.sessionId}`);
9695
+ }
9696
+ }
9697
+ if (!this._agentSessionId) {
9698
+ const startMethods = ["thread/start", "thread.start"];
9699
+ let started = false;
9700
+ for (const method of startMethods) {
9701
+ try {
9702
+ const res = await this.sendRequest(method, model ? { model } : {}, INIT_TIMEOUT_MS);
9703
+ const threadId = this.extractThreadId(res);
9704
+ if (threadId) {
9705
+ this.setThreadId(threadId, `response:${method}`);
9706
+ console.log(`[CodexPersistentRuntime] Thread started (response): ${threadId}, session=${this.sessionId}`);
9707
+ }
9708
+ started = true;
9709
+ break;
9710
+ } catch (err) {
9711
+ const msg = err instanceof Error ? err.message : String(err);
9712
+ console.warn(`[CodexPersistentRuntime] Thread start failed (${method}): ${msg}, session=${this.sessionId}`);
9713
+ }
9714
+ }
9715
+ if (!started) {
9716
+ throw new Error("Failed to start thread (no supported method)");
9717
+ }
9718
+ }
9719
+ await this.waitForThreadId(1e4);
9720
+ this.initialized = true;
9721
+ })().finally(() => {
9722
+ this.initializing = null;
9723
+ });
9724
+ return this.initializing;
9725
+ }
9726
+ setThreadId(threadId, source) {
9727
+ if (!threadId) return;
9728
+ if (this._agentSessionId && this._agentSessionId !== threadId) {
9729
+ console.warn(
9730
+ `[CodexPersistentRuntime] ThreadId changed (${source}): ${this._agentSessionId} -> ${threadId}, session=${this.sessionId}`
9731
+ );
9732
+ }
9733
+ this._agentSessionId = threadId;
9734
+ if (this.threadIdWaiters.length) {
9735
+ const waiters = this.threadIdWaiters;
9736
+ this.threadIdWaiters = [];
9737
+ for (const w of waiters) {
9738
+ try {
9739
+ w(threadId);
9740
+ } catch {
9741
+ }
9742
+ }
9743
+ }
9744
+ }
9745
+ waitForThreadId(timeoutMs) {
9746
+ if (this._agentSessionId) return Promise.resolve(this._agentSessionId);
9747
+ return new Promise((resolve4, reject) => {
9748
+ const onId = (id) => {
9749
+ clearTimeout(timer);
9750
+ resolve4(id);
9751
+ };
9752
+ const timer = setTimeout(() => {
9753
+ this.threadIdWaiters = this.threadIdWaiters.filter((w) => w !== onId);
9754
+ reject(new Error(`Timeout waiting for threadId after ${timeoutMs}ms`));
9755
+ }, timeoutMs);
9756
+ this.threadIdWaiters.push(onId);
9757
+ });
9758
+ }
9759
+ extractThreadId(obj) {
9760
+ if (!obj || typeof obj !== "object") return void 0;
9761
+ const rec = obj;
9762
+ const direct = rec.threadId ?? rec.thread_id ?? rec.id;
9763
+ if (typeof direct === "string" && direct.trim()) return direct;
9764
+ const thread = rec.thread;
9765
+ if (thread && typeof thread === "object") {
9766
+ const t = thread;
9767
+ const nested = t.id ?? t.threadId ?? t.thread_id;
9768
+ if (typeof nested === "string" && nested.trim()) return nested;
9769
+ }
9770
+ const result = rec.result;
9771
+ if (result && typeof result === "object") return this.extractThreadId(result);
9772
+ return void 0;
9773
+ }
9774
+ async sendNotification(method, params) {
9775
+ const msg = { jsonrpc: "2.0", method, params };
9776
+ await this.writeToStdin(JSON.stringify(msg) + "\n");
9777
+ }
9778
+ sendRequest(method, params, timeoutMs = INIT_TIMEOUT_MS) {
9779
+ const id = this.nextId++;
9780
+ const msg = { jsonrpc: "2.0", id, method, params };
9781
+ return new Promise(async (resolve4, reject) => {
9782
+ const timeout = setTimeout(() => {
9783
+ this.pending.delete(id);
9784
+ reject(new Error(`JSON-RPC timeout: ${method}`));
9785
+ }, timeoutMs);
9786
+ this.pending.set(id, { resolve: resolve4, reject, timeout });
9787
+ try {
9788
+ await this.writeToStdin(JSON.stringify(msg) + "\n");
9789
+ } catch (err) {
9790
+ clearTimeout(timeout);
9791
+ this.pending.delete(id);
9792
+ reject(err instanceof Error ? err : new Error(String(err)));
9793
+ }
9794
+ });
9795
+ }
9796
+ writeToStdin(data) {
9797
+ if (!this.process?.stdin || this.process.stdin.destroyed) {
9798
+ throw new Error(`[CodexPersistentRuntime] stdin not available. session=${this.sessionId}`);
9799
+ }
9800
+ return new Promise((resolve4, reject) => {
9801
+ const stdin = this.process.stdin;
9802
+ const ok = stdin.write(data);
9803
+ if (ok) {
9804
+ resolve4();
9805
+ return;
9806
+ }
9807
+ const onDrain = () => {
9808
+ cleanup();
9809
+ resolve4();
9810
+ };
9811
+ const onError = (err) => {
9812
+ cleanup();
9813
+ reject(err);
9814
+ };
9815
+ const onTimeout = () => {
9816
+ cleanup();
9817
+ reject(new Error(`STDIN_DRAIN_TIMEOUT after ${STDIN_DRAIN_TIMEOUT_MS2}ms`));
9818
+ };
9819
+ const cleanup = () => {
9820
+ clearTimeout(timer);
9821
+ stdin.off("drain", onDrain);
9822
+ stdin.off("error", onError);
9823
+ };
9824
+ const timer = setTimeout(onTimeout, STDIN_DRAIN_TIMEOUT_MS2);
9825
+ stdin.once("drain", onDrain);
9826
+ stdin.once("error", onError);
9827
+ });
9828
+ }
9829
+ // -------------------------------------------------------------------------
9830
+ // Turn lifecycle
9831
+ // -------------------------------------------------------------------------
9832
+ completeTurn() {
9833
+ if (!this.callbacks) return;
9834
+ const durationMs = Date.now() - this.turnStartTime;
9835
+ const resultMeta = this.resultModel || this.resultUsage ? {
9836
+ model: this.resultModel,
9837
+ durationMs,
9838
+ numTurns: this.resultNumTurns,
9839
+ usage: this.resultUsage
9840
+ } : void 0;
9841
+ console.log(`[CodexPersistentRuntime] Turn completed: duration=${durationMs}ms, chunks=${this.chunksSent}, session=${this.sessionId}`);
9842
+ const cb = this.callbacks;
9843
+ this.endTurn();
9844
+ cb.onComplete(this._agentSessionId, resultMeta);
9845
+ }
9846
+ endTurn() {
9847
+ this.clearTimers();
9848
+ this.callbacks = null;
9849
+ this._turnState = "idle";
9850
+ this.chunksSent = 0;
9851
+ }
9852
+ // -------------------------------------------------------------------------
9853
+ // Timers
9854
+ // -------------------------------------------------------------------------
9855
+ resetInactivityTimer() {
9856
+ if (this.inactivityTimer) {
9857
+ clearTimeout(this.inactivityTimer);
9858
+ }
9859
+ if (this._turnState === "idle") return;
9860
+ this.inactivityTimer = setTimeout(() => {
9861
+ if (this._turnState !== "idle") {
9862
+ console.warn(`[CodexPersistentRuntime] Inactivity timeout (${INACTIVITY_TIMEOUT_MS2}ms). session=${this.sessionId}`);
9863
+ if (this.callbacks) {
9864
+ this.callbacks.onError(`INACTIVITY_TIMEOUT: No stdout for ${INACTIVITY_TIMEOUT_MS2}ms during streaming`);
9865
+ this.endTurn();
9866
+ }
9867
+ }
9868
+ }, INACTIVITY_TIMEOUT_MS2);
9869
+ }
9870
+ clearTimers() {
9871
+ if (this.inactivityTimer) {
9872
+ clearTimeout(this.inactivityTimer);
9873
+ this.inactivityTimer = null;
9874
+ }
9875
+ }
9876
+ waitForExit(timeoutMs) {
9877
+ return new Promise((resolve4) => {
9878
+ if (!this.process || !this.alive) {
9879
+ resolve4(true);
9880
+ return;
9881
+ }
9882
+ const onExit = () => {
9883
+ clearTimeout(timer);
9884
+ resolve4(true);
9885
+ };
9886
+ const timer = setTimeout(() => {
9887
+ this.process?.removeListener("exit", onExit);
9888
+ resolve4(false);
9889
+ }, timeoutMs);
9890
+ this.process.once("exit", onExit);
9891
+ });
9892
+ }
9893
+ };
9894
+
9895
+ // src/agent/agent-manager.ts
8845
9896
  var instance3 = null;
8846
9897
  function getAgentManager() {
8847
9898
  if (!instance3) {
@@ -8849,6 +9900,11 @@ function getAgentManager() {
8849
9900
  }
8850
9901
  return instance3;
8851
9902
  }
9903
+ var AGENT_DISABLE_PERSISTENT = process.env.AGENT_DISABLE_PERSISTENT === "1";
9904
+ var AGENT_MAX_PERSISTENT = parseInt(process.env.AGENT_MAX_PERSISTENT || "5", 10);
9905
+ var AGENT_IDLE_TIMEOUT_MS = parseInt(process.env.AGENT_IDLE_TIMEOUT_MS || "300000", 10);
9906
+ var IDLE_SCAN_INTERVAL_MS = 6e4;
9907
+ var CLAUDE_PERSISTENT_BLOCKED_MODELS = ["haiku"];
8852
9908
  var AgentManager = class {
8853
9909
  constructor() {
8854
9910
  this.sessions = /* @__PURE__ */ new Map();
@@ -8856,8 +9912,41 @@ var AgentManager = class {
8856
9912
  this.initialized = false;
8857
9913
  // EP1133: Lock for config file writes to prevent race conditions
8858
9914
  this.configWriteLock = Promise.resolve();
9915
+ // EP1360: Persistent runtime management
9916
+ this.runtimes = /* @__PURE__ */ new Map();
9917
+ // EP1360: Per-session downgrade. If a persistent runtime wedges/crashes for a session,
9918
+ // we permanently fall back to spawn-per-message for the remainder of that session.
9919
+ this.persistentDisabledSessions = /* @__PURE__ */ new Map();
9920
+ this.idleScanTimer = null;
8859
9921
  this.pidDir = path20.join(os7.homedir(), ".episoda", "agent-pids");
8860
9922
  }
9923
+ /**
9924
+ * EP1360: Best-effort child process RSS sampling.
9925
+ * Returns RSS in MB, or null if not available.
9926
+ *
9927
+ * NOTE: We intentionally avoid adding native deps here; `ps` is available
9928
+ * on macOS/Linux. This instrumentation must never break message flow.
9929
+ */
9930
+ async getChildRssMb(pid) {
9931
+ if (!pid || pid <= 0) return null;
9932
+ try {
9933
+ if (typeof import_child_process14.execFile !== "function") return null;
9934
+ const stdout = await new Promise((resolve4, reject) => {
9935
+ (0, import_child_process14.execFile)("ps", ["-o", "rss=", "-p", String(pid)], { timeout: 1e3 }, (err, out) => {
9936
+ if (err) {
9937
+ reject(err);
9938
+ return;
9939
+ }
9940
+ resolve4(String(out));
9941
+ });
9942
+ });
9943
+ const kb = Number(String(stdout).trim());
9944
+ if (!Number.isFinite(kb) || kb <= 0) return null;
9945
+ return Math.round(kb / 1024 * 10) / 10;
9946
+ } catch {
9947
+ return null;
9948
+ }
9949
+ }
8861
9950
  /**
8862
9951
  * EP1133: Acquire lock for config file writes
8863
9952
  * Ensures sequential writes to prevent file corruption
@@ -9152,6 +10241,12 @@ var AgentManager = class {
9152
10241
  } catch (error) {
9153
10242
  console.warn("[AgentManager] Codex CLI not available:", error instanceof Error ? error.message : error);
9154
10243
  }
10244
+ if (!AGENT_DISABLE_PERSISTENT) {
10245
+ this.startIdleScanner();
10246
+ console.log(`[AgentManager] EP1360: Persistent runtimes enabled (max=${AGENT_MAX_PERSISTENT}, idleTimeout=${AGENT_IDLE_TIMEOUT_MS}ms)`);
10247
+ } else {
10248
+ console.log("[AgentManager] EP1360: Persistent runtimes DISABLED (AGENT_DISABLE_PERSISTENT=1)");
10249
+ }
9155
10250
  this.initialized = true;
9156
10251
  console.log("[AgentManager] Initialized");
9157
10252
  }
@@ -9276,7 +10371,8 @@ var AgentManager = class {
9276
10371
  /**
9277
10372
  * Send a message to an agent session
9278
10373
  *
9279
- * Spawns a new agent CLI process for each message.
10374
+ * EP1360: Uses persistent runtimes when enabled/available; falls back to
10375
+ * spawn-per-message for incident response, pool pressure, or per-session downgrade.
9280
10376
  * EP1133: Supports both Claude Code and Codex CLI with provider-specific handling.
9281
10377
  * EP1251: Supports credentials refresh for GitHub token on resume.
9282
10378
  */
@@ -9286,6 +10382,22 @@ var AgentManager = class {
9286
10382
  if (!session) {
9287
10383
  return { success: false, error: "Session not found" };
9288
10384
  }
10385
+ const shouldDisablePersistentFromError = (error) => {
10386
+ return error.startsWith("ECHO_TIMEOUT:") || error.startsWith("INACTIVITY_TIMEOUT:") || error.startsWith("PROCESS_EXIT:") || error.startsWith("SPAWN_ERROR:") || error.startsWith("SYNC_RUNTIME_ERROR:");
10387
+ };
10388
+ const wrapRuntimeOnComplete = (agentSid, resultMetadata) => {
10389
+ if (agentSid) {
10390
+ session.agentSessionId = agentSid;
10391
+ session.claudeSessionId = agentSid;
10392
+ }
10393
+ onComplete(agentSid, resultMetadata);
10394
+ };
10395
+ const wrapRuntimeOnError = (error) => {
10396
+ if (shouldDisablePersistentFromError(error)) {
10397
+ this.disablePersistentForSession(sessionId, error);
10398
+ }
10399
+ onError(error);
10400
+ };
9289
10401
  const mcpAuth = await this.resolveMcpAuth();
9290
10402
  if (mcpAuth.source === "config") {
9291
10403
  console.log("[AgentManager] EP1287: Using MCP token from ~/.episoda/config.json");
@@ -9298,6 +10410,78 @@ var AgentManager = class {
9298
10410
  session.canWrite = canWrite;
9299
10411
  session.readOnlyReason = readOnlyReason;
9300
10412
  }
10413
+ if (!isFirstMessage) {
10414
+ const runtime = this.getRuntime(sessionId);
10415
+ if (runtime) {
10416
+ try {
10417
+ if (runtime.turnState !== "idle") {
10418
+ const err = `TURN_IN_PROGRESS: Persistent runtime is busy (turnState=${runtime.turnState})`;
10419
+ console.warn(`[AgentManager] EP1360: ${err}, session=${sessionId}`);
10420
+ onError(err);
10421
+ return { success: false, error: err };
10422
+ }
10423
+ let runtimeMessage = message;
10424
+ if (session.canWrite === false) {
10425
+ if (session.provider === "codex") {
10426
+ const readOnlyReminder = `
10427
+ [SYSTEM REMINDER - READ-ONLY MODE]
10428
+ You are in READ-ONLY mode. Do NOT:
10429
+ - Create, modify, or delete any files
10430
+ - Run commands that write to the filesystem
10431
+ - Make git commits or changes
10432
+ Reason: ${session.readOnlyReason || "No write access to this module."}
10433
+ If changes are needed, explain what needs to be done but do not execute.
10434
+ [END SYSTEM REMINDER]
10435
+
10436
+ `;
10437
+ runtimeMessage = readOnlyReminder + runtimeMessage;
10438
+ } else {
10439
+ const readOnlyReminder = `
10440
+ REMINDER: You are in READ-ONLY mode. You cannot create, modify, or delete files.
10441
+ Reason: ${session.readOnlyReason || "No write access to this module."}
10442
+ If changes are needed, explain what needs to be done.`;
10443
+ runtimeMessage = `${readOnlyReminder}
10444
+
10445
+ ${runtimeMessage}`;
10446
+ }
10447
+ }
10448
+ const pid = runtime.pid;
10449
+ void this.getChildRssMb(pid).then((rss) => {
10450
+ if (rss !== null) {
10451
+ console.log(`[AgentManager] EP1360: Persistent turn start RSS: ${rss}MB, provider=${session.provider}, session=${sessionId}, pid=${pid}`);
10452
+ }
10453
+ });
10454
+ const onCompleteWithRss = (agentSid, resultMetadata) => {
10455
+ void this.getChildRssMb(pid).then((rss) => {
10456
+ if (rss !== null) {
10457
+ console.log(`[AgentManager] EP1360: Persistent turn complete RSS: ${rss}MB, provider=${session.provider}, session=${sessionId}, pid=${pid}`);
10458
+ }
10459
+ });
10460
+ wrapRuntimeOnComplete(agentSid, resultMetadata);
10461
+ };
10462
+ await runtime.sendMessage(runtimeMessage, {
10463
+ onChunk,
10464
+ onToolUse,
10465
+ onToolResult,
10466
+ onCompaction: options.onCompaction,
10467
+ onComplete: onCompleteWithRss,
10468
+ onError: wrapRuntimeOnError
10469
+ });
10470
+ console.log(`[AgentManager] EP1360: Sent follow-up message via persistent ${session.provider} runtime, session=${sessionId}`);
10471
+ return { success: true };
10472
+ } catch (error) {
10473
+ const msg = error instanceof Error ? error.message : String(error);
10474
+ if (msg.includes("turn state is '") || msg.startsWith("TURN_IN_PROGRESS:")) {
10475
+ const err = `TURN_IN_PROGRESS: ${msg}`;
10476
+ console.warn(`[AgentManager] EP1360: ${err}, session=${sessionId}`);
10477
+ onError(err);
10478
+ return { success: false, error: err };
10479
+ }
10480
+ console.warn(`[AgentManager] EP1360: Persistent runtime failed for follow-up, falling back to spawn-per-message: ${msg}`);
10481
+ this.disablePersistentForSession(sessionId, `SYNC_RUNTIME_ERROR: ${msg}`);
10482
+ }
10483
+ }
10484
+ }
9301
10485
  if (credentials?.githubToken) {
9302
10486
  session.credentials.githubToken = credentials.githubToken;
9303
10487
  session.credentials.githubTokenExpiresAt = credentials.githubTokenExpiresAt;
@@ -9345,6 +10529,8 @@ Violations will result in errors and may affect your ability to assist.
9345
10529
  }
9346
10530
  let binaryPath;
9347
10531
  let args;
10532
+ let argsBeforeMessage = 0;
10533
+ let runtimeFirstMessage = message;
9348
10534
  if (provider === "codex") {
9349
10535
  binaryPath = await ensureCodexBinary();
9350
10536
  args = [
@@ -9390,6 +10576,8 @@ If changes are needed, explain what needs to be done but do not execute.
9390
10576
  `;
9391
10577
  fullMessage = readOnlyReminder + fullMessage;
9392
10578
  }
10579
+ argsBeforeMessage = args.length;
10580
+ runtimeFirstMessage = fullMessage;
9393
10581
  args.push(fullMessage);
9394
10582
  } else {
9395
10583
  binaryPath = await ensureClaudeBinary();
@@ -9425,6 +10613,9 @@ Reason: ${session.readOnlyReason || "No write access to this module."}
9425
10613
  If changes are needed, explain what needs to be done.`;
9426
10614
  args.push("--append-system-prompt", readOnlyReminder);
9427
10615
  console.log("[AgentManager] EP1205: Appended read-only reminder to subsequent message");
10616
+ runtimeFirstMessage = `${readOnlyReminder}
10617
+
10618
+ ${message}`;
9428
10619
  }
9429
10620
  }
9430
10621
  if (resumeSessionId) {
@@ -9481,6 +10672,7 @@ If changes are needed, explain what needs to be done.`;
9481
10672
  args.push("--mcp-config", mcpConfigJson);
9482
10673
  console.log(`[AgentManager] EP1253: MCP config with env: ${mcpConfigJson.substring(0, 200)}...`);
9483
10674
  }
10675
+ argsBeforeMessage = args.length;
9484
10676
  args.push("--", message);
9485
10677
  }
9486
10678
  console.log(`[AgentManager] Spawning ${provider} CLI for session ${sessionId}`);
@@ -9725,7 +10917,95 @@ If changes are needed, explain what needs to be done.`;
9725
10917
  envVars.ANTHROPIC_API_KEY = session.credentials.apiKey;
9726
10918
  }
9727
10919
  }
9728
- const childProcess = (0, import_child_process12.spawn)(spawnCmd, spawnArgs, {
10920
+ if (provider === "claude") {
10921
+ envVars.CLAUDE_CODE_DISABLE_PLUGIN_CACHE = "1";
10922
+ }
10923
+ const daemonSpawnRssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
10924
+ const spawnTimestamp = Date.now();
10925
+ const modelLower = (session.credentials.preferredModel || "").toLowerCase();
10926
+ const modelBlockedForPersistent = provider === "claude" && CLAUDE_PERSISTENT_BLOCKED_MODELS.some((blocked) => modelLower.includes(blocked));
10927
+ if (modelBlockedForPersistent) {
10928
+ console.log(`[AgentManager] EP1360: Model "${session.credentials.preferredModel}" incompatible with persistent mode (#17406) \u2014 using spawn-per-message`);
10929
+ }
10930
+ if (this.isPersistentEnabled() && !modelBlockedForPersistent) {
10931
+ try {
10932
+ const baseArgs = args.slice(0, argsBeforeMessage);
10933
+ let persistentArgs;
10934
+ if (provider === "claude") {
10935
+ persistentArgs = [...baseArgs];
10936
+ persistentArgs.push(
10937
+ "--input-format",
10938
+ "stream-json",
10939
+ "--replay-user-messages"
10940
+ );
10941
+ } else {
10942
+ persistentArgs = ["app-server"];
10943
+ }
10944
+ let runtimeBinaryPath;
10945
+ let runtimeArgs;
10946
+ if (spawnCmd === "npx") {
10947
+ runtimeBinaryPath = "npx";
10948
+ runtimeArgs = ["--yes", binaryPath.replace("npx:", ""), ...persistentArgs];
10949
+ } else {
10950
+ runtimeBinaryPath = spawnCmd;
10951
+ runtimeArgs = persistentArgs;
10952
+ }
10953
+ const runtime = this.createRuntime(sessionId, provider, {
10954
+ sessionId,
10955
+ binaryPath: runtimeBinaryPath,
10956
+ args: runtimeArgs,
10957
+ env: envVars,
10958
+ cwd: session.projectPath,
10959
+ ...provider === "codex" ? {
10960
+ model: session.credentials.preferredModel,
10961
+ resumeThreadId: session.agentSessionId || session.claudeSessionId
10962
+ } : {}
10963
+ });
10964
+ if (runtime) {
10965
+ if (runtime.turnState !== "idle") {
10966
+ const err = `TURN_IN_PROGRESS: Persistent runtime is busy (turnState=${runtime.turnState})`;
10967
+ console.warn(`[AgentManager] EP1360: ${err}, session=${sessionId}`);
10968
+ onError(err);
10969
+ return { success: false, error: err };
10970
+ }
10971
+ if (runtime.pid) {
10972
+ session.pid = runtime.pid;
10973
+ this.writePidFile(sessionId, runtime.pid);
10974
+ }
10975
+ const pid = runtime.pid;
10976
+ void this.getChildRssMb(pid).then((rss) => {
10977
+ if (rss !== null) {
10978
+ console.log(`[AgentManager] EP1360: Persistent turn start RSS: ${rss}MB, provider=${provider}, session=${sessionId}, pid=${pid}`);
10979
+ }
10980
+ });
10981
+ const onCompleteWithRss = (agentSid, resultMetadata) => {
10982
+ void this.getChildRssMb(pid).then((rss) => {
10983
+ if (rss !== null) {
10984
+ console.log(`[AgentManager] EP1360: Persistent turn complete RSS: ${rss}MB, provider=${provider}, session=${sessionId}, pid=${pid}`);
10985
+ }
10986
+ });
10987
+ wrapRuntimeOnComplete(agentSid, resultMetadata);
10988
+ };
10989
+ await runtime.sendMessage(runtimeFirstMessage, {
10990
+ onChunk,
10991
+ onToolUse,
10992
+ onToolResult,
10993
+ onCompaction: options.onCompaction,
10994
+ onComplete: onCompleteWithRss,
10995
+ onError: wrapRuntimeOnError
10996
+ });
10997
+ console.log(`[AgentManager] EP1360: Started persistent ${provider} runtime for session ${sessionId}, pid=${runtime.pid}`);
10998
+ return { success: true };
10999
+ }
11000
+ } catch (error) {
11001
+ console.warn(
11002
+ `[AgentManager] EP1360: Persistent runtime creation failed, falling back to spawn-per-message: ${error instanceof Error ? error.message : error}`
11003
+ );
11004
+ const msg = error instanceof Error ? error.message : String(error);
11005
+ this.disablePersistentForSession(sessionId, `SYNC_RUNTIME_ERROR: ${msg}`);
11006
+ }
11007
+ }
11008
+ const childProcess = (0, import_child_process14.spawn)(spawnCmd, spawnArgs, {
9729
11009
  cwd: session.projectPath,
9730
11010
  env: envVars,
9731
11011
  stdio: ["pipe", "pipe", "pipe"]
@@ -9736,6 +11016,14 @@ If changes are needed, explain what needs to be done.`;
9736
11016
  session.pid = childProcess.pid;
9737
11017
  this.writePidFile(sessionId, childProcess.pid);
9738
11018
  }
11019
+ let spawnChildRssMb = null;
11020
+ let resultChildRssMb = null;
11021
+ void this.getChildRssMb(childProcess.pid).then((rss) => {
11022
+ spawnChildRssMb = rss;
11023
+ if (rss !== null) {
11024
+ console.log(`[AgentManager] EP1360: Child RSS at spawn: ${rss}MB, provider=${provider}, session=${sessionId}, pid=${childProcess.pid}`);
11025
+ }
11026
+ });
9739
11027
  childProcess.stderr?.on("data", (data) => {
9740
11028
  console.error(`[AgentManager] stderr: ${data.toString()}`);
9741
11029
  });
@@ -9751,6 +11039,8 @@ If changes are needed, explain what needs to be done.`;
9751
11039
  let stdoutEventCount = 0;
9752
11040
  let chunksSent = 0;
9753
11041
  const streamStartTime = Date.now();
11042
+ let ttftLogged = false;
11043
+ let parsedLineCount = 0;
9754
11044
  let hasContent = false;
9755
11045
  childProcess.stdout?.on("data", (data) => {
9756
11046
  const rawData = data.toString();
@@ -9765,6 +11055,12 @@ If changes are needed, explain what needs to be done.`;
9765
11055
  if (!line.trim()) continue;
9766
11056
  try {
9767
11057
  const parsed = JSON.parse(line);
11058
+ parsedLineCount++;
11059
+ if (!ttftLogged) {
11060
+ ttftLogged = true;
11061
+ const ttftMs = Date.now() - spawnTimestamp;
11062
+ console.log(`[AgentManager] EP1360: TTFT (spawn to first JSON): ${ttftMs}ms, provider=${provider}, session=${sessionId}, isFirstMessage=${isFirstMessage}`);
11063
+ }
9768
11064
  if (stdoutEventCount <= 10 || chunksSent === 0) {
9769
11065
  console.log(`[AgentManager] EP1191: Parsed event type: ${parsed.type}`);
9770
11066
  }
@@ -9819,6 +11115,12 @@ If changes are needed, explain what needs to be done.`;
9819
11115
  }
9820
11116
  break;
9821
11117
  case "turn.completed":
11118
+ void this.getChildRssMb(childProcess.pid).then((rss) => {
11119
+ resultChildRssMb = rss;
11120
+ if (rss !== null) {
11121
+ console.log(`[AgentManager] EP1360: Child RSS at turn.completed: ${rss}MB, provider=${provider}, session=${sessionId}, pid=${childProcess.pid}`);
11122
+ }
11123
+ });
9822
11124
  if (parsed.usage) {
9823
11125
  const u = parsed.usage;
9824
11126
  const inputTokens = u.input_tokens ?? u.prompt_tokens ?? 0;
@@ -9894,6 +11196,12 @@ If changes are needed, explain what needs to be done.`;
9894
11196
  }
9895
11197
  break;
9896
11198
  case "result":
11199
+ void this.getChildRssMb(childProcess.pid).then((rss) => {
11200
+ resultChildRssMb = rss;
11201
+ if (rss !== null) {
11202
+ console.log(`[AgentManager] EP1360: Child RSS at result: ${rss}MB, provider=${provider}, session=${sessionId}, pid=${childProcess.pid}`);
11203
+ }
11204
+ });
9897
11205
  if (parsed.session_id) {
9898
11206
  extractedSessionId = parsed.session_id;
9899
11207
  session.agentSessionId = extractedSessionId;
@@ -9965,6 +11273,12 @@ If changes are needed, explain what needs to be done.`;
9965
11273
  });
9966
11274
  childProcess.on("exit", (code, signal) => {
9967
11275
  const duration = Date.now() - streamStartTime;
11276
+ const daemonExitRssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
11277
+ const daemonDeltaMb = daemonExitRssMb - daemonSpawnRssMb;
11278
+ console.log(`[AgentManager] EP1360: Daemon RSS at exit: ${daemonExitRssMb}MB (delta: ${daemonDeltaMb >= 0 ? "+" : ""}${daemonDeltaMb}MB from spawn ${daemonSpawnRssMb}MB), session=${sessionId}`);
11279
+ const childDeltaMb = spawnChildRssMb !== null && resultChildRssMb !== null ? Math.round((resultChildRssMb - spawnChildRssMb) * 10) / 10 : null;
11280
+ console.log(`[AgentManager] EP1360: Child RSS (spawn-per-message): spawn=${spawnChildRssMb ?? "null"}MB, result=${resultChildRssMb ?? "null"}MB${childDeltaMb !== null ? ` (delta: ${childDeltaMb >= 0 ? "+" : ""}${childDeltaMb}MB)` : ""}, session=${sessionId}, pid=${childProcess.pid}`);
11281
+ console.log(`[AgentManager] EP1360: Stream stats: parsedLines=${parsedLineCount}, rawDataEvents=${stdoutEventCount}, chunksSent=${chunksSent}, session=${sessionId}`);
9968
11282
  console.log(`[AgentManager] EP1191: ${provider} CLI exited for session ${sessionId}: code=${code}, signal=${signal}, duration=${duration}ms, stdoutEvents=${stdoutEventCount}, chunksSent=${chunksSent}`);
9969
11283
  this.processes.delete(sessionId);
9970
11284
  this.removePidFile(sessionId);
@@ -10018,7 +11332,18 @@ If changes are needed, explain what needs to be done.`;
10018
11332
  console.log(`[AgentManager] Aborting session ${sessionId} with SIGINT`);
10019
11333
  agentProcess.kill("SIGINT");
10020
11334
  }
11335
+ const runtime = this.runtimes.get(sessionId);
11336
+ if (runtime) {
11337
+ console.log(`[AgentManager] EP1360: Shutting down persistent runtime for aborted session ${sessionId}`);
11338
+ void runtime.shutdown();
11339
+ this.runtimes.delete(sessionId);
11340
+ }
10021
11341
  const session = this.sessions.get(sessionId);
11342
+ if (session) {
11343
+ session.pid = void 0;
11344
+ }
11345
+ this.removePidFile(sessionId);
11346
+ this.persistentDisabledSessions.delete(sessionId);
10022
11347
  if (session) {
10023
11348
  session.status = "stopping";
10024
11349
  }
@@ -10052,6 +11377,16 @@ If changes are needed, explain what needs to be done.`;
10052
11377
  });
10053
11378
  });
10054
11379
  }
11380
+ const runtime = this.runtimes.get(sessionId);
11381
+ if (runtime) {
11382
+ try {
11383
+ await runtime.shutdown();
11384
+ } catch (error) {
11385
+ console.warn(`[AgentManager] EP1360: Error shutting down runtime for session ${sessionId}:`, error);
11386
+ }
11387
+ this.runtimes.delete(sessionId);
11388
+ }
11389
+ this.persistentDisabledSessions.delete(sessionId);
10055
11390
  this.sessions.delete(sessionId);
10056
11391
  this.processes.delete(sessionId);
10057
11392
  this.removePidFile(sessionId);
@@ -10080,8 +11415,183 @@ If changes are needed, explain what needs to be done.`;
10080
11415
  * Stop all active sessions
10081
11416
  */
10082
11417
  async stopAllSessions() {
11418
+ this.stopIdleScanner();
10083
11419
  const sessionIds = Array.from(this.sessions.keys());
10084
11420
  await Promise.all(sessionIds.map((id) => this.stopSession(id)));
11421
+ this.persistentDisabledSessions.clear();
11422
+ }
11423
+ // ---------------------------------------------------------------------------
11424
+ // EP1360: Persistent Runtime Lifecycle
11425
+ // ---------------------------------------------------------------------------
11426
+ /**
11427
+ * EP1360: Check if persistent runtimes are enabled.
11428
+ */
11429
+ isPersistentEnabled() {
11430
+ return !AGENT_DISABLE_PERSISTENT;
11431
+ }
11432
+ /**
11433
+ * EP1360: Get the persistent runtime for a session, if one exists and is alive.
11434
+ */
11435
+ getRuntime(sessionId) {
11436
+ if (this.persistentDisabledSessions.has(sessionId)) {
11437
+ return void 0;
11438
+ }
11439
+ const runtime = this.runtimes.get(sessionId);
11440
+ if (runtime && runtime.isAlive()) {
11441
+ return runtime;
11442
+ }
11443
+ if (runtime && !runtime.isAlive()) {
11444
+ this.runtimes.delete(sessionId);
11445
+ const session = this.sessions.get(sessionId);
11446
+ if (session) {
11447
+ session.pid = void 0;
11448
+ }
11449
+ this.removePidFile(sessionId);
11450
+ }
11451
+ return void 0;
11452
+ }
11453
+ /**
11454
+ * EP1360: Create a persistent runtime for a session.
11455
+ * Returns the runtime if a slot is available (possibly after evicting an idle runtime).
11456
+ * Returns null if all slots are occupied by active streams (degrade to spawn-per-message).
11457
+ */
11458
+ createRuntime(sessionId, provider, options) {
11459
+ if (AGENT_DISABLE_PERSISTENT) {
11460
+ return null;
11461
+ }
11462
+ if (this.persistentDisabledSessions.has(sessionId)) {
11463
+ return null;
11464
+ }
11465
+ const existing = this.getRuntime(sessionId);
11466
+ if (existing) {
11467
+ return existing;
11468
+ }
11469
+ if (this.runtimes.size >= AGENT_MAX_PERSISTENT) {
11470
+ if (!this.evictIdleRuntime()) {
11471
+ console.log(`[AgentManager] EP1360: All ${AGENT_MAX_PERSISTENT} persistent slots active \u2014 degrading to spawn-per-message for session ${sessionId}`);
11472
+ return null;
11473
+ }
11474
+ }
11475
+ let runtime;
11476
+ if (provider === "claude") {
11477
+ const claudeRuntime = new ClaudePersistentRuntime(options);
11478
+ claudeRuntime.spawn();
11479
+ runtime = claudeRuntime;
11480
+ } else {
11481
+ const codexRuntime = new CodexPersistentRuntime(options);
11482
+ codexRuntime.spawn();
11483
+ runtime = codexRuntime;
11484
+ }
11485
+ this.runtimes.set(sessionId, runtime);
11486
+ console.log(`[AgentManager] EP1360: Created persistent ${provider} runtime for session ${sessionId} (${this.runtimes.size}/${AGENT_MAX_PERSISTENT} slots used)`);
11487
+ return runtime;
11488
+ }
11489
+ /**
11490
+ * EP1360: Shut down a persistent runtime for a session.
11491
+ */
11492
+ async shutdownRuntime(sessionId) {
11493
+ const runtime = this.runtimes.get(sessionId);
11494
+ if (!runtime) return;
11495
+ try {
11496
+ await runtime.shutdown();
11497
+ } catch (error) {
11498
+ console.warn(`[AgentManager] EP1360: Error shutting down runtime for session ${sessionId}:`, error);
11499
+ }
11500
+ this.runtimes.delete(sessionId);
11501
+ const session = this.sessions.get(sessionId);
11502
+ if (session) {
11503
+ session.pid = void 0;
11504
+ }
11505
+ this.removePidFile(sessionId);
11506
+ }
11507
+ /**
11508
+ * EP1360: Permanently disable persistent mode for a session (remainder of session).
11509
+ * Minimal "auto-downgrade" mechanism: once the runtime shows runtime-level failure
11510
+ * signals (timeouts/crash/spawn), future messages use spawn-per-message.
11511
+ */
11512
+ disablePersistentForSession(sessionId, reason) {
11513
+ if (this.persistentDisabledSessions.has(sessionId)) return;
11514
+ this.persistentDisabledSessions.set(sessionId, { reason, at: Date.now() });
11515
+ console.warn(`[AgentManager] EP1360: Disabling persistent mode for session ${sessionId} (downgrade to spawn-per-message): ${reason}`);
11516
+ const runtime = this.runtimes.get(sessionId);
11517
+ if (runtime) {
11518
+ void runtime.shutdown().catch((err) => {
11519
+ console.warn(`[AgentManager] EP1360: Error shutting down downgraded runtime for session ${sessionId}:`, err);
11520
+ });
11521
+ this.runtimes.delete(sessionId);
11522
+ }
11523
+ const session = this.sessions.get(sessionId);
11524
+ if (session) {
11525
+ session.pid = void 0;
11526
+ }
11527
+ this.removePidFile(sessionId);
11528
+ }
11529
+ /**
11530
+ * EP1360: Evict the least-recently-used idle runtime to free a slot.
11531
+ * Returns true if a slot was freed, false if all runtimes are actively streaming.
11532
+ */
11533
+ evictIdleRuntime() {
11534
+ let oldestIdleSessionId = null;
11535
+ let oldestActivityTime = Infinity;
11536
+ for (const [sid, runtime2] of this.runtimes) {
11537
+ if (runtime2.turnState === "idle") {
11538
+ const session2 = this.sessions.get(sid);
11539
+ const activityTime = session2?.lastActivityAt?.getTime() ?? 0;
11540
+ if (activityTime < oldestActivityTime) {
11541
+ oldestActivityTime = activityTime;
11542
+ oldestIdleSessionId = sid;
11543
+ }
11544
+ }
11545
+ }
11546
+ if (!oldestIdleSessionId) {
11547
+ return false;
11548
+ }
11549
+ console.log(`[AgentManager] EP1360: Evicting idle runtime for session ${oldestIdleSessionId} (LRU)`);
11550
+ const runtime = this.runtimes.get(oldestIdleSessionId);
11551
+ if (runtime) {
11552
+ void runtime.shutdown().catch((err) => {
11553
+ console.warn(`[AgentManager] EP1360: Error during eviction shutdown:`, err);
11554
+ });
11555
+ }
11556
+ this.runtimes.delete(oldestIdleSessionId);
11557
+ const session = this.sessions.get(oldestIdleSessionId);
11558
+ if (session) {
11559
+ session.pid = void 0;
11560
+ }
11561
+ this.removePidFile(oldestIdleSessionId);
11562
+ return true;
11563
+ }
11564
+ /**
11565
+ * EP1360: Start the idle runtime scanner.
11566
+ * Runs every 60s and shuts down runtimes that have been idle longer than AGENT_IDLE_TIMEOUT_MS.
11567
+ */
11568
+ startIdleScanner() {
11569
+ if (this.idleScanTimer) return;
11570
+ this.idleScanTimer = setInterval(() => {
11571
+ const now = Date.now();
11572
+ for (const [sid, runtime] of this.runtimes) {
11573
+ if (runtime.turnState !== "idle") continue;
11574
+ const session = this.sessions.get(sid);
11575
+ const lastActivity = session?.lastActivityAt?.getTime() ?? 0;
11576
+ const idleMs = now - lastActivity;
11577
+ if (idleMs > AGENT_IDLE_TIMEOUT_MS) {
11578
+ console.log(`[AgentManager] EP1360: Runtime for session ${sid} idle for ${Math.round(idleMs / 1e3)}s \u2014 shutting down`);
11579
+ void this.shutdownRuntime(sid);
11580
+ }
11581
+ }
11582
+ }, IDLE_SCAN_INTERVAL_MS);
11583
+ if (this.idleScanTimer.unref) {
11584
+ this.idleScanTimer.unref();
11585
+ }
11586
+ }
11587
+ /**
11588
+ * EP1360: Stop the idle runtime scanner.
11589
+ */
11590
+ stopIdleScanner() {
11591
+ if (this.idleScanTimer) {
11592
+ clearInterval(this.idleScanTimer);
11593
+ this.idleScanTimer = null;
11594
+ }
10085
11595
  }
10086
11596
  /**
10087
11597
  * Get session info
@@ -10132,7 +11642,8 @@ If changes are needed, explain what needs to be done.`;
10132
11642
  moduleUid: session.moduleUid,
10133
11643
  status: reconStatus,
10134
11644
  agentSessionId: session.agentSessionId || session.claudeSessionId,
10135
- lastActivityAt: session.lastActivityAt
11645
+ lastActivityAt: session.lastActivityAt,
11646
+ persistentRuntime: this.runtimes.has(sessionId)
10136
11647
  });
10137
11648
  }
10138
11649
  return result;
@@ -10403,7 +11914,7 @@ var AgentCommandQueue = class {
10403
11914
  };
10404
11915
 
10405
11916
  // src/utils/dev-server.ts
10406
- var import_child_process13 = require("child_process");
11917
+ var import_child_process15 = require("child_process");
10407
11918
  var import_core12 = __toESM(require_dist());
10408
11919
  var fs20 = __toESM(require("fs"));
10409
11920
  var path21 = __toESM(require("path"));
@@ -10452,7 +11963,7 @@ function writeToLog(logPath, line, isError = false) {
10452
11963
  }
10453
11964
  async function killProcessOnPort(port) {
10454
11965
  try {
10455
- const result = (0, import_child_process13.execSync)(`lsof -ti:${port} 2>/dev/null || true`, { encoding: "utf8" }).trim();
11966
+ const result = (0, import_child_process15.execSync)(`lsof -ti:${port} 2>/dev/null || true`, { encoding: "utf8" }).trim();
10456
11967
  if (!result) {
10457
11968
  console.log(`[DevServer] EP929: No process found on port ${port}`);
10458
11969
  return true;
@@ -10461,7 +11972,7 @@ async function killProcessOnPort(port) {
10461
11972
  console.log(`[DevServer] EP929: Found ${pids.length} process(es) on port ${port}: ${pids.join(", ")}`);
10462
11973
  for (const pid of pids) {
10463
11974
  try {
10464
- (0, import_child_process13.execSync)(`kill -15 ${pid} 2>/dev/null || true`, { encoding: "utf8" });
11975
+ (0, import_child_process15.execSync)(`kill -15 ${pid} 2>/dev/null || true`, { encoding: "utf8" });
10465
11976
  console.log(`[DevServer] EP929: Sent SIGTERM to PID ${pid}`);
10466
11977
  } catch {
10467
11978
  }
@@ -10469,8 +11980,8 @@ async function killProcessOnPort(port) {
10469
11980
  await new Promise((resolve4) => setTimeout(resolve4, 1e3));
10470
11981
  for (const pid of pids) {
10471
11982
  try {
10472
- (0, import_child_process13.execSync)(`kill -0 ${pid} 2>/dev/null`, { encoding: "utf8" });
10473
- (0, import_child_process13.execSync)(`kill -9 ${pid} 2>/dev/null || true`, { encoding: "utf8" });
11983
+ (0, import_child_process15.execSync)(`kill -0 ${pid} 2>/dev/null`, { encoding: "utf8" });
11984
+ (0, import_child_process15.execSync)(`kill -9 ${pid} 2>/dev/null || true`, { encoding: "utf8" });
10474
11985
  console.log(`[DevServer] EP929: Force killed PID ${pid}`);
10475
11986
  } catch {
10476
11987
  }
@@ -10521,7 +12032,7 @@ function spawnDevServerProcess(projectPath, port, moduleUid, logPath, customComm
10521
12032
  if (injectedCount > 0) {
10522
12033
  console.log(`[DevServer] EP998: Injecting ${injectedCount} env vars from database`);
10523
12034
  }
10524
- const devProcess = (0, import_child_process13.spawn)(cmd, args, {
12035
+ const devProcess = (0, import_child_process15.spawn)(cmd, args, {
10525
12036
  cwd: projectPath,
10526
12037
  env: mergedEnv,
10527
12038
  stdio: ["ignore", "pipe", "pipe"],
@@ -10884,7 +12395,7 @@ function getInstallCommand2(cwd) {
10884
12395
  }
10885
12396
 
10886
12397
  // src/daemon/daemon-process.ts
10887
- var import_child_process14 = require("child_process");
12398
+ var import_child_process16 = require("child_process");
10888
12399
  var fs23 = __toESM(require("fs"));
10889
12400
  var http2 = __toESM(require("http"));
10890
12401
  var os9 = __toESM(require("os"));
@@ -11000,6 +12511,7 @@ async function fetchEnvVars2(projectId) {
11000
12511
  }
11001
12512
  }
11002
12513
  var Daemon = class _Daemon {
12514
+ // sessionId -> last seq
11003
12515
  constructor() {
11004
12516
  this.machineId = "";
11005
12517
  this.machineUuid = null;
@@ -11059,6 +12571,8 @@ var Daemon = class _Daemon {
11059
12571
  // EP1324: Retry limiting for failed update attempts
11060
12572
  this.lastFailedUpdateVersion = null;
11061
12573
  this.updateFailedAttempts = 0;
12574
+ // EP1360: Per-session monotonic event seq for daemon→platform stream gap detection.
12575
+ this.agentEventSeq = /* @__PURE__ */ new Map();
11062
12576
  this.ipcServer = new IPCServer();
11063
12577
  }
11064
12578
  static {
@@ -11098,6 +12612,11 @@ var Daemon = class _Daemon {
11098
12612
  static {
11099
12613
  this.MAX_UPDATE_ATTEMPTS = 3;
11100
12614
  }
12615
+ nextAgentSeq(sessionId) {
12616
+ const next = (this.agentEventSeq.get(sessionId) ?? 0) + 1;
12617
+ this.agentEventSeq.set(sessionId, next);
12618
+ return next;
12619
+ }
11101
12620
  /**
11102
12621
  * Start the daemon
11103
12622
  */
@@ -11211,7 +12730,7 @@ var Daemon = class _Daemon {
11211
12730
  const configDir = getConfigDir9();
11212
12731
  const logPath = path24.join(configDir, "daemon.log");
11213
12732
  const logFd = fs23.openSync(logPath, "a");
11214
- const child = (0, import_child_process14.spawn)("node", [__filename], {
12733
+ const child = (0, import_child_process16.spawn)("node", [__filename], {
11215
12734
  detached: true,
11216
12735
  stdio: ["ignore", logFd, logFd],
11217
12736
  env: { ...process.env, EPISODA_DAEMON_MODE: "1" }
@@ -11981,7 +13500,7 @@ var Daemon = class _Daemon {
11981
13500
  await client.send({
11982
13501
  type: "agent_result",
11983
13502
  commandId,
11984
- result: { success: true, status: "chunk", sessionId, chunk }
13503
+ result: { success: true, status: "chunk", sessionId, seq: this.nextAgentSeq(sessionId), chunk }
11985
13504
  });
11986
13505
  } catch (sendError) {
11987
13506
  console.error(`[Daemon] EP912: Failed to send chunk (WebSocket may be disconnected):`, sendError);
@@ -11999,6 +13518,7 @@ var Daemon = class _Daemon {
11999
13518
  success: true,
12000
13519
  status: "tool_use",
12001
13520
  sessionId,
13521
+ seq: this.nextAgentSeq(sessionId),
12002
13522
  toolUse: event
12003
13523
  }
12004
13524
  });
@@ -12016,6 +13536,7 @@ var Daemon = class _Daemon {
12016
13536
  success: true,
12017
13537
  status: "tool_result",
12018
13538
  sessionId,
13539
+ seq: this.nextAgentSeq(sessionId),
12019
13540
  toolResult: event
12020
13541
  }
12021
13542
  });
@@ -12034,6 +13555,7 @@ var Daemon = class _Daemon {
12034
13555
  success: true,
12035
13556
  status: "compaction",
12036
13557
  sessionId,
13558
+ seq: this.nextAgentSeq(sessionId),
12037
13559
  compaction: event
12038
13560
  }
12039
13561
  });
@@ -12049,7 +13571,7 @@ var Daemon = class _Daemon {
12049
13571
  await client.send({
12050
13572
  type: "agent_result",
12051
13573
  commandId,
12052
- result: { success: true, status: "complete", sessionId, claudeSessionId, resultMetadata }
13574
+ result: { success: true, status: "complete", sessionId, seq: this.nextAgentSeq(sessionId), claudeSessionId, resultMetadata }
12053
13575
  });
12054
13576
  } catch (sendError) {
12055
13577
  console.error(`[Daemon] EP912: Failed to send complete (WebSocket may be disconnected):`, sendError);
@@ -12063,7 +13585,7 @@ var Daemon = class _Daemon {
12063
13585
  await client.send({
12064
13586
  type: "agent_result",
12065
13587
  commandId,
12066
- result: { success: false, status: "error", sessionId, error }
13588
+ result: { success: false, status: "error", sessionId, seq: this.nextAgentSeq(sessionId), error }
12067
13589
  });
12068
13590
  } catch (sendError) {
12069
13591
  console.error(`[Daemon] EP912: Failed to send error (WebSocket may be disconnected):`, sendError);
@@ -12136,6 +13658,7 @@ var Daemon = class _Daemon {
12136
13658
  success: startResult.success,
12137
13659
  status: startResult.success ? "started" : "error",
12138
13660
  sessionId: cmd.sessionId,
13661
+ seq: this.nextAgentSeq(cmd.sessionId),
12139
13662
  error: startResult.error
12140
13663
  };
12141
13664
  } else if (cmd.action === "message") {
@@ -12159,14 +13682,16 @@ var Daemon = class _Daemon {
12159
13682
  success: sendResult.success,
12160
13683
  status: sendResult.success ? "started" : "error",
12161
13684
  sessionId: cmd.sessionId,
13685
+ seq: this.nextAgentSeq(cmd.sessionId),
12162
13686
  error: sendResult.error
12163
13687
  };
12164
13688
  } else if (cmd.action === "abort") {
12165
13689
  await agentManager.abortSession(cmd.sessionId);
12166
- result = { success: true, status: "aborted", sessionId: cmd.sessionId };
13690
+ result = { success: true, status: "aborted", sessionId: cmd.sessionId, seq: this.nextAgentSeq(cmd.sessionId) };
12167
13691
  } else if (cmd.action === "stop") {
12168
13692
  await agentManager.stopSession(cmd.sessionId);
12169
- result = { success: true, status: "complete", sessionId: cmd.sessionId };
13693
+ result = { success: true, status: "complete", sessionId: cmd.sessionId, seq: this.nextAgentSeq(cmd.sessionId) };
13694
+ this.agentEventSeq.delete(cmd.sessionId);
12170
13695
  } else {
12171
13696
  result = {
12172
13697
  success: false,
@@ -12192,6 +13717,7 @@ var Daemon = class _Daemon {
12192
13717
  success: false,
12193
13718
  status: "error",
12194
13719
  sessionId,
13720
+ seq: this.nextAgentSeq(sessionId),
12195
13721
  error: errorMsg
12196
13722
  }
12197
13723
  });