@songsid/agend 2.1.0-beta.8 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +1 -0
  2. package/README.zh-TW.md +2 -1
  3. package/dist/access-path.js +3 -3
  4. package/dist/access-path.js.map +1 -1
  5. package/dist/agent-cli.js +1 -1
  6. package/dist/agent-cli.js.map +1 -1
  7. package/dist/agent-endpoint.d.ts +8 -0
  8. package/dist/agent-endpoint.js +36 -8
  9. package/dist/agent-endpoint.js.map +1 -1
  10. package/dist/backend/antigravity.d.ts +8 -0
  11. package/dist/backend/antigravity.js +46 -3
  12. package/dist/backend/antigravity.js.map +1 -1
  13. package/dist/backend/claude-code.d.ts +6 -0
  14. package/dist/backend/claude-code.js +20 -4
  15. package/dist/backend/claude-code.js.map +1 -1
  16. package/dist/backend/codex.d.ts +12 -1
  17. package/dist/backend/codex.js +55 -1
  18. package/dist/backend/codex.js.map +1 -1
  19. package/dist/backend/factory.js +4 -1
  20. package/dist/backend/factory.js.map +1 -1
  21. package/dist/backend/grok.d.ts +59 -0
  22. package/dist/backend/grok.js +329 -0
  23. package/dist/backend/grok.js.map +1 -0
  24. package/dist/backend/kiro.d.ts +6 -0
  25. package/dist/backend/kiro.js +38 -3
  26. package/dist/backend/kiro.js.map +1 -1
  27. package/dist/backend/opencode.d.ts +5 -0
  28. package/dist/backend/opencode.js +18 -0
  29. package/dist/backend/opencode.js.map +1 -1
  30. package/dist/backend/types.d.ts +59 -3
  31. package/dist/backend/types.js +15 -2
  32. package/dist/backend/types.js.map +1 -1
  33. package/dist/channel/adapters/discord.d.ts +20 -0
  34. package/dist/channel/adapters/discord.js +88 -11
  35. package/dist/channel/adapters/discord.js.map +1 -1
  36. package/dist/channel/adapters/telegram.js +4 -1
  37. package/dist/channel/adapters/telegram.js.map +1 -1
  38. package/dist/channel/mcp-tools.js +18 -2
  39. package/dist/channel/mcp-tools.js.map +1 -1
  40. package/dist/chat-export.js +5 -2
  41. package/dist/chat-export.js.map +1 -1
  42. package/dist/classic-channel-manager.d.ts +16 -1
  43. package/dist/classic-channel-manager.js +76 -4
  44. package/dist/classic-channel-manager.js.map +1 -1
  45. package/dist/cli.js +82 -16
  46. package/dist/cli.js.map +1 -1
  47. package/dist/config-validator.js +70 -1
  48. package/dist/config-validator.js.map +1 -1
  49. package/dist/config.d.ts +3 -1
  50. package/dist/config.js +14 -1
  51. package/dist/config.js.map +1 -1
  52. package/dist/daemon.d.ts +83 -5
  53. package/dist/daemon.js +556 -173
  54. package/dist/daemon.js.map +1 -1
  55. package/dist/daily-summary.js +7 -2
  56. package/dist/daily-summary.js.map +1 -1
  57. package/dist/fleet-context.d.ts +11 -0
  58. package/dist/fleet-manager.d.ts +129 -8
  59. package/dist/fleet-manager.js +1125 -142
  60. package/dist/fleet-manager.js.map +1 -1
  61. package/dist/fleet-system-prompt.js +1 -1
  62. package/dist/general-knowledge/skills/fleet-config/SKILL.md +19 -13
  63. package/dist/general-knowledge/skills/fleet-health/SKILL.md +30 -51
  64. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -1
  65. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +19 -13
  66. package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -5
  67. package/dist/general-knowledge/skills/scheduling/SKILL.md +41 -0
  68. package/dist/general-knowledge/skills/session-management/SKILL.md +29 -116
  69. package/dist/instance-lifecycle.d.ts +14 -1
  70. package/dist/instance-lifecycle.js +59 -20
  71. package/dist/instance-lifecycle.js.map +1 -1
  72. package/dist/locale.js +28 -2
  73. package/dist/locale.js.map +1 -1
  74. package/dist/logger.d.ts +6 -0
  75. package/dist/logger.js +23 -7
  76. package/dist/logger.js.map +1 -1
  77. package/dist/outbound-handlers.js +199 -11
  78. package/dist/outbound-handlers.js.map +1 -1
  79. package/dist/outbound-schemas.d.ts +44 -2
  80. package/dist/outbound-schemas.js +50 -3
  81. package/dist/outbound-schemas.js.map +1 -1
  82. package/dist/pause-marker.d.ts +5 -0
  83. package/dist/pause-marker.js +36 -0
  84. package/dist/pause-marker.js.map +1 -0
  85. package/dist/scheduler/db.d.ts +8 -0
  86. package/dist/scheduler/db.js +74 -5
  87. package/dist/scheduler/db.js.map +1 -1
  88. package/dist/scheduler/db.test.js +50 -0
  89. package/dist/scheduler/db.test.js.map +1 -1
  90. package/dist/scheduler/scheduler.d.ts +6 -0
  91. package/dist/scheduler/scheduler.js +99 -16
  92. package/dist/scheduler/scheduler.js.map +1 -1
  93. package/dist/scheduler/scheduler.test.js +131 -1
  94. package/dist/scheduler/scheduler.test.js.map +1 -1
  95. package/dist/scheduler/types.d.ts +9 -3
  96. package/dist/scheduler/types.js.map +1 -1
  97. package/dist/settings-api.d.ts +20 -2
  98. package/dist/settings-api.js +213 -9
  99. package/dist/settings-api.js.map +1 -1
  100. package/dist/setup-wizard.js +8 -0
  101. package/dist/setup-wizard.js.map +1 -1
  102. package/dist/tmux-control.d.ts +7 -0
  103. package/dist/tmux-control.js +22 -1
  104. package/dist/tmux-control.js.map +1 -1
  105. package/dist/tmux-manager.d.ts +1 -1
  106. package/dist/tmux-manager.js.map +1 -1
  107. package/dist/topic-commands.d.ts +14 -0
  108. package/dist/topic-commands.js +107 -15
  109. package/dist/topic-commands.js.map +1 -1
  110. package/dist/types.d.ts +29 -1
  111. package/dist/tz-utils.d.ts +26 -0
  112. package/dist/tz-utils.js +46 -0
  113. package/dist/tz-utils.js.map +1 -0
  114. package/dist/ui/dashboard.html +3 -3
  115. package/dist/ui/settings.html +453 -74
  116. package/dist/web-api.js +3 -1
  117. package/dist/web-api.js.map +1 -1
  118. package/dist/workflow-templates/default.md +0 -4
  119. package/package.json +2 -1
package/dist/daemon.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { join, dirname, basename, resolve } from "node:path";
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, chmodSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, chmodSync, renameSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
+ import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
6
7
  import { TmuxManager } from "./tmux-manager.js";
7
8
  import { TranscriptMonitor } from "./transcript-monitor.js";
8
9
  import { ContextGuardian } from "./context-guardian.js";
@@ -20,32 +21,71 @@ const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "sta
20
21
  const SCHEDULE_TOOLS = new Set(["create_schedule", "list_schedules", "update_schedule", "delete_schedule"]);
21
22
  const DECISION_TOOLS = new Set(["post_decision", "list_decisions", "update_decision"]);
22
23
  const TASK_TOOL = "task";
