@episoda/cli 0.2.152 → 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.152",
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",
@@ -7754,8 +7754,9 @@ async function handleWorktreeCreate(request2) {
7754
7754
  createBranch,
7755
7755
  repoUrl,
7756
7756
  envVars,
7757
- setupScript
7757
+ setupScript,
7758
7758
  // EP1229: detachedHead removed - planning worktrees no longer exist
7759
+ moduleType
7759
7760
  } = request2;
7760
7761
  console.log(`[Worktree] K1273: Creating worktree for ${moduleUid}`);
7761
7762
  console.log(`[Worktree] Project: ${projectSlug}`);
@@ -7878,7 +7879,9 @@ ${buildCmd}` : buildCmd;
7878
7879
  }
7879
7880
  let previewUrl;
7880
7881
  let port;
7881
- if (finalStatus === "ready" && !isCloud) {
7882
+ if (moduleType === "ops") {
7883
+ console.log(`[Worktree] EP1363: Skipping preview for ops module ${moduleUid}`);
7884
+ } else if (finalStatus === "ready" && !isCloud) {
7882
7885
  port = allocatePort(moduleUid);
7883
7886
  console.log(`[Worktree] EP1143: Allocated port ${port} for ${moduleUid}`);
7884
7887
  const previewManager = getPreviewManager();
@@ -8532,7 +8535,7 @@ function generateCodexMcpConfigToml(servers, projectPath) {
8532
8535
  }
8533
8536
 
8534
8537
  // src/agent/agent-manager.ts
8535
- var import_child_process12 = require("child_process");
8538
+ var import_child_process14 = require("child_process");
8536
8539
  var path20 = __toESM(require("path"));
8537
8540
  var fs19 = __toESM(require("fs"));
8538
8541
  var os7 = __toESM(require("os"));
@@ -8839,6 +8842,1057 @@ function generateClaudeConfig(options = {}) {
8839
8842
 
8840
8843
  // src/agent/agent-manager.ts
8841
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
8842
9896
  var instance3 = null;
8843
9897
  function getAgentManager() {
8844
9898
  if (!instance3) {
@@ -8846,6 +9900,11 @@ function getAgentManager() {
8846
9900
  }
8847
9901
  return instance3;
8848
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"];
8849
9908
  var AgentManager = class {
8850
9909
  constructor() {
8851
9910
  this.sessions = /* @__PURE__ */ new Map();
@@ -8853,8 +9912,41 @@ var AgentManager = class {
8853
9912
  this.initialized = false;
8854
9913
  // EP1133: Lock for config file writes to prevent race conditions
8855
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;
8856
9921
  this.pidDir = path20.join(os7.homedir(), ".episoda", "agent-pids");
8857
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
+ }
8858
9950
  /**
8859
9951
  * EP1133: Acquire lock for config file writes
8860
9952
  * Ensures sequential writes to prevent file corruption
@@ -9149,6 +10241,12 @@ var AgentManager = class {
9149
10241
  } catch (error) {
9150
10242
  console.warn("[AgentManager] Codex CLI not available:", error instanceof Error ? error.message : error);
9151
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
+ }
9152
10250
  this.initialized = true;
9153
10251
  console.log("[AgentManager] Initialized");
9154
10252
  }
@@ -9273,7 +10371,8 @@ var AgentManager = class {
9273
10371
  /**
9274
10372
  * Send a message to an agent session
9275
10373
  *
9276
- * 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.
9277
10376
  * EP1133: Supports both Claude Code and Codex CLI with provider-specific handling.
9278
10377
  * EP1251: Supports credentials refresh for GitHub token on resume.
9279
10378
  */
@@ -9283,6 +10382,22 @@ var AgentManager = class {
9283
10382
  if (!session) {
9284
10383
  return { success: false, error: "Session not found" };
9285
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
+ };
9286
10401
  const mcpAuth = await this.resolveMcpAuth();
9287
10402
  if (mcpAuth.source === "config") {
9288
10403
  console.log("[AgentManager] EP1287: Using MCP token from ~/.episoda/config.json");
@@ -9295,6 +10410,78 @@ var AgentManager = class {
9295
10410
  session.canWrite = canWrite;
9296
10411
  session.readOnlyReason = readOnlyReason;
9297
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
+ }
9298
10485
  if (credentials?.githubToken) {
9299
10486
  session.credentials.githubToken = credentials.githubToken;
9300
10487
  session.credentials.githubTokenExpiresAt = credentials.githubTokenExpiresAt;
@@ -9342,6 +10529,8 @@ Violations will result in errors and may affect your ability to assist.
9342
10529
  }
9343
10530
  let binaryPath;
9344
10531
  let args;
10532
+ let argsBeforeMessage = 0;
10533
+ let runtimeFirstMessage = message;
9345
10534
  if (provider === "codex") {
9346
10535
  binaryPath = await ensureCodexBinary();
9347
10536
  args = [
@@ -9387,6 +10576,8 @@ If changes are needed, explain what needs to be done but do not execute.
9387
10576
  `;
