@0xmaxma/claude-gateway 1.3.25 → 1.3.32

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.
Files changed (72) hide show
  1. package/README.md +50 -2
  2. package/config.template.json +6 -2
  3. package/dist/agent/incident-store.d.ts +89 -0
  4. package/dist/agent/incident-store.d.ts.map +1 -0
  5. package/dist/agent/incident-store.js +299 -0
  6. package/dist/agent/incident-store.js.map +1 -0
  7. package/dist/agent/incident.d.ts +156 -0
  8. package/dist/agent/incident.d.ts.map +1 -0
  9. package/dist/agent/incident.js +177 -0
  10. package/dist/agent/incident.js.map +1 -0
  11. package/dist/agent/recovery-executor.d.ts +117 -0
  12. package/dist/agent/recovery-executor.d.ts.map +1 -0
  13. package/dist/agent/recovery-executor.js +168 -0
  14. package/dist/agent/recovery-executor.js.map +1 -0
  15. package/dist/agent/recovery-policy.d.ts +97 -0
  16. package/dist/agent/recovery-policy.d.ts.map +1 -0
  17. package/dist/agent/recovery-policy.js +164 -0
  18. package/dist/agent/recovery-policy.js.map +1 -0
  19. package/dist/agent/runner.d.ts +66 -6
  20. package/dist/agent/runner.d.ts.map +1 -1
  21. package/dist/agent/runner.js +332 -27
  22. package/dist/agent/runner.js.map +1 -1
  23. package/dist/agent/safe-mode.d.ts +61 -0
  24. package/dist/agent/safe-mode.d.ts.map +1 -0
  25. package/dist/agent/safe-mode.js +102 -0
  26. package/dist/agent/safe-mode.js.map +1 -0
  27. package/dist/agent/triage.d.ts +94 -0
  28. package/dist/agent/triage.d.ts.map +1 -0
  29. package/dist/agent/triage.js +209 -0
  30. package/dist/agent/triage.js.map +1 -0
  31. package/dist/agent/turn-trace.d.ts +120 -0
  32. package/dist/agent/turn-trace.d.ts.map +1 -0
  33. package/dist/agent/turn-trace.js +122 -0
  34. package/dist/agent/turn-trace.js.map +1 -0
  35. package/dist/api/gateway-router.d.ts +21 -0
  36. package/dist/api/gateway-router.d.ts.map +1 -1
  37. package/dist/api/gateway-router.js +58 -17
  38. package/dist/api/gateway-router.js.map +1 -1
  39. package/dist/api/router.d.ts.map +1 -1
  40. package/dist/api/router.js +104 -4
  41. package/dist/api/router.js.map +1 -1
  42. package/dist/config/migrator.d.ts +4 -0
  43. package/dist/config/migrator.d.ts.map +1 -1
  44. package/dist/config/migrator.js +60 -3
  45. package/dist/config/migrator.js.map +1 -1
  46. package/dist/history/db.d.ts +13 -1
  47. package/dist/history/db.d.ts.map +1 -1
  48. package/dist/history/db.js +67 -9
  49. package/dist/history/db.js.map +1 -1
  50. package/dist/history/types.d.ts +10 -0
  51. package/dist/history/types.d.ts.map +1 -1
  52. package/dist/index.js +3 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/session/process.d.ts +26 -0
  55. package/dist/session/process.d.ts.map +1 -1
  56. package/dist/session/process.js +77 -1
  57. package/dist/session/process.js.map +1 -1
  58. package/dist/shell/claude-pty-shell.js +59 -0
  59. package/dist/shell/claude-pty-shell.js.map +1 -1
  60. package/dist/shell/control-channel.d.ts +74 -0
  61. package/dist/shell/control-channel.d.ts.map +1 -0
  62. package/dist/shell/control-channel.js +114 -0
  63. package/dist/shell/control-channel.js.map +1 -0
  64. package/dist/types.d.ts +22 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/ui/web-ui.d.ts.map +1 -1
  67. package/dist/ui/web-ui.js +103 -2
  68. package/dist/ui/web-ui.js.map +1 -1
  69. package/mcp/tools/skills/handlers.ts +4 -2
  70. package/mcp/tools/telegram/receiver-server.ts +163 -0
  71. package/mcp/tools/telegram/typing.ts +124 -0
  72. package/package.json +3 -1
