@songsid/agend 2.1.2-beta.15 → 2.1.2-beta.16
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/access-path.js +15 -6
- package/dist/access-path.js.map +1 -1
- package/dist/backend/claude-code.d.ts +37 -0
- package/dist/backend/claude-code.js +43 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +22 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.d.ts +23 -0
- package/dist/backend/grok.js +25 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +20 -0
- package/dist/backend/kiro.js +59 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +27 -0
- package/dist/backend/types.js +1 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +15 -0
- package/dist/channel/adapters/discord.js +90 -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 +25 -14
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +4 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/reconnect-backoff.d.ts +17 -0
- package/dist/channel/reconnect-backoff.js +21 -0
- package/dist/channel/reconnect-backoff.js.map +1 -0
- package/dist/channel/types.d.ts +29 -0
- package/dist/classic-channel-manager.js +1 -5
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +129 -50
- package/dist/cli.js.map +1 -1
- package/dist/completion.d.ts +27 -0
- package/dist/completion.js +121 -0
- package/dist/completion.js.map +1 -0
- package/dist/config-validator.js +9 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/cost-guard.d.ts +3 -1
- package/dist/cost-guard.js +3 -1
- package/dist/cost-guard.js.map +1 -1
- package/dist/daemon.d.ts +164 -5
- package/dist/daemon.js +711 -290
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.d.ts +31 -0
- package/dist/event-log.js +96 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +188 -1
- package/dist/fleet-manager.js +697 -61
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +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 +25 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +1 -0
- package/dist/locale.js.map +1 -1
- package/dist/mcp-liveness.d.ts +21 -0
- package/dist/mcp-liveness.js +27 -0
- package/dist/mcp-liveness.js.map +1 -0
- package/dist/outbound-handlers.d.ts +7 -0
- package/dist/outbound-handlers.js +79 -5
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -2
- package/dist/outbound-schemas.js +13 -2
- package/dist/outbound-schemas.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/process-memory.d.ts +31 -0
- package/dist/process-memory.js +79 -0
- package/dist/process-memory.js.map +1 -0
- package/dist/quickstart.js +17 -16
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.js +3 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/sd-notify.d.ts +27 -0
- package/dist/sd-notify.js +33 -1
- package/dist/sd-notify.js.map +1 -1
- package/dist/secret-file.d.ts +33 -0
- package/dist/secret-file.js +36 -0
- package/dist/secret-file.js.map +1 -0
- package/dist/setup-wizard.js +9 -7
- package/dist/setup-wizard.js.map +1 -1
- package/dist/tmux-control.d.ts +58 -5
- package/dist/tmux-control.js +102 -14
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +31 -1
- package/dist/tmux-manager.js +40 -11
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +22 -2
- package/dist/topic-commands.js +102 -19
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/ui/view.html +123 -2
- package/dist/usage/providers.d.ts +50 -0
- package/dist/usage/providers.js +611 -0
- package/dist/usage/providers.js.map +1 -0
- package/dist/usage/usage-api.d.ts +50 -0
- package/dist/usage/usage-api.js +111 -0
- package/dist/usage/usage-api.js.map +1 -0
- package/dist/view-api.js +1 -0
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.js +5 -2
- package/dist/web-api.js.map +1 -1
- package/package.json +4 -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
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { join, dirname, basename, resolve } from "node:path";
|
|
2
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync,
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
6
|
import { rotateLogIfNeeded } from "./logger.js";
|
|
7
|
+
import { mcpServerState } from "./mcp-liveness.js";
|
|
7
8
|
import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
|
|
8
9
|
import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
|
|
9
10
|
import { TranscriptMonitor } from "./transcript-monitor.js";
|
|
10
11
|
import { ContextGuardian } from "./context-guardian.js";
|
|
11
12
|
import { IpcServer } from "./channel/ipc-bridge.js";
|
|
13
|
+
import { daemonBudgetMs } from "./channel/ipc-timeouts.js";
|
|
12
14
|
import { MessageBus } from "./channel/message-bus.js";
|
|
13
15
|
import { shellQuote } from "./backend/types.js";
|
|
14
16
|
import { getTmuxSession } from "./config.js";
|
|
15
17
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
16
18
|
import { HangDetector } from "./hang-detector.js";
|
|
19
|
+
import { writeSecretFile } from "./secret-file.js";
|
|
20
|
+
import { PaneWriteLock } from "./pane-write-lock.js";
|
|
17
21
|
import { buildFleetInstructions } from "./instructions.js";
|
|
18
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
23
|
const __dirname = dirname(__filename);
|
|
@@ -38,9 +42,9 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
|
|
|
38
42
|
export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
|
|
39
43
|
export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
|
|
40
44
|
export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
|
|
41
|
-
/** @deprecated State detection is event-driven; this now aliases the safety sweep. */
|
|
42
|
-
export const DEFAULT_STATE_POLL_INTERVAL_MS = DEFAULT_STATE_SAFETY_SWEEP_MS;
|
|
43
45
|
const LAST_INBOUND_FILE = "last-inbound-at";
|
|
46
|
+
/** Minimum gap between "health check is failing" notifications for one instance. */
|
|
47
|
+
const HEALTH_ERROR_NOTIFY_INTERVAL_MS = 10 * 60_000;
|
|
44
48
|
/**
|
|
45
49
|
* Whether two working directories belong to the same project, so a fleet-scoped
|
|
46
50
|
* decision recorded in one reaches the other. Covers the worktree/checkout
|
|
@@ -104,6 +108,40 @@ export function writeLastInboundAt(instanceDir, timestamp) {
|
|
|
104
108
|
writeFileSync(temp, String(timestamp));
|
|
105
109
|
renameSync(temp, target);
|
|
106
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Render the handoff/routing metadata that rides along with an inbound message
|
|
113
|
+
* into the block actually pasted into the agent's pane.
|
|
114
|
+
*
|
|
115
|
+
* These fields were all populated by the sender (outbound-handlers builds them
|
|
116
|
+
* into `ipcMeta`) and delivered over IPC, but never rendered — so the receiving
|
|
117
|
+
* agent could not see them, and several documented flows could not work:
|
|
118
|
+
*
|
|
119
|
+
* - `report_result` requires a `correlation_id` the agent had no way to know, so
|
|
120
|
+
* the "correlation_id not recognized" warning fired on essentially every call.
|
|
121
|
+
* - Delegation cancel buttons are retired by correlation id, so they never retired.
|
|
122
|
+
* - `react`, `edit_message` and `reply.reply_to` need `message_id`, which the tool
|
|
123
|
+
* descriptions tell the agent to take "from the inbound block".
|
|
124
|
+
* - `download_attachment` needs `attachment_file_id`.
|
|
125
|
+
* - `requires_reply` was invisible, so a delegated task looked like an FYI.
|
|
126
|
+
*
|
|
127
|
+
* Only non-empty fields are emitted, so a plain user message gains at most a
|
|
128
|
+
* message_id line.
|
|
129
|
+
*/
|
|
130
|
+
export function renderHandoffMetadata(meta) {
|
|
131
|
+
const rows = [];
|
|
132
|
+
const add = (label, value) => {
|
|
133
|
+
if (value && value.trim())
|
|
134
|
+
rows.push(`${label}: ${value.trim()}`);
|
|
135
|
+
};
|
|
136
|
+
add("message_id", meta.message_id);
|
|
137
|
+
add("correlation_id", meta.correlation_id);
|
|
138
|
+
add("request_kind", meta.request_kind);
|
|
139
|
+
add("task_summary", meta.task_summary);
|
|
140
|
+
add("working_directory", meta.working_directory);
|
|
141
|
+
add("branch", meta.branch);
|
|
142
|
+
add("attachment_file_id", meta.attachment_file_id);
|
|
143
|
+
return rows.length ? `\n(${rows.join(" | ")})` : "";
|
|
144
|
+
}
|
|
107
145
|
/** Headless inactivity timer used by the daemon and unit tests. */
|
|
108
146
|
export class AutoPauseController {
|
|
109
147
|
thresholdMs;
|
|
@@ -148,24 +186,47 @@ export class AutoPauseController {
|
|
|
148
186
|
* Pane motion wins over a ready match because several backends keep their ready
|
|
149
187
|
* marker in a persistent header/footer while generating. A stable ready pane is
|
|
150
188
|
* idle; changing content is working; stable non-ready content eventually sticks.
|
|
189
|
+
*
|
|
190
|
+
* ## Why an optional busy pattern exists
|
|
191
|
+
*
|
|
192
|
+
* "Motion wins" degrades badly when a backend's ready marker is *always* on
|
|
193
|
+
* screen. `stuck` requires `!ready`, so for such a backend the state can only ever
|
|
194
|
+
* be idle or working — the stuck edge, and with it `handleStuckTransition` and the
|
|
195
|
+
* hang notification, is unreachable. A frozen CLI is then reported as idle, which
|
|
196
|
+
* also clears pending work: the message the user sent is quietly booked as done.
|
|
197
|
+
*
|
|
198
|
+
* claude-code was exactly this case (see ClaudeCodeBackend.getReadyPattern). A
|
|
199
|
+
* backend that can point at a marker meaning "generating right now" supplies
|
|
200
|
+
* `getBusyPattern()`, and that match vetoes ready regardless of what the ready
|
|
201
|
+
* pattern says.
|
|
151
202
|
*/
|
|
152
203
|
export class PaneStateMachine {
|
|
153
204
|
stuckTimeoutMs;
|
|
154
205
|
readyPattern;
|
|
206
|
+
busyPattern;
|
|
155
207
|
lastPaneHash = null;
|
|
156
208
|
lastPaneChangeAt;
|
|
157
209
|
lastObservedAt;
|
|
158
210
|
stateChangedAt;
|
|
159
211
|
currentState = "idle";
|
|
160
|
-
constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now()) {
|
|
212
|
+
constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern) {
|
|
161
213
|
this.stuckTimeoutMs = stuckTimeoutMs;
|
|
162
214
|
// Stateful g/y regexes mutate lastIndex and can alternate true/false across
|
|
163
215
|
// polls. State detection must be deterministic for identical pane content.
|
|
164
216
|
this.readyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
|
|
217
|
+
this.busyPattern = busyPattern
|
|
218
|
+
? new RegExp(busyPattern.source, busyPattern.flags.replace(/[gy]/g, ""))
|
|
219
|
+
: null;
|
|
165
220
|
this.lastPaneChangeAt = now;
|
|
166
221
|
this.lastObservedAt = now;
|
|
167
222
|
this.stateChangedAt = now;
|
|
168
223
|
}
|
|
224
|
+
/** Ready, unless the backend can positively see that it is still generating. */
|
|
225
|
+
isReady(pane) {
|
|
226
|
+
if (this.busyPattern?.test(pane))
|
|
227
|
+
return false;
|
|
228
|
+
return this.readyPattern.test(pane);
|
|
229
|
+
}
|
|
169
230
|
observe(pane, now = Date.now()) {
|
|
170
231
|
const paneHash = createHash("sha256").update(pane).digest("hex");
|
|
171
232
|
const firstObservation = this.lastPaneHash === null;
|
|
@@ -175,7 +236,7 @@ export class PaneStateMachine {
|
|
|
175
236
|
this.lastPaneChangeAt = now;
|
|
176
237
|
}
|
|
177
238
|
this.lastObservedAt = now;
|
|
178
|
-
const ready = this.
|
|
239
|
+
const ready = this.isReady(pane);
|
|
179
240
|
const nextState = firstObservation
|
|
180
241
|
? ready ? "idle" : "working"
|
|
181
242
|
: paneChanged
|
|
@@ -241,6 +302,21 @@ const FIRST_ENTER_SETTLE_MS = 1_750;
|
|
|
241
302
|
const FIRST_DELIVERY_WINDOW_MS = 5_000;
|
|
242
303
|
/** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
|
|
243
304
|
const NATIVE_QUEUE_PASTE_VERIFY_MS = 2_000;
|
|
305
|
+
/** Enter-confirmation poll: 10 × 200ms ≈ 2s of observed silence before giving up. */
|
|
306
|
+
const CONFIRM_BUSY_POLLS = 10;
|
|
307
|
+
const CONFIRM_BUSY_POLL_MS = 200;
|
|
308
|
+
/**
|
|
309
|
+
* Ceiling on the whole confirmation, including time re-earned after a control-mode
|
|
310
|
+
* reconnect. Without it a reconnect loop could restart the poll indefinitely and
|
|
311
|
+
* hold the pane write lock along with it.
|
|
312
|
+
*/
|
|
313
|
+
const CONFIRM_BUSY_MAX_WAIT_MS = 10_000;
|
|
314
|
+
/**
|
|
315
|
+
* How long a delivery waits for an in-flight spawn. Comfortably past the default
|
|
316
|
+
* 25s startup timeout plus dialog dismissal; past it we fall back to the old
|
|
317
|
+
* behaviour rather than holding a message indefinitely.
|
|
318
|
+
*/
|
|
319
|
+
const SPAWN_SETTLE_MAX_WAIT_MS = 60_000;
|
|
244
320
|
/**
|
|
245
321
|
* One-shot timing gate for the first paste after a CLI reaches its ready
|
|
246
322
|
* prompt. Recent tmux versions expose a short interval where the prompt is
|
|
@@ -299,7 +375,6 @@ export class Daemon extends EventEmitter {
|
|
|
299
375
|
ipcServer = null;
|
|
300
376
|
messageBus;
|
|
301
377
|
transcriptMonitor = null;
|
|
302
|
-
toolTracker = null;
|
|
303
378
|
guardian = null;
|
|
304
379
|
adapter = null;
|
|
305
380
|
pendingIpcRequests = new Map();
|
|
@@ -310,6 +385,8 @@ export class Daemon extends EventEmitter {
|
|
|
310
385
|
// Pending ack: react 🫡 on first transcript activity after receiving a message
|
|
311
386
|
pendingAckMessage = null;
|
|
312
387
|
// Tool status tracking for channel adapter
|
|
388
|
+
/** Last activity published to the fleet manager; null when nothing is running. */
|
|
389
|
+
currentActivity = null;
|
|
313
390
|
toolStatusMessageId = null;
|
|
314
391
|
toolStatusLines = [];
|
|
315
392
|
toolStatusDebounce = null;
|
|
@@ -325,9 +402,19 @@ export class Daemon extends EventEmitter {
|
|
|
325
402
|
lastSpawnAt = 0;
|
|
326
403
|
crashTimestamps = [];
|
|
327
404
|
healthCheckPaused = false;
|
|
405
|
+
lastHealthErrorNotifyAt = 0;
|
|
328
406
|
/** CLI pane availability, independent from the daemon process and tri-state. */
|
|
329
407
|
processStatus = "running";
|
|
330
408
|
spawning = false;
|
|
409
|
+
/**
|
|
410
|
+
* Resolves when the in-flight spawn finishes; null when none is running.
|
|
411
|
+
*
|
|
412
|
+
* `spawning` alone can only be polled. Delivery needs to *wait*, and a boolean
|
|
413
|
+
* poll would either busy-loop or race the flag being cleared.
|
|
414
|
+
*/
|
|
415
|
+
spawnSettled = null;
|
|
416
|
+
resolveSpawnSettled = null;
|
|
417
|
+
spawnDepth = 0;
|
|
331
418
|
skipResume = false;
|
|
332
419
|
backgroundSessionRecoveryAttempted = false;
|
|
333
420
|
/** Whether the last spawn started a fresh session (not resumed). */
|
|
@@ -350,6 +437,7 @@ export class Daemon extends EventEmitter {
|
|
|
350
437
|
instanceStateIdleDebounceMs = DEFAULT_STATE_IDLE_DEBOUNCE_MS;
|
|
351
438
|
instanceStateStuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS;
|
|
352
439
|
instanceStateReadyPattern = null;
|
|
440
|
+
instanceStateBusyPattern = null;
|
|
353
441
|
instanceStateMonitorActive = false;
|
|
354
442
|
statePollInFlight = false;
|
|
355
443
|
autoPauseController;
|
|
@@ -370,7 +458,13 @@ export class Daemon extends EventEmitter {
|
|
|
370
458
|
recentEvents = [];
|
|
371
459
|
recentToolActivity = [];
|
|
372
460
|
snapshotConsumed = false;
|
|
461
|
+
/** Orders inbound channel messages against each other (queue-depth accounting
|
|
462
|
+
* and ⏳/👀/✅ reactions live here). It does NOT cover the other writers that
|
|
463
|
+
* reach the pane — {@link paneWriteLock} does. */
|
|
373
464
|
pasteLock = Promise.resolve();
|
|
465
|
+
/** Mutual exclusion for *every* write into the pane, whichever subsystem it
|
|
466
|
+
* comes from. See PaneWriteLock for why interleaving is destructive. */
|
|
467
|
+
paneWriteLock = new PaneWriteLock();
|
|
374
468
|
pendingInstructionsUpdate;
|
|
375
469
|
pendingInstructionsNotice = false;
|
|
376
470
|
// Whether the warmup steering-reload notice should be injected after spawn.
|
|
@@ -383,6 +477,10 @@ export class Daemon extends EventEmitter {
|
|
|
383
477
|
firstDeliveryDelay = new FirstDeliveryDelay();
|
|
384
478
|
// PTY error pattern monitoring
|
|
385
479
|
errorMonitorTimer = null;
|
|
480
|
+
/** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
|
|
481
|
+
static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
|
|
482
|
+
lastMcpDeathNotifiedAt = 0;
|
|
483
|
+
mcpDeathNotifiedForPid = null;
|
|
386
484
|
/** Prevent in-flight monitor callbacks from re-arming after a pause. */
|
|
387
485
|
runtimeMonitorsFrozen = false;
|
|
388
486
|
errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
|
|
@@ -509,9 +607,6 @@ export class Daemon extends EventEmitter {
|
|
|
509
607
|
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) {
|
|
510
608
|
key = String(msg.fleetRequestId);
|
|
511
609
|
}
|
|
512
|
-
else if (type === "fleet_outbound_response" && msg.requestId != null) {
|
|
513
|
-
key = `fleet_out_${msg.requestId}`;
|
|
514
|
-
}
|
|
515
610
|
if (key && this.pendingIpcRequests.has(key)) {
|
|
516
611
|
const handler = this.pendingIpcRequests.get(key);
|
|
517
612
|
this.pendingIpcRequests.delete(key);
|
|
@@ -662,7 +757,12 @@ export class Daemon extends EventEmitter {
|
|
|
662
757
|
else {
|
|
663
758
|
await new Promise(r => setTimeout(r, 5000));
|
|
664
759
|
}
|
|
665
|
-
|
|
760
|
+
// This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
|
|
761
|
+
// delivery is already in flight or queued. Without the lock the notice and
|
|
762
|
+
// that delivery race into the same pane.
|
|
763
|
+
await this.paneWriteLock.run(async () => {
|
|
764
|
+
await this.tmux?.pasteText(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), this.systemPasteOptions());
|
|
765
|
+
});
|
|
666
766
|
// Record the value the agent has now been told about so the next
|
|
667
767
|
// unchanged restart skips the reload.
|
|
668
768
|
try {
|
|
@@ -695,27 +795,31 @@ export class Daemon extends EventEmitter {
|
|
|
695
795
|
this.transcriptMonitor.on("tool_use", (name, input) => {
|
|
696
796
|
this.logger.debug({ tool: name }, "Tool use");
|
|
697
797
|
ackIfPending();
|
|
698
|
-
this.hangDetector?.recordActivity();
|
|
699
798
|
this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
|
|
700
799
|
this.recordRecentToolActivity(this.summarizeTool(name, input));
|
|
800
|
+
this.publishActivity(this.summarizeTool(name, input));
|
|
701
801
|
});
|
|
702
802
|
this.transcriptMonitor.on("tool_result", (name, _output) => {
|
|
703
|
-
this.hangDetector?.recordActivity();
|
|
704
803
|
this.recordRecentEvent({ type: "tool_result", name });
|
|
804
|
+
// The tool finished; whatever comes next has not started yet. Better to
|
|
805
|
+
// show only elapsed time than to leave a stale "Bash: npm test" on screen.
|
|
806
|
+
this.publishActivity(null);
|
|
705
807
|
});
|
|
706
808
|
this.transcriptMonitor.on("assistant_text", (text) => {
|
|
707
809
|
this.logger.debug({ text: text.slice(0, 200) }, "Claude response");
|
|
708
810
|
ackIfPending();
|
|
709
|
-
this.hangDetector?.recordActivity();
|
|
710
811
|
this.recordRecentEvent({ type: "assistant_text", preview: text.slice(0, 100) });
|
|
711
812
|
});
|
|
712
813
|
this.transcriptMonitor.startPolling();
|
|
713
|
-
// HangDetector
|
|
714
|
-
//
|
|
715
|
-
//
|
|
814
|
+
// HangDetector is the notification bridge to the fleet manager; pane state
|
|
815
|
+
// transitions below are the sole source of hang events (see hang-detector.ts).
|
|
816
|
+
// `hang_detector.enabled: false` opts out of the notification entirely.
|
|
817
|
+
// NOTE: `hang_detector.timeout_minutes` is NOT read here — the stuck timeout
|
|
818
|
+
// the pane monitor actually uses is resolved separately below. The two have
|
|
819
|
+
// long been separate; documented rather than silently ignored.
|
|
716
820
|
const hangConfig = this.config.hang_detector;
|
|
717
821
|
if (hangConfig?.enabled !== false) {
|
|
718
|
-
this.hangDetector = new HangDetector(
|
|
822
|
+
this.hangDetector = new HangDetector();
|
|
719
823
|
}
|
|
720
824
|
// 8. Context guardian
|
|
721
825
|
const statusFile = join(this.instanceDir, "statusline.json");
|
|
@@ -723,7 +827,6 @@ export class Daemon extends EventEmitter {
|
|
|
723
827
|
this.guardian.startWatching();
|
|
724
828
|
this.guardian.on("status_update", () => {
|
|
725
829
|
this.saveSessionId();
|
|
726
|
-
this.hangDetector?.recordStatuslineUpdate();
|
|
727
830
|
});
|
|
728
831
|
// Context rotation removed: all CLI backends have built-in auto-compact.
|
|
729
832
|
// Crash recovery (health check + respawn with snapshot) is retained below.
|
|
@@ -743,6 +846,40 @@ export class Daemon extends EventEmitter {
|
|
|
743
846
|
this.startInstanceStateMonitor();
|
|
744
847
|
this.logger.info(`${this.name} ready`);
|
|
745
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Detect a CRASHED MCP server and report it once.
|
|
851
|
+
*
|
|
852
|
+
* The MCP server writes its pid to channel.mcp.pid at startup and unlinks it on
|
|
853
|
+
* a clean exit, so "file present + pid dead" specifically means it died without
|
|
854
|
+
* cleaning up (crash / OOM / SIGKILL). A missing file is either "not started
|
|
855
|
+
* yet" or "exited cleanly" — neither is an incident, and an orphan exit implies
|
|
856
|
+
* the CLI itself died, which the crash detector already covers.
|
|
857
|
+
*
|
|
858
|
+
* The daemon deliberately does NOT try to respawn it: MCP is a stdio protocol
|
|
859
|
+
* where the CLI spawns the server and owns its pipes, so a daemon-spawned
|
|
860
|
+
* process would have no client reading it. Only the CLI can restore its own
|
|
861
|
+
* tools — hence notify, and let the operator decide about restarting.
|
|
862
|
+
*/
|
|
863
|
+
checkMcpServerAlive() {
|
|
864
|
+
if (this.isPaused)
|
|
865
|
+
return;
|
|
866
|
+
const status = mcpServerState(this.instanceDir);
|
|
867
|
+
if (status.state === "unknown")
|
|
868
|
+
return;
|
|
869
|
+
if (status.state === "alive") {
|
|
870
|
+
this.mcpDeathNotifiedForPid = null; // the CLI respawned it — re-arm
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// Dead: report once per pid, and at most once per cooldown window.
|
|
874
|
+
if (this.mcpDeathNotifiedForPid === status.pid)
|
|
875
|
+
return;
|
|
876
|
+
if (Date.now() - this.lastMcpDeathNotifiedAt < Daemon.MCP_DEATH_COOLDOWN_MS)
|
|
877
|
+
return;
|
|
878
|
+
this.mcpDeathNotifiedForPid = status.pid;
|
|
879
|
+
this.lastMcpDeathNotifiedAt = Date.now();
|
|
880
|
+
this.logger.error({ pid: status.pid }, "MCP server process is gone — instance has no agend tools");
|
|
881
|
+
this.emit("mcp_died", { name: this.name, pid: status.pid });
|
|
882
|
+
}
|
|
746
883
|
startHealthCheck() {
|
|
747
884
|
if (this.runtimeMonitorsFrozen || this.healthCheckTimer)
|
|
748
885
|
return;
|
|
@@ -758,254 +895,285 @@ export class Daemon extends EventEmitter {
|
|
|
758
895
|
this.healthCheckTimer = null;
|
|
759
896
|
if (this.runtimeMonitorsFrozen)
|
|
760
897
|
return;
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
898
|
+
// The whole tick is guarded: an unguarded throw in here (a tmux hiccup,
|
|
899
|
+
// ENOSPC on the crash-history write) became an unhandled rejection, which
|
|
900
|
+
// the CLI turned into stopAll() + exit(1) for the ENTIRE fleet, and it also
|
|
901
|
+
// ended this instance's health loop because scheduleNext() sat after the
|
|
902
|
+
// throwing code. Catch, log, and keep checking.
|
|
903
|
+
try {
|
|
904
|
+
// Instance directory removed externally (e.g. `rm -rf ~/.agend/instances/<name>`).
|
|
905
|
+
// Stop the loop permanently — otherwise every tick triggers a respawn, whose
|
|
906
|
+
// writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
|
|
907
|
+
// spamming errors every ~30s forever.
|
|
908
|
+
if (!existsSync(this.instanceDir)) {
|
|
909
|
+
this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
|
|
910
|
+
this.healthCheckPaused = true;
|
|
911
|
+
this.healthCheckTimer = null;
|
|
912
|
+
this.emitSupervisionEnded("instance directory was removed from disk", "Recreate the instance, or delete it from fleet.yaml.");
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) {
|
|
916
|
+
scheduleNext();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
// The CLI owns the MCP server process, so the daemon can only observe it.
|
|
920
|
+
this.checkMcpServerAlive();
|
|
921
|
+
// Human-readable backend label for logs (e.g. "claude", "kiro-cli")
|
|
922
|
+
const cliLabel = this.backend?.binaryName ?? "CLI";
|
|
923
|
+
let paneStatus = await this.tmux.getPaneStatus();
|
|
924
|
+
// Auto-pause intentionally exits the pane process. A health tick that
|
|
925
|
+
// began just before pause must not classify that exit as a crash.
|
|
926
|
+
if (this.isPaused || this.pauseWakeState === "waking") {
|
|
927
|
+
scheduleNext();
|
|
928
|
+
return;
|
|
789
929
|
}
|
|
790
|
-
scheduleNext();
|
|
791
|
-
return;
|
|
792
|
-
}
|
|
793
|
-
// A null status is ambiguous: it can be a transient `tmux list-panes`
|
|
794
|
-
// failure (e.g. tmux busy during a fleet-restart storm) rather than a
|
|
795
|
-
// real exit. Re-confirm once after a short delay before treating it as
|
|
796
|
-
// a crash. A non-null {alive:false} is a definite dead pane (real exit)
|
|
797
|
-
// and needs no recheck.
|
|
798
|
-
if (paneStatus === null) {
|
|
799
|
-
await new Promise(r => setTimeout(r, 1500));
|
|
800
|
-
paneStatus = await this.tmux.getPaneStatus();
|
|
801
930
|
if (paneStatus?.alive) {
|
|
802
|
-
|
|
931
|
+
// Instance output.log is fed by tmux pipe-pane and was previously never
|
|
932
|
+
// rotated (only fleet.log / daemon.log were). Cap growth every tick.
|
|
933
|
+
if (!this.config.lightweight) {
|
|
934
|
+
rotateLogIfNeeded(join(this.instanceDir, "output.log"));
|
|
935
|
+
}
|
|
803
936
|
scheduleNext();
|
|
804
937
|
return;
|
|
805
938
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
this.healthCheckPaused = true;
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
this.setProcessStatus("crashed");
|
|
820
|
-
// Distinguish tmux server crash from single window crash.
|
|
821
|
-
// nullReason records *why* getPaneStatus returned null (for diagnosing
|
|
822
|
-
// whether this was a real window loss or a transient query failure).
|
|
823
|
-
let crashType = "window";
|
|
824
|
-
let nullReason;
|
|
825
|
-
if (!paneStatus) {
|
|
826
|
-
const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
|
|
827
|
-
if (!serverAlive) {
|
|
828
|
-
crashType = "server";
|
|
829
|
-
nullReason = "server_gone";
|
|
830
|
-
this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
|
|
831
|
-
// Fleet-level circuit breaker: pause all instances on repeated tmux server crashes
|
|
832
|
-
Daemon.tmuxServerCrashTimestamps.push(Date.now());
|
|
833
|
-
const cutoff = Date.now() - 5 * 60_000;
|
|
834
|
-
Daemon.tmuxServerCrashTimestamps = Daemon.tmuxServerCrashTimestamps.filter(t => t > cutoff);
|
|
835
|
-
if (Daemon.tmuxServerCrashTimestamps.length >= 2 && !Daemon.tmuxServerPaused) {
|
|
836
|
-
Daemon.tmuxServerPaused = true;
|
|
837
|
-
this.logger.error("Fleet-level tmux server circuit breaker triggered — pausing all respawns for 30s");
|
|
838
|
-
this.emit("tmux_server_crash", this.name);
|
|
839
|
-
if (!Daemon.tmuxServerRecoveryTimer) {
|
|
840
|
-
Daemon.tmuxServerRecoveryTimer = setTimeout(() => {
|
|
841
|
-
Daemon.tmuxServerRecoveryTimer = null;
|
|
842
|
-
Daemon.tmuxServerPaused = false;
|
|
843
|
-
}, 30_000);
|
|
844
|
-
}
|
|
939
|
+
// A null status is ambiguous: it can be a transient `tmux list-panes`
|
|
940
|
+
// failure (e.g. tmux busy during a fleet-restart storm) rather than a
|
|
941
|
+
// real exit. Re-confirm once after a short delay before treating it as
|
|
942
|
+
// a crash. A non-null {alive:false} is a definite dead pane (real exit)
|
|
943
|
+
// and needs no recheck.
|
|
944
|
+
if (paneStatus === null) {
|
|
945
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
946
|
+
paneStatus = await this.tmux.getPaneStatus();
|
|
947
|
+
if (paneStatus?.alive) {
|
|
948
|
+
this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
|
|
845
949
|
scheduleNext();
|
|
846
950
|
return;
|
|
847
951
|
}
|
|
848
|
-
await new Promise(r => setTimeout(r, 2_000)); // let session stabilize
|
|
849
952
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
953
|
+
// paneStatus === null → window gone entirely (e.g. tmux server crash)
|
|
954
|
+
// paneStatus.alive === false → pane dead, exit code available
|
|
955
|
+
const exitCode = paneStatus?.exitCode;
|
|
956
|
+
this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
|
|
957
|
+
// Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
|
|
958
|
+
if (paneStatus && exitCode === 0) {
|
|
959
|
+
this.setProcessStatus("stopped");
|
|
960
|
+
this.logger.info("CLI exited normally (code 0) — pausing health check");
|
|
961
|
+
await this.tmux.killWindow();
|
|
962
|
+
this.healthCheckPaused = true;
|
|
963
|
+
this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
this.setProcessStatus("crashed");
|
|
967
|
+
// Distinguish tmux server crash from single window crash.
|
|
968
|
+
// nullReason records *why* getPaneStatus returned null (for diagnosing
|
|
969
|
+
// whether this was a real window loss or a transient query failure).
|
|
970
|
+
let crashType = "window";
|
|
971
|
+
let nullReason;
|
|
972
|
+
if (!paneStatus) {
|
|
973
|
+
const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
|
|
974
|
+
if (!serverAlive) {
|
|
975
|
+
crashType = "server";
|
|
976
|
+
nullReason = "server_gone";
|
|
977
|
+
this.logger.error(`tmux server died — all ${cliLabel} windows lost`);
|
|
978
|
+
// Fleet-level circuit breaker: pause all instances on repeated tmux server crashes
|
|
979
|
+
Daemon.tmuxServerCrashTimestamps.push(Date.now());
|
|
980
|
+
const cutoff = Date.now() - 5 * 60_000;
|
|
981
|
+
Daemon.tmuxServerCrashTimestamps = Daemon.tmuxServerCrashTimestamps.filter(t => t > cutoff);
|
|
982
|
+
if (Daemon.tmuxServerCrashTimestamps.length >= 2 && !Daemon.tmuxServerPaused) {
|
|
983
|
+
Daemon.tmuxServerPaused = true;
|
|
984
|
+
this.logger.error("Fleet-level tmux server circuit breaker triggered — pausing all respawns for 30s");
|
|
985
|
+
this.emit("tmux_server_crash", this.name);
|
|
986
|
+
if (!Daemon.tmuxServerRecoveryTimer) {
|
|
987
|
+
Daemon.tmuxServerRecoveryTimer = setTimeout(() => {
|
|
988
|
+
Daemon.tmuxServerRecoveryTimer = null;
|
|
989
|
+
Daemon.tmuxServerPaused = false;
|
|
990
|
+
}, 30_000);
|
|
991
|
+
}
|
|
992
|
+
scheduleNext();
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
await new Promise(r => setTimeout(r, 2_000)); // let session stabilize
|
|
858
996
|
}
|
|
859
|
-
|
|
860
|
-
|
|
997
|
+
else {
|
|
998
|
+
// null but server alive: window-level disappearance. Probe whether
|
|
999
|
+
// the window truly no longer exists vs a transient query glitch.
|
|
1000
|
+
nullReason = "no_window";
|
|
1001
|
+
try {
|
|
1002
|
+
const windows = await TmuxManager.listWindows(this.tmuxSessionName);
|
|
1003
|
+
if (windows.some(w => w.name === this.name))
|
|
1004
|
+
nullReason = "window_present_query_glitch";
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
nullReason = "query_error";
|
|
1008
|
+
}
|
|
1009
|
+
this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
|
|
861
1010
|
}
|
|
862
|
-
this.logger.warn({ exitCode, nullReason }, `${cliLabel} window not found (tmux server alive)`);
|
|
863
1011
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1012
|
+
else {
|
|
1013
|
+
this.logger.warn({ exitCode }, `${cliLabel} process exited`);
|
|
1014
|
+
}
|
|
1015
|
+
// Capture last output before killing. Best-effort even when the pane is
|
|
1016
|
+
// gone (paneStatus null) — gives the crash record something to diagnose
|
|
1017
|
+
// from instead of an empty lastOutput.
|
|
1018
|
+
let lastOutput;
|
|
1019
|
+
try {
|
|
1020
|
+
const raw = await this.tmux.capturePaneWithHistory(50);
|
|
1021
|
+
// Strip ANSI escape codes for readability
|
|
1022
|
+
const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
1023
|
+
lastOutput = cleaned.trimEnd() || undefined;
|
|
1024
|
+
}
|
|
1025
|
+
catch { /* best effort — pane may already be gone */ }
|
|
1026
|
+
// Kill the dead window (remain-on-exit keeps it around) before respawn
|
|
1027
|
+
if (paneStatus) {
|
|
1028
|
+
await this.tmux.killWindow();
|
|
1029
|
+
}
|
|
1030
|
+
// Detect claude-code background session conflict — recover without counting as crash
|
|
1031
|
+
if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
|
|
1032
|
+
if (!this.backgroundSessionRecoveryAttempted) {
|
|
1033
|
+
this.backgroundSessionRecoveryAttempted = true;
|
|
1034
|
+
this.logger.warn("Detected lingering background agent session — starting fresh (no resume)");
|
|
1035
|
+
const sidFile = join(this.instanceDir, "session-id");
|
|
1036
|
+
try {
|
|
1037
|
+
unlinkSync(sidFile);
|
|
1038
|
+
}
|
|
1039
|
+
catch { }
|
|
1040
|
+
this.skipResume = true;
|
|
1041
|
+
await new Promise(r => setTimeout(r, 2_000));
|
|
1042
|
+
try {
|
|
1043
|
+
await this.spawnClaudeWindow();
|
|
1044
|
+
this.setProcessStatus("running");
|
|
1045
|
+
this.logger.info("Recovered from background session conflict");
|
|
1046
|
+
this.emit("crash_respawn", this.name);
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
|
|
1050
|
+
}
|
|
1051
|
+
return; // Don't count as crash
|
|
1052
|
+
}
|
|
1053
|
+
// Already attempted recovery — fall through to normal crash handling
|
|
1054
|
+
}
|
|
1055
|
+
// Detect a --continue/--resume failure (no conversation to resume). The
|
|
1056
|
+
// session-id file persists across the crash, so a blind respawn would add
|
|
1057
|
+
// --continue again and crash in the same way → loop. Clear the session id
|
|
1058
|
+
// and skip resume so the next spawn starts fresh. (skipResume also stops
|
|
1059
|
+
// saveSessionId below from resurrecting the id from statusline.json.)
|
|
1060
|
+
if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
|
|
1061
|
+
this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
|
|
889
1062
|
try {
|
|
890
|
-
unlinkSync(
|
|
1063
|
+
unlinkSync(join(this.instanceDir, "session-id"));
|
|
891
1064
|
}
|
|
892
|
-
catch { }
|
|
1065
|
+
catch { /* may not exist */ }
|
|
893
1066
|
this.skipResume = true;
|
|
894
|
-
|
|
1067
|
+
}
|
|
1068
|
+
// Append to crash history
|
|
1069
|
+
this.appendCrashHistory({ exitCode, lastOutput, crashType, reason: nullReason });
|
|
1070
|
+
if (max_retries <= 0) {
|
|
1071
|
+
this.healthCheckPaused = true;
|
|
1072
|
+
this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
|
|
1073
|
+
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.");
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
// Detect rapid crash: sliding window — 3+ crashes in 5 minutes
|
|
1077
|
+
this.crashTimestamps.push(Date.now());
|
|
1078
|
+
const crashWindowMs = 5 * 60_000;
|
|
1079
|
+
this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
|
|
1080
|
+
if (this.crashTimestamps.length >= 3) {
|
|
1081
|
+
this.healthCheckPaused = true;
|
|
1082
|
+
this.logger.error({ crashesInWindow: this.crashTimestamps.length }, "3+ crashes in 5 minutes — pausing respawn");
|
|
1083
|
+
// P1: Persist crash state so next process restart skips resume
|
|
895
1084
|
try {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
catch (err) {
|
|
902
|
-
this.logger.error({ err: err.message }, "Recovery from background session conflict failed");
|
|
1085
|
+
writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
|
|
1086
|
+
crashesInWindow: this.crashTimestamps.length,
|
|
1087
|
+
lastCrashAt: Date.now(),
|
|
1088
|
+
resumeDisabled: true,
|
|
1089
|
+
}));
|
|
903
1090
|
}
|
|
904
|
-
|
|
1091
|
+
catch { /* best effort */ }
|
|
1092
|
+
this.emit("crash_loop", this.name);
|
|
1093
|
+
return; // don't schedule next — paused
|
|
905
1094
|
}
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
// session-id file persists across the crash, so a blind respawn would add
|
|
910
|
-
// --continue again and crash in the same way → loop. Clear the session id
|
|
911
|
-
// and skip resume so the next spawn starts fresh. (skipResume also stops
|
|
912
|
-
// saveSessionId below from resurrecting the id from statusline.json.)
|
|
913
|
-
if (lastOutput && /no conversation found|no conversation to (continue|resume)|no previous (session|conversation)|--continue/i.test(lastOutput)) {
|
|
914
|
-
this.logger.warn("Detected --continue/resume failure — clearing session-id; next spawn starts fresh");
|
|
915
|
-
try {
|
|
916
|
-
unlinkSync(join(this.instanceDir, "session-id"));
|
|
1095
|
+
// Reset crash count if enough time has passed
|
|
1096
|
+
if (reset_after > 0 && Date.now() - this.lastCrashAt > reset_after) {
|
|
1097
|
+
this.crashCount = 0;
|
|
917
1098
|
}
|
|
918
|
-
|
|
919
|
-
this.
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
this.logger.warn(`${cliLabel} window died — automatic restart is disabled`);
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
// Detect rapid crash: sliding window — 3+ crashes in 5 minutes
|
|
929
|
-
this.crashTimestamps.push(Date.now());
|
|
930
|
-
const crashWindowMs = 5 * 60_000;
|
|
931
|
-
this.crashTimestamps = this.crashTimestamps.filter(t => t > Date.now() - crashWindowMs);
|
|
932
|
-
if (this.crashTimestamps.length >= 3) {
|
|
933
|
-
this.healthCheckPaused = true;
|
|
934
|
-
this.logger.error({ crashesInWindow: this.crashTimestamps.length }, "3+ crashes in 5 minutes — pausing respawn");
|
|
935
|
-
// P1: Persist crash state so next process restart skips resume
|
|
936
|
-
try {
|
|
937
|
-
writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
|
|
938
|
-
crashesInWindow: this.crashTimestamps.length,
|
|
939
|
-
lastCrashAt: Date.now(),
|
|
940
|
-
resumeDisabled: true,
|
|
941
|
-
}));
|
|
1099
|
+
this.crashCount++;
|
|
1100
|
+
this.lastCrashAt = Date.now();
|
|
1101
|
+
if (this.crashCount > max_retries) {
|
|
1102
|
+
this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
|
|
1103
|
+
this.healthCheckPaused = true;
|
|
1104
|
+
this.emitSupervisionEnded(`it crashed ${this.crashCount} times, exceeding restart_policy.max_retries (${max_retries})`, "Check the logs for the cause, then restart it.");
|
|
1105
|
+
return; // don't schedule next — given up
|
|
942
1106
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
this.crashCount = 0;
|
|
950
|
-
}
|
|
951
|
-
this.crashCount++;
|
|
952
|
-
this.lastCrashAt = Date.now();
|
|
953
|
-
if (this.crashCount > max_retries) {
|
|
954
|
-
this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
|
|
955
|
-
return; // don't schedule next — given up
|
|
956
|
-
}
|
|
957
|
-
// Calculate backoff delay
|
|
958
|
-
const delay = backoff === "exponential"
|
|
959
|
-
? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
|
|
960
|
-
: 1000 * this.crashCount;
|
|
961
|
-
this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
|
|
962
|
-
await new Promise(r => setTimeout(r, delay));
|
|
963
|
-
try {
|
|
964
|
-
this.saveSessionId();
|
|
965
|
-
this.transcriptMonitor?.resetOffset();
|
|
966
|
-
// Kill orphan MCP server from the crashed CLI session.
|
|
967
|
-
// MCP server writes its PID to channel.mcp.pid on startup.
|
|
1107
|
+
// Calculate backoff delay
|
|
1108
|
+
const delay = backoff === "exponential"
|
|
1109
|
+
? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
|
|
1110
|
+
: 1000 * this.crashCount;
|
|
1111
|
+
this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
|
|
1112
|
+
await new Promise(r => setTimeout(r, delay));
|
|
968
1113
|
try {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
try {
|
|
979
|
-
const windows = await TmuxManager.listWindows(this.tmuxSessionName);
|
|
980
|
-
for (const w of windows) {
|
|
981
|
-
if (w.name === this.name) {
|
|
982
|
-
const tm = new TmuxManager(this.tmuxSessionName, w.id);
|
|
983
|
-
await tm.killWindow();
|
|
984
|
-
}
|
|
1114
|
+
this.saveSessionId();
|
|
1115
|
+
this.transcriptMonitor?.resetOffset();
|
|
1116
|
+
// Kill orphan MCP server from the crashed CLI session.
|
|
1117
|
+
// MCP server writes its PID to channel.mcp.pid on startup.
|
|
1118
|
+
try {
|
|
1119
|
+
const pidFile = join(this.instanceDir, "channel.mcp.pid");
|
|
1120
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
1121
|
+
process.kill(pid, "SIGTERM");
|
|
1122
|
+
this.logger.info({ pid }, "Killed orphan MCP server");
|
|
985
1123
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
// Try --resume first; spawnClaudeWindow falls back to fresh session if resume fails
|
|
991
|
-
const resumed = await this.spawnClaudeWindow();
|
|
992
|
-
if (!resumed) {
|
|
993
|
-
// Resume failed → fresh session → inject snapshot for context
|
|
994
|
-
await this.injectSnapshotMessage();
|
|
995
|
-
}
|
|
996
|
-
else {
|
|
997
|
-
// Clean up stale snapshot — resume restored full context
|
|
1124
|
+
catch { /* no pid file or process already dead */ }
|
|
1125
|
+
// Kill any same-name windows before respawn to prevent orphans.
|
|
1126
|
+
// Wrapped in try-catch: if tmux server is dead, listWindows throws —
|
|
1127
|
+
// must not block spawnClaudeWindow (which calls ensureSession).
|
|
998
1128
|
try {
|
|
999
|
-
|
|
1129
|
+
const windows = await TmuxManager.listWindows(this.tmuxSessionName);
|
|
1130
|
+
for (const w of windows) {
|
|
1131
|
+
if (w.name === this.name) {
|
|
1132
|
+
const tm = new TmuxManager(this.tmuxSessionName, w.id);
|
|
1133
|
+
await tm.killWindow();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1000
1136
|
}
|
|
1001
|
-
catch { /* may
|
|
1137
|
+
catch { /* tmux server may be dead — ensureSession in trySpawn will recover */ }
|
|
1138
|
+
// Write snapshot before spawn — consumed only if resume fails
|
|
1139
|
+
this.writeRotationSnapshot("crash");
|
|
1140
|
+
// Try --resume first; spawnClaudeWindow falls back to fresh session if resume fails
|
|
1141
|
+
const resumed = await this.spawnClaudeWindow();
|
|
1142
|
+
if (!resumed) {
|
|
1143
|
+
// Resume failed → fresh session → inject snapshot for context
|
|
1144
|
+
await this.injectSnapshotMessage();
|
|
1145
|
+
}
|
|
1146
|
+
else {
|
|
1147
|
+
// Clean up stale snapshot — resume restored full context
|
|
1148
|
+
try {
|
|
1149
|
+
unlinkSync(join(this.instanceDir, "rotation-state.json"));
|
|
1150
|
+
}
|
|
1151
|
+
catch { /* may not exist */ }
|
|
1152
|
+
}
|
|
1153
|
+
this.setProcessStatus("running");
|
|
1154
|
+
this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
|
|
1155
|
+
this.emit("crash_respawn", this.name);
|
|
1156
|
+
}
|
|
1157
|
+
catch (err) {
|
|
1158
|
+
this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
|
|
1002
1159
|
}
|
|
1003
|
-
this.setProcessStatus("running");
|
|
1004
|
-
this.logger.info({ resumed }, `Respawned ${cliLabel} window after crash`);
|
|
1005
|
-
this.emit("crash_respawn", this.name);
|
|
1006
1160
|
}
|
|
1007
1161
|
catch (err) {
|
|
1008
|
-
this.logger.error({ err },
|
|
1162
|
+
this.logger.error({ err }, "Health check tick failed — continuing");
|
|
1163
|
+
// Surface it to the operator, not just the log — a health check that
|
|
1164
|
+
// keeps throwing means this instance is no longer being supervised.
|
|
1165
|
+
// Throttled: the tick repeats every ~30s, so an unnotified persistent
|
|
1166
|
+
// fault would otherwise post twice a minute forever.
|
|
1167
|
+
const now = Date.now();
|
|
1168
|
+
if (now - this.lastHealthErrorNotifyAt > HEALTH_ERROR_NOTIFY_INTERVAL_MS) {
|
|
1169
|
+
this.lastHealthErrorNotifyAt = now;
|
|
1170
|
+
this.emit("health_check_error", {
|
|
1171
|
+
name: this.name,
|
|
1172
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
scheduleNext();
|
|
1176
|
+
return;
|
|
1009
1177
|
}
|
|
1010
1178
|
scheduleNext();
|
|
1011
1179
|
}, healthCheckIntervalMs);
|
|
@@ -1017,6 +1185,21 @@ export class Daemon extends EventEmitter {
|
|
|
1017
1185
|
* A dead remain-on-exit pane still contains the old ready prompt, so allowing
|
|
1018
1186
|
* the pane monitor to capture it would re-create a false idle state.
|
|
1019
1187
|
*/
|
|
1188
|
+
/**
|
|
1189
|
+
* Announce that this instance is no longer being supervised.
|
|
1190
|
+
*
|
|
1191
|
+
* Four health-check exits set `healthCheckPaused = true` and returned without
|
|
1192
|
+
* telling anyone: a clean CLI exit, `max_retries <= 0`, crash retries exhausted,
|
|
1193
|
+
* and the instance directory being deleted. Only the crash-LOOP case emitted an
|
|
1194
|
+
* event, so a fleet could quietly contain a dead instance that still looked fine
|
|
1195
|
+
* on the dashboard — while messages routed to it queued or failed with a bare ❌.
|
|
1196
|
+
*
|
|
1197
|
+
* `crash_loop` already had this treatment; this is the same bridge for the other
|
|
1198
|
+
* four, carrying a human-readable cause and the operator's next step.
|
|
1199
|
+
*/
|
|
1200
|
+
emitSupervisionEnded(reason, remedy) {
|
|
1201
|
+
this.emit("supervision_ended", { name: this.name, reason, remedy });
|
|
1202
|
+
}
|
|
1020
1203
|
setProcessStatus(status) {
|
|
1021
1204
|
if (this.processStatus === status)
|
|
1022
1205
|
return;
|
|
@@ -1062,6 +1245,7 @@ export class Daemon extends EventEmitter {
|
|
|
1062
1245
|
if (!this.backend)
|
|
1063
1246
|
return; // lightweight mode has no backend
|
|
1064
1247
|
const readyPattern = this.backend.getReadyPattern();
|
|
1248
|
+
const busyPattern = this.backend.getBusyPattern?.() ?? null;
|
|
1065
1249
|
this.errorMonitorTimer = setInterval(async () => {
|
|
1066
1250
|
if (!this.tmux || this.spawning)
|
|
1067
1251
|
return;
|
|
@@ -1074,20 +1258,30 @@ export class Daemon extends EventEmitter {
|
|
|
1074
1258
|
for (const dialog of dialogs) {
|
|
1075
1259
|
if (!dialog.pattern.test(pane))
|
|
1076
1260
|
continue;
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1261
|
+
// These keys go straight into the pane. Sent while a message delivery is
|
|
1262
|
+
// mid-transaction, an `Escape` wipes the pasted text and an `Enter`
|
|
1263
|
+
// submits it half-composed — the user sees a message that vanished. Skip
|
|
1264
|
+
// (not queue) when the pane is busy: this poller runs every 5s, and the
|
|
1265
|
+
// dialog will still be on screen next tick.
|
|
1266
|
+
const dismissed = await this.paneWriteLock.tryRun(async () => {
|
|
1267
|
+
this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
|
|
1268
|
+
const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
|
|
1269
|
+
for (const key of dialog.keys) {
|
|
1270
|
+
if (SPECIAL_KEYS.has(key)) {
|
|
1271
|
+
await this.tmux.sendSpecialKey(key);
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
1275
|
+
}
|
|
1276
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1085
1277
|
}
|
|
1086
|
-
|
|
1278
|
+
});
|
|
1279
|
+
if (!dismissed) {
|
|
1280
|
+
this.logger.debug({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
|
|
1087
1281
|
}
|
|
1088
|
-
return; // Dialog
|
|
1282
|
+
return; // Dialog handled (or deliberately deferred): skip error checks this cycle
|
|
1089
1283
|
}
|
|
1090
|
-
this.evaluateErrorPatterns(pane, patterns, readyPattern);
|
|
1284
|
+
this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
|
|
1091
1285
|
}
|
|
1092
1286
|
catch {
|
|
1093
1287
|
// capturePane can fail if window is transitioning — ignore
|
|
@@ -1095,7 +1289,7 @@ export class Daemon extends EventEmitter {
|
|
|
1095
1289
|
}, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
|
|
1096
1290
|
}
|
|
1097
1291
|
/** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
|
|
1098
|
-
evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now()) {
|
|
1292
|
+
evaluateErrorPatterns(pane, patterns, readyPattern, now = Date.now(), busyPattern = null) {
|
|
1099
1293
|
// Count occurrences across the WHOLE pane (not just text after the last
|
|
1100
1294
|
// ready prompt). Clone with `g` so stateful backend regexes cannot leak
|
|
1101
1295
|
// lastIndex between monitor cycles.
|
|
@@ -1103,10 +1297,15 @@ export class Daemon extends EventEmitter {
|
|
|
1103
1297
|
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
1104
1298
|
return (pane.match(new RegExp(pattern.source, flags)) || []).length;
|
|
1105
1299
|
};
|
|
1300
|
+
// Same veto as the state machine: a backend whose ready marker is permanently
|
|
1301
|
+
// on screen would otherwise "recover" on the very first tick after the error,
|
|
1302
|
+
// rebaselining the occurrence count while the error is still displayed — one
|
|
1303
|
+
// notification, a false recovery log, then silence.
|
|
1304
|
+
const looksReady = () => !busyPattern?.test(pane) && readyPattern.test(pane);
|
|
1106
1305
|
// State: waiting for recovery. A missing/outdated ready pattern must not
|
|
1107
1306
|
// suppress every future error forever, so the gate has a hard deadline.
|
|
1108
1307
|
if (this.errorWaitingForRecovery) {
|
|
1109
|
-
if (
|
|
1308
|
+
if (looksReady()) {
|
|
1110
1309
|
const downtime = Math.round((now - this.errorDetectedAt) / 1000);
|
|
1111
1310
|
const active = patterns.find(ep => Daemon.errorPatternKey(ep) === this.activeErrorPatternKey);
|
|
1112
1311
|
if (active) {
|
|
@@ -1205,6 +1404,12 @@ export class Daemon extends EventEmitter {
|
|
|
1205
1404
|
* Interrupt the CLI's current generation (cancel button / `/cancel`).
|
|
1206
1405
|
* Direct tmux key event (not a paste) so it registers as the interrupt key.
|
|
1207
1406
|
* kiro-cli interrupts on Ctrl+C; the others (claude-code, codex, …) on Escape.
|
|
1407
|
+
*
|
|
1408
|
+
* Deliberately NOT taken through `paneWriteLock`. Cancel is the user's way out
|
|
1409
|
+
* of a pane that is busy or wedged — queueing it behind the very delivery chain
|
|
1410
|
+
* it exists to unblock would make the button useless exactly when it is needed.
|
|
1411
|
+
* The cost is accepted: an Escape landing between a paste and its Enter discards
|
|
1412
|
+
* that message, which is what the user asked for anyway.
|
|
1208
1413
|
*/
|
|
1209
1414
|
async sendEscape() {
|
|
1210
1415
|
const cancelKey = this.backend?.getCancelKey() ?? "Escape";
|
|
@@ -1242,7 +1447,6 @@ export class Daemon extends EventEmitter {
|
|
|
1242
1447
|
this.toolStatusDebounce = null;
|
|
1243
1448
|
}
|
|
1244
1449
|
this.pendingIpcRequests.clear();
|
|
1245
|
-
this.hangDetector?.stop();
|
|
1246
1450
|
if (this.adapter)
|
|
1247
1451
|
await this.adapter.stop();
|
|
1248
1452
|
// Notify MCP servers of graceful shutdown (prevents reconnect attempts)
|
|
@@ -1269,7 +1473,12 @@ export class Daemon extends EventEmitter {
|
|
|
1269
1473
|
if (!killed)
|
|
1270
1474
|
this.logger.warn("CLI did not exit gracefully within 3s, force killing window");
|
|
1271
1475
|
// Always kill window — remain-on-exit keeps dead panes around after CLI exits
|
|
1476
|
+
const stoppedWindowId = this.tmux.getWindowId();
|
|
1272
1477
|
await this.tmux.killWindow();
|
|
1478
|
+
// The control client is shared across the fleet and outlives this daemon, so
|
|
1479
|
+
// a registration left behind here is re-resolved on every reconnect forever.
|
|
1480
|
+
if (stoppedWindowId)
|
|
1481
|
+
this.controlClient?.unregisterWindow(stoppedWindowId);
|
|
1273
1482
|
const windowIdFile = join(this.instanceDir, "window-id");
|
|
1274
1483
|
try {
|
|
1275
1484
|
unlinkSync(windowIdFile);
|
|
@@ -1405,7 +1614,7 @@ export class Daemon extends EventEmitter {
|
|
|
1405
1614
|
return;
|
|
1406
1615
|
}
|
|
1407
1616
|
this.pauseWakeState = "waking";
|
|
1408
|
-
this.
|
|
1617
|
+
this.beginSpawn();
|
|
1409
1618
|
const transition = this.autoPauseController.wakeOnDeliver(async () => {
|
|
1410
1619
|
let timeout;
|
|
1411
1620
|
try {
|
|
@@ -1446,7 +1655,7 @@ export class Daemon extends EventEmitter {
|
|
|
1446
1655
|
throw err;
|
|
1447
1656
|
}
|
|
1448
1657
|
finally {
|
|
1449
|
-
this.
|
|
1658
|
+
this.endSpawn();
|
|
1450
1659
|
if (this.pauseWakeTransition === transition)
|
|
1451
1660
|
this.pauseWakeTransition = null;
|
|
1452
1661
|
}
|
|
@@ -1458,6 +1667,10 @@ export class Daemon extends EventEmitter {
|
|
|
1458
1667
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
1459
1668
|
if (snapshot.state === "idle" && previous !== "idle") {
|
|
1460
1669
|
this.pendingWork.recordIdle(snapshot.observedAt);
|
|
1670
|
+
// The turn is over. A transcript can end on a tool_use with no matching
|
|
1671
|
+
// tool_result (interrupted, crashed, cancelled), which would otherwise leave
|
|
1672
|
+
// the last tool pinned to the progress line for the rest of the session.
|
|
1673
|
+
this.publishActivity(null);
|
|
1461
1674
|
}
|
|
1462
1675
|
if (snapshot.state !== previous) {
|
|
1463
1676
|
this.logger.info({
|
|
@@ -1548,6 +1761,13 @@ export class Daemon extends EventEmitter {
|
|
|
1548
1761
|
this.instanceStateMachine.observe(pane, observedChangeAt);
|
|
1549
1762
|
const snapshot = this.instanceStateMachine.observe(pane, Date.now());
|
|
1550
1763
|
this.applyInstanceStateSnapshot(snapshot, pane);
|
|
1764
|
+
// Backends without a transcript feed can still say what they are doing, if
|
|
1765
|
+
// their TUI names it. Free-riding on a capture that already happened: no
|
|
1766
|
+
// extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
|
|
1767
|
+
// safety sweep), which matches the progress ticker's own 60s interval.
|
|
1768
|
+
if (this.backend?.getPaneActivity) {
|
|
1769
|
+
this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
|
|
1770
|
+
}
|
|
1551
1771
|
if (snapshot.state === "idle") {
|
|
1552
1772
|
this.clearInstanceStateStuckTimer();
|
|
1553
1773
|
}
|
|
@@ -1602,7 +1822,8 @@ export class Daemon extends EventEmitter {
|
|
|
1602
1822
|
? rawConfig.idle_debounce_ms
|
|
1603
1823
|
: DEFAULT_STATE_IDLE_DEBOUNCE_MS;
|
|
1604
1824
|
this.instanceStateReadyPattern = this.backend.getReadyPattern();
|
|
1605
|
-
this.
|
|
1825
|
+
this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
|
|
1826
|
+
this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
|
|
1606
1827
|
this.instanceStateLastOutputAt = 0;
|
|
1607
1828
|
this.instanceStateMonitorActive = true;
|
|
1608
1829
|
if (this.controlClient) {
|
|
@@ -1643,6 +1864,12 @@ export class Daemon extends EventEmitter {
|
|
|
1643
1864
|
paneTail: sanitizePaneTail(pane),
|
|
1644
1865
|
readyPattern: readyPattern.toString(),
|
|
1645
1866
|
readyMatched: deterministicReadyPattern.test(pane),
|
|
1867
|
+
// A pane that is stuck *while* the ready marker matches means the busy
|
|
1868
|
+
// pattern is what held it back — worth seeing when tuning either regex.
|
|
1869
|
+
busyPattern: this.instanceStateBusyPattern?.toString(),
|
|
1870
|
+
busyMatched: this.instanceStateBusyPattern
|
|
1871
|
+
? new RegExp(this.instanceStateBusyPattern.source, this.instanceStateBusyPattern.flags.replace(/[gy]/g, "")).test(pane)
|
|
1872
|
+
: undefined,
|
|
1646
1873
|
unchangedForMs: snapshot.unchangedForMs,
|
|
1647
1874
|
pendingWork: this.pendingWork.hasPendingWork(),
|
|
1648
1875
|
};
|
|
@@ -1685,6 +1912,40 @@ export class Daemon extends EventEmitter {
|
|
|
1685
1912
|
return this.messageBus;
|
|
1686
1913
|
}
|
|
1687
1914
|
// ── Tool status tracking ──────────────────────────────────────
|
|
1915
|
+
/**
|
|
1916
|
+
* Tell the fleet manager what this instance is doing right now, for the live
|
|
1917
|
+
* progress line on the cancel button.
|
|
1918
|
+
*
|
|
1919
|
+
* Purely cosmetic — nothing decides anything from it, so it is fine that only
|
|
1920
|
+
* backends with a transcript feed report at all (claude-code today). For the
|
|
1921
|
+
* rest the progress line keeps showing just elapsed time.
|
|
1922
|
+
*
|
|
1923
|
+
* Repeats are dropped: the ticker only edits the channel message when the text
|
|
1924
|
+
* changes, and a stream of identical broadcasts would defeat that.
|
|
1925
|
+
*/
|
|
1926
|
+
publishActivity(activity) {
|
|
1927
|
+
const next = activity && activity.trim() ? activity.trim() : null;
|
|
1928
|
+
if (next === this.currentActivity)
|
|
1929
|
+
return;
|
|
1930
|
+
this.currentActivity = next;
|
|
1931
|
+
this.ipcServer?.broadcast({
|
|
1932
|
+
type: "instance_activity",
|
|
1933
|
+
instanceName: this.name,
|
|
1934
|
+
activity: next,
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Options for every system-initiated paste (startup notice, session snapshot,
|
|
1939
|
+
* runtime-dialog keys).
|
|
1940
|
+
*
|
|
1941
|
+
* One place, so the three call sites cannot drift apart on it. Backends with a
|
|
1942
|
+
* native input queue do not get the retry Enter: on those, a second bare Enter
|
|
1943
|
+
* is not the no-op the retry assumed but a queue mutation — the same reason
|
|
1944
|
+
* `deliverMessage` refuses to probe for busy with one.
|
|
1945
|
+
*/
|
|
1946
|
+
systemPasteOptions() {
|
|
1947
|
+
return { retryEnter: this.backend?.supportsQueuedInput?.() !== true };
|
|
1948
|
+
}
|
|
1688
1949
|
summarizeTool(name, input) {
|
|
1689
1950
|
const inp = input;
|
|
1690
1951
|
if (!inp)
|
|
@@ -1774,7 +2035,6 @@ export class Daemon extends EventEmitter {
|
|
|
1774
2035
|
writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
|
|
1775
2036
|
this.pendingInstructionsUpdate = undefined;
|
|
1776
2037
|
}
|
|
1777
|
-
this.hangDetector?.recordInbound();
|
|
1778
2038
|
this.pendingWork.recordInbound();
|
|
1779
2039
|
// v3: record user messages for rotation snapshot
|
|
1780
2040
|
this.recordRecentUserMessage(content, meta);
|
|
@@ -1797,12 +2057,26 @@ export class Daemon extends EventEmitter {
|
|
|
1797
2057
|
// #77: show the sender's display name for readability, keeping the machine
|
|
1798
2058
|
// instance name in parens so the recipient's send_to_instance target is valid.
|
|
1799
2059
|
const fromLabel = meta.from_display ? `${meta.from_display} (${fromInstance})` : fromInstance;
|
|
1800
|
-
formatted = `[from:${fromLabel}] ${content}
|
|
2060
|
+
formatted = `[from:${fromLabel}] ${content}`;
|
|
2061
|
+
formatted += renderHandoffMetadata(meta);
|
|
2062
|
+
// A delegated task that requires a reply must not read like a chatty FYI —
|
|
2063
|
+
// the "you may stay silent" line is for the latter only.
|
|
2064
|
+
formatted += meta.requires_reply === "true"
|
|
2065
|
+
? "\n(A reply IS required: use report_result with the correlation_id above — or send_to_instance. Not direct text.)"
|
|
2066
|
+
: "\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)";
|
|
1801
2067
|
}
|
|
1802
2068
|
else {
|
|
1803
2069
|
const via = meta.source ? ` via ${meta.source}` : "";
|
|
1804
2070
|
const idTag = meta.user_id ? `, id:${meta.user_id}` : "";
|
|
1805
|
-
formatted = `[user:${user}${via}${idTag}] ${content}
|
|
2071
|
+
formatted = `[user:${user}${via}${idTag}] ${content}`;
|
|
2072
|
+
// Reactions queued since the last real message (#432). One leading line of
|
|
2073
|
+
// context, present only when something is pending — a reaction no longer
|
|
2074
|
+
// costs a turn of its own.
|
|
2075
|
+
if (meta.pending_reactions) {
|
|
2076
|
+
formatted = `[Recent reactions: ${meta.pending_reactions}]\n${formatted}`;
|
|
2077
|
+
}
|
|
2078
|
+
formatted += renderHandoffMetadata(meta);
|
|
2079
|
+
formatted += "\n(Reply using the reply tool — do NOT respond with direct text)";
|
|
1806
2080
|
}
|
|
1807
2081
|
if (meta.reply_to_text) {
|
|
1808
2082
|
formatted += `\n(reply_to: "${meta.reply_to_text}")`;
|
|
@@ -1866,6 +2140,8 @@ export class Daemon extends EventEmitter {
|
|
|
1866
2140
|
// Odd number of fences = unclosed. Remove all code fences from the message.
|
|
1867
2141
|
formatted = formatted.replace(/```/g, "");
|
|
1868
2142
|
}
|
|
2143
|
+
// Before anything reads the window id: a spawn in progress is about to change it.
|
|
2144
|
+
await this.waitForSpawnToSettle();
|
|
1869
2145
|
let windowId = this.getWindowId();
|
|
1870
2146
|
const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
|
|
1871
2147
|
let handingOffToNativeQueue = false;
|
|
@@ -1881,9 +2157,33 @@ export class Daemon extends EventEmitter {
|
|
|
1881
2157
|
}
|
|
1882
2158
|
else {
|
|
1883
2159
|
this.logger.debug("CLI busy — queuing message until idle");
|
|
1884
|
-
await this.controlClient.waitUntilIdle(windowId);
|
|
2160
|
+
const becameIdle = await this.controlClient.waitUntilIdle(windowId);
|
|
2161
|
+
if (!becameIdle) {
|
|
2162
|
+
// The pane never freed up. Report the failure instead of pasting into a
|
|
2163
|
+
// wedged CLI (where the text would sit unsubmitted and the next message
|
|
2164
|
+
// would land on top of it) — and instead of holding the queue silently.
|
|
2165
|
+
this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
|
|
2166
|
+
if (status)
|
|
2167
|
+
this.emit("message_failed", status); // ❌
|
|
2168
|
+
return false;
|
|
2169
|
+
}
|
|
1885
2170
|
}
|
|
1886
2171
|
}
|
|
2172
|
+
// Everything above is *waiting*; everything below *writes*. Only the write is
|
|
2173
|
+
// held under the pane lock — holding it across the idle wait (up to 30 min)
|
|
2174
|
+
// would starve the runtime-dialog dismisser, which is often the very thing
|
|
2175
|
+
// that would let the pane go idle again.
|
|
2176
|
+
return this.paneWriteLock.run(() => this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status));
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* The write half of a delivery: paste → Enter → confirm, with retries.
|
|
2180
|
+
*
|
|
2181
|
+
* Always called under {@link paneWriteLock}. Split out from `deliverMessage`
|
|
2182
|
+
* precisely so the lock's scope is visible at the call site rather than being
|
|
2183
|
+
* an invariant maintained by comments.
|
|
2184
|
+
*/
|
|
2185
|
+
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status) {
|
|
2186
|
+
let windowId = initialWindowId;
|
|
1887
2187
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
1888
2188
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
1889
2189
|
const maxAttempts = 3;
|
|
@@ -1966,8 +2266,20 @@ export class Daemon extends EventEmitter {
|
|
|
1966
2266
|
await this.tmux.sendSpecialKey("Enter");
|
|
1967
2267
|
becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
|
|
1968
2268
|
}
|
|
1969
|
-
if (becameBusy
|
|
1970
|
-
|
|
2269
|
+
if (becameBusy) {
|
|
2270
|
+
if (status)
|
|
2271
|
+
this.emit("message_confirmed", status); // ✅
|
|
2272
|
+
}
|
|
2273
|
+
else {
|
|
2274
|
+
// Both Enters were swallowed: the text is sitting UNSUBMITTED in the
|
|
2275
|
+
// CLI's input box. This used to return true, so the reaction stayed at 👀
|
|
2276
|
+
// forever and the next delivery pasted on top — submitting two messages
|
|
2277
|
+
// as one. Say so instead.
|
|
2278
|
+
this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
|
|
2279
|
+
if (status)
|
|
2280
|
+
this.emit("message_failed", status); // ❌
|
|
2281
|
+
return false;
|
|
2282
|
+
}
|
|
1971
2283
|
}
|
|
1972
2284
|
else {
|
|
1973
2285
|
// No control client to observe output: fall back to the legacy double-Enter.
|
|
@@ -2012,6 +2324,7 @@ export class Daemon extends EventEmitter {
|
|
|
2012
2324
|
}
|
|
2013
2325
|
/** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
|
|
2014
2326
|
async recoverWindow() {
|
|
2327
|
+
const previousWindowId = this.tmux?.getWindowId();
|
|
2015
2328
|
try {
|
|
2016
2329
|
const windows = await TmuxManager.listWindows(this.tmuxSessionName);
|
|
2017
2330
|
const match = windows.find(w => w.name === this.name);
|
|
@@ -2019,6 +2332,12 @@ export class Daemon extends EventEmitter {
|
|
|
2019
2332
|
return undefined;
|
|
2020
2333
|
this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
|
|
2021
2334
|
writeFileSync(join(this.instanceDir, "window-id"), match.id);
|
|
2335
|
+
// The window we were talking to is gone; leaving it registered means the
|
|
2336
|
+
// control client re-resolves a dead id — one tmux subprocess — on every
|
|
2337
|
+
// reconnect, for the life of the fleet process.
|
|
2338
|
+
if (previousWindowId && previousWindowId !== match.id) {
|
|
2339
|
+
this.controlClient?.unregisterWindow(previousWindowId);
|
|
2340
|
+
}
|
|
2022
2341
|
await this.controlClient?.registerWindow(match.id);
|
|
2023
2342
|
this.bindInstanceStateOutputListener(match.id);
|
|
2024
2343
|
this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
|
|
@@ -2029,12 +2348,38 @@ export class Daemon extends EventEmitter {
|
|
|
2029
2348
|
return undefined;
|
|
2030
2349
|
}
|
|
2031
2350
|
}
|
|
2032
|
-
/**
|
|
2351
|
+
/**
|
|
2352
|
+
* Poll up to ~2s (200ms × 10) for the pane to emit output after `since`.
|
|
2353
|
+
*
|
|
2354
|
+
* A `false` here is not "no output" — it is read as **"the message was pasted but
|
|
2355
|
+
* never submitted"**, which re-sends Enter and then reports ❌ to the user. So a
|
|
2356
|
+
* false negative costs a possible double submit and a failure notice for a
|
|
2357
|
+
* message that actually arrived.
|
|
2358
|
+
*
|
|
2359
|
+
* That is exactly what a control-mode reconnect used to produce. `connect()`
|
|
2360
|
+
* drops every output timestamp, so `hasOutputSince` answers `false` for a pane
|
|
2361
|
+
* that did react — the evidence was thrown away, not absent. Blind time is
|
|
2362
|
+
* therefore not counted against the budget: after a reset the poll restarts from
|
|
2363
|
+
* the moment observation resumed. A hard wall-clock cap keeps a reconnect loop
|
|
2364
|
+
* from extending this forever.
|
|
2365
|
+
*/
|
|
2033
2366
|
async confirmBusyAfterEnter(windowId, since) {
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2367
|
+
const client = this.controlClient;
|
|
2368
|
+
const hardDeadline = Date.now() + CONFIRM_BUSY_MAX_WAIT_MS;
|
|
2369
|
+
let observedFrom = since;
|
|
2370
|
+
let polls = 0;
|
|
2371
|
+
while (polls < CONFIRM_BUSY_POLLS) {
|
|
2372
|
+
await new Promise(r => setTimeout(r, CONFIRM_BUSY_POLL_MS));
|
|
2373
|
+
if (client.hasOutputSince(windowId, observedFrom))
|
|
2037
2374
|
return true;
|
|
2375
|
+
const resetAt = client.getObservationResetAt();
|
|
2376
|
+
if (resetAt > observedFrom && Date.now() < hardDeadline) {
|
|
2377
|
+
this.logger.debug({ windowId, resetAt }, "Control mode reconnected mid-confirmation — restarting the Enter check from when we could see again");
|
|
2378
|
+
observedFrom = resetAt;
|
|
2379
|
+
polls = 0;
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
polls++;
|
|
2038
2383
|
}
|
|
2039
2384
|
return false;
|
|
2040
2385
|
}
|
|
@@ -2105,8 +2450,8 @@ export class Daemon extends EventEmitter {
|
|
|
2105
2450
|
});
|
|
2106
2451
|
const timeout = setTimeout(() => {
|
|
2107
2452
|
this.pendingIpcRequests.delete(fleetReqId);
|
|
2108
|
-
respond(null,
|
|
2109
|
-
},
|
|
2453
|
+
respond(null, `Task operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
|
|
2454
|
+
}, daemonBudgetMs(tool));
|
|
2110
2455
|
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
2111
2456
|
clearTimeout(timeout);
|
|
2112
2457
|
respond(respMsg.result, respMsg.error);
|
|
@@ -2128,8 +2473,8 @@ export class Daemon extends EventEmitter {
|
|
|
2128
2473
|
});
|
|
2129
2474
|
const timeout = setTimeout(() => {
|
|
2130
2475
|
this.pendingIpcRequests.delete(fleetReqId);
|
|
2131
|
-
respond(null,
|
|
2132
|
-
},
|
|
2476
|
+
respond(null, `Decision operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
|
|
2477
|
+
}, daemonBudgetMs(tool));
|
|
2133
2478
|
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
2134
2479
|
clearTimeout(timeout);
|
|
2135
2480
|
respond(respMsg.result, respMsg.error);
|
|
@@ -2155,8 +2500,8 @@ export class Daemon extends EventEmitter {
|
|
|
2155
2500
|
// Wait for fleet_schedule_response via pending request map
|
|
2156
2501
|
const timeout = setTimeout(() => {
|
|
2157
2502
|
this.pendingIpcRequests.delete(fleetReqId);
|
|
2158
|
-
respond(null,
|
|
2159
|
-
},
|
|
2503
|
+
respond(null, `Schedule operation timed out after ${daemonBudgetMs(tool) / 1000}s`);
|
|
2504
|
+
}, daemonBudgetMs(tool));
|
|
2160
2505
|
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
2161
2506
|
clearTimeout(timeout);
|
|
2162
2507
|
respond(respMsg.result, respMsg.error);
|
|
@@ -2177,7 +2522,9 @@ export class Daemon extends EventEmitter {
|
|
|
2177
2522
|
fleetRequestId: fleetReqId,
|
|
2178
2523
|
senderSessionName,
|
|
2179
2524
|
});
|
|
2180
|
-
|
|
2525
|
+
// Shared table, so delete_instance and the deployment tools get the same
|
|
2526
|
+
// budget here as the MCP client expects (the ad-hoc list omitted them).
|
|
2527
|
+
const crossTimeoutMs = daemonBudgetMs(tool);
|
|
2181
2528
|
const timeout = setTimeout(() => {
|
|
2182
2529
|
this.pendingIpcRequests.delete(fleetReqId);
|
|
2183
2530
|
respond(null, `Cross-instance operation timed out after ${crossTimeoutMs / 1000}s`);
|
|
@@ -2220,8 +2567,8 @@ export class Daemon extends EventEmitter {
|
|
|
2220
2567
|
this.ipcServer?.broadcast({ type: "fleet_outbound", tool, args, fleetRequestId: fleetReqId });
|
|
2221
2568
|
const timeout = setTimeout(() => {
|
|
2222
2569
|
this.pendingIpcRequests.delete(outboundKey);
|
|
2223
|
-
respond(null,
|
|
2224
|
-
},
|
|
2570
|
+
respond(null, `Fleet outbound timed out after ${daemonBudgetMs(tool) / 1000}s`);
|
|
2571
|
+
}, daemonBudgetMs(tool));
|
|
2225
2572
|
this.pendingIpcRequests.set(outboundKey, (respMsg) => {
|
|
2226
2573
|
clearTimeout(timeout);
|
|
2227
2574
|
respond(respMsg.result, respMsg.error);
|
|
@@ -2410,7 +2757,9 @@ export class Daemon extends EventEmitter {
|
|
|
2410
2757
|
// Small delay to let the CLI fully render its ready prompt
|
|
2411
2758
|
await new Promise(r => setTimeout(r, 1_000));
|
|
2412
2759
|
try {
|
|
2413
|
-
|
|
2760
|
+
// Messages can arrive during a restart and be queued on pasteLock before the
|
|
2761
|
+
// snapshot lands; both write to the pane, so both go through the same lock.
|
|
2762
|
+
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.`, this.systemPasteOptions()));
|
|
2414
2763
|
this.logger.info("Injected session snapshot as first message");
|
|
2415
2764
|
this.emit("snapshot_injected", this.name);
|
|
2416
2765
|
}
|
|
@@ -2419,9 +2768,69 @@ export class Daemon extends EventEmitter {
|
|
|
2419
2768
|
this.emit("snapshot_failed", this.name);
|
|
2420
2769
|
}
|
|
2421
2770
|
}
|
|
2771
|
+
/**
|
|
2772
|
+
* Depth, not a flag: the wake path marks a spawn and then calls trySpawn, which
|
|
2773
|
+
* marks another. With a plain boolean the inner one's completion would clear the
|
|
2774
|
+
* latch while the outer spawn was still dismissing dialogs, releasing delivery
|
|
2775
|
+
* into exactly the window this exists to close.
|
|
2776
|
+
*/
|
|
2777
|
+
beginSpawn() {
|
|
2778
|
+
this.spawnDepth++;
|
|
2779
|
+
this.spawning = true;
|
|
2780
|
+
if (!this.spawnSettled) {
|
|
2781
|
+
this.spawnSettled = new Promise(resolve => { this.resolveSpawnSettled = resolve; });
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
endSpawn() {
|
|
2785
|
+
this.spawnDepth = Math.max(0, this.spawnDepth - 1);
|
|
2786
|
+
if (this.spawnDepth > 0)
|
|
2787
|
+
return; // an enclosing spawn is still running
|
|
2788
|
+
this.spawning = false;
|
|
2789
|
+
const resolve = this.resolveSpawnSettled;
|
|
2790
|
+
this.spawnSettled = null;
|
|
2791
|
+
this.resolveSpawnSettled = null;
|
|
2792
|
+
resolve?.();
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* Hold until the CLI has finished starting up.
|
|
2796
|
+
*
|
|
2797
|
+
* Startup is not a quiet period: `dismissDialogsUntilReady` is clicking through
|
|
2798
|
+
* trust prompts and session pickers. A pane showing a modal dialog produces no
|
|
2799
|
+
* output, so `waitUntilIdle` reports it idle and a queued message gets pasted
|
|
2800
|
+
* *into the dialog* — where the text is discarded and the Enter picks a menu
|
|
2801
|
+
* item. The paste and the Enter both "succeed", so this loses the message
|
|
2802
|
+
* without even a ❌.
|
|
2803
|
+
*
|
|
2804
|
+
* The pane write lock (#414) does not cover this: it serialises each key
|
|
2805
|
+
* sequence, but it is released between one dialog and the next.
|
|
2806
|
+
*
|
|
2807
|
+
* Bounded, and called BEFORE the pane lock is taken — waiting on the spawn while
|
|
2808
|
+
* holding the lock the spawn itself needs would deadlock.
|
|
2809
|
+
*/
|
|
2810
|
+
async waitForSpawnToSettle() {
|
|
2811
|
+
const settled = this.spawnSettled;
|
|
2812
|
+
if (!settled)
|
|
2813
|
+
return;
|
|
2814
|
+
this.logger.debug("Holding delivery until the CLI has finished starting up");
|
|
2815
|
+
let timer;
|
|
2816
|
+
const cap = new Promise(resolve => {
|
|
2817
|
+
timer = setTimeout(resolve, SPAWN_SETTLE_MAX_WAIT_MS);
|
|
2818
|
+
timer.unref?.();
|
|
2819
|
+
});
|
|
2820
|
+
try {
|
|
2821
|
+
await Promise.race([settled, cap]);
|
|
2822
|
+
}
|
|
2823
|
+
finally {
|
|
2824
|
+
if (timer)
|
|
2825
|
+
clearTimeout(timer);
|
|
2826
|
+
}
|
|
2827
|
+
if (this.spawning) {
|
|
2828
|
+
this.logger.warn("CLI still starting after the delivery hold — delivering anyway");
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2422
2831
|
/** Spawn a CLI window. Returns true if --resume was used successfully. */
|
|
2423
2832
|
async spawnClaudeWindow() {
|
|
2424
|
-
this.
|
|
2833
|
+
this.beginSpawn();
|
|
2425
2834
|
let resumedSuccessfully = false;
|
|
2426
2835
|
try {
|
|
2427
2836
|
this.toolStatusLines = [];
|
|
@@ -2458,7 +2867,7 @@ export class Daemon extends EventEmitter {
|
|
|
2458
2867
|
this.backgroundSessionRecoveryAttempted = false;
|
|
2459
2868
|
}
|
|
2460
2869
|
finally {
|
|
2461
|
-
this.
|
|
2870
|
+
this.endSpawn();
|
|
2462
2871
|
}
|
|
2463
2872
|
return resumedSuccessfully;
|
|
2464
2873
|
}
|
|
@@ -2529,11 +2938,13 @@ export class Daemon extends EventEmitter {
|
|
|
2529
2938
|
// the global web token) from impersonating instances.
|
|
2530
2939
|
const agentTokenPath = join(this.instanceDir, "agent.token");
|
|
2531
2940
|
const agentToken = randomBytes(32).toString("hex");
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2941
|
+
const tokenWrite = writeSecretFile(agentTokenPath, agentToken);
|
|
2942
|
+
if (!tokenWrite.ok) {
|
|
2943
|
+
// Do not fail the spawn over it — an instance that cannot start is worse
|
|
2944
|
+
// than one whose token is readable. But say so, loudly: the whole point of
|
|
2945
|
+
// this token is that other local processes cannot use it.
|
|
2946
|
+
this.logger.error({ path: agentTokenPath, mode: tokenWrite.mode?.toString(8), reason: tokenWrite.reason }, "Agent token file is not owner-only — other local users can impersonate this instance");
|
|
2535
2947
|
}
|
|
2536
|
-
catch { }
|
|
2537
2948
|
// AGEND_HOME points the child's agent-cli at the same data dir the daemon
|
|
2538
2949
|
// is using, so it can locate <instanceDir>/agent.token.
|
|
2539
2950
|
const agendHome = join(this.instanceDir, "..", "..");
|
|
@@ -2551,7 +2962,12 @@ export class Daemon extends EventEmitter {
|
|
|
2551
2962
|
windowId = this.tmux.getWindowId();
|
|
2552
2963
|
}
|
|
2553
2964
|
else {
|
|
2965
|
+
// A crash respawn makes a brand new window. The dead one stays registered
|
|
2966
|
+
// unless we say so — the respawn branch above already does.
|
|
2967
|
+
const retired = this.tmux.getWindowId();
|
|
2554
2968
|
windowId = await this.tmux.createWindow(cmd, resolvedCwd, this.name);
|
|
2969
|
+
if (retired && retired !== windowId)
|
|
2970
|
+
this.controlClient?.unregisterWindow(retired);
|
|
2555
2971
|
}
|
|
2556
2972
|
writeFileSync(join(this.instanceDir, "window-id"), windowId);
|
|
2557
2973
|
// Enable remain-on-exit to capture exit codes on crash
|
|
@@ -2617,15 +3033,20 @@ export class Daemon extends EventEmitter {
|
|
|
2617
3033
|
for (const dialog of startupDialogs) {
|
|
2618
3034
|
if (dialog.pattern.test(pane)) {
|
|
2619
3035
|
this.logger.debug(`Dismissing startup dialog: ${dialog.description}`);
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
3036
|
+
// Restart is exactly when inbound messages pile up, and nothing gates
|
|
3037
|
+
// delivery on `spawning`. Take the pane lock for the key sequence so a
|
|
3038
|
+
// queued message cannot be pasted into a half-dismissed trust dialog.
|
|
3039
|
+
await this.paneWriteLock.run(async () => {
|
|
3040
|
+
for (const key of dialog.keys) {
|
|
3041
|
+
if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
|
|
3042
|
+
await this.tmux.sendSpecialKey(key);
|
|
3043
|
+
}
|
|
3044
|
+
else {
|
|
3045
|
+
await this.tmux.sendKeys(key);
|
|
3046
|
+
}
|
|
3047
|
+
await new Promise(r => setTimeout(r, 200));
|
|
2626
3048
|
}
|
|
2627
|
-
|
|
2628
|
-
}
|
|
3049
|
+
});
|
|
2629
3050
|
// Wait for next screen to render
|
|
2630
3051
|
if (this.controlClient) {
|
|
2631
3052
|
const wid = readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim();
|