9388
10577
  fullMessage = readOnlyReminder + fullMessage;
9389
10578
  }
10579
+ argsBeforeMessage = args.length;
10580
+ runtimeFirstMessage = fullMessage;
9390
10581
  args.push(fullMessage);
9391
10582
  } else {
9392
10583
  binaryPath = await ensureClaudeBinary();
@@ -9422,6 +10613,9 @@ Reason: ${session.readOnlyReason || "No write access to this module."}
9422
10613
  If changes are needed, explain what needs to be done.`;
9423
10614
  args.push("--append-system-prompt", readOnlyReminder);
9424
10615
  console.log("[AgentManager] EP1205: Appended read-only reminder to subsequent message");
10616
+ runtimeFirstMessage = `${readOnlyReminder}
10617
+
10618
+ ${message}`;
9425
10619
  }
9426
10620
  }
9427
10621
  if (resumeSessionId) {
@@ -9478,6 +10672,7 @@ If changes are needed, explain what needs to be done.`;
9478
10672
  args.push("--mcp-config", mcpConfigJson);
9479
10673
  console.log(`[AgentManager] EP1253: MCP config with env: ${mcpConfigJson.substring(0, 200)}...`);
9480
10674
  }
10675
+ argsBeforeMessage = args.length;
9481
10676
  args.push("--", message);
9482
10677
  }
9483
10678
  console.log(`[AgentManager] Spawning ${provider} CLI for session ${sessionId}`);
@@ -9722,7 +10917,95 @@ If changes are needed, explain what needs to be done.`;
9722
10917
  envVars.ANTHROPIC_API_KEY = session.credentials.apiKey;
9723
10918
  }
9724
10919
  }
9725
- 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, {
9726
11009
  cwd: session.projectPath,
9727
11010
  env: envVars,
9728
11011
  stdio: ["pipe", "pipe", "pipe"]
@@ -9733,6 +11016,14 @@ If changes are needed, explain what needs to be done.`;
9733
11016
  session.pid = childProcess.pid;
9734
11017
  this.writePidFile(sessionId, childProcess.pid);
9735
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
+ });
9736
11027
  childProcess.stderr?.on("data", (data) => {
9737
11028
  console.error(`[AgentManager] stderr: ${data.toString()}`);
9738
11029
  });
@@ -9748,6 +11039,8 @@ If changes are needed, explain what needs to be done.`;
9748
11039
  let stdoutEventCount = 0;
9749
11040
  let chunksSent = 0;
9750
11041
  const streamStartTime = Date.now();
11042
+ let ttftLogged = false;
11043
+ let parsedLineCount = 0;
9751
11044
  let hasContent = false;
9752
11045
  childProcess.stdout?.on("data", (data) => {
9753
11046
  const rawData = data.toString();
@@ -9762,6 +11055,12 @@ If changes are needed, explain what needs to be done.`;
9762
11055
  if (!line.trim()) continue;
9763
11056
  try {
9764
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
+ }
9765
11064
  if (stdoutEventCount <= 10 || chunksSent === 0) {
9766
11065
  console.log(`[AgentManager] EP1191: Parsed event type: ${parsed.type}`);
9767
11066
  }