@@ -49,6 +49,11 @@ const line_reply_manager_1 = require("./line-reply-manager");
49
49
  const markdown_1 = require("../telegram/markdown");
50
50
  const skills_1 = require("../skills");
51
51
  const builtin_commands_1 = require("./builtin-commands");
52
+ const safe_mode_1 = require("./safe-mode");
53
+ const recovery_executor_1 = require("./recovery-executor");
54
+ const recovery_policy_1 = require("./recovery-policy");
55
+ const incident_1 = require("./incident");
56
+ const pty_stream_registry_1 = require("../shell/pty-stream-registry");
52
57
  const screen_1 = require("../shell/screen");
53
58
  const db_1 = require("../history/db");
54
59
  const media_store_1 = require("../history/media-store");
@@ -56,14 +61,16 @@ const cleanup_1 = require("../history/cleanup");
56
61
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
57
62
  const DEFAULT_MAX_CONCURRENT = 20;
58
63
  const ANTHROPIC_SOCKET_ERROR = 'socket connection was closed unexpectedly';
59
- // History re-injection ladder for request_too_large (32MB) recovery. Index =
60
- // number of consecutive 32MB recoveries on a session; the value is how many
61
- // history messages the NEXT spawn re-injects. Index 0 is the healthy default
62
- // (= MAX_HISTORY_MESSAGES, sourced from it so the two never drift); each retry
63
- // steps down a rung, shrinking the re-loaded context until it drops under
64
- // Anthropic's 32MB request ceiling. Past the last rung (0 history) the context
65
- // can't shrink further, so the runner stops escalating and asks the user to
66
- // /clear instead of looping forever.
64
+ // History re-injection ladder for request_too_large (32MB) recovery: the
65
+ // candidate history sizes a recovering session can step down to. The HEALTHY
66
+ // spawn uses the configured cap (resolveMaxHistoryMessages), not an index into
67
+ // this array; on each consecutive 32MB recovery the session drops to the next
68
+ // ladder rung STRICTLY BELOW that cap (see spawnHistoryLimit), so every retry
69
+ // actually shrinks the re-loaded context instead of re-trying the same size.
70
+ // Once even the 0-history rung still trips 32MB the runner stops escalating and
71
+ // asks the user to /clear instead of looping forever. The leading
72
+ // MAX_HISTORY_MESSAGES only acts as a recovery rung when an operator configures
73
+ // a cap higher than it.
67
74
  const TOO_LARGE_HISTORY_LADDER = [process_1.MAX_HISTORY_MESSAGES, 40, 30, 20, 10, 0];
68
75
  exports.MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
