@songsid/agend 2.1.1-beta.16 → 2.1.1-beta.18

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 (50) hide show
  1. package/dist/backend/claude-code.d.ts +37 -0
  2. package/dist/backend/claude-code.js +40 -1
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/kiro.d.ts +20 -0
  5. package/dist/backend/kiro.js +30 -0
  6. package/dist/backend/kiro.js.map +1 -1
  7. package/dist/backend/types.d.ts +27 -0
  8. package/dist/backend/types.js.map +1 -1
  9. package/dist/channel/adapters/discord.d.ts +15 -0
  10. package/dist/channel/adapters/discord.js +81 -1
  11. package/dist/channel/adapters/discord.js.map +1 -1
  12. package/dist/channel/adapters/telegram.d.ts +21 -0
  13. package/dist/channel/adapters/telegram.js +82 -0
  14. package/dist/channel/adapters/telegram.js.map +1 -1
  15. package/dist/channel/ipc-bridge.d.ts +9 -1
  16. package/dist/channel/ipc-bridge.js +12 -3
  17. package/dist/channel/ipc-bridge.js.map +1 -1
  18. package/dist/channel/ipc-timeouts.d.ts +40 -0
  19. package/dist/channel/ipc-timeouts.js +58 -0
  20. package/dist/channel/ipc-timeouts.js.map +1 -0
  21. package/dist/channel/mcp-server.js +5 -4
  22. package/dist/channel/mcp-server.js.map +1 -1
  23. package/dist/channel/types.d.ts +29 -0
  24. package/dist/daemon.d.ts +114 -5
  25. package/dist/daemon.js +336 -59
  26. package/dist/daemon.js.map +1 -1
  27. package/dist/fleet-manager.d.ts +92 -1
  28. package/dist/fleet-manager.js +346 -20
  29. package/dist/fleet-manager.js.map +1 -1
  30. package/dist/hang-detector.d.ts +19 -13
  31. package/dist/hang-detector.js +19 -49
  32. package/dist/hang-detector.js.map +1 -1
  33. package/dist/instance-lifecycle.js +9 -0
  34. package/dist/instance-lifecycle.js.map +1 -1
  35. package/dist/outbound-handlers.d.ts +7 -0
  36. package/dist/outbound-handlers.js +62 -4
  37. package/dist/outbound-handlers.js.map +1 -1
  38. package/dist/pane-write-lock.d.ts +48 -0
  39. package/dist/pane-write-lock.js +73 -0
  40. package/dist/pane-write-lock.js.map +1 -0
  41. package/dist/tmux-control.d.ts +48 -1
  42. package/dist/tmux-control.js +79 -6
  43. package/dist/tmux-control.js.map +1 -1
  44. package/package.json +1 -1
  45. package/dist/channel/tool-tracker.d.ts +0 -13
  46. package/dist/channel/tool-tracker.js +0 -58
  47. package/dist/channel/tool-tracker.js.map +0 -1
  48. package/dist/daemon-entry.d.ts +0 -1
  49. package/dist/daemon-entry.js +0 -30
  50. package/dist/daemon-entry.js.map +0 -1
package/dist/daemon.js CHANGED
@@ -10,11 +10,13 @@ import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
10
10
  import { TranscriptMonitor } from "./transcript-monitor.js";
11
11
  import { ContextGuardian } from "./context-guardian.js";
12
12
  import { IpcServer } from "./channel/ipc-bridge.js";
13
+ import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
13
14
  import { MessageBus } from "./channel/message-bus.js";
14
15
  import { shellQuote } from "./backend/types.js";
15
16
  import { getTmuxSession } from "./config.js";
16
17
  import { routeToolCall } from "./channel/tool-router.js";
17
18
  import { HangDetector } from "./hang-detector.js";
19
+ import { PaneWriteLock } from "./pane-write-lock.js";
18
20
  import { buildFleetInstructions } from "./instructions.js";
19
21
  const __filename = fileURLToPath(import.meta.url);
20
22
  const __dirname = dirname(__filename);
@@ -39,8 +41,6 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
39
41
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
40
42
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
41
43
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
42
- /** @deprecated State detection is event-driven; this now aliases the safety sweep. */
43
- export const DEFAULT_STATE_POLL_INTERVAL_MS = DEFAULT_STATE_SAFETY_SWEEP_MS;
44
44
  const LAST_INBOUND_FILE = "last-inbound-at";
45
45
  /** Minimum gap between "health check is failing" notifications for one instance. */
46
46
  const HEALTH_ERROR_NOTIFY_INTERVAL_MS = 10 * 60_000;