@@ -9816,6 +11115,12 @@ If changes are needed, explain what needs to be done.`;
9816
11115
  }
9817
11116
  break;
9818
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
+ });
9819
11124
  if (parsed.usage) {
9820
11125
  const u = parsed.usage;
9821
11126
  const inputTokens = u.input_tokens ?? u.prompt_tokens ?? 0;
@@ -9891,6 +11196,12 @@ If changes are needed, explain what needs to be done.`;
9891
11196
  }
9892
11197
  break;
9893
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
+ });
9894
11205
  if (parsed.session_id) {
9895
11206
  extractedSessionId = parsed.session_id;
9896
11207
  session.agentSessionId = extractedSessionId;
@@ -9962,6 +11273,12 @@ If changes are needed, explain what needs to be done.`;
9962
11273
  });
9963
11274
  childProcess.on("exit", (code, signal) => {
9964
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}`);
9965
11282
  console.log(`[AgentManager] EP1191: ${provider} CLI exited for session ${sessionId}: code=${code}, signal=${signal}, duration=${duration}ms, stdoutEvents=${stdoutEventCount}, chunksSent=${chunksSent}`);
9966
11283
  this.processes.delete(sessionId);
9967
11284
  this.removePidFile(sessionId);
@@ -10015,7 +11332,18 @@ If changes are needed, explain what needs to be done.`;
10015
11332
  console.log(`[AgentManager] Aborting session ${sessionId} with SIGINT`);
10016
11333
  agentProcess.kill("SIGINT");
10017
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
+ }
10018
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);
10019
11347
  if (session) {
10020
11348
  session.status = "stopping";
10021
11349
  }
@@ -10049,6 +11377,16 @@ If changes are needed, explain what needs to be done.`;
10049
11377
  });
10050
11378
  });
10051
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);
10052
11390
  this.sessions.delete(sessionId);
10053
11391
  this.processes.delete(sessionId);
10054
11392
  this.removePidFile(sessionId);
@@ -10077,8 +11415,183 @@ If changes are needed, explain what needs to be done.`;
10077
11415
  * Stop all active sessions
10078
11416
  */
10079
11417
  async stopAllSessions() {
11418
+ this.stopIdleScanner();
10080
11419
  const sessionIds = Array.from(this.sessions.keys());
10081
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
+ }
10082
11595
  }
10083
11596
  /**
10084
11597
  * Get session info
@@ -10129,7 +11642,8 @@ If changes are needed, explain what needs to be done.`;
10129
11642
  moduleUid: session.moduleUid,
10130
11643
  status: reconStatus,
10131
11644
  agentSessionId: session.agentSessionId || session.claudeSessionId,
10132
- lastActivityAt: session.lastActivityAt
11645
+ lastActivityAt: session.lastActivityAt,
11646
+ persistentRuntime: this.runtimes.has(sessionId)
10133
11647
  });
10134
11648
  }
10135
11649
  return result;
@@ -10400,7 +11914,7 @@ var AgentCommandQueue = class {
10400
11914
  };
10401
11915
 
10402
11916
  // src/utils/dev-server.ts
10403
- var import_child_process13 = require("child_process");
11917
+ var import_child_process15 = require("child_process");
10404
11918
  var import_core12 = __toESM(require_dist());
10405
11919
  var fs20 = __toESM(require("fs"));
10406
11920
  var path21 = __toESM(require("path"));
@@ -10449,7 +11963,7 @@ function writeToLog(logPath, line, isError = false) {
10449
11963
  }