24
+ /** Point a resumed CLI at its one backend-native instruction source. */
25
+ export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
26
+ const source = binaryName === "codex" || binaryName === "grok"
27
+ ? "AGENTS.md"
28
+ : binaryName === "kiro-cli"
29
+ ? `.kiro/steering/agend-${instanceName}.md`
30
+ : binaryName === "agy"
31
+ ? ".agents/agents.md"
32
+ : binaryName === "gemini"
33
+ ? "GEMINI.md"
34
+ : join(instanceDir, "fleet-instructions.md");
35
+ return `[system] Your AgEnD instructions have been updated. Reload only ${source}; do not scan other instruction directories. Do not reply to this message.`;
36
+ }
23
37
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
24
- export const DEFAULT_STATE_POLL_INTERVAL_MS = 5_000;
25
- /** Headless idle timer used by the daemon and unit tests. */
38
+ export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
39
+ export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
40
+ /** @deprecated State detection is event-driven; this now aliases the safety sweep. */
41
+ export const DEFAULT_STATE_POLL_INTERVAL_MS = DEFAULT_STATE_SAFETY_SWEEP_MS;
42
+ const LAST_INBOUND_FILE = "last-inbound-at";
43
+ /** Read the last real channel inbound timestamp persisted across daemon restarts. */
44
+ export function readLastInboundAt(instanceDir, now = Date.now()) {
45
+ try {
46
+ const value = Number(readFileSync(join(instanceDir, LAST_INBOUND_FILE), "utf-8").trim());
47
+ return Number.isFinite(value) && value >= 0 && value <= now ? value : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Atomically persist the last real channel inbound timestamp. */
54
+ export function writeLastInboundAt(instanceDir, timestamp) {
55
+ mkdirSync(instanceDir, { recursive: true });
56
+ const target = join(instanceDir, LAST_INBOUND_FILE);
57
+ const temp = `${target}.${process.pid}.tmp`;
58
+ writeFileSync(temp, String(timestamp));
59
+ renameSync(temp, target);
60
+ }
61
+ /** Headless inactivity timer used by the daemon and unit tests. */
26
62
  export class AutoPauseController {
27
63
  thresholdMs;
28
- idleSince = null;
64
+ lastActivityAt;
29
65
  pausedAt = null;
30
- constructor(thresholdMs) {
66
+ constructor(thresholdMs, lastActivityAt = Date.now()) {
31
67
  this.thresholdMs = thresholdMs;
68
+ this.lastActivityAt = lastActivityAt;
69
+ }
70
+ recordActivity(now = Date.now()) {
71
+ this.lastActivityAt = now;
32
72
  }
33
73
  observe(state, now = Date.now()) {
34
74
  if (this.pausedAt !== null || this.thresholdMs <= 0)
35
75
  return false;
36
- if (state !== "idle") {
37
- this.idleSince = null;
38
- return false;
39
- }
40
- this.idleSince ??= now;
41
- return now - this.idleSince >= this.thresholdMs;
76
+ // Inactivity is based on the last user inbound, not on how long this daemon
77
+ // has observed the idle pane. This preserves pause eligibility across fleet
78
+ // restarts while still ensuring an actively working instance is never paused.
79
+ return state === "idle" && now - this.lastActivityAt >= this.thresholdMs;
42
80
  }
43
81
  markPaused(now = Date.now()) {
44
82
  this.pausedAt = now;
45
83
  }
46
- markAwake() {
84
+ markAwake(now = Date.now()) {
47
85
  this.pausedAt = null;
48
- this.idleSince = null;
86
+ // A deliberate wake is activity in its own right and must provide a fresh
87
+ // window for the wake-before-deliver facade to enqueue its inbound message.
88
+ this.lastActivityAt = now;
49
89
  }
50
90
  async wakeOnDeliver(wake) {
51
91
  if (this.pausedAt === null)
@@ -105,6 +145,16 @@ export class PaneStateMachine {
105
145
  }
106
146
  return this.snapshot(now);
107
147
  }
148
+ /** Record pane motion from tmux control mode without capturing pane content. */
149
+ recordOutput(now = Date.now()) {
150
+ this.lastPaneChangeAt = now;
151
+ this.lastObservedAt = now;
152
+ if (this.currentState !== "working") {
153
+ this.currentState = "working";
154
+ this.stateChangedAt = now;
155
+ }
156
+ return this.snapshot(now);
157
+ }
108
158
  snapshot(now = Date.now()) {
109
159
  return {
110
160
  state: this.currentState,
@@ -114,6 +164,79 @@ export class PaneStateMachine {
114
164
  };
115
165
  }
116
166
  }
167
+ /** Tracks whether an inbound arrived after the most recent confirmed idle prompt. */
168
+ export class PendingWorkTracker {
169
+ lastInboundAt = 0;
170
+ lastIdleAt;
171
+ sequence = 0;
172
+ lastInboundOrder = 0;
173
+ lastIdleOrder = 0;
174
+ constructor(now = Date.now()) {
175
+ this.lastIdleAt = now;
176
+ }
177
+ recordInbound(now = Date.now()) {
178
+ this.lastInboundAt = now;
179
+ this.lastInboundOrder = ++this.sequence;
180
+ }
181
+ recordIdle(now = Date.now()) {
182
+ // An async pane poll can finish after a newer inbound. Do not let its stale
183
+ // observation clear work which had not arrived when the pane was captured.
184
+ if (now < this.lastInboundAt)
185
+ return;
186
+ this.lastIdleAt = now;
187
+ this.lastIdleOrder = ++this.sequence;
188
+ }
189
+ hasPendingWork() {
190
+ return this.lastInboundOrder > this.lastIdleOrder;
191
+ }
192
+ }
193
+ const NORMAL_ENTER_SETTLE_MS = 500;
194
+ const FIRST_ENTER_SETTLE_MS = 1_750;
195
+ const FIRST_DELIVERY_WINDOW_MS = 5_000;
196
+ /**
197
+ * One-shot timing gate for the first paste after a CLI reaches its ready
198
+ * prompt. Recent tmux versions expose a short interval where the prompt is
199
+ * visible but the TUI can still swallow Enter while completing its redraw.
200
+ */
201
+ export class FirstDeliveryDelay {
202
+ readyAt = 0;
203
+ pending = false;
204
+ recordReady(now = Date.now()) {
205
+ this.readyAt = now;
206
+ this.pending = true;
207
+ }
208
+ consume(now = Date.now()) {
209
+ if (!this.pending)
210
+ return NORMAL_ENTER_SETTLE_MS;
211
+ this.pending = false;
212
+ return this.readyAt > 0 && now - this.readyAt < FIRST_DELIVERY_WINDOW_MS
213
+ ? FIRST_ENTER_SETTLE_MS
214
+ : NORMAL_ENTER_SETTLE_MS;
215
+ }
216
+ }
217
+ /** Redact likely credentials and control sequences before pane text reaches logs. */
218
+ export function sanitizePaneTail(pane, lineCount = 5) {
219
+ const secretAssignment = /\b(token|secret|password|passwd|api[_-]?key|authorization)\b\s*[:=]\s*\S+/gi;
220
+ const bearer = /\bBearer\s+\S+/gi;
221
+ const knownToken = /\b(?:sk-[A-Za-z0-9_-]+|ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+|AKIA[A-Z0-9]{16})\b/g;
222
+ const jwt = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
223
+ const opaqueSecret = /\b[A-Za-z0-9_+/=-]{32,}\b/g;
224
+ const lines = pane
225
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
226
+ .split(/\r?\n/);
227
+ while (lines.length > 0 && /^\s*$/.test(lines[lines.length - 1]))
228
+ lines.pop();
229
+ return lines
230
+ .slice(-lineCount)
231
+ .map(line => line
232
+ .replace(bearer, "Bearer [REDACTED]")
233
+ .replace(secretAssignment, "$1=[REDACTED]")
234
+ .replace(knownToken, "[REDACTED]")
235
+ .replace(jwt, "[REDACTED]")
236
+ .replace(opaqueSecret, "[REDACTED]")
237
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
238
+ .slice(0, 200));
239
+ }
117
240
  export class Daemon extends EventEmitter {
118
241
  name;
119
242
  config;
@@ -134,6 +257,7 @@ export class Daemon extends EventEmitter {
134
257
  // Track chatId/threadId from inbound messages for automatic outbound routing
135
258
  lastChatId;
136
259
  lastThreadId;
260
+ lastAdapterId;
137
261
  // Pending ack: react 🫡 on first transcript activity after receiving a message
138
262
  pendingAckMessage = null;
139
263
  // Tool status tracking for channel adapter
@@ -152,6 +276,8 @@ export class Daemon extends EventEmitter {
152
276
  lastSpawnAt = 0;
153
277
  crashTimestamps = [];
154
278
  healthCheckPaused = false;
279
+ /** CLI pane availability, independent from the daemon process and tri-state. */
280
+ processStatus = "running";
155
281
  spawning = false;
156
282
  skipResume = false;
157
283
  backgroundSessionRecoveryAttempted = false;
@@ -163,7 +289,19 @@ export class Daemon extends EventEmitter {
163
289
  hangDetector = null;
164
290
  instanceState = "idle";
165
291
  instanceStateMachine = null;
292
+ pendingWork = new PendingWorkTracker();
293
+ /** Fallback safety sweep used only when no shared tmux control client exists. */
166
294
  instanceStateMonitorTimer = null;
295
+ instanceStateIdleTimer = null;
296
+ instanceStateStuckTimer = null;
297
+ instanceStateOutputListener = null;
298
+ instanceStateOutputEventName = null;
299
+ instanceStateSafetyListener = null;
300
+ instanceStateLastOutputAt = 0;
301
+ instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
302
+ instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
303
+ instanceStateReadyPattern = null;
304
+ instanceStateMonitorActive = false;
167
305
  statePollInFlight = false;
168
306
  autoPauseController;
169
307
  pauseRequested = false;
@@ -186,12 +324,15 @@ export class Daemon extends EventEmitter {
186
324
  warmupNeeded = false;
187
325
  lastBuiltInstructions = "";
188
326
  pasteQueueDepth = 0;
327
+ firstDeliveryDelay = new FirstDeliveryDelay();
189
328
  // PTY error pattern monitoring
190
329
  errorMonitorTimer = null;
191
330
  /** Prevent in-flight monitor callbacks from re-arming after a pause. */
192
331
  runtimeMonitorsFrozen = false;
193
332
  errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
194
333
  errorDetectedAt = 0;
334
+ errorRecoveryDeadlineAt = 0;
335
+ activeErrorPatternKey = null;
195
336
  /** Whether this instance is in an abnormal error state (auto-pause is normal). */
196
337
  get isErrorState() {
197
338
  return this.errorWaitingForRecovery || (this.healthCheckPaused && !this.isPaused) || Daemon.tmuxServerPaused;
@@ -203,11 +344,16 @@ export class Daemon extends EventEmitter {
203
344
  get isCrashLoop() {
204
345
  return this.crashCount >= 3;
205
346
  }
347
+ /** The most recent error type detected by the error monitor (e.g. "rate_limit", "auth_error"). */
348
+ get lastErrorType() {
349
+ return this.lastDetectedErrorType;
350
+ }
206
351
  lastFailoverAt = 0; // cooldown: prevent repeated failover triggers
207
352
  static FAILOVER_COOLDOWN_MS = 5 * 60_000; // 5 minutes
208
- lastErrorNotifiedAt = new Map(); // per-type cooldown for all actions
353
+ lastErrorNotifiedAt = new Map(); // per-pattern cooldown for all actions
209
354
  static ERROR_COOLDOWN_MS = 5 * 60_000;
210
- // Count-based dedup: per error type, the number of pattern occurrences already
355
+ static ERROR_RECOVERY_TIMEOUT_MS = 5 * 60_000;
356
+ // Count-based dedup: per error pattern, the number of occurrences already
211
357
  // accounted for. A scan counts occurrences across the WHOLE pane; count > this
212
358
  // baseline means a NEW error appeared. On recovery we absorb the current count
213
359
  // (not reset to 0) so the just-handled error doesn't re-trigger, while a later
@@ -216,6 +362,15 @@ export class Daemon extends EventEmitter {
216
362
  // still registers as new (prevents the old hash-dedup's permanent suppression).
217
363
  lastErrorCount = new Map();
218
364
  lastDetectedErrorType = null;
365
+ static errorPatternKey(ep) {
366
+ return `${ep.type}:${ep.pattern.source}`;
367
+ }
368
+ clearErrorRecoveryGate() {
369
+ this.errorWaitingForRecovery = false;
370
+ this.errorDetectedAt = 0;
371
+ this.errorRecoveryDeadlineAt = 0;
372
+ this.activeErrorPatternKey = null;
373
+ }
219
374
  constructor(name, config, instanceDir, topicMode = false, backend, controlClient, rootLogger) {
220
375
  super();
221
376
  this.name = name;
@@ -230,17 +385,14 @@ export class Daemon extends EventEmitter {
230
385
  this.tmuxSessionName = getTmuxSession();
231
386
  this.messageBus = new MessageBus();
232
387
  this.messageBus.setLogger(this.logger);
233
- const autoPauseMinutes = typeof config.auto_pause_after === "number" ? config.auto_pause_after : 0; // default: disabled
234
- this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000);
388
+ // General is the dispatcher it must stay warm to route messages, so it is
389
+ // never auto-paused regardless of auto_pause_after.
390
+ const isGeneral = config.general_topic === true || name === "general";
391
+ const autoPauseMinutes = isGeneral ? 0 : (typeof config.auto_pause_after === "number" ? config.auto_pause_after : 0); // default: disabled
392
+ this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000, readLastInboundAt(instanceDir) ?? Date.now());
235
393
  }
236
394
  async start() {
237
395
  mkdirSync(this.instanceDir, { recursive: true });
238
- // A daemon restart performs a normal CLI start, so any persisted auto-pause
239
- // marker from the previous daemon is stale.
240
- try {
241
- unlinkSync(join(this.instanceDir, "paused-state.json"));
242
- }
243
- catch { }
244
396
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
245
397
  this.logger.info(`Starting ${this.name}`);
246
398
  // P1: Read crash state from previous run — skip resume if last run was a crash loop
@@ -266,6 +418,7 @@ export class Daemon extends EventEmitter {
266
418
  if (saved.chatId) {
267
419
  this.lastChatId = saved.chatId;
268
420
  this.lastThreadId = saved.threadId || undefined;
421
+ this.lastAdapterId = saved.adapterId || undefined;
269
422
  }
270
423
  }
271
424
  }
@@ -383,6 +536,7 @@ export class Daemon extends EventEmitter {
383
536
  instanceName: this.name,
384
537
  ...snapshot,
385
538
  state: this.isPaused ? "paused" : snapshot.state,
539
+ processStatus: this.processStatus,
386
540
  pausedAt: this.lastPausedAt,
387
541
  });
388
542
  }
@@ -446,7 +600,7 @@ export class Daemon extends EventEmitter {
446
600
  else {
447
601
  await new Promise(r => setTimeout(r, 5000));
448
602
  }
449
- await this.tmux?.pasteText("[system] Your instructions/steering files have been updated. Re-read your steering files. Do not reply to this message.");
603
+ await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
450
604
  // Record the value the agent has now been told about so the next
451
605
  // unchanged restart skips the reload.
452
606
  try {
@@ -527,8 +681,10 @@ export class Daemon extends EventEmitter {
527
681
  if (this.runtimeMonitorsFrozen || this.healthCheckTimer)
528
682
  return;
529
683
  const { max_retries, backoff, reset_after } = this.config.restart_policy;
530
- if (max_retries <= 0)
531
- return; // restart disabled
684
+ // Liveness monitoring remains active when automatic restart is disabled;
685
+ // otherwise a dead pane leaves its last idle snapshot cached forever.
686
+ const configuredInterval = this.config.restart_policy.health_check_interval_ms ?? 30_000;
687
+ const healthCheckIntervalMs = Math.min(60_000, configuredInterval > 0 ? configuredInterval : 30_000);
532
688
  const scheduleNext = () => {
533
689
  if (this.runtimeMonitorsFrozen || this.healthCheckTimer)
534
690
  return;
@@ -583,11 +739,13 @@ export class Daemon extends EventEmitter {
583
739
  this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
584
740
  // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
585
741
  if (paneStatus && exitCode === 0) {
742
+ this.setProcessStatus("stopped");
586
743
  this.logger.info("CLI exited normally (code 0) — pausing health check");
587
744
  await this.tmux.killWindow();
588
745
  this.healthCheckPaused = true;
589
746
  return;
590
747
  }
748
+ this.setProcessStatus("crashed");
591
749
  // Distinguish tmux server crash from single window crash.
592
750
  // nullReason records *why* getPaneStatus returned null (for diagnosing
593
751
  // whether this was a real window loss or a transient query failure).
@@ -665,6 +823,7 @@ export class Daemon extends EventEmitter {
665
823
  await new Promise(r => setTimeout(r, 2_000));
666
824
  try {
667
825
  await this.spawnClaudeWindow();
826
+ this.setProcessStatus("running");
668
827
  this.logger.info("Recovered from background session conflict");
669
828
  this.emit("crash_respawn", this.name);
670
829
  }
@@ -690,6 +849,11 @@ export class Daemon extends EventEmitter {
690
849
  }
691
850
  // Append to crash history
692
851
  this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: nullReason });
852
+ if (max_retries <= 0) {
853
+ this.healthCheckPaused = true;
854
+ this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
855
+ return;
856
+ }
693
857
  // Detect rapid crash: sliding window — 3+ crashes in 5 minutes
694
858
  this.crashTimestamps.push(Date.now());
695
859
  const crashWindowMs = 5 * 60_000;
@@ -765,6 +929,7 @@ export class Daemon extends EventEmitter {
765
929
  }
766
930
  catch { /* may not exist */ }
767
931
  }
932
+ this.setProcessStatus("running");
768
933
  this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
769
934
  this.emit("crash_respawn", this.name);
770
935
  }
@@ -772,10 +937,39 @@ export class Daemon extends EventEmitter {
772
937
  this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
773
938
  }
774
939
  scheduleNext();
775
- }, this.config.restart_policy.health_check_interval_ms ?? 30_000);
940
+ }, healthCheckIntervalMs);
776
941
  };
777
942
  scheduleNext();
778
943
  }
944
+ /**
945
+ * Publish pane-process availability separately from idle/working/stuck.
946
+ * A dead remain-on-exit pane still contains the old ready prompt, so allowing
947
+ * the pane monitor to capture it would re-create a false idle state.
948
+ */
949
+ setProcessStatus(status) {
950
+ if (this.processStatus === status)
951
+ return;
952
+ this.processStatus = status;
953
+ if (status === "running") {
954
+ // A successful crash respawn is a new pane generation. It is ready by
955
+ // this point, so an error gate inherited from the dead pane must not keep
956
+ // the new process from being monitored.
957
+ this.clearErrorRecoveryGate();
958
+ this.startInstanceStateMonitor();
959
+ }
960
+ else {
961
+ this.stopInstanceStateMonitor();
962
+ // Force the next ready capture to emit a fresh transition after respawn.
963
+ this.instanceState = "working";
964
+ }
965
+ this.ipcServer?.broadcast({
966
+ type: "instance_process_state",
967
+ instanceName: this.name,
968
+ status,
969
+ observedAt: Date.now(),
970
+ });
971
+ this.emit("instance_process_state", { name: this.name, status });
972
+ }
779
973
  /**
780
974
  * Periodically scan PTY output for backend-defined error patterns.
781
975
  *
@@ -805,13 +999,6 @@ export class Daemon extends EventEmitter {
805
999
  if (!alive)
806
1000
  return;
807
1001
  const pane = await this.tmux.capturePane();
808
- // Count occurrences of a pattern across the WHOLE pane (not just the text
809
- // after the last ready prompt — a fast recovery can put "ready" AFTER the
810
- // error line, which the old scanText approach missed).
811
- const countMatches = (pattern) => {
812
- const g = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g");
813
- return (pane.match(g) || []).length;
814
- };
815
1002
  // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch)
816
1003
  for (const dialog of dialogs) {
817
1004
  if (!dialog.pattern.test(pane))
@@ -829,73 +1016,96 @@ export class Daemon extends EventEmitter {
829
1016
  }
830
1017
  return; // Dialog dismissed, skip error checks this cycle
831
1018
  }
832
- // State: waiting for recovery — check if agent is back to ready
833
- if (this.errorWaitingForRecovery) {
834
- if (readyPattern.test(pane)) {
835
- const downtime = Math.round((Date.now() - this.errorDetectedAt) / 1000);
836
- // Absorb the current count of the just-handled error type so it isn't
837
- // re-triggered; a later NEW occurrence pushes the count higher again.
838
- if (this.lastDetectedErrorType) {
839
- const ep = patterns.find(p => p.type === this.lastDetectedErrorType);
840
- if (ep)
841
- this.lastErrorCount.set(ep.type, countMatches(ep.pattern));
842
- }
843
- this.errorWaitingForRecovery = false;
844
- this.errorDetectedAt = 0;
845
- this.logger.info({ downtime_s: downtime }, "PTY error recovered — agent is ready again");
846
- this.emit("pty_recovered", { name: this.name, downtime_s: downtime });
847
- }
848
- return; // Don't check for errors while waiting for recovery
849
- }
850
- // State: monitoring — count-based new-error detection over the full pane
851
- for (const ep of patterns) {
852
- const count = countMatches(ep.pattern);
853
- const seen = this.lastErrorCount.get(ep.type) ?? 0;
854
- if (count <= seen) {
855
- // Occurrences scrolled out of the capture buffer → lower the baseline
856
- // so a future re-occurrence still counts as new (no permanent suppress).
857
- if (count < seen)
858
- this.lastErrorCount.set(ep.type, count);
859
- continue;
860
- }
861
- // count > seen → a NEW occurrence of this error appeared.
862
- // Cooldown: 2nd-layer guard so the same type isn't re-notified within
863
- // the window. Leave the count unconsumed so it fires once cooldown ends.
864
- if (!ep.skipCooldown) {
865
- const lastNotified = this.lastErrorNotifiedAt.get(ep.type) ?? 0;
866
- if (Date.now() - lastNotified < Daemon.ERROR_COOLDOWN_MS) {
867
- this.logger.debug({ errorType: ep.type }, "PTY error suppressed (cooldown active)");
868
- break;
869
- }
870
- }
871
- if (ep.action === "failover" && Date.now() - this.lastFailoverAt < Daemon.FAILOVER_COOLDOWN_MS) {
872
- this.logger.debug({ errorType: ep.type }, "PTY error suppressed (failover cooldown active)");
873
- break;
874
- }
875
- this.lastErrorCount.set(ep.type, count);
876
- // skipRecoveryWait: this error self-recovers (e.g. a timeout — Kiro is
877
- // back at its prompt immediately). Its ready-pattern only matches the
878
- // startup banner, so entering "waiting for recovery" would never clear
879
- // and would block ALL future error detection. Just absorb the baseline
880
- // (done above) and keep monitoring so the next occurrence still fires.
881
- if (!ep.skipRecoveryWait) {
882
- this.errorWaitingForRecovery = true;
883
- this.errorDetectedAt = Date.now();
884
- this.lastDetectedErrorType = ep.type;
885
- }
886
- this.lastErrorNotifiedAt.set(ep.type, Date.now());
887
- if (ep.action === "failover")
888
- this.lastFailoverAt = Date.now();
889
- this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${ep.message}`);
890
- this.emit("pty_error", { name: this.name, ...ep });
891
- break; // Only handle first new error per scan
892
- }
1019
+ this.evaluateErrorPatterns(pane, patterns, readyPattern);
893
1020
  }
894
1021
  catch {
895
1022
  // capturePane can fail if window is transitioning — ignore
896
1023
  }
897
1024
  }, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
898
1025
  }
1026
+ /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
1027
+ evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now()) {
1028
+ // Count occurrences across the WHOLE pane (not just text after the last
1029
+ // ready prompt). Clone with `g` so stateful backend regexes cannot leak
1030
+ // lastIndex between monitor cycles.
1031
+ const countMatches = (pattern) => {
1032
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
1033
+ return (pane.match(new RegExp(pattern.source, flags)) || []).length;
1034
+ };
1035
+ // State: waiting for recovery. A missing/outdated ready pattern must not
1036
+ // suppress every future error forever, so the gate has a hard deadline.
1037
+ if (this.errorWaitingForRecovery) {
1038
+ if (readyPattern.test(pane)) {
1039
+ const downtime = Math.round((now - this.errorDetectedAt) / 1000);
1040
+ const active = patterns.find(ep => Daemon.errorPatternKey(ep) === this.activeErrorPatternKey);
1041
+ if (active) {
1042
+ this.lastErrorCount.set(Daemon.errorPatternKey(active), countMatches(active.pattern));
1043
+ }
1044
+ this.clearErrorRecoveryGate();
1045
+ this.logger.info({ downtime_s: downtime }, "PTY error recovered — agent is ready again");
1046
+ this.emit("pty_recovered", { name: this.name, downtime_s: downtime });
1047
+ return;
1048
+ }
1049
+ if (now <= this.errorRecoveryDeadlineAt)
1050
+ return;
1051
+ // Re-arm the active occurrence as a reminder. Without lowering its
1052
+ // baseline, clearing the gate alone would still leave count === seen and
1053
+ // could never satisfy the promised post-timeout re-notification.
1054
+ const active = patterns.find(ep => Daemon.errorPatternKey(ep) === this.activeErrorPatternKey);
1055
+ if (active) {
1056
+ const key = Daemon.errorPatternKey(active);
1057
+ const count = countMatches(active.pattern);
1058
+ if (count > 0)
1059
+ this.lastErrorCount.set(key, count - 1);
1060
+ }
1061
+ this.logger.warn({ errorType: this.lastDetectedErrorType }, "PTY error recovery deadline expired — resuming error scan");
1062
+ this.clearErrorRecoveryGate();
1063
+ }
1064
+ // State: monitoring — count-based new-error detection over the full pane.
1065
+ for (const ep of patterns) {
1066
+ const key = Daemon.errorPatternKey(ep);
1067
+ const count = countMatches(ep.pattern);
1068
+ const seen = this.lastErrorCount.get(key) ?? 0;
1069
+ if (count <= seen) {
1070
+ // Occurrences scrolled out of the capture buffer → lower the baseline
1071
+ // so a future re-occurrence still counts as new (no permanent suppress).
1072
+ if (count < seen)
1073
+ this.lastErrorCount.set(key, count);
1074
+ continue;
1075
+ }
1076
+ // count > seen → a NEW occurrence of this exact pattern appeared.
1077
+ // Leave a cooldown-suppressed count unconsumed so it can fire once the
1078
+ // cooldown expires, but continue scanning unrelated patterns this cycle.
1079
+ if (!ep.skipCooldown) {
1080
+ const lastNotified = this.lastErrorNotifiedAt.get(key) ?? 0;
1081
+ if (now - lastNotified < Daemon.ERROR_COOLDOWN_MS) {
1082
+ this.logger.debug({ errorType: ep.type }, "PTY error suppressed (cooldown active)");
1083
+ continue;
1084
+ }
1085
+ }
1086
+ if (ep.action === "failover" && now - this.lastFailoverAt < Daemon.FAILOVER_COOLDOWN_MS) {
1087
+ this.logger.debug({ errorType: ep.type }, "PTY error suppressed (failover cooldown active)");
1088
+ continue;
1089
+ }
1090
+ this.lastErrorCount.set(key, count);
1091
+ // skipRecoveryWait: this error self-recovers (e.g. a timeout — Kiro is
1092
+ // back at its prompt immediately). Keep monitoring so the next occurrence
1093
+ // can fire without waiting for a possibly startup-only ready pattern.
1094
+ if (!ep.skipRecoveryWait) {
1095
+ this.errorWaitingForRecovery = true;
1096
+ this.errorDetectedAt = now;
1097
+ this.errorRecoveryDeadlineAt = now + Daemon.ERROR_RECOVERY_TIMEOUT_MS;
1098
+ this.activeErrorPatternKey = key;
1099
+ this.lastDetectedErrorType = ep.type;
1100
+ }
1101
+ this.lastErrorNotifiedAt.set(key, now);
1102
+ if (ep.action === "failover")
1103
+ this.lastFailoverAt = now;
1104
+ this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${ep.message}`);
1105
+ this.emit("pty_error", { name: this.name, ...ep });
1106
+ break; // Only handle first unsuppressed new error per scan
1107
+ }
1108
+ }
899
1109
  /**
900
1110
  * Interrupt the CLI's current generation (cancel button / `/cancel`).
901
1111
  * Direct tmux key event (not a paste) so it registers as the interrupt key.
@@ -925,12 +1135,19 @@ export class Daemon extends EventEmitter {
925
1135
  this.healthCheckPaused = true;
926
1136
  let killed = false;
927
1137
  const quitCmd = this.backend?.getQuitCommand();
1138
+ const quitKey = this.backend?.getQuitKey?.();
928
1139
  if (quitCmd) {
929
1140
  await this.tmux.sendKeys(quitCmd);
930
1141
  // Delay before Enter to prevent tmux server race when multiple
931
1142
  // instances stop in parallel (same pattern as pasteText).
932
1143
  await new Promise(r => setTimeout(r, 150));
933
1144
  await this.tmux.sendSpecialKey("Enter");
1145
+ }
1146
+ else if (quitKey) {
1147
+ // Some CLIs quit via a key chord (e.g. grok Ctrl+Q), not a typed command.
1148
+ await this.tmux.sendSpecialKey(quitKey);
1149
+ }
1150
+ if (quitCmd || quitKey) {
934
1151
  // Wait up to 3s for graceful exit, polling every 200ms. A healthy CLI
935
1152
  // exits within ~1s; a longer wait just delays the force-kill fallback.
936
1153
  for (let i = 0; i < 15; i++) {
@@ -972,10 +1189,6 @@ export class Daemon extends EventEmitter {
972
1189
  catch (e) {
973
1190
  this.logger.debug({ err: e }, "Failed to remove PID file");
974
1191
  }
975
- try {
976
- unlinkSync(join(this.instanceDir, "paused-state.json"));
977
- }
978
- catch { }
979
1192
  }
980
1193
  getHangDetector() {
981
1194
  return this.hangDetector;
@@ -1011,10 +1224,17 @@ export class Daemon extends EventEmitter {
1011
1224
  try {
1012
1225
  this.saveSessionId();
1013
1226
  const quitCmd = this.backend?.getQuitCommand();
1014
- if (quitCmd && this.tmux) {
1015
- await this.tmux.sendKeys(quitCmd);
1016
- await new Promise(r => setTimeout(r, 150));
1017
- await this.tmux.sendSpecialKey("Enter");
1227
+ const quitKey = this.backend?.getQuitKey?.();
1228
+ if (this.tmux) {
1229
+ if (quitCmd) {
1230
+ await this.tmux.sendKeys(quitCmd);
1231
+ await new Promise(r => setTimeout(r, 150));
1232
+ await this.tmux.sendSpecialKey("Enter");
1233
+ }
1234
+ else if (quitKey) {
1235
+ // Key-chord quit (e.g. grok Ctrl+Q) — no typed command to send.
1236
+ await this.tmux.sendSpecialKey(quitKey);
1237
+ }
1018
1238
  }
1019
1239
  let exited = false;
1020
1240
  for (let i = 0; i < 15; i++) {
@@ -1040,9 +1260,7 @@ export class Daemon extends EventEmitter {
1040
1260
  }
1041
1261
  this.pauseWakeState = "paused";
1042
1262
  this.autoPauseController.markPaused();
1043
- writeFileSync(join(this.instanceDir, "paused-state.json"), JSON.stringify({
1044
- paused_at: this.lastPausedAt,
1045
- }));
1263
+ writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now());
1046
1264
  this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
1047
1265
  this.ipcServer?.broadcast({
1048
1266
  type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
@@ -1101,10 +1319,10 @@ export class Daemon extends EventEmitter {
1101
1319
  this.pauseWakeState = "active";
1102
1320
  this.healthCheckPaused = false;
1103
1321
  this.pauseRequested = false;
1104
- try {
1105
- unlinkSync(join(this.instanceDir, "paused-state.json"));
1106
- }
1107
- catch { }
1322
+ // trySpawn resolved only after the new CLI reached its ready prompt.
1323
+ // Discard any recovery gate retained while monitors were frozen.
1324
+ this.clearErrorRecoveryGate();
1325
+ clearPausedMarker(this.instanceDir);
1108
1326
  this.transcriptMonitor?.resetOffset();
1109
1327
  this.resumeRuntimeMonitors();
1110
1328
  this.logger.info("Instance auto-woke");
@@ -1125,62 +1343,201 @@ export class Daemon extends EventEmitter {
1125
1343
  this.pauseWakeTransition = null;
1126
1344
  }
1127
1345
  }
1346
+ applyInstanceStateSnapshot(snapshot, pane) {
1347
+ const previous = this.instanceState;
1348
+ this.instanceState = snapshot.state;
1349
+ // Only a transition back to idle completes pending work. Repeated idle
1350
+ // observations between enqueue and paste must not clear a newer inbound.
1351
+ if (snapshot.state === "idle" && previous !== "idle") {
1352
+ this.pendingWork.recordIdle(snapshot.observedAt);
1353
+ }
1354
+ if (snapshot.state !== previous) {
1355
+ this.logger.info({
1356
+ previousState: previous,
1357
+ state: snapshot.state,
1358
+ unchangedForMs: snapshot.unchangedForMs,
1359
+ }, "Instance execution state changed");
1360
+ this.emit("instance_state", { name: this.name, ...snapshot });
1361
+ this.ipcServer?.broadcast({ type: "instance_state", instanceName: this.name, ...snapshot });
1362
+ if (snapshot.state === "stuck" && pane && this.instanceStateReadyPattern) {
1363
+ this.handleStuckTransition(pane, snapshot, this.instanceStateReadyPattern);
1364
+ }
1365
+ }
1366
+ if (snapshot.state !== "idle")
1367
+ this.pauseRequested = false;
1368
+ if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)) {
1369
+ this.pauseRequested = true;
1370
+ this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
1371
+ }
1372
+ }
1373
+ clearInstanceStateIdleTimer() {
1374
+ if (this.instanceStateIdleTimer)
1375
+ clearTimeout(this.instanceStateIdleTimer);
1376
+ this.instanceStateIdleTimer = null;
1377
+ }
1378
+ clearInstanceStateStuckTimer() {
1379
+ if (this.instanceStateStuckTimer)
1380
+ clearTimeout(this.instanceStateStuckTimer);
1381
+ this.instanceStateStuckTimer = null;
1382
+ }
1383
+ scheduleInstanceStateStuckDeadline(changeAt) {
1384
+ // Keep one deadline timer per instance. High-volume TUIs can emit hundreds
1385
+ // of %output records per second; replacing a long timeout for every chunk
1386
+ // would trade tmux subprocess CPU for timer churn.
1387
+ if (this.instanceStateStuckTimer)
1388
+ return;
1389
+ const delay = Math.max(0, changeAt + this.instanceStateStuckTimeoutMs - Date.now());
1390
+ this.instanceStateStuckTimer = setTimeout(() => {
1391
+ this.instanceStateStuckTimer = null;
1392
+ const latestChangeAt = this.instanceStateLastOutputAt || changeAt;
1393
+ if (latestChangeAt + this.instanceStateStuckTimeoutMs > Date.now()) {
1394
+ this.scheduleInstanceStateStuckDeadline(latestChangeAt);
1395
+ return;
1396
+ }
1397
+ void this.captureAndEvaluateInstanceState("stuck_deadline", this.instanceStateLastOutputAt);
1398
+ }, delay);
1399
+ }
1400
+ scheduleInstanceStateIdleCapture() {
1401
+ if (this.instanceStateIdleTimer)
1402
+ return;
1403
+ const expectedOutputAt = this.instanceStateLastOutputAt;
1404
+ const delay = Math.max(0, expectedOutputAt + this.instanceStateIdleDebounceMs - Date.now());
1405
+ this.instanceStateIdleTimer = setTimeout(() => {
1406
+ this.instanceStateIdleTimer = null;
1407
+ const latestOutputAt = this.instanceStateLastOutputAt;
1408
+ if (latestOutputAt > expectedOutputAt
1409
+ && latestOutputAt + this.instanceStateIdleDebounceMs > Date.now()) {
1410
+ this.scheduleInstanceStateIdleCapture();
1411
+ return;
1412
+ }
1413
+ void this.captureAndEvaluateInstanceState("idle_debounce", latestOutputAt);
1414
+ }, delay);
1415
+ }
1416
+ async captureAndEvaluateInstanceState(reason, expectedOutputAt = 0) {
1417
+ if (!this.instanceStateMonitorActive || this.runtimeMonitorsFrozen || !this.tmux || !this.instanceStateMachine || this.spawning)
1418
+ return;
1419
+ if (this.statePollInFlight) {
1420
+ if (reason === "idle_debounce")
1421
+ this.scheduleInstanceStateIdleCapture();
1422
+ return;
1423
+ }
1424
+ this.statePollInFlight = true;
1425
+ const captureStartedAt = Date.now();
1426
+ try {
1427
+ const pane = await this.tmux.capturePane();
1428
+ // Output received while capture-pane was in flight makes this snapshot
1429
+ // stale. Its output handler has already armed a new debounce.
1430
+ if ((expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
1431
+ || (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt))
1432
+ return;
1433
+ const observedChangeAt = expectedOutputAt || captureStartedAt;
1434
+ this.instanceStateMachine.observe(pane, observedChangeAt);
1435
+ const snapshot = this.instanceStateMachine.observe(pane, Date.now());
1436
+ this.applyInstanceStateSnapshot(snapshot, pane);
1437
+ if (snapshot.state === "idle") {
1438
+ this.clearInstanceStateStuckTimer();
1439
+ }
1440
+ else if (snapshot.state === "working") {
1441
+ // If control mode missed the pane change, the safety capture becomes
1442
+ // the new progress timestamp and re-arms both deadlines.
1443
+ if (!expectedOutputAt)
1444
+ this.instanceStateLastOutputAt = captureStartedAt;
1445
+ this.scheduleInstanceStateStuckDeadline(this.instanceStateLastOutputAt || captureStartedAt);
1446
+ }
1447
+ }
1448
+ catch (err) {
1449
+ this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
1450
+ }
1451
+ finally {
1452
+ this.statePollInFlight = false;
1453
+ }
1454
+ }
1455
+ handleInstancePaneOutput(event) {
1456
+ if (!this.instanceStateMonitorActive || this.runtimeMonitorsFrozen)
1457
+ return;
1458
+ const windowId = this.tmux?.getWindowId();
1459
+ if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
1460
+ return;
1461
+ this.instanceStateLastOutputAt = event.at;
1462
+ const snapshot = this.instanceStateMachine.recordOutput(event.at);
1463
+ this.applyInstanceStateSnapshot(snapshot);
1464
+ this.scheduleInstanceStateIdleCapture();
1465
+ this.scheduleInstanceStateStuckDeadline(event.at);
1466
+ }
1467
+ bindInstanceStateOutputListener(windowId) {
1468
+ if (!this.controlClient || !this.instanceStateMonitorActive)
1469
+ return;
1470
+ if (!this.instanceStateOutputListener) {
1471
+ this.instanceStateOutputListener = event => this.handleInstancePaneOutput(event);
1472
+ }
1473
+ if (this.instanceStateOutputEventName) {
1474
+ this.controlClient.removeListener(this.instanceStateOutputEventName, this.instanceStateOutputListener);
1475
+ }
1476
+ this.instanceStateOutputEventName = `output:${windowId}`;
1477
+ this.controlClient.on(this.instanceStateOutputEventName, this.instanceStateOutputListener);
1478
+ }
1128
1479
  startInstanceStateMonitor() {
1129
- if (this.runtimeMonitorsFrozen || !this.tmux || !this.backend || this.instanceStateMonitorTimer)
1480
+ if (this.runtimeMonitorsFrozen || !this.tmux || !this.backend || this.instanceStateMonitorActive)
1130
1481
  return;
1131
1482
  const rawConfig = this.config.hang_detector;
1132
1483
  const timeoutMinutes = rawConfig?.timeout_minutes;
1133
- const stuckTimeoutMs = typeof timeoutMinutes === "number" && timeoutMinutes > 0
1484
+ this.instanceStateStuckTimeoutMs = typeof timeoutMinutes === "number" && timeoutMinutes > 0
1134
1485
  ? timeoutMinutes * 60_000
1135
1486
  : DEFAULT_STUCK_TIMEOUT_MS;
1136
- const pollIntervalMs = typeof rawConfig?.poll_interval_ms === "number" && rawConfig.poll_interval_ms > 0
1137
- ? rawConfig.poll_interval_ms
1138
- : DEFAULT_STATE_POLL_INTERVAL_MS;
1139
- this.instanceStateMachine = new PaneStateMachine(this.backend.getReadyPattern(), stuckTimeoutMs);
1140
- const poll = async () => {
1141
- if (!this.tmux || this.spawning || this.statePollInFlight)
1142
- return;
1143
- this.statePollInFlight = true;
1144
- try {
1145
- const paneStatus = await this.tmux.getPaneStatus();
1146
- if (!paneStatus?.alive)
1147
- return;
1148
- const pane = await this.tmux.capturePane();
1149
- const previous = this.instanceState;
1150
- const snapshot = this.instanceStateMachine.observe(pane);
1151
- this.instanceState = snapshot.state;
1152
- if (snapshot.state !== previous) {
1153
- this.logger.info({
1154
- previousState: previous,
1155
- state: snapshot.state,
1156
- unchangedForMs: snapshot.unchangedForMs,
1157
- }, "Instance execution state changed");
1158
- this.emit("instance_state", { name: this.name, ...snapshot });
1159
- this.ipcServer?.broadcast({ type: "instance_state", instanceName: this.name, ...snapshot });
1160
- // Emit exactly once per transition into stuck. Further notifications
1161
- // require observable progress (working) or a ready prompt (idle) first.
1162
- if (snapshot.state === "stuck") {
1163
- this.hangDetector?.emit("hang");
1164
- }
1165
- }
1166
- if (snapshot.state !== "idle")
1167
- this.pauseRequested = false;
1168
- if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)) {
1169
- this.pauseRequested = true;
1170
- this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
1171
- }
1172
- }
1173
- catch (err) {
1174
- // A pane can disappear between status and capture during restart. Keep
1175
- // the last known state and retry on the next poll.
1176
- this.logger.debug({ err: err.message }, "Instance state poll failed");
1177
- }
1178
- finally {
1179
- this.statePollInFlight = false;
1180
- }
1487
+ this.instanceStateIdleDebounceMs = typeof rawConfig?.idle_debounce_ms === "number" && rawConfig.idle_debounce_ms >= 0
1488
+ ? rawConfig.idle_debounce_ms
1489
+ : DEFAULT_STATE_IDLE_DEBOUNCE_MS;
1490
+ this.instanceStateReadyPattern = this.backend.getReadyPattern();
1491
+ this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs);
1492
+ this.instanceStateLastOutputAt = 0;
1493
+ this.instanceStateMonitorActive = true;
1494
+ if (this.controlClient) {
1495
+ this.instanceStateSafetyListener = () => { void this.captureAndEvaluateInstanceState("safety_sweep"); };
1496
+ this.bindInstanceStateOutputListener(this.tmux.getWindowId());
1497
+ this.controlClient.on("safety_sweep", this.instanceStateSafetyListener);
1498
+ }
1499
+ else {
1500
+ // Standalone/test fallback. Fleet production uses the one shared control
1501
+ // client and therefore one fleet-level safety sweep instead of N timers.
1502
+ this.instanceStateMonitorTimer = setInterval(() => {
1503
+ void this.captureAndEvaluateInstanceState("safety_sweep");
1504
+ }, DEFAULT_STATE_SAFETY_SWEEP_MS);
1505
+ }
1506
+ void this.captureAndEvaluateInstanceState("initial");
1507
+ }
1508
+ stopInstanceStateMonitor() {
1509
+ this.instanceStateMonitorActive = false;
1510
+ this.clearInstanceStateIdleTimer();
1511
+ this.clearInstanceStateStuckTimer();
1512
+ if (this.instanceStateMonitorTimer)
1513
+ clearInterval(this.instanceStateMonitorTimer);
1514
+ this.instanceStateMonitorTimer = null;
1515
+ if (this.controlClient && this.instanceStateOutputListener && this.instanceStateOutputEventName) {
1516
+ this.controlClient.removeListener(this.instanceStateOutputEventName, this.instanceStateOutputListener);
1517
+ }
1518
+ if (this.controlClient && this.instanceStateSafetyListener) {
1519
+ this.controlClient.removeListener("safety_sweep", this.instanceStateSafetyListener);
1520
+ }
1521
+ this.instanceStateOutputListener = null;
1522
+ this.instanceStateOutputEventName = null;
1523
+ this.instanceStateSafetyListener = null;
1524
+ }
1525
+ handleStuckTransition(pane, snapshot, readyPattern) {
1526
+ const deterministicReadyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
1527
+ const diagnostic = {
1528
+ backend: this.backend?.binaryName ?? this.config.backend ?? "unknown",
1529
+ paneTail: sanitizePaneTail(pane),
1530
+ readyPattern: readyPattern.toString(),
1531
+ readyMatched: deterministicReadyPattern.test(pane),
1532
+ unchangedForMs: snapshot.unchangedForMs,
1533
+ pendingWork: this.pendingWork.hasPendingWork(),
1181
1534
  };
1182
- void poll();
1183
- this.instanceStateMonitorTimer = setInterval(() => { void poll(); }, pollIntervalMs);
1535
+ if (!diagnostic.pendingWork) {
1536
+ this.logger.debug(diagnostic, "Suppressing stuck notification without pending work");
1537
+ return;
1538
+ }
1539
+ this.logger.warn(diagnostic, "Instance pane stuck with pending work");
1540
+ this.hangDetector?.emit("hang", { unchangedForMs: snapshot.unchangedForMs });
1184
1541
  }
1185
1542
  /** Stop every runtime poller/watcher while preserving IPC and daemon state. */
1186
1543
  freezeRuntimeMonitors() {
@@ -1193,10 +1550,7 @@ export class Daemon extends EventEmitter {
1193
1550
  clearInterval(this.errorMonitorTimer);
1194
1551
  this.errorMonitorTimer = null;
1195
1552
  }
1196
- if (this.instanceStateMonitorTimer) {
1197
- clearInterval(this.instanceStateMonitorTimer);
1198
- this.instanceStateMonitorTimer = null;
1199
- }
1553
+ this.stopInstanceStateMonitor();
1200
1554
  this.transcriptMonitor?.stop();
1201
1555
  this.guardian?.stop();
1202
1556
  }
@@ -1291,12 +1645,23 @@ export class Daemon extends EventEmitter {
1291
1645
  // Remember (and persist) the reply target. Only real channel messages have a
1292
1646
  // non-empty chat_id; cross-instance messages have chat_id="" and must NOT
1293
1647
  // overwrite it (their reply would otherwise go nowhere).
1294
- this.updateLastChat(meta.chat_id, meta.thread_id);
1648
+ this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
1649
+ if (meta.chat_id) {
1650
+ const inboundAt = Date.now();
1651
+ this.autoPauseController.recordActivity(inboundAt);
1652
+ try {
1653
+ writeLastInboundAt(this.instanceDir, inboundAt);
1654
+ }
1655
+ catch (err) {
1656
+ this.logger.warn({ err: err.message }, "Failed to persist last inbound timestamp");
1657
+ }
1658
+ }
1295
1659
  if (this.pendingInstructionsUpdate) {
1296
1660
  writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
1297
1661
  this.pendingInstructionsUpdate = undefined;
1298
1662
  }
1299
1663
  this.hangDetector?.recordInbound();
1664
+ this.pendingWork.recordInbound();
1300
1665
  // v3: record user messages for rotation snapshot
1301
1666
  this.recordRecentUserMessage(content, meta);
1302
1667
  // Format message with metadata prefix for the agent
@@ -1315,7 +1680,10 @@ export class Daemon extends EventEmitter {
1315
1680
  }
1316
1681
  let formatted;
1317
1682
  if (fromInstance) {
1318
- formatted = `[from:${fromInstance}] ${content}\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)`;
1683
+ // #77: show the sender's display name for readability, keeping the machine
1684
+ // instance name in parens so the recipient's send_to_instance target is valid.
1685
+ const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
1686
+ formatted = `[from:${fromLabel}] ${content}\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)`;
1319
1687
  }
1320
1688
  else {
1321
1689
  const via = meta.source ? ` via ${meta.source}` : "";
@@ -1346,7 +1714,7 @@ export class Daemon extends EventEmitter {
1346
1714
  }
1347
1715
  if (this.pendingInstructionsNotice) {
1348
1716
  this.pendingInstructionsNotice = false;
1349
- await this.deliverMessage("[system] Your instructions/steering files have been updated. Re-read your steering files. Do not reply to this message.");
1717
+ await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
1350
1718
  }
1351
1719
  const status = (chatId && messageId)
1352
1720
  ? { chatId: meta.thread_id || chatId, messageId }
@@ -1403,8 +1771,14 @@ export class Daemon extends EventEmitter {
1403
1771
  await new Promise(r => setTimeout(r, 2000));
1404
1772
  continue;
1405
1773
  }
1406
- // Settle the bracketed paste, then submit.
1407
- await new Promise(r => setTimeout(r, 500));
1774
+ // Settle the bracketed paste, then submit. The first delivery immediately
1775
+ // after ready gets a longer one-shot delay because the TUI may still be
1776
+ // completing its final redraw even though the prompt already matched.
1777
+ const enterSettleMs = this.firstDeliveryDelay.consume();
1778
+ if (enterSettleMs > NORMAL_ENTER_SETTLE_MS) {
1779
+ this.logger.debug({ enterSettleMs }, "First delivery after ready — extending Enter settle delay");
1780
+ }
1781
+ await new Promise(r => setTimeout(r, enterSettleMs));
1408
1782
  const enterAt = Date.now();
1409
1783
  await this.tmux.sendSpecialKey("Enter");
1410
1784
  if (status)
@@ -1447,6 +1821,7 @@ export class Daemon extends EventEmitter {
1447
1821
  this.tmux = new TmuxManager(this.tmuxSessionName, match.id);
1448
1822
  writeFileSync(join(this.instanceDir, "window-id"), match.id);
1449
1823
  await this.controlClient?.registerWindow(match.id);
1824
+ this.bindInstanceStateOutputListener(match.id);
1450
1825
  this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
1451
1826
  return match.id;
1452
1827
  }
@@ -1793,6 +2168,7 @@ export class Daemon extends EventEmitter {
1793
2168
  },
1794
2169
  skipPermissions: this.config.skipPermissions,
1795
2170
  model: this.modelOverride ?? this.config.model,
2171
+ kiroUi: this.config.kiro_ui,
1796
2172
  skipResume: this.skipResume,
1797
2173
  instructions,
1798
2174
  agentMode: isCliMode ? "cli" : "mcp",
@@ -1972,6 +2348,7 @@ export class Daemon extends EventEmitter {
1972
2348
  }
1973
2349
  // Register with control client and wait for output + idle
1974
2350
  await this.controlClient?.registerWindow(windowId);
2351
+ this.bindInstanceStateOutputListener(windowId);
1975
2352
  if (this.controlClient) {
1976
2353
  const total = startupTimeoutMs ?? this.config.startup_timeout_ms ?? 25_000;
1977
2354
  const outputTimeout = Math.round(total * 0.6);
@@ -1996,7 +2373,10 @@ export class Daemon extends EventEmitter {
1996
2373
  // but a startup crash would already be caught by waitForOutput/waitForIdle above.
1997
2374
  if (!await this.tmux.isWindowAlive())
1998
2375
  return false;
1999
- return this.dismissDialogsUntilReady(3);
2376
+ const ready = await this.dismissDialogsUntilReady(3);
2377
+ if (ready)
2378
+ this.firstDeliveryDelay.recordReady();
2379
+ return ready;
2000
2380
  }
2001
2381
  /**
2002
2382
  * Repeatedly check pane content, dismiss any confirmation dialogs,
@@ -2063,14 +2443,17 @@ export class Daemon extends EventEmitter {
2063
2443
  * messages) so it never overwrites a real channel target. Persisted to
2064
2444
  * last-chat.json so the reply target survives a restart (see start()).
2065
2445
  */
2066
- updateLastChat(chatId, threadId) {
2446
+ updateLastChat(chatId, threadId, adapterId) {
2067
2447
  if (!chatId)
2068
2448
  return;
2069
2449
  this.lastChatId = chatId;
2070
- if (threadId)
2071
- this.lastThreadId = threadId;
2450
+ // An unthreaded inbound must clear a previous topic rather than leaking it
2451
+ // into the next reply target.
2452
+ this.lastThreadId = threadId || undefined;
2453
+ if (adapterId)
2454
+ this.lastAdapterId = adapterId;
2072
2455
  try {
2073
- writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId }));
2456
+ writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
2074
2457
  }
2075
2458
  catch { /* best effort */ }
2076
2459
  }