@@ -185,24 +185,47 @@ export class AutoPauseController {
185
185
  * Pane motion wins over a ready match because several backends keep their ready
186
186
  * marker in a persistent header/footer while generating. A stable ready pane is
187
187
  * idle; changing content is working; stable non-ready content eventually sticks.
188
+ *
189
+ * ## Why an optional busy pattern exists
190
+ *
191
+ * "Motion wins" degrades badly when a backend's ready marker is *always* on
192
+ * screen. `stuck` requires `!ready`, so for such a backend the state can only ever
193
+ * be idle or working — the stuck edge, and with it `handleStuckTransition` and the
194
+ * hang notification, is unreachable. A frozen CLI is then reported as idle, which
195
+ * also clears pending work: the message the user sent is quietly booked as done.
196
+ *
197
+ * claude-code was exactly this case (see ClaudeCodeBackend.getReadyPattern). A
198
+ * backend that can point at a marker meaning "generating right now" supplies
199
+ * `getBusyPattern()`, and that match vetoes ready regardless of what the ready
200
+ * pattern says.
188
201
  */
189
202
  export class PaneStateMachine {
190
203
  stuckTimeoutMs;
191
204
  readyPattern;
205
+ busyPattern;
192
206
  lastPaneHash = null;
193
207
  lastPaneChangeAt;
194
208
  lastObservedAt;
195
209
  stateChangedAt;
196
210
  currentState = "idle";
197
- constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now()) {
211
+ constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern) {
198
212
  this.stuckTimeoutMs = stuckTimeoutMs;
199
213
  // Stateful g/y regexes mutate lastIndex and can alternate true/false across
200
214
  // polls. State detection must be deterministic for identical pane content.
201
215
  this.readyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
216
+ this.busyPattern = busyPattern
217
+ ? new RegExp(busyPattern.source, busyPattern.flags.replace(/[gy]/g, ""))
218
+ : null;
202
219
  this.lastPaneChangeAt = now;
203
220
  this.lastObservedAt = now;
204
221
  this.stateChangedAt = now;
205
222
  }
223
+ /** Ready, unless the backend can positively see that it is still generating. */
224
+ isReady(pane) {
225
+ if (this.busyPattern?.test(pane))
226
+ return false;
227
+ return this.readyPattern.test(pane);
228
+ }
206
229
  observe(pane, now = Date.now()) {
207
230
  const paneHash = createHash("sha256").update(pane).digest("hex");
208
231
  const firstObservation = this.lastPaneHash === null;
@@ -212,7 +235,7 @@ export class PaneStateMachine {
212
235
  this.lastPaneChangeAt = now;
213
236
  }
214
237
  this.lastObservedAt = now;
215
- const ready = this.readyPattern.test(pane);
238
+ const ready = this.isReady(pane);
216
239
  const nextState = firstObservation
217
240
  ? ready ? "idle" : "working"
218
241
  : paneChanged
@@ -278,6 +301,21 @@ const FIRST_ENTER_SETTLE_MS = 1_750;
278
301
  const FIRST_DELIVERY_WINDOW_MS = 5_000;
279
302
  /** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
280
303
  const NATIVE_QUEUE_PASTE_VERIFY_MS = 2_000;
304
+ /** Enter-confirmation poll: 10 × 200ms ≈ 2s of observed silence before giving up. */
305
+ const CONFIRM_BUSY_POLLS = 10;
306
+ const CONFIRM_BUSY_POLL_MS = 200;
307
+ /**
308
+ * Ceiling on the whole confirmation, including time re-earned after a control-mode
309
+ * reconnect. Without it a reconnect loop could restart the poll indefinitely and
310
+ * hold the pane write lock along with it.
311
+ */
312
+ const CONFIRM_BUSY_MAX_WAIT_MS = 10_000;
313
+ /**
314
+ * How long a delivery waits for an in-flight spawn. Comfortably past the default
315
+ * 25s startup timeout plus dialog dismissal; past it we fall back to the old
316
+ * behaviour rather than holding a message indefinitely.
317
+ */
318
+ const SPAWN_SETTLE_MAX_WAIT_MS = 60_000;
281
319
  /**
282
320
  * One-shot timing gate for the first paste after a CLI reaches its ready
283
321
  * prompt. Recent tmux versions expose a short interval where the prompt is
@@ -336,7 +374,6 @@ export class Daemon extends EventEmitter {
336
374
  ipcServer = null;
337
375
  messageBus;
338
376
  transcriptMonitor = null;
339
- toolTracker = null;
340
377
  guardian = null;
341
378
  adapter = null;
342
379
  pendingIpcRequests = new Map();
@@ -347,6 +384,8 @@ export class Daemon extends EventEmitter {
347
384
  // Pending ack: react 🫡 on first transcript activity after receiving a message
348
385
  pendingAckMessage = null;
349
386
  // Tool status tracking for channel adapter
387
+ /** Last activity published to the fleet manager; null when nothing is running. */
388
+ currentActivity = null;
350
389
  toolStatusMessageId = null;
351
390
  toolStatusLines = [];
352
391
  toolStatusDebounce = null;
@@ -366,6 +405,15 @@ export class Daemon extends EventEmitter {
366
405
  /** CLI pane availability, independent from the daemon process and tri-state. */
367
406
  processStatus = "running";
368
407
  spawning = false;
408
+ /**
409
+ * Resolves when the in-flight spawn finishes; null when none is running.
410
+ *
411
+ * `spawning` alone can only be polled. Delivery needs to *wait*, and a boolean
412
+ * poll would either busy-loop or race the flag being cleared.
413
+ */
414
+ spawnSettled = null;
415
+ resolveSpawnSettled = null;
416
+ spawnDepth = 0;
369
417
  skipResume = false;
370
418
  backgroundSessionRecoveryAttempted = false;
371
419
  /** Whether the last spawn started a fresh session (not resumed). */
@@ -388,6 +436,7 @@ export class Daemon extends EventEmitter {
388
436
  instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
389
437
  instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
390
438
  instanceStateReadyPattern = null;
439
+ instanceStateBusyPattern = null;
391
440
  instanceStateMonitorActive = false;
392
441
  statePollInFlight = false;
393
442
  autoPauseController;
@@ -408,7 +457,13 @@ export class Daemon extends EventEmitter {
408
457
  recentEvents = [];
409
458
  recentToolActivity = [];
410
459
  snapshotConsumed = false;
460
+ /** Orders inbound channel messages against each other (queue-depth accounting
461
+ * and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
462
+ * reach the pane — {@link paneWriteLock} does. */
411
463
  pasteLock = Promise.resolve();
464
+ /** Mutual exclusion for *every* write into the pane, whichever subsystem it
465
+ * comes from. See PaneWriteLock for why interleaving is destructive. */
466
+ paneWriteLock = new PaneWriteLock();
412
467
  pendingInstructionsUpdate;
413
468
  pendingInstructionsNotice = false;
414
469
  // Whether the warmup steering-reload notice should be injected after spawn.
@@ -551,9 +606,6 @@ export class Daemon extends EventEmitter {
551
606
  if ((type === "fleet_schedule_response" || type === "fleet_outbound_response" || type === "fleet_decision_response" || type === "fleet_task_response" || type === "fleet_display_name_response" || type === "fleet_description_response") && msg.fleetRequestId) {
552
607
  key = String(msg.fleetRequestId);
553
608
  }
554
- else if (type === "fleet_outbound_response" && msg.requestId != null) {
555
- key = `fleet_out_${msg.requestId}`;
556
- }
557
609
  if (key && this.pendingIpcRequests.has(key)) {
558
610
  const handler = this.pendingIpcRequests.get(key);
559
611
  this.pendingIpcRequests.delete(key);
@@ -602,6 +654,14 @@ export class Daemon extends EventEmitter {
602
654
  // Fleet manager routed a message to us (topic mode)
603
655
  const meta = msg.meta;
604
656
  const targetSession = msg.targetSession;
657
+ // `no_wake` marks low-value signals (a non-approval reaction, #408): worth
658
+ // showing an instance that is already awake, never worth resuming a paused
659
+ // one and spending a turn on. Dropped rather than queued, so a paused
660
+ // instance does not surface a pile of stale emoji on its next wake.
661
+ if (meta?.no_wake === "true" && this.isPaused) {
662
+ this.logger.debug({ preview: String(msg.content).slice(0, 60) }, "Dropping no_wake message for paused instance");
663
+ return;
664
+ }
605
665
  void this.wake().then(() => {
606
666
  this.pushChannelMessage(msg.content, meta, targetSession);
607
667
  }).catch(err => {
@@ -704,7 +764,12 @@ export class Daemon extends EventEmitter {
704
764
  else {
705
765
  await new Promise(r => setTimeout(r, 5000));
706
766
  }
707
- await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
767
+ // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
768
+ // delivery is already in flight or queued. Without the lock the notice and
769
+ // that delivery race into the same pane.
770
+ await this.paneWriteLock.run(async () => {
771
+ await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir));
772
+ });
708
773
  // Record the value the agent has now been told about so the next
709
774
  // unchanged restart skips the reload.
710
775
  try {
@@ -737,27 +802,31 @@ export class Daemon extends EventEmitter {
737
802
  this.transcriptMonitor.on("tool_use", (name, input) => {
738
803
  this.logger.debug({ tool: name }, "Tool use");
739
804
  ackIfPending();
740
- this.hangDetector?.recordActivity();
741
805
  this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
742
806
  this.recordRecentToolActivity(this.summarizeTool(name, input));
807
+ this.publishActivity(this.summarizeTool(name, input));
743
808
  });
744
809
  this.transcriptMonitor.on("tool_result", (name, _output) => {
745
- this.hangDetector?.recordActivity();
746
810
  this.recordRecentEvent({ type: "tool_result", name });
811
+ // The tool finished; whatever comes next has not started yet. Better to
812
+ // show only elapsed time than to leave a stale "Bash: npm test" on screen.
813
+ this.publishActivity(null);
747
814
  });
748
815
  this.transcriptMonitor.on("assistant_text", (text) => {
749
816
  this.logger.debug({ text: text.slice(0, 200) }, "Claude response");
750
817
  ackIfPending();
751
- this.hangDetector?.recordActivity();
752
818
  this.recordRecentEvent({ type: "assistant_text", preview: text.slice(0, 100) });
753
819
  });
754
820
  this.transcriptMonitor.startPolling();
755
- // HangDetector remains the fleet-manager notification event bridge. Its
756
- // legacy silence timer is intentionally not started: pane state transitions
757
- // below are now the sole source of hang events.
821
+ // HangDetector is the notification bridge to the fleet manager; pane state
822
+ // transitions below are the sole source of hang events (see hang-detector.ts).
823
+ // `hang_detector.enabled: false` opts out of the notification entirely.
824
+ // NOTE: `hang_detector.timeout_minutes` is NOT read here — the stuck timeout
825
+ // the pane monitor actually uses is resolved separately below. The two have
826
+ // long been separate; documented rather than silently ignored.
758
827
  const hangConfig = this.config.hang_detector;
759
828
  if (hangConfig?.enabled !== false) {
760
- this.hangDetector = new HangDetector(hangConfig?.timeout_minutes ?? 10);
829
+ this.hangDetector = new HangDetector();
761
830
  }
762
831
  // 8. Context guardian
763
832
  const statusFile = join(this.instanceDir, "statusline.json");
@@ -765,7 +834,6 @@ export class Daemon extends EventEmitter {
765
834
  this.guardian.startWatching();
766
835
  this.guardian.on("status_update", () => {
767
836
  this.saveSessionId();
768
- this.hangDetector?.recordStatuslineUpdate();
769
837
  });
770
838
  // Context rotation removed: all CLI backends have built-in auto-compact.
771
839
  // Crash recovery (health check + respawn with snapshot) is retained below.
@@ -848,6 +916,7 @@ export class Daemon extends EventEmitter {
848
916
  this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
849
917
  this.healthCheckPaused = true;
850
918
  this.healthCheckTimer = null;
919
+ this.emitSupervisionEnded("instance directory was removed from disk", "Recreate the instance, or delete it from fleet.yaml.");
851
920
  return;
852
921
  }
853
922
  if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) {
@@ -898,6 +967,7 @@ export class Daemon extends EventEmitter {
898
967
  this.logger.info("CLI exited normally (code 0) — pausing health check");
899
968
  await this.tmux.killWindow();
900
969
  this.healthCheckPaused = true;
970
+ this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.");
901
971
  return;
902
972
  }
903
973
  this.setProcessStatus("crashed");
@@ -1007,6 +1077,7 @@ export class Daemon extends EventEmitter {
1007
1077
  if (max_retries <= 0) {
1008
1078
  this.healthCheckPaused = true;
1009
1079
  this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
1080
+ this.emitSupervisionEnded("the CLI died and automatic restart is disabled (restart_policy.max_retries is 0)", "Start it manually, or raise max_retries in fleet.yaml.");
1010
1081
  return;
1011
1082
  }
1012
1083
  // Detect rapid crash: sliding window — 3+ crashes in 5 minutes
@@ -1036,6 +1107,8 @@ export class Daemon extends EventEmitter {
1036
1107
  this.lastCrashAt = Date.now();
1037
1108
  if (this.crashCount > max_retries) {
1038
1109
  this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
1110
+ this.healthCheckPaused = true;
1111
+ this.emitSupervisionEnded(`it crashed ${this.crashCount} times, exceeding restart_policy.max_retries (${max_retries})`, "Check the logs for the cause, then restart it.");
1039
1112
  return; // don't schedule next — given up
1040
1113
  }
1041
1114
  // Calculate backoff delay
@@ -1119,6 +1192,21 @@ export class Daemon extends EventEmitter {
1119
1192
  * A dead remain-on-exit pane still contains the old ready prompt, so allowing
1120
1193
  * the pane monitor to capture it would re-create a false idle state.
1121
1194
  */
1195
+ /**
1196
+ * Announce that this instance is no longer being supervised.
1197
+ *
1198
+ * Four health-check exits set `healthCheckPaused = true` and returned without
1199
+ * telling anyone: a clean CLI exit, `max_retries <= 0`, crash retries exhausted,
1200
+ * and the instance directory being deleted. Only the crash-LOOP case emitted an
1201
+ * event, so a fleet could quietly contain a dead instance that still looked fine
1202
+ * on the dashboard — while messages routed to it queued or failed with a bare ❌.
1203
+ *
1204
+ * `crash_loop` already had this treatment; this is the same bridge for the other
1205
+ * four, carrying a human-readable cause and the operator's next step.
1206
+ */
1207
+ emitSupervisionEnded(reason, remedy) {
1208
+ this.emit("supervision_ended", { name: this.name, reason, remedy });
1209
+ }
1122
1210
  setProcessStatus(status) {
1123
1211
  if (this.processStatus === status)
1124
1212
  return;
@@ -1164,6 +1252,7 @@ export class Daemon extends EventEmitter {
1164
1252
  if (!this.backend)
1165
1253
  return; // lightweight mode has no backend
1166
1254
  const readyPattern = this.backend.getReadyPattern();
1255
+ const busyPattern = this.backend.getBusyPattern?.() ?? null;
1167
1256
  this.errorMonitorTimer = setInterval(async () => {
1168
1257
  if (!this.tmux || this.spawning)
1169
1258
  return;
@@ -1176,20 +1265,30 @@ export class Daemon extends EventEmitter {
1176
1265
  for (const dialog of dialogs) {
1177
1266
  if (!dialog.pattern.test(pane))
1178
1267
  continue;
1179
- this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
1180
- const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
1181
- for (const key of dialog.keys) {
1182
- if (SPECIAL_KEYS.has(key)) {
1183
- await this.tmux.sendSpecialKey(key);
1184
- }
1185
- else {
1186
- await this.tmux.pasteText(key);
1268
+ // These keys go straight into the pane. Sent while a message delivery is
1269
+ // mid-transaction, an `Escape` wipes the pasted text and an `Enter`
1270
+ // submits it half-composed — the user sees a message that vanished. Skip
1271
+ // (not queue) when the pane is busy: this poller runs every 5s, and the
1272
+ // dialog will still be on screen next tick.
1273
+ const dismissed = await this.paneWriteLock.tryRun(async () => {
1274
+ this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
1275
+ const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
1276
+ for (const key of dialog.keys) {
1277
+ if (SPECIAL_KEYS.has(key)) {
1278
+ await this.tmux.sendSpecialKey(key);
1279
+ }
1280
+ else {
1281
+ await this.tmux.pasteText(key);
1282
+ }
1283
+ await new Promise(r => setTimeout(r, 200));
1187
1284
  }
1188
- await new Promise(r => setTimeout(r, 200));
1285
+ });
1286
+ if (!dismissed) {
1287
+ this.logger.debug({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
1189
1288
  }
1190
- return; // Dialog dismissed, skip error checks this cycle
1289
+ return; // Dialog handled (or deliberately deferred): skip error checks this cycle
1191
1290
  }
1192
- this.evaluateErrorPatterns(pane, patterns, readyPattern);
1291
+ this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
1193
1292
  }
1194
1293
  catch {
1195
1294
  // capturePane can fail if window is transitioning — ignore
@@ -1197,7 +1296,7 @@ export class Daemon extends EventEmitter {
1197
1296
  }, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
1198
1297
  }
1199
1298
  /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
1200
- evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now()) {
1299
+ evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
1201
1300
  // Count occurrences across the WHOLE pane (not just text after the last
1202
1301
  // ready prompt). Clone with `g` so stateful backend regexes cannot leak
1203
1302
  // lastIndex between monitor cycles.
@@ -1205,10 +1304,15 @@ export class Daemon extends EventEmitter {
1205
1304
  const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
1206
1305
  return (pane.match(new RegExp(pattern.source, flags)) || []).length;
1207
1306
  };
1307
+ // Same veto as the state machine: a backend whose ready marker is permanently
1308
+ // on screen would otherwise "recover" on the very first tick after the error,
1309
+ // rebaselining the occurrence count while the error is still displayed — one
1310
+ // notification, a false recovery log, then silence.
1311
+ const looksReady = () => !busyPattern?.test(pane) && readyPattern.test(pane);
1208
1312
  // State: waiting for recovery. A missing/outdated ready pattern must not
1209
1313
  // suppress every future error forever, so the gate has a hard deadline.
1210
1314
  if (this.errorWaitingForRecovery) {
1211
- if (readyPattern.test(pane)) {
1315
+ if (looksReady()) {
1212
1316
  const downtime = Math.round((now - this.errorDetectedAt) / 1000);
1213
1317
  const active = patterns.find(ep => Daemon.errorPatternKey(ep) === this.activeErrorPatternKey);
1214
1318
  if (active) {
@@ -1307,6 +1411,12 @@ export class Daemon extends EventEmitter {
1307
1411
  * Interrupt the CLI's current generation (cancel button / `/cancel`).
1308
1412
  * Direct tmux key event (not a paste) so it registers as the interrupt key.
1309
1413
  * kiro-cli interrupts on Ctrl+C; the others (claude-code, codex, …) on Escape.
1414
+ *
1415
+ * Deliberately NOT taken through `paneWriteLock`. Cancel is the user's way out
1416
+ * of a pane that is busy or wedged — queueing it behind the very delivery chain
1417
+ * it exists to unblock would make the button useless exactly when it is needed.
1418
+ * The cost is accepted: an Escape landing between a paste and its Enter discards
1419
+ * that message, which is what the user asked for anyway.
1310
1420
  */
1311
1421
  async sendEscape() {
1312
1422
  const cancelKey = this.backend?.getCancelKey() ?? "Escape";
@@ -1344,7 +1454,6 @@ export class Daemon extends EventEmitter {
1344
1454
  this.toolStatusDebounce = null;
1345
1455
  }
1346
1456
  this.pendingIpcRequests.clear();
1347
- this.hangDetector?.stop();
1348
1457
  if (this.adapter)
1349
1458
  await this.adapter.stop();
1350
1459
  // Notify MCP servers of graceful shutdown (prevents reconnect attempts)
@@ -1371,7 +1480,12 @@ export class Daemon extends EventEmitter {
1371
1480
  if (!killed)
1372
1481
  this.logger.warn("CLI did not exit gracefully within 3s, force killing window");
1373
1482
  // Always kill window — remain-on-exit keeps dead panes around after CLI exits
1483
+ const stoppedWindowId = this.tmux.getWindowId();
1374
1484
  await this.tmux.killWindow();
1485
+ // The control client is shared across the fleet and outlives this daemon, so
1486
+ // a registration left behind here is re-resolved on every reconnect forever.
1487
+ if (stoppedWindowId)
1488
+ this.controlClient?.unregisterWindow(stoppedWindowId);
1375
1489
  const windowIdFile = join(this.instanceDir, "window-id");
1376
1490
  try {
1377
1491
  unlinkSync(windowIdFile);
@@ -1507,7 +1621,7 @@ export class Daemon extends EventEmitter {
1507
1621
  return;
1508
1622
  }
1509
1623
  this.pauseWakeState = "waking";
1510
- this.spawning = true;
1624
+ this.beginSpawn();
1511
1625
  const transition = this.autoPauseController.wakeOnDeliver(async () => {
1512
1626
  let timeout;
1513
1627
  try {
@@ -1548,7 +1662,7 @@ export class Daemon extends EventEmitter {
1548
1662
  throw err;
1549
1663
  }
1550
1664
  finally {
1551
- this.spawning = false;
1665
+ this.endSpawn();
1552
1666
  if (this.pauseWakeTransition === transition)
1553
1667
  this.pauseWakeTransition = null;
1554
1668
  }
@@ -1560,6 +1674,10 @@ export class Daemon extends EventEmitter {
1560
1674
  // observations between enqueue and paste must not clear a newer inbound.
1561
1675
  if (snapshot.state === "idle" && previous !== "idle") {
1562
1676
  this.pendingWork.recordIdle(snapshot.observedAt);
1677
+ // The turn is over. A transcript can end on a tool_use with no matching
1678
+ // tool_result (interrupted, crashed, cancelled), which would otherwise leave
1679
+ // the last tool pinned to the progress line for the rest of the session.
1680
+ this.publishActivity(null);
1563
1681
  }
1564
1682
  if (snapshot.state !== previous) {
1565
1683
  this.logger.info({
@@ -1650,6 +1768,13 @@ export class Daemon extends EventEmitter {
1650
1768
  this.instanceStateMachine.observe(pane, observedChangeAt);
1651
1769
  const snapshot = this.instanceStateMachine.observe(pane, Date.now());
1652
1770
  this.applyInstanceStateSnapshot(snapshot, pane);
1771
+ // Backends without a transcript feed can still say what they are doing, if
1772
+ // their TUI names it. Free-riding on a capture that already happened: no
1773
+ // extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
1774
+ // safety sweep), which matches the progress ticker's own 60s interval.
1775
+ if (this.backend?.getPaneActivity) {
1776
+ this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
1777
+ }
1653
1778
  if (snapshot.state === "idle") {
1654
1779
  this.clearInstanceStateStuckTimer();
1655
1780
  }
@@ -1704,7 +1829,8 @@ export class Daemon extends EventEmitter {
1704
1829
  ? rawConfig.idle_debounce_ms
1705
1830
  : DEFAULT_STATE_IDLE_DEBOUNCE_MS;
1706
1831
  this.instanceStateReadyPattern = this.backend.getReadyPattern();
1707
- this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs);
1832
+ this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
1833
+ this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
1708
1834
  this.instanceStateLastOutputAt = 0;
1709
1835
  this.instanceStateMonitorActive = true;
1710
1836
  if (this.controlClient) {
@@ -1745,6 +1871,12 @@ export class Daemon extends EventEmitter {
1745
1871
  paneTail: sanitizePaneTail(pane),
1746
1872
  readyPattern: readyPattern.toString(),
1747
1873
  readyMatched: deterministicReadyPattern.test(pane),
1874
+ // A pane that is stuck *while* the ready marker matches means the busy
1875
+ // pattern is what held it back — worth seeing when tuning either regex.
1876
+ busyPattern: this.instanceStateBusyPattern?.toString(),
1877
+ busyMatched: this.instanceStateBusyPattern
1878
+ ? new RegExp(this.instanceStateBusyPattern.source, this.instanceStateBusyPattern.flags.replace(/[gy]/g, "")).test(pane)
1879
+ : undefined,
1748
1880
  unchangedForMs: snapshot.unchangedForMs,
1749
1881
  pendingWork: this.pendingWork.hasPendingWork(),
1750
1882
  };
@@ -1787,6 +1919,28 @@ export class Daemon extends EventEmitter {
1787
1919
  return this.messageBus;
1788
1920
  }
1789
1921
  // ── Tool status tracking ──────────────────────────────────────
1922
+ /**
1923
+ * Tell the fleet manager what this instance is doing right now, for the live
1924
+ * progress line on the cancel button.
1925
+ *
1926
+ * Purely cosmetic — nothing decides anything from it, so it is fine that only
1927
+ * backends with a transcript feed report at all (claude-code today). For the
1928
+ * rest the progress line keeps showing just elapsed time.
1929
+ *
1930
+ * Repeats are dropped: the ticker only edits the channel message when the text
1931
+ * changes, and a stream of identical broadcasts would defeat that.
1932
+ */
1933
+ publishActivity(activity) {
1934
+ const next = activity && activity.trim() ? activity.trim() : null;
1935
+ if (next === this.currentActivity)
1936
+ return;
1937
+ this.currentActivity = next;
1938
+ this.ipcServer?.broadcast({
1939
+ type: "instance_activity",
1940
+ instanceName: this.name,
1941
+ activity: next,
1942
+ });
1943
+ }
1790
1944
  summarizeTool(name, input) {
1791
1945
  const inp = input;
1792
1946
  if (!inp)
@@ -1876,7 +2030,6 @@ export class Daemon extends EventEmitter {
1876
2030
  writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
1877
2031
  this.pendingInstructionsUpdate = undefined;
1878
2032
  }
1879
- this.hangDetector?.recordInbound();
1880
2033
  this.pendingWork.recordInbound();
1881
2034
  // v3: record user messages for rotation snapshot
1882
2035
  this.recordRecentUserMessage(content, meta);
@@ -1976,6 +2129,8 @@ export class Daemon extends EventEmitter {
1976
2129
  // Odd number of fences = unclosed. Remove all code fences from the message.
1977
2130
  formatted = formatted.replace(/```/g, "");
1978
2131
  }
2132
+ // Before anything reads the window id: a spawn in progress is about to change it.
2133
+ await this.waitForSpawnToSettle();
1979
2134
  let windowId = this.getWindowId();
1980
2135
  const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
1981
2136
  let handingOffToNativeQueue = false;
@@ -2003,6 +2158,21 @@ export class Daemon extends EventEmitter {
2003
2158
  }
2004
2159
  }
2005
2160
  }
2161
+ // Everything above is *waiting*; everything below *writes*. Only the write is
2162
+ // held under the pane lock — holding it across the idle wait (up to 30 min)
2163
+ // would starve the runtime-dialog dismisser, which is often the very thing
2164
+ // that would let the pane go idle again.
2165
+ return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
2166
+ }
2167
+ /**
2168
+ * The write half of a delivery: paste → Enter → confirm, with retries.
2169
+ *
2170
+ * Always called under {@link paneWriteLock}. Split out from `deliverMessage`
2171
+ * precisely so the lock's scope is visible at the call site rather than being
2172
+ * an invariant maintained by comments.
2173
+ */
2174
+ async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
2175
+ let windowId = initialWindowId;
2006
2176
  // Bug A: paste with backoff. Transient failures are usually a stale window id
2007
2177
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
2008
2178
  const maxAttempts = 3;
@@ -2143,6 +2313,7 @@ export class Daemon extends EventEmitter {
2143
2313
  }
2144
2314
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
2145
2315
  async recoverWindow() {
2316
+ const previousWindowId = this.tmux?.getWindowId();
2146
2317
  try {
2147
2318
  const windows = await TmuxManager.listWindows(this.tmuxSessionName);
2148
2319
  const match = windows.find(w => w.name === this.name);
@@ -2150,6 +2321,12 @@ export class Daemon extends EventEmitter {
2150
2321
  return undefined;
2151
2322
  this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
2152
2323
  writeFileSync(join(this.instanceDir, "window-id"), match.id);
2324
+ // The window we were talking to is gone; leaving it registered means the
2325
+ // control client re-resolves a dead id — one tmux subprocess — on every
2326
+ // reconnect, for the life of the fleet process.
2327
+ if (previousWindowId && previousWindowId !== match.id) {
2328
+ this.controlClient?.unregisterWindow(previousWindowId);
2329
+ }
2153
2330
  await this.controlClient?.registerWindow(match.id);
2154
2331
  this.bindInstanceStateOutputListener(match.id);
2155
2332
  this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
@@ -2160,12 +2337,38 @@ export class Daemon extends EventEmitter {
2160
2337
  return undefined;
2161
2338
  }
2162
2339
  }
2163
- /** Poll up to ~2s (200ms × 10) for the pane to emit output after `since`. */
2340
+ /**
2341
+ * Poll up to ~2s (200ms × 10) for the pane to emit output after `since`.
2342
+ *
2343
+ * A `false` here is not "no output" — it is read as **"the message was pasted but
2344
+ * never submitted"**, which re-sends Enter and then reports ❌ to the user. So a
2345
+ * false negative costs a possible double submit and a failure notice for a
2346
+ * message that actually arrived.
2347
+ *
2348
+ * That is exactly what a control-mode reconnect used to produce. `connect()`
2349
+ * drops every output timestamp, so `hasOutputSince` answers `false` for a pane
2350
+ * that did react — the evidence was thrown away, not absent. Blind time is
2351
+ * therefore not counted against the budget: after a reset the poll restarts from
2352
+ * the moment observation resumed. A hard wall-clock cap keeps a reconnect loop
2353
+ * from extending this forever.
2354
+ */
2164
2355
  async confirmBusyAfterEnter(windowId, since) {
2165
- for (let i = 0; i < 10; i++) {
2166
- await new Promise(r => setTimeout(r, 200));
2167
- if (this.controlClient.hasOutputSince(windowId, since))
2356
+ const client = this.controlClient;
2357
+ const hardDeadline = Date.now() + CONFIRM_BUSY_MAX_WAIT_MS;
2358
+ let observedFrom = since;
2359
+ let polls = 0;
2360
+ while (polls < CONFIRM_BUSY_POLLS) {
2361
+ await new Promise(r => setTimeout(r, CONFIRM_BUSY_POLL_MS));
2362
+ if (client.hasOutputSince(windowId, observedFrom))
2168
2363
  return true;
2364
+ const resetAt = client.getObservationResetAt();
2365
+ if (resetAt > observedFrom && Date.now() < hardDeadline) {
2366
+ this.logger.debug({ windowId, resetAt }, "Control mode reconnected mid-confirmation — restarting the Enter check from when we could see again");
2367
+ observedFrom = resetAt;
2368
+ polls = 0;
2369
+ continue;
2370
+ }
2371
+ polls++;
2169
2372
  }
2170
2373
  return false;
2171
2374
  }
@@ -2236,8 +2439,8 @@ export class Daemon extends EventEmitter {
2236
2439
  });
2237
2440
  const timeout = setTimeout(() => {
2238
2441
  this.pendingIpcRequests.delete(fleetReqId);
2239
- respond(null, "Task operation timed out after 30s");
2240
- }, 30_000);
2442
+ respond(null, `Task operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2443
+ }, daemonBudgetMs(tool));
2241
2444
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2242
2445
  clearTimeout(timeout);
2243
2446
  respond(respMsg.result, respMsg.error);
@@ -2259,8 +2462,8 @@ export class Daemon extends EventEmitter {
2259
2462
  });
2260
2463
  const timeout = setTimeout(() => {
2261
2464
  this.pendingIpcRequests.delete(fleetReqId);
2262
- respond(null, "Decision operation timed out after 30s");
2263
- }, 30_000);
2465
+ respond(null, `Decision operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2466
+ }, daemonBudgetMs(tool));
2264
2467
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2265
2468
  clearTimeout(timeout);
2266
2469
  respond(respMsg.result, respMsg.error);
@@ -2286,8 +2489,8 @@ export class Daemon extends EventEmitter {
2286
2489
  // Wait for fleet_schedule_response via pending request map
2287
2490
  const timeout = setTimeout(() => {
2288
2491
  this.pendingIpcRequests.delete(fleetReqId);
2289
- respond(null, "Schedule operation timed out after 30s");
2290
- }, 30_000);
2492
+ respond(null, `Schedule operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
2493
+ }, daemonBudgetMs(tool));
2291
2494
  this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2292
2495
  clearTimeout(timeout);
2293
2496
  respond(respMsg.result, respMsg.error);
@@ -2308,7 +2511,9 @@ export class Daemon extends EventEmitter {
2308
2511
  fleetRequestId: fleetReqId,
2309
2512
  senderSessionName,
2310
2513
  });
2311
- const crossTimeoutMs = (tool === "start_instance" || tool === "create_instance" || tool === "replace_instance") ? 60_000 : 30_000;
2514
+ // Shared table, so delete_instance and the deployment tools get the same
2515
+ // budget here as the MCP client expects (the ad-hoc list omitted them).
2516
+ const crossTimeoutMs = daemonBudgetMs(tool);
2312
2517
  const timeout = setTimeout(() => {
2313
2518
  this.pendingIpcRequests.delete(fleetReqId);
2314
2519
  respond(null, `Cross-instance operation timed out after ${crossTimeoutMs / 1000}s`);
@@ -2351,8 +2556,8 @@ export class Daemon extends EventEmitter {
2351
2556
  this.ipcServer?.broadcast({ type: "fleet_outbound", tool, args, fleetRequestId: fleetReqId });
2352
2557
  const timeout = setTimeout(() => {
2353
2558
  this.pendingIpcRequests.delete(outboundKey);
2354
- respond(null, "Fleet outbound timed out after 30s");
2355
- }, 30_000);
2559
+ respond(null, `Fleet outbound timed out after ${daemonBudgetMs(tool) / 1000}s`);
2560
+ }, daemonBudgetMs(tool));
2356
2561
  this.pendingIpcRequests.set(outboundKey, (respMsg) => {
2357
2562
  clearTimeout(timeout);
2358
2563
  respond(respMsg.result, respMsg.error);
@@ -2541,7 +2746,9 @@ export class Daemon extends EventEmitter {
2541
2746
  // Small delay to let the CLI fully render its ready prompt
2542
2747
  await new Promise(r => setTimeout(r, 1_000));
2543
2748
  try {
2544
- await this.tmux.pasteText(`[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`);
2749
+ // Messages can arrive during a restart and be queued on pasteLock before the
2750
+ // snapshot lands; both write to the pane, so both go through the same lock.
2751
+ await this.paneWriteLock.run(() => this.tmux.pasteText(`[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`));
2545
2752
  this.logger.info("Injected session snapshot as first message");
2546
2753
  this.emit("snapshot_injected", this.name);
2547
2754
  }
@@ -2550,9 +2757,69 @@ export class Daemon extends EventEmitter {
2550
2757
  this.emit("snapshot_failed", this.name);
2551
2758
  }
2552
2759
  }
2760
+ /**
2761
+ * Depth, not a flag: the wake path marks a spawn and then calls trySpawn, which
2762
+ * marks another. With a plain boolean the inner one's completion would clear the
2763
+ * latch while the outer spawn was still dismissing dialogs, releasing delivery
2764
+ * into exactly the window this exists to close.
2765
+ */
2766
+ beginSpawn() {
2767
+ this.spawnDepth++;
2768
+ this.spawning = true;
2769
+ if (!this.spawnSettled) {
2770
+ this.spawnSettled = new Promise(resolve => { this.resolveSpawnSettled = resolve; });
2771
+ }
2772
+ }
2773
+ endSpawn() {
2774
+ this.spawnDepth = Math.max(0, this.spawnDepth - 1);
2775
+ if (this.spawnDepth > 0)
2776
+ return; // an enclosing spawn is still running
2777
+ this.spawning = false;
2778
+ const resolve = this.resolveSpawnSettled;
2779
+ this.spawnSettled = null;
2780
+ this.resolveSpawnSettled = null;
2781
+ resolve?.();
2782
+ }
2783
+ /**
2784
+ * Hold until the CLI has finished starting up.
2785
+ *
2786
+ * Startup is not a quiet period: `dismissDialogsUntilReady` is clicking through
2787
+ * trust prompts and session pickers. A pane showing a modal dialog produces no
2788
+ * output, so `waitUntilIdle` reports it idle and a queued message gets pasted
2789
+ * *into the dialog* — where the text is discarded and the Enter picks a menu
2790
+ * item. The paste and the Enter both "succeed", so this loses the message
2791
+ * without even a ❌.
2792
+ *
2793
+ * The pane write lock (#414) does not cover this: it serialises each key
2794
+ * sequence, but it is released between one dialog and the next.
2795
+ *
2796
+ * Bounded, and called BEFORE the pane lock is taken — waiting on the spawn while
2797
+ * holding the lock the spawn itself needs would deadlock.
2798
+ */
2799
+ async waitForSpawnToSettle() {
2800
+ const settled = this.spawnSettled;
2801
+ if (!settled)
2802
+ return;
2803
+ this.logger.debug("Holding delivery until the CLI has finished starting up");
2804
+ let timer;
2805
+ const cap = new Promise(resolve => {
2806
+ timer = setTimeout(resolve, SPAWN_SETTLE_MAX_WAIT_MS);
2807
+ timer.unref?.();
2808
+ });
2809
+ try {
2810
+ await Promise.race([settled, cap]);
2811
+ }
2812
+ finally {
2813
+ if (timer)
2814
+ clearTimeout(timer);
2815
+ }
2816
+ if (this.spawning) {
2817
+ this.logger.warn("CLI still starting after the delivery hold — delivering anyway");
2818
+ }
2819
+ }
2553
2820
  /** Spawn a CLI window. Returns true if --resume was used successfully. */
2554
2821
  async spawnClaudeWindow() {
2555
- this.spawning = true;
2822
+ this.beginSpawn();
2556
2823
  let resumedSuccessfully = false;
2557
2824
  try {
2558
2825
  this.toolStatusLines = [];
@@ -2589,7 +2856,7 @@ export class Daemon extends EventEmitter {
2589
2856
  this.backgroundSessionRecoveryAttempted = false;
2590
2857
  }
2591
2858
  finally {
2592
- this.spawning = false;
2859
+ this.endSpawn();
2593
2860
  }
2594
2861
  return resumedSuccessfully;
2595
2862
  }
@@ -2682,7 +2949,12 @@ export class Daemon extends EventEmitter {
2682
2949
  windowId = this.tmux.getWindowId();
2683
2950
  }
2684
2951
  else {
2952
+ // A crash respawn makes a brand new window. The dead one stays registered
2953
+ // unless we say so — the respawn branch above already does.
2954
+ const retired = this.tmux.getWindowId();
2685
2955
  windowId = await this.tmux.createWindow(cmd, resolvedCwd, this.name);
2956
+ if (retired && retired !== windowId)
2957
+ this.controlClient?.unregisterWindow(retired);
2686
2958
  }
2687
2959
  writeFileSync(join(this.instanceDir, "window-id"), windowId);
2688
2960
  // Enable remain-on-exit to capture exit codes on crash
@@ -2748,15 +3020,20 @@ export class Daemon extends EventEmitter {
2748
3020
  for (const dialog of startupDialogs) {
2749
3021
  if (dialog.pattern.test(pane)) {
2750
3022
  this.logger.debug(`Dismissing startup dialog: ${dialog.description}`);
2751
- for (const key of dialog.keys) {
2752
- if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
2753
- await this.tmux.sendSpecialKey(key);
2754
- }
2755
- else {
2756
- await this.tmux.sendKeys(key);
3023
+ // Restart is exactly when inbound messages pile up, and nothing gates
3024
+ // delivery on `spawning`. Take the pane lock for the key sequence so a
3025
+ // queued message cannot be pasted into a half-dismissed trust dialog.
3026
+ await this.paneWriteLock.run(async () => {
3027
+ for (const key of dialog.keys) {
3028
+ if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
3029
+ await this.tmux.sendSpecialKey(key);
3030
+ }
3031
+ else {
3032
+ await this.tmux.sendKeys(key);
3033
+ }
3034
+ await new Promise(r => setTimeout(r, 200));
2757
3035
  }
2758
- await new Promise(r => setTimeout(r, 200));
2759
- }
3036
+ });
2760
3037
  // Wait for next screen to render
2761
3038
  if (this.controlClient) {
2762
3039
  const wid = readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim();