@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.
- package/dist/backend/claude-code.d.ts +37 -0
- package/dist/backend/claude-code.js +40 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/kiro.d.ts +20 -0
- package/dist/backend/kiro.js +30 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +27 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +15 -0
- package/dist/channel/adapters/discord.js +81 -1
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +21 -0
- package/dist/channel/adapters/telegram.js +82 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +9 -1
- package/dist/channel/ipc-bridge.js +12 -3
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/ipc-timeouts.d.ts +40 -0
- package/dist/channel/ipc-timeouts.js +58 -0
- package/dist/channel/ipc-timeouts.js.map +1 -0
- package/dist/channel/mcp-server.js +5 -4
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/types.d.ts +29 -0
- package/dist/daemon.d.ts +114 -5
- package/dist/daemon.js +336 -59
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +92 -1
- package/dist/fleet-manager.js +346 -20
- package/dist/fleet-manager.js.map +1 -1
- package/dist/hang-detector.d.ts +19 -13
- package/dist/hang-detector.js +19 -49
- package/dist/hang-detector.js.map +1 -1
- package/dist/instance-lifecycle.js +9 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/outbound-handlers.d.ts +7 -0
- package/dist/outbound-handlers.js +62 -4
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/pane-write-lock.d.ts +48 -0
- package/dist/pane-write-lock.js +73 -0
- package/dist/pane-write-lock.js.map +1 -0
- package/dist/tmux-control.d.ts +48 -1
- package/dist/tmux-control.js +79 -6
- package/dist/tmux-control.js.map +1 -1
- package/package.json +1 -1
- package/dist/channel/tool-tracker.d.ts +0 -13
- package/dist/channel/tool-tracker.js +0 -58
- package/dist/channel/tool-tracker.js.map +0 -1
- package/dist/daemon-entry.d.ts +0 -1
- package/dist/daemon-entry.js +0 -30
- 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.
|
|
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
|
-
|
|
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
|
|
756
|
-
//
|
|
757
|
-
//
|
|
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(
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1285
|
+
});
|
|
1286
|
+
if (!dismissed) {
|
|
1287
|
+
this.logger.debug({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
|
|
1189
1288
|
}
|
|
1190
|
-
return; // Dialog
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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,
|
|
2240
|
-
},
|
|
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,
|
|
2263
|
-
},
|
|
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,
|
|
2290
|
-
},
|
|
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
|
-
|
|
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,
|
|
2355
|
-
},
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
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
|
-
|
|
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();
|