10450
11964
  async function killProcessOnPort(port) {
10451
11965
  try {
10452
- 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();
10453
11967
  if (!result) {
10454
11968
  console.log(`[DevServer] EP929: No process found on port ${port}`);
10455
11969
  return true;
@@ -10458,7 +11972,7 @@ async function killProcessOnPort(port) {
10458
11972
  console.log(`[DevServer] EP929: Found ${pids.length} process(es) on port ${port}: ${pids.join(", ")}`);
10459
11973
  for (const pid of pids) {
10460
11974
  try {
10461
- (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" });
10462
11976
  console.log(`[DevServer] EP929: Sent SIGTERM to PID ${pid}`);
10463
11977
  } catch {
10464
11978
  }
@@ -10466,8 +11980,8 @@ async function killProcessOnPort(port) {
10466
11980
  await new Promise((resolve4) => setTimeout(resolve4, 1e3));
10467
11981
  for (const pid of pids) {
10468
11982
  try {
10469
- (0, import_child_process13.execSync)(`kill -0 ${pid} 2>/dev/null`, { encoding: "utf8" });
10470
- (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" });
10471
11985
  console.log(`[DevServer] EP929: Force killed PID ${pid}`);
10472
11986
  } catch {
10473
11987
  }
@@ -10518,7 +12032,7 @@ function spawnDevServerProcess(projectPath, port, moduleUid, logPath, customComm
10518
12032
  if (injectedCount > 0) {
10519
12033
  console.log(`[DevServer] EP998: Injecting ${injectedCount} env vars from database`);
10520
12034
  }
10521
- const devProcess = (0, import_child_process13.spawn)(cmd, args, {
12035
+ const devProcess = (0, import_child_process15.spawn)(cmd, args, {
10522
12036
  cwd: projectPath,
10523
12037
  env: mergedEnv,
10524
12038
  stdio: ["ignore", "pipe", "pipe"],
@@ -10881,7 +12395,7 @@ function getInstallCommand2(cwd) {
10881
12395
  }
10882
12396
 
10883
12397
  // src/daemon/daemon-process.ts
10884
- var import_child_process14 = require("child_process");
12398
+ var import_child_process16 = require("child_process");
10885
12399
  var fs23 = __toESM(require("fs"));
10886
12400
  var http2 = __toESM(require("http"));
10887
12401
  var os9 = __toESM(require("os"));
@@ -10997,6 +12511,7 @@ async function fetchEnvVars2(projectId) {
10997
12511
  }
10998
12512
  }
10999
12513
  var Daemon = class _Daemon {
12514
+ // sessionId -> last seq
11000
12515
  constructor() {
11001
12516
  this.machineId = "";
11002
12517
  this.machineUuid = null;
@@ -11056,6 +12571,8 @@ var Daemon = class _Daemon {
11056
12571
  // EP1324: Retry limiting for failed update attempts
11057
12572
  this.lastFailedUpdateVersion = null;
11058
12573
  this.updateFailedAttempts = 0;
12574
+ // EP1360: Per-session monotonic event seq for daemon→platform stream gap detection.
12575
+ this.agentEventSeq = /* @__PURE__ */ new Map();
11059
12576
  this.ipcServer = new IPCServer();
11060
12577
  }
11061
12578
  static {
@@ -11095,6 +12612,11 @@ var Daemon = class _Daemon {
11095
12612
  static {
11096
12613
  this.MAX_UPDATE_ATTEMPTS = 3;
11097
12614
  }
12615
+ nextAgentSeq(sessionId) {
12616
+ const next = (this.agentEventSeq.get(sessionId) ?? 0) + 1;
12617
+ this.agentEventSeq.set(sessionId, next);
12618
+ return next;
12619
+ }
11098
12620
  /**
11099
12621
  * Start the daemon
11100
12622
  */
@@ -11208,7 +12730,7 @@ var Daemon = class _Daemon {
11208
12730
  const configDir = getConfigDir9();
11209
12731
  const logPath = path24.join(configDir, "daemon.log");
11210
12732
  const logFd = fs23.openSync(logPath, "a");
11211
- const child = (0, import_child_process14.spawn)("node", [__filename], {
12733
+ const child = (0, import_child_process16.spawn)("node", [__filename], {
11212
12734
  detached: true,
11213
12735
  stdio: ["ignore", logFd, logFd],
11214
12736
  env: { ...process.env, EPISODA_DAEMON_MODE: "1" }
@@ -11978,7 +13500,7 @@ var Daemon = class _Daemon {
11978
13500
  await client.send({
11979
13501
  type: "agent_result",
11980
13502
  commandId,
11981
- result: { success: true, status: "chunk", sessionId, chunk }
13503
+ result: { success: true, status: "chunk", sessionId, seq: this.nextAgentSeq(sessionId), chunk }
11982
13504
  });
11983
13505
  } catch (sendError) {
11984
13506
  console.error(`[Daemon] EP912: Failed to send chunk (WebSocket may be disconnected):`, sendError);
@@ -11996,6 +13518,7 @@ var Daemon = class _Daemon {
11996
13518
  success: true,
11997
13519
  status: "tool_use",
11998
13520
  sessionId,
13521
+ seq: this.nextAgentSeq(sessionId),
11999
13522
  toolUse: event
12000
13523
  }
12001
13524
  });
@@ -12013,6 +13536,7 @@ var Daemon = class _Daemon {
12013
13536
  success: true,
12014
13537
  status: "tool_result",
12015
13538
  sessionId,
13539
+ seq: this.nextAgentSeq(sessionId),
12016
13540
  toolResult: event
12017
13541
  }
12018
13542
  });
@@ -12031,6 +13555,7 @@ var Daemon = class _Daemon {
12031
13555
  success: true,
12032
13556
  status: "compaction",
12033
13557
  sessionId,
13558
+ seq: this.nextAgentSeq(sessionId),
12034
13559
  compaction: event
12035
13560
  }
12036
13561
  });
@@ -12046,7 +13571,7 @@ var Daemon = class _Daemon {
12046
13571
  await client.send({
12047
13572
  type: "agent_result",
12048
13573
  commandId,
12049
- result: { success: true, status: "complete", sessionId, claudeSessionId, resultMetadata }
13574
+ result: { success: true, status: "complete", sessionId, seq: this.nextAgentSeq(sessionId), claudeSessionId, resultMetadata }
12050
13575
  });
12051
13576
  } catch (sendError) {
12052
13577
  console.error(`[Daemon] EP912: Failed to send complete (WebSocket may be disconnected):`, sendError);
@@ -12060,7 +13585,7 @@ var Daemon = class _Daemon {
12060
13585
  await client.send({
12061
13586
  type: "agent_result",
12062
13587
  commandId,
12063
- result: { success: false, status: "error", sessionId, error }
13588
+ result: { success: false, status: "error", sessionId, seq: this.nextAgentSeq(sessionId), error }
12064
13589
  });
12065
13590
  } catch (sendError) {
12066
13591
  console.error(`[Daemon] EP912: Failed to send error (WebSocket may be disconnected):`, sendError);
@@ -12133,6 +13658,7 @@ var Daemon = class _Daemon {
12133
13658
  success: startResult.success,
12134
13659
  status: startResult.success ? "started" : "error",
12135
13660
  sessionId: cmd.sessionId,
13661
+ seq: this.nextAgentSeq(cmd.sessionId),
12136
13662
  error: startResult.error
12137
13663
  };
12138
13664
  } else if (cmd.action === "message") {
@@ -12156,14 +13682,16 @@ var Daemon = class _Daemon {
12156
13682
  success: sendResult.success,
12157
13683
  status: sendResult.success ? "started" : "error",
12158
13684
  sessionId: cmd.sessionId,
13685
+ seq: this.nextAgentSeq(cmd.sessionId),
12159
13686
  error: sendResult.error
12160
13687
  };
12161
13688
  } else if (cmd.action === "abort") {
12162
13689
  await agentManager.abortSession(cmd.sessionId);
12163
- result = { success: true, status: "aborted", sessionId: cmd.sessionId };
13690
+ result = { success: true, status: "aborted", sessionId: cmd.sessionId, seq: this.nextAgentSeq(cmd.sessionId) };
12164
13691
  } else if (cmd.action === "stop") {
12165
13692
  await agentManager.stopSession(cmd.sessionId);
12166
- 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);
12167
13695
  } else {
12168
13696
  result = {
12169
13697
  success: false,
@@ -12189,6 +13717,7 @@ var Daemon = class _Daemon {
12189
13717
  success: false,
12190
13718
  status: "error",
12191
13719
  sessionId,
13720
+ seq: this.nextAgentSeq(sessionId),
12192
13721
  error: errorMsg
12193
13722
  }
12194
13723
  });