69
76
  exports.DEFAULT_MODELS = [
@@ -82,6 +89,14 @@ const PROTECTED_WORKSPACE_FILES = [
82
89
  'IDENTITY.md', 'USER.md', 'HEARTBEAT.md',
83
90
  ];
84
91
  const MAX_API_IMAGES = 5;
92
+ // Hard timeout for the one-shot local `claude -p` triage during recovery
93
+ // (Epic #195, Phase 3b). A slow/hung triage collapses to a safe notify-only.
94
+ const RECOVERY_TRIAGE_TIMEOUT_MS = 15000;
95
+ // TTL after which a per-turn recovery budget entry is evicted. A turn's budget
96
+ // only matters during its active stall window (a few interventions spaced by a
97
+ // 30s cooldown), so anything older is a finished turn — pruned to keep the
98
+ // budget map from growing unbounded over a long-lived runner (Epic #195, 3b).
99
+ const RECOVERY_BUDGET_TTL_MS = 10 * 60000;
85
100
  // Trailing-edge window for coalescing channel messages that carry an image (or that
86
101
  // arrive while an image is already buffered) into a single turn. Reset on every new
87
102
  // related update, so an album burst or a client-split photo+caption is gathered
@@ -157,6 +172,27 @@ class AgentRunner extends events_1.EventEmitter {
157
172
  // restarting would just churn a context that cannot shrink. Cleared together
158
173
  // with the counter on a successful result and on /clear.
159
174
  this.tooLargeExhausted = new Set();
175
+ // Safe-mode manager (Epic #195, Phase 3): tracks repeated PTY-backend failures
176
+ // and, past a threshold, forces this agent to the headless backend so it keeps
177
+ // serving turns without a gateway restart. In-memory only — a restart clears it
178
+ // and re-reads the user's real config. spawnSession reads isActive() to set
179
+ // SessionProcess.forceHeadless.
180
+ this.safeMode = new safe_mode_1.SafeModeManager({
181
+ audit: (e) => this.logger.info('Safe-mode transition', {
182
+ agentId: e.agentId,
183
+ action: e.action,
184
+ reason: e.reason,
185
+ failures: e.failures,
186
+ }),
187
+ });
188
+ // Recovery executor state (Epic #195, Phase 3b). Per-turn intervention budget,
189
+ // keyed by turnKey so it resets each turn. In-memory only.
190
+ this.recoveryBudgets = new Map();
191
+ // Last injected turn per chat, for the C1 guarded resend: `delivered` flips
192
+ // true once assistant output reaches the channel, so a resend after recovery
193
+ // only fires when the stalled turn produced nothing (no double-submit). `resent`
194
+ // guards against resending more than once per turn.
195
+ this.lastTurn = new Map();
160
196
  // Tracks pending Telegram image paths per chatId (queue) for size accumulation after each turn.
161
197
  this.pendingImagePaths = new Map();
162
198
  // Coalescing buffer: collects channel messages that arrive close together so a
@@ -177,6 +213,46 @@ class AgentRunner extends events_1.EventEmitter {
177
213
  this.skillRegistry = { skills: new Map() };
178
214
  // Cancel function for the daily history cleanup timer
179
215
  this.cancelCleanup = null;
216
+ /**
217
+ * One-shot local `claude -p` triage runner (Phase 3b). Feeds the closed
218
+ * classification prompt on stdin and returns raw stdout. Hard timeout →
219
+ * timedOut, which the executor collapses to a safe notify-only verdict. Only
220
+ * invoked when autoRecover is enabled.
221
+ */
222
+ this.recoveryTriageSpawn = async (prompt) => {
223
+ const { spawn } = await Promise.resolve().then(() => __importStar(require('child_process')));
224
+ return new Promise((resolve) => {
225
+ let done = false;
226
+ let out = '';
227
+ const finish = (r) => {
228
+ if (done)
229
+ return;
230
+ done = true;
231
+ clearTimeout(timer);
232
+ resolve(r);
233
+ };
234
+ const timer = setTimeout(() => {
235
+ try {
236
+ child.kill('SIGKILL');
237
+ }
238
+ catch { /* already gone */ }
239
+ finish({ stdout: '', timedOut: true });
240
+ }, RECOVERY_TRIAGE_TIMEOUT_MS);
241
+ const child = spawn('claude', ['-p', '--output-format', 'text'], {
242
+ stdio: ['pipe', 'pipe', 'ignore'],
243
+ });
244
+ child.stdout?.on('data', (d) => { out += String(d); });
245
+ child.on('error', () => finish({ stdout: '', timedOut: true }));
246
+ child.on('close', () => finish({ stdout: out }));
247
+ try {
248
+ child.stdin?.write(prompt);
249
+ child.stdin?.end();
250
+ }
251
+ catch {
252
+ finish({ stdout: '', timedOut: true });
253
+ }
254
+ });
255
+ };
180
256
  this.agentConfig = agentConfig;
181
257
  this.gatewayConfig = gatewayConfig;
182
258
  this.logger = logger ?? (0, logger_1.createLogger)(agentConfig.id, gatewayConfig.gateway.logDir);
@@ -229,6 +305,13 @@ class AgentRunner extends events_1.EventEmitter {
229
305
  this.handleCommandRequest(raw, res);
230
306
  return;
231
307
  }
308
+ if (url.pathname === '/recover') {
309
+ // Cross-process recovery bridge (Epic #195, Phase 3b): the receiver
310
+ // process detects a stall but recovery must run here, where the live
311
+ // control surfaces (session stdin, restart, safe-mode) live.
312
+ this.handleRecoverRequest(raw, res);
313
+ return;
314
+ }
232
315
  // Default: /channel — existing channel message handler
233
316
  // Connection: close prevents keep-alive pool reuse: after the OS's
234
317
  // keepAliveTimeout expires the server closes the TCP connection, and the
@@ -315,6 +398,160 @@ class AgentRunner extends events_1.EventEmitter {
315
398
  });
316
399
  this.logger.info('Channel callback server listening', { port: this.callbackPort });
317
400
  }
401
+ /**
402
+ * Handle POST /recover from the receiver process (Epic #195, Phase 3b).
403
+ * The watchdog detects a stall in the receiver, but recovery must run here in
404
+ * the runner, where the live control surfaces live. Runs the pure executor
405
+ * with effects bound to the affected session and returns the outcome so the
406
+ * receiver can persist it to the incident bundle.
407
+ */
408
+ async handleRecoverRequest(raw, res) {
409
+ const respond = (data, status = 200) => {
410
+ res.writeHead(status, { 'Content-Type': 'application/json' });
411
+ res.end(JSON.stringify(data));
412
+ };
413
+ let body;
414
+ try {
415
+ body = JSON.parse(raw);
416
+ }
417
+ catch {
418
+ respond({ ok: false, error: 'Invalid JSON' }, 400);
419
+ return;
420
+ }
421
+ const chatId = body.chatId ?? '';
422
+ const stage = body.stage ?? '';
423
+ if (!chatId || !stage) {
424
+ respond({ ok: false, error: 'missing chatId/stage' }, 400);
425
+ return;
426
+ }
427
+ const session = this.sessions.get(chatId);
428
+ const autoRecover = this.gatewayConfig.gateway.selfHealing?.autoRecover === true;
429
+ const req = {
430
+ incidentId: body.incidentId ?? '',
431
+ agentId: this.agentConfig.id,
432
+ chatId,
433
+ // Prefer the live session's id (survives a request that names a stale one).
434
+ sessionId: session?.sessionId ?? body.sessionId ?? '',
435
+ stage,
436
+ failureClass: body.failureClass ?? null,
437
+ turnKey: body.turnKey ?? chatId,
438
+ };
439
+ try {
440
+ const result = await (0, recovery_executor_1.runRecovery)(req, {
441
+ autoRecover,
442
+ effects: this.buildRecoveryEffects(chatId),
443
+ now: () => Date.now(),
444
+ budget: {
445
+ get: (turnKey) => this.recoveryBudgets.get(turnKey) ?? (0, recovery_policy_1.initialBudget)(turnKey),
446
+ set: (s) => {
447
+ this.recoveryBudgets.set(s.turnKey, s);
448
+ this.pruneRecoveryBudgets(s.lastAt);
449
+ },
450
+ },
451
+ // Live triage is the most sensitive surface — only ever run when the
452
+ // operator has opted in. When off, the executor uses the deterministic
453
+ // per-stage default action (still whitelist-clamped).
454
+ triageSpawn: autoRecover ? this.recoveryTriageSpawn : undefined,
455
+ gatherEvidence: async () => {
456
+ const s = this.sessions.get(chatId);
457
+ if (!s)
458
+ return null;
459
+ try {
460
+ const snap = await pty_stream_registry_1.ptyStreamRegistry.screenText(s.sessionId);
461
+ if (!snap)
462
+ return null;
463
+ // Scrub the untrusted screen before it leaves the process / reaches
464
+ // the triage model. Redact the chat id explicitly on top of the
465
+ // pattern-based secret scrub.
466
+ return { screenText: (0, incident_1.scrubText)(snap.text, [chatId]) };
467
+ }
468
+ catch {
469
+ return null;
470
+ }
471
+ },
472
+ resendAfterRecover: autoRecover,
473
+ log: (msg, meta) => this.logger.info(msg, meta),
474
+ });
475
+ respond({ ok: true, outcome: (0, recovery_executor_1.toRecoveryOutcome)(result), result });
476
+ }
477
+ catch (err) {
478
+ this.logger.error('Recovery attempt failed', { chatId, stage, error: err.message });
479
+ respond({ ok: false, error: err.message }, 500);
480
+ }
481
+ }
482
+ /**
483
+ * Route raw interactive-terminal input to the session with this actual
484
+ * sessionId (Issue #201). The dashboard's Terminal Viewer input mode
485
+ * streams keystrokes here via the pty-stream WebSocket. Sessions are keyed by
486
+ * chatId internally, so we match on the process's own sessionId. Returns true
487
+ * only when a live pty-shell session accepted the bytes (headless / missing /
488
+ * not-writable → false), so the caller can surface an accurate result.
489
+ */
490
+ sendInputToSession(sessionId, data) {
491
+ if (!sessionId || typeof data !== 'string' || data.length === 0)
492
+ return false;
493
+ for (const proc of this.sessions.values()) {
494
+ if (proc.sessionId === sessionId) {
495
+ return proc.sendInput(data);
496
+ }
497
+ }
498
+ return false;
499
+ }
500
+ /**
501
+ * Build the recovery effects bound to one chat's session (Phase 3b). Each
502
+ * effect resolves the session fresh so it survives a restart mid-recovery.
503
+ * Keystroke effects go through the wrapper control channel; restart/backend
504
+ * effects act on the session and safe-mode manager. Effects intentionally
505
+ * omitted (redeliver-forward, restart-receiver, bridge-menu) are transport /
506
+ * wrapper concerns not bridged to the runner — the executor reports them as
507
+ * unsupported rather than guessing.
508
+ */
509
+ buildRecoveryEffects(chatId) {
510
+ const control = (key, option) => {
511
+ this.sessions.get(chatId)?.sendControl(key, option);
512
+ };
513
+ return {
514
+ esc: () => control('esc'),
515
+ escEsc: () => control('esc-esc'),
516
+ enter: () => control('enter'),
517
+ selectOption: (option) => control('select-option', option),
518
+ restartSession: () => this.restartProcess(chatId),
519
+ fallbackHeadless: async () => {
520
+ // Flip to the headless backend, then restart so the new session respawns
521
+ // headless (spawnSession reads safeMode.isActive → forceHeadless).
522
+ this.safeMode.enter(this.agentConfig.id, 'recovery: fallback-headless');
523
+ await this.restartProcess(chatId);
524
+ },
525
+ resendLast: () => {
526
+ const lt = this.lastTurn.get(chatId);
527
+ // Guard: only resend a turn that produced no output and was not already
528
+ // resent — never double-submit.
529
+ if (!lt || lt.delivered || lt.resent)
530
+ return false;
531
+ const s = this.sessions.get(chatId);
532
+ if (!s)
533
+ return false;
534
+ lt.resent = true;
535
+ s.setProcessing(true);
536
+ s.sendMessage(lt.text);
537
+ s.touch();
538
+ return true;
539
+ },
540
+ };
541
+ }
542
+ /**
543
+ * Evict finished-turn budget entries (Epic #195, Phase 3b). Called on each
544
+ * budget write so the per-turn map cannot grow without bound on a long-lived
545
+ * runner. `nowMs` is the timestamp of the write just made; any entry whose last
546
+ * attempt predates the TTL belongs to a turn that is no longer being recovered.
547
+ */
548
+ pruneRecoveryBudgets(nowMs) {
549
+ const cutoff = nowMs - RECOVERY_BUDGET_TTL_MS;
550
+ for (const [key, state] of this.recoveryBudgets) {
551
+ if (state.lastAt < cutoff)
552
+ this.recoveryBudgets.delete(key);
553
+ }
554
+ }
318
555
  /**
319
556
  * Handle POST /command requests from the receiver process.
320
557
  * Supports: get_model, set_model, restart.
@@ -599,7 +836,12 @@ class AgentRunner extends events_1.EventEmitter {
599
836
  }
600
837
  }
601
838
  session.setProcessing(true);
602
- session.sendMessage(blocks.join('\n'));
839
+ const turnText = blocks.join('\n');
840
+ session.sendMessage(turnText);
841
+ // Remember this turn for the C1 guarded resend (Phase 3b): reset the
842
+ // delivered/resent flags so a resend can only fire if this turn stalls
843
+ // before producing output.
844
+ this.lastTurn.set(chatId, { text: turnText, delivered: false, resent: false });
603
845
  session.touch();
604
846
  this.logger.debug('Injected channel turn into session', {
605
847
  chatId,
@@ -716,19 +958,32 @@ class AgentRunner extends events_1.EventEmitter {
716
958
  // Apply per-session model override (caller already normalized to undefined when == agent default)
717
959
  if (modelOverride)
718
960
  proc.modelOverride = modelOverride;
961
+ // Per-agent → global → MAX_HISTORY_MESSAGES: the configured cap on how many
962
+ // history messages a healthy spawn re-injects. Lets an operator lower the
963
+ // context loaded at session start (e.g. 50 → 30) without touching code.
964
+ const configuredMax = (0, process_1.resolveMaxHistoryMessages)(this.agentConfig.history?.maxHistoryMessages, this.gatewayConfig.gateway.history?.maxHistoryMessages);
719
965
  // request_too_large (32MB) recovery: shrink the re-injected history on each
720
966
  // consecutive retry so a pathological context eventually fits. recoveryCount
721
- // is 0 for healthy sessions → ladder rung 0 = MAX_HISTORY_MESSAGES (same as
722
- // the SessionProcess default), so applying it unconditionally is a no-op for
723
- // healthy sessions and the only path that sets historyLimit for recovering ones.
967
+ // is 0 for healthy sessions → use the configured cap directly; a recovering
968
+ // session steps down to the ladder rungs STRICTLY BELOW that cap, so each
969
+ // retry genuinely shrinks instead of re-trying the same (already-too-large)
970
+ // size when the cap has been lowered.
724
971
  const recoveryCount = this.tooLargeRecoveries.get(mapKey) ?? 0;
725
- const ladderIdx = Math.min(recoveryCount, TOO_LARGE_HISTORY_LADDER.length - 1);
726
- proc.historyLimit = TOO_LARGE_HISTORY_LADDER[ladderIdx];
972
+ proc.historyLimit = this.spawnHistoryLimit(configuredMax, recoveryCount);
727
973
  if (recoveryCount > 0) {
728
974
  this.logger.info('Spawning with reduced history after request_too_large', {
729
975
  mapKey, recoveryCount, historyLimit: proc.historyLimit,
730
976
  });
731
977
  }
978
+ // Safe mode (Epic #195, Phase 3): if the PTY backend has repeatedly failed
979
+ // for this agent, force the headless backend for this spawn. Cleared
980
+ // automatically once safe mode exits (a later healthy turn / user restore).
981
+ if (this.safeMode.isActive(this.agentConfig.id)) {
982
+ proc.forceHeadless = true;
983
+ this.logger.info('Spawning in safe mode (headless backend forced)', {
984
+ mapKey, agentId: this.agentConfig.id,
985
+ });
986
+ }
732
987
  await proc.start();
733
988
  // Forward all session output lines so listeners on AgentRunner (GatewayRouter,
734
989
  // CronScheduler, tests) receive them without needing individual session references.
@@ -736,7 +991,16 @@ class AgentRunner extends events_1.EventEmitter {
736
991
  // Notify typing indicator when session permanently fails (max restarts exceeded)
737
992
  if (source !== 'api') {
738
993
  proc.once('failed', () => {
739
- this.writeTypingError(mapKey, 'PROCESS_FAILED');
994
+ // Safe mode (Epic #195, Phase 3): a hard failure of the PTY (interactive
995
+ // TUI) backend counts toward the safe-mode threshold. Once crossed, the
996
+ // agent auto-flips to headless on the next spawn so the user's next
997
+ // message is served instead of re-wedging. Headless failures don't count
998
+ // (there is no PTY wrapper to blame / fall back from). When we just
999
+ // entered safe mode, tell the user that specifically instead of the
1000
+ // generic "stopped" notice (one .error file, so pick the better code).
1001
+ const enteredSafeMode = proc.backend === 'pty-shell' &&
1002
+ this.safeMode.recordPtyFailure(this.agentConfig.id);
1003
+ this.writeTypingError(mapKey, enteredSafeMode ? 'SAFE_MODE_ENABLED' : 'PROCESS_FAILED');
740
1004
  this.sessions.delete(mapKey);
741
1005
  // LINE: surface an error for any outstanding postback button so a tap
742
1006
  // returns the interrupted notice instead of a stale "still thinking".
@@ -899,6 +1163,13 @@ class AgentRunner extends events_1.EventEmitter {
899
1163
  // the next 32MB (if any) starts fresh at the top of the ladder.
900
1164
  this.tooLargeRecoveries.delete(mapKey);
901
1165
  this.tooLargeExhausted.delete(mapKey);
1166
+ // Safe mode (Epic #195, Phase 3): a healthy PTY turn resets the
1167
+ // consecutive-failure counter and lifts safe mode if it was active
1168
+ // (the interactive backend recovered). Headless successes don't
1169
+ // touch it — that's the fallback doing its job, not the PTY healing.
1170
+ if (proc.backend === 'pty-shell') {
1171
+ this.safeMode.recordSuccess(this.agentConfig.id);
1172
+ }
902
1173
  }
903
1174
  // When a menu was rendered to buttons this turn, the wrapper appends
904
1175
  // the same menu text to the turn's result — strip that suffix so it
@@ -941,6 +1212,11 @@ class AgentRunner extends events_1.EventEmitter {
941
1212
  else {
942
1213
  this.writeAutoForward(mapKey, text);
943
1214
  }
1215
+ // Assistant output reached the channel — mark the turn delivered so
1216
+ // a later recovery does not resend a message that was answered (C1).
1217
+ const lt = this.lastTurn.get(mapKey);
1218
+ if (lt)
1219
+ lt.delivered = true;
944
1220
  }
945
1221
  replyCalled = false; // reset for next turn
946
1222
  replyToolUseId = null;
@@ -1261,6 +1537,30 @@ class AgentRunner extends events_1.EventEmitter {
1261
1537
  }
1262
1538
  // Process will be re-spawned on next incoming message
1263
1539
  }
1540
+ /**
1541
+ * The 32MB-recovery rungs for a given healthy cap: the ladder sizes STRICTLY
1542
+ * below the cap, in descending order. Filtering by `< cap` (not `<=`) drops any
1543
+ * rung equal to or above the cap so a lowered cap never yields a recovery step
1544
+ * that re-injects the same (or more) history — every step actually shrinks.
1545
+ */
1546
+ recoveryRungs(configuredMax) {
1547
+ return TOO_LARGE_HISTORY_LADDER.filter(r => r < configuredMax);
1548
+ }
1549
+ /**
1550
+ * History re-injection cap for a spawn, given the configured healthy cap and how
1551
+ * many consecutive 32MB recoveries have happened on the session. recoveryCount 0
1552
+ * = healthy → the full configured cap. Each later recovery drops to the next
1553
+ * rung strictly below the cap; once those are exhausted it stays at 0 (no
1554
+ * history). Kept in sync with the exhaustion threshold in handleRequestTooLarge.
1555
+ */
1556
+ spawnHistoryLimit(configuredMax, recoveryCount) {
1557
+ if (recoveryCount <= 0)
1558
+ return configuredMax;
1559
+ const rungs = this.recoveryRungs(configuredMax);
1560
+ if (rungs.length === 0)
1561
+ return 0;
1562
+ return rungs[Math.min(recoveryCount - 1, rungs.length - 1)];
1563
+ }
1264
1564
  /**
1265
1565
  * Unified recovery for the recoverable "Request too large (max 32MB)" error.
1266
1566
  * Reached from two backends that surface the SAME error differently:
@@ -1270,21 +1570,26 @@ class AgentRunner extends events_1.EventEmitter {
1270
1570
  * (is_error + "Request too large (max"); the long-lived process otherwise
1271
1571
  * stays alive and rejects every subsequent turn forever (Bug B).
1272
1572
  *
1273
- * Each consecutive recovery shrinks the history re-injected on the next spawn
1274
- * (TOO_LARGE_HISTORY_LADDER: 50→40→30→20→10→0) so a pathological context drops
1275
- * under the 32MB ceiling. The respawn happens on the user's NEXT message (no
1276
- * auto-loop), and the counter resets on the next successful result. Once even a
1277
- * zero-history spawn still trips 32MB, stop escalating and ask the user to
1278
- * /clear rather than climb the ladder again.
1573
+ * Each consecutive recovery shrinks the history re-injected on the next spawn,
1574
+ * stepping down the TOO_LARGE_HISTORY_LADDER rungs strictly below the configured
1575
+ * cap (default 50 40→30→20→10→0), so a pathological context drops under the
1576
+ * 32MB ceiling. The respawn happens on the user's NEXT message (no auto-loop),
1577
+ * and the counter resets on the next successful result. Once even a zero-history
1578
+ * spawn still trips 32MB, stop escalating and ask the user to /clear rather than
1579
+ * climb the ladder again.
1279
1580
  */
1280
1581
  handleRequestTooLarge(mapKey, proc) {
1281
1582
  proc.setProcessing(false);
1583
+ // Recovery steps through the ladder rungs strictly below the configured cap;
1584
+ // the number of those rungs is how many shrink attempts exist before even the
1585
+ // smallest (0-history) spawn has been tried and still trips 32MB.
1586
+ const configuredMax = (0, process_1.resolveMaxHistoryMessages)(this.agentConfig.history?.maxHistoryMessages, this.gatewayConfig.gateway.history?.maxHistoryMessages);
1587
+ const stepCount = this.recoveryRungs(configuredMax).length; // shrink attempts available
1282
1588
  const count = (this.tooLargeRecoveries.get(mapKey) ?? 0) + 1;
1283
- const lastRung = TOO_LARGE_HISTORY_LADDER.length - 1; // index of the 0-history rung
1284
- if (count > lastRung) {
1285
- // Even the zero-history spawn tripped 32MB context can't shrink further.
1286
- // Pin the counter at the last rung and always surface the /clear next step.
1287
- this.tooLargeRecoveries.set(mapKey, lastRung);
1589
+ if (count > stepCount) {
1590
+ // Even the smallest rung tripped 32MB — context can't shrink further.
1591
+ // Pin the counter at the last step and always surface the /clear next step.
1592
+ this.tooLargeRecoveries.set(mapKey, stepCount);
1288
1593
  this.logger.error('Request too large persists with zero re-injected history', { mapKey, count });
1289
1594
  this.writeAutoForward(mapKey, '⚠️ ยังเกิน 32MB แม้จะล้าง context จนว่างแล้ว — พิมพ์ /clear เพื่อเริ่มเซสชันใหม่ หรือ /restart ค่ะ');
1290
1595
  // Restart ONCE to clear the wedged process the first time the ladder is
@@ -1297,7 +1602,7 @@ class AgentRunner extends events_1.EventEmitter {
1297
1602
  }
1298
1603
  this.tooLargeRecoveries.set(mapKey, count);
1299
1604
  this.logger.warn('Request too large (32MB) — restarting with reduced history', {
1300
- mapKey, attempt: count, nextHistoryLimit: TOO_LARGE_HISTORY_LADDER[count],
1605
+ mapKey, attempt: count, nextHistoryLimit: this.spawnHistoryLimit(configuredMax, count),
1301
1606
  });
1302
1607
  // Ordering matters and makes the notice delivery race-free: writeAutoForward
1303
1608
  // persists the `.forward` file synchronously HERE, before restartProcess()