@songsid/agend 2.1.7 → 2.1.8-beta.10
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/agent-cli.js +14 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +10 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/claude-code.d.ts +35 -5
- package/dist/backend/claude-code.js +288 -17
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/cli-env-probe-worker.d.ts +1 -0
- package/dist/backend/cli-env-probe-worker.js +25 -0
- package/dist/backend/cli-env-probe-worker.js.map +1 -0
- package/dist/backend/codex-session-lookup.d.ts +16 -0
- package/dist/backend/codex-session-lookup.js +76 -2
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +7 -4
- package/dist/backend/codex.js +120 -30
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +9 -0
- package/dist/backend/credential-profile.js +20 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/grok.js +19 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/muse.d.ts +6 -0
- package/dist/backend/muse.js +37 -3
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +21 -2
- package/dist/backend/types.js +14 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +16 -4
- package/dist/channel/adapters/discord.js +20 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +6 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +138 -48
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +11 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +7 -0
- package/dist/cross-instance-envelope.js +19 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +151 -6
- package/dist/daemon.js +550 -72
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +9 -1
- package/dist/delivery-outbox.js +25 -4
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +140 -18
- package/dist/fleet-manager.js +733 -106
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +160 -0
- package/dist/forged-envelope-scan.js +312 -0
- package/dist/forged-envelope-scan.js.map +1 -0
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +52 -0
- package/dist/instance-lifecycle.d.ts +81 -6
- package/dist/instance-lifecycle.js +201 -17
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +34 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +11 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +18 -1
- package/dist/login-flows.js +9 -2
- package/dist/login-flows.js.map +1 -1
- package/dist/ls-rows.d.ts +17 -0
- package/dist/ls-rows.js +73 -0
- package/dist/ls-rows.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +3 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +21 -4
- package/dist/outbound-schemas.js +12 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.js +4 -2
- package/dist/paths.js.map +1 -1
- package/dist/pause-marker.d.ts +10 -1
- package/dist/pause-marker.js +26 -2
- package/dist/pause-marker.js.map +1 -1
- package/dist/service-installer.d.ts +8 -0
- package/dist/service-installer.js +36 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +8 -8
- package/dist/settings-api.js +12 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/status-emojis.d.ts +19 -0
- package/dist/status-emojis.js.map +1 -1
- package/dist/tool-permissions.js +8 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +9 -0
- package/dist/topic-commands.js +64 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +3 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +63 -0
- package/dist/transcript-sources.js +171 -27
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/settings.html +143 -49
- package/dist/wake-coordinator.d.ts +143 -0
- package/dist/wake-coordinator.js +321 -0
- package/dist/wake-coordinator.js.map +1 -0
- package/dist/web-api.d.ts +3 -1
- package/dist/web-api.js +1 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-terminal.d.ts +2 -0
- package/dist/web-terminal.js +3 -6
- package/dist/web-terminal.js.map +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +7 -0
package/dist/daemon.js
CHANGED
|
@@ -454,6 +454,11 @@ const MUSE_DIRECT_RESUME_IDLE_POLL_MS = 5_000;
|
|
|
454
454
|
* verified prompt before asking the native process to exit. Each instance is
|
|
455
455
|
* bounded so a fleet update can stop all daemons in parallel. */
|
|
456
456
|
const KIRO_STOP_IDLE_BUDGET_MS = 15_000;
|
|
457
|
+
/**
|
|
458
|
+
* Bound after a non-stop quit before a dead pane with no relaunch is a wedge
|
|
459
|
+
* (#1030). Exceeds the wake budget (30s+) so a slow relaunch never trips it.
|
|
460
|
+
*/
|
|
461
|
+
const QUIT_RELAUNCH_WATCH_MS = 90_000;
|
|
457
462
|
const KIRO_STOP_QUIT_GRACE_MS = 5_000;
|
|
458
463
|
const KIRO_STOP_SIGTERM_GRACE_MS = 2_000;
|
|
459
464
|
/**
|
|
@@ -906,6 +911,11 @@ export class BlockingProcessDetector {
|
|
|
906
911
|
this.notified = false;
|
|
907
912
|
}
|
|
908
913
|
}
|
|
914
|
+
/** Response types the fleet sends back for tool calls the daemon forwarded. */
|
|
915
|
+
const FLEET_RESPONSE_TYPES = new Set([
|
|
916
|
+
"fleet_schedule_response", "fleet_outbound_response", "fleet_decision_response", "fleet_task_response",
|
|
917
|
+
"fleet_display_name_response", "fleet_description_response", "fleet_persona_emoji_response",
|
|
918
|
+
]);
|
|
909
919
|
export class Daemon extends EventEmitter {
|
|
910
920
|
name;
|
|
911
921
|
config;
|
|
@@ -979,6 +989,25 @@ export class Daemon extends EventEmitter {
|
|
|
979
989
|
* after the startup scan can still swallow that message's Enter.
|
|
980
990
|
*/
|
|
981
991
|
inputTransientGuardGeneration = null;
|
|
992
|
+
/**
|
|
993
|
+
* The spawn whose passive transients are over, independently of the
|
|
994
|
+
* startup-proof tail above. A transient screen can be quoted verbatim by the
|
|
995
|
+
* transcript (a cropped quote of the resume screen is byte-identical to the
|
|
996
|
+
* real one), so the pane alone must not be able to hold input. AgEnD's own
|
|
997
|
+
* launch state retires the check, per launch attempt: a launch that cannot show one, the real
|
|
998
|
+
* transient seen and then gone, or a "transient" that never changed for
|
|
999
|
+
* INPUT_TRANSIENT_STALL_MS. (The first submitted message retires the whole
|
|
1000
|
+
* guard, as before.)
|
|
1001
|
+
*/
|
|
1002
|
+
inputTransientRetiredAttempt = null;
|
|
1003
|
+
/** The launch attempt in which a passive transient was actually observed on screen. */
|
|
1004
|
+
inputTransientSeenAttempt = null;
|
|
1005
|
+
/**
|
|
1006
|
+
* One per command actually launched. A spawn can launch more than once
|
|
1007
|
+
* (spawnClaudeWindow retries a failed resume without a new beginSpawn), and
|
|
1008
|
+
* each launch paints its own transient, so retirement is per attempt.
|
|
1009
|
+
*/
|
|
1010
|
+
launchAttempt = 0;
|
|
982
1011
|
skipResume = false;
|
|
983
1012
|
startupAborted = false;
|
|
984
1013
|
/** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
|
|
@@ -1041,6 +1070,16 @@ export class Daemon extends EventEmitter {
|
|
|
1041
1070
|
* would silently no-op exactly when we most need to stop feeding the CLI.
|
|
1042
1071
|
*/
|
|
1043
1072
|
pausePending = false;
|
|
1073
|
+
/**
|
|
1074
|
+
* #1044: pause on the idle edge only if the auth error is still on screen.
|
|
1075
|
+
* Set when nothing could vouch for the hit — the backend has no token-free
|
|
1076
|
+
* auth check (muse), or it could not answer. A real expired session prints
|
|
1077
|
+
* its failure at the end of the turn it broke, so it is still at the bottom
|
|
1078
|
+
* when that turn ends; a phrase the pattern caught mid-turn has scrolled on
|
|
1079
|
+
* by then. Recovery cannot decide this: an expired session is back at its
|
|
1080
|
+
* prompt after every failed turn, which is what "recovered" means.
|
|
1081
|
+
*/
|
|
1082
|
+
pauseReconfirmAuth = false;
|
|
1044
1083
|
/**
|
|
1045
1084
|
* An auth failure was detected and nothing has resolved it yet.
|
|
1046
1085
|
*
|
|
@@ -1051,6 +1090,15 @@ export class Daemon extends EventEmitter {
|
|
|
1051
1090
|
authFailureUnresolved = false;
|
|
1052
1091
|
/** One login-screen auth report per spawn — the screen persists across polls. */
|
|
1053
1092
|
loginScreenReported = false;
|
|
1093
|
+
/**
|
|
1094
|
+
* While the lifecycle is still starting this daemon, `pty_error` has no
|
|
1095
|
+
* listener and `InstanceLifecycle.daemons` has no entry — so an incident the
|
|
1096
|
+
* startup scan raises (sign-in screen, fatal modal) would be dropped on the
|
|
1097
|
+
* floor while its dedupe flag stays set forever. Held here, then re-emitted by
|
|
1098
|
+
* releaseStartupIncidents() once the handlers and the registry are in place.
|
|
1099
|
+
*/
|
|
1100
|
+
holdingStartupIncidents = false;
|
|
1101
|
+
heldStartupIncidents = [];
|
|
1054
1102
|
/** A fatal startup screen (see StartupDialog.fatal) was already reported this run. */
|
|
1055
1103
|
fatalStartupReported = false;
|
|
1056
1104
|
/**
|
|
@@ -1121,6 +1169,8 @@ export class Daemon extends EventEmitter {
|
|
|
1121
1169
|
lastProgressBroadcastAt = 0;
|
|
1122
1170
|
// PTY error pattern monitoring
|
|
1123
1171
|
errorMonitorTimer = null;
|
|
1172
|
+
quitWatchTimer = null;
|
|
1173
|
+
quitWatchToken = null;
|
|
1124
1174
|
interactivePromptDetector = new InteractivePromptDetector();
|
|
1125
1175
|
blockingProcessDetector = new BlockingProcessDetector();
|
|
1126
1176
|
/** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
|
|
@@ -1266,6 +1316,24 @@ export class Daemon extends EventEmitter {
|
|
|
1266
1316
|
this.lastErrorCount.set(key, count);
|
|
1267
1317
|
this.seededErrorCountBaselines.set(key, count);
|
|
1268
1318
|
}
|
|
1319
|
+
/** Start buffering `pty_error` until releaseStartupIncidents(); see holdingStartupIncidents. */
|
|
1320
|
+
holdStartupIncidents() {
|
|
1321
|
+
this.holdingStartupIncidents = true;
|
|
1322
|
+
}
|
|
1323
|
+
/** Stop buffering and deliver, in order, every `pty_error` raised while the lifecycle was starting us. */
|
|
1324
|
+
releaseStartupIncidents() {
|
|
1325
|
+
this.holdingStartupIncidents = false;
|
|
1326
|
+
const held = this.heldStartupIncidents.splice(0);
|
|
1327
|
+
for (const args of held)
|
|
1328
|
+
super.emit("pty_error", ...args);
|
|
1329
|
+
}
|
|
1330
|
+
emit(event, ...args) {
|
|
1331
|
+
if (event === "pty_error" && this.holdingStartupIncidents) {
|
|
1332
|
+
this.heldStartupIncidents.push(args);
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
return super.emit(event, ...args);
|
|
1336
|
+
}
|
|
1269
1337
|
/**
|
|
1270
1338
|
* Undo the auth suspicion a pattern match armed, once the backend's token-free
|
|
1271
1339
|
* probe has said the credentials are fine.
|
|
@@ -1422,6 +1490,11 @@ export class Daemon extends EventEmitter {
|
|
|
1422
1490
|
finishDurableDelivery(delivery, outcome, evidence) {
|
|
1423
1491
|
if (!delivery)
|
|
1424
1492
|
return;
|
|
1493
|
+
// Delivered or uncertain means the pane write started: the work reached
|
|
1494
|
+
// the CLI (or may have), so it is activity (Phase 2a). Only a failure is
|
|
1495
|
+
// not — a pre-write failure never gets here; it is retried before begin.
|
|
1496
|
+
if (outcome !== "failed")
|
|
1497
|
+
this.noteWorkActivity();
|
|
1425
1498
|
this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
|
|
1426
1499
|
}
|
|
1427
1500
|
finishDurableSubmission(delivery, verdict) {
|
|
@@ -1440,6 +1513,56 @@ export class Daemon extends EventEmitter {
|
|
|
1440
1513
|
return;
|
|
1441
1514
|
this.retryDurableDeliveryBeforeBegin(delivery, reason);
|
|
1442
1515
|
}
|
|
1516
|
+
/** Restart the idle clock for work that reached the CLI, and persist it like a user inbound. */
|
|
1517
|
+
noteWorkActivity(now = Date.now()) {
|
|
1518
|
+
this.autoPauseController.recordActivity(now);
|
|
1519
|
+
try {
|
|
1520
|
+
writeLastInboundAt(this.instanceDir, now);
|
|
1521
|
+
}
|
|
1522
|
+
catch (err) {
|
|
1523
|
+
this.logger.warn({ err: err.message }, "Failed to persist last activity timestamp");
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* A daemon started to wake, restart or explicitly start an instance begins
|
|
1528
|
+
* its idle window now (Phase 2a). Only the fleet's own boot keeps the
|
|
1529
|
+
* persisted last-inbound seed, so a long-idle fleet still pauses after a
|
|
1530
|
+
* restart; a woken instance used to inherit a week-old timestamp and pause
|
|
1531
|
+
* one second after it became ready.
|
|
1532
|
+
*/
|
|
1533
|
+
seedActivityNow(now = Date.now()) {
|
|
1534
|
+
this.autoPauseController.recordActivity(now);
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Phase 2b (design §1.4): why this awake instance is not taking input, in
|
|
1538
|
+
* words for a notice, or null when nothing known holds it. Read from the
|
|
1539
|
+
* daemon's own state only; it never inspects the pane itself.
|
|
1540
|
+
*/
|
|
1541
|
+
notAcceptingReason() {
|
|
1542
|
+
if (this.pauseWakeState !== "active")
|
|
1543
|
+
return `it is ${this.pauseWakeState}`;
|
|
1544
|
+
if (this.inputBlockedDialogKey !== null)
|
|
1545
|
+
return `a dialog is holding its input (${this.inputBlockedDialogKey})`;
|
|
1546
|
+
if (this.authFailureUnresolved)
|
|
1547
|
+
return "its login is failing";
|
|
1548
|
+
if (this.instanceState === "stuck")
|
|
1549
|
+
return "the CLI looks stuck";
|
|
1550
|
+
return null;
|
|
1551
|
+
}
|
|
1552
|
+
workLeaseCheck = null;
|
|
1553
|
+
/** Lifecycle-provided: whether the fleet holds a work lease on this instance. */
|
|
1554
|
+
setWorkLeaseCheck(check) {
|
|
1555
|
+
this.workLeaseCheck = check ?? null;
|
|
1556
|
+
}
|
|
1557
|
+
workLeaseActive() {
|
|
1558
|
+
try {
|
|
1559
|
+
return this.workLeaseCheck?.() === true;
|
|
1560
|
+
}
|
|
1561
|
+
catch (err) {
|
|
1562
|
+
this.logger.debug({ err }, "Work lease check failed; treating as no lease");
|
|
1563
|
+
return false;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1443
1566
|
async start() {
|
|
1444
1567
|
mkdirSync(this.instanceDir, { recursive: true });
|
|
1445
1568
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
@@ -1487,21 +1610,7 @@ export class Daemon extends EventEmitter {
|
|
|
1487
1610
|
await this.ipcServer.listen();
|
|
1488
1611
|
ipcListening = true;
|
|
1489
1612
|
// Permanent IPC dispatcher: routes responses to pending requests by type+id key
|
|
1490
|
-
this.ipcServer.on("message", (msg) =>
|
|
1491
|
-
const type = msg.type;
|
|
1492
|
-
if (!type)
|
|
1493
|
-
return;
|
|
1494
|
-
// Build lookup key matching the pattern used when registering
|
|
1495
|
-
let key;
|
|
1496
|
-
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) {
|
|
1497
|
-
key = String(msg.fleetRequestId);
|
|
1498
|
-
}
|
|
1499
|
-
if (key && this.pendingIpcRequests.has(key)) {
|
|
1500
|
-
const handler = this.pendingIpcRequests.get(key);
|
|
1501
|
-
this.pendingIpcRequests.delete(key);
|
|
1502
|
-
handler(msg);
|
|
1503
|
-
}
|
|
1504
|
-
});
|
|
1613
|
+
this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
|
|
1505
1614
|
// IPC message relay: when daemon wants to push a channel message to Claude,
|
|
1506
1615
|
// it broadcasts to all IPC clients (the MCP server is one of them).
|
|
1507
1616
|
// When MCP server sends a tool_call, daemon handles it via the messageBus.
|
|
@@ -2458,6 +2567,13 @@ export class Daemon extends EventEmitter {
|
|
|
2458
2567
|
return;
|
|
2459
2568
|
const pane = await this.tmux.capturePane();
|
|
2460
2569
|
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
2570
|
+
// A sign-in screen that appears AFTER the startup scan ended (first run:
|
|
2571
|
+
// theme → login can take longer than its budget) is the same auth
|
|
2572
|
+
// incident the scan reports. Structural predicate only — the loose
|
|
2573
|
+
// patterns are for a fresh startup pane, not a long-lived transcript.
|
|
2574
|
+
const loginFlow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
|
|
2575
|
+
if (loginFlow?.loginScreenActive && this.reportLoginScreen(pane))
|
|
2576
|
+
return;
|
|
2461
2577
|
const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
|
|
2462
2578
|
if (interactivePrompt) {
|
|
2463
2579
|
this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
|
|
@@ -2985,33 +3101,128 @@ export class Daemon extends EventEmitter {
|
|
|
2985
3101
|
this.logger.warn(record, `${cliLabel} exited on its own (exit code ${exitCode ?? "unknown"}) — last output recorded`);
|
|
2986
3102
|
}
|
|
2987
3103
|
}
|
|
2988
|
-
/**
|
|
2989
|
-
|
|
3104
|
+
/**
|
|
3105
|
+
* Send the backend-specific graceful quit command/key sequence.
|
|
3106
|
+
*
|
|
3107
|
+
* Observability (#1030): every send is logged with reason + caller, the
|
|
3108
|
+
* outcome is logged, and a non-stop quit arms a bounded relaunch watch —
|
|
3109
|
+
* sol's silent wedge (quit landed, codex exited to its resume screen, no
|
|
3110
|
+
* relaunch, daemon still running) was invisible precisely because none of
|
|
3111
|
+
* this existed.
|
|
3112
|
+
*/
|
|
3113
|
+
async sendQuitSequence(reason = "unspecified", caller = "unknown", opts) {
|
|
2990
3114
|
if (!this.tmux || !this.backend)
|
|
2991
3115
|
return false;
|
|
3116
|
+
const backend = this.backend.binaryName;
|
|
2992
3117
|
const quitCmd = this.backend.getQuitCommand();
|
|
2993
3118
|
const quitKey = this.backend.getQuitKey?.();
|
|
3119
|
+
const quitVia = quitCmd ? `quit command ${quitCmd}` : quitKey ? `quit key ${quitKey}` : "no quit sequence";
|
|
3120
|
+
this.logger.info({ backend, quit: quitCmd ?? quitKey ?? null, reason, caller }, `Sending backend quit sequence (${reason} via ${caller})`);
|
|
2994
3121
|
if (quitCmd || quitKey)
|
|
2995
|
-
this.noteStopRequest(
|
|
3122
|
+
this.noteStopRequest(quitVia, reason);
|
|
3123
|
+
let sent = false;
|
|
3124
|
+
let step = "sendKeys";
|
|
2996
3125
|
if (quitCmd) {
|
|
2997
|
-
if (
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3126
|
+
if (await this.tmux.sendKeys(quitCmd)) {
|
|
3127
|
+
// Delay before Enter to prevent tmux server races when instances stop in
|
|
3128
|
+
// parallel (same pattern as pasteText).
|
|
3129
|
+
await new Promise(r => setTimeout(r, 150));
|
|
3130
|
+
step = "enter";
|
|
3131
|
+
sent = await this.tmux.sendSpecialKey("Enter");
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
else if (quitKey) {
|
|
3135
|
+
step = "quit key";
|
|
3136
|
+
sent = true;
|
|
3137
|
+
const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
|
|
3138
|
+
for (let i = 0; i < presses; i++) {
|
|
3139
|
+
if (!await this.tmux.sendSpecialKey(quitKey)) {
|
|
3140
|
+
sent = false;
|
|
3141
|
+
break;
|
|
3142
|
+
}
|
|
3143
|
+
if (i + 1 < presses)
|
|
3144
|
+
await new Promise(r => setTimeout(r, 250));
|
|
3145
|
+
}
|
|
3003
3146
|
}
|
|
3004
|
-
|
|
3147
|
+
else {
|
|
3148
|
+
this.logger.warn({ backend, reason, caller }, "Backend has no quit command or key — nothing sent");
|
|
3005
3149
|
return false;
|
|
3006
|
-
const presses = Math.max(1, Math.floor(this.backend.getQuitKeyPresses?.() ?? 1));
|
|
3007
|
-
for (let i = 0; i < presses; i++) {
|
|
3008
|
-
const sent = await this.tmux.sendSpecialKey(quitKey);
|
|
3009
|
-
if (!sent)
|
|
3010
|
-
return false;
|
|
3011
|
-
if (i + 1 < presses)
|
|
3012
|
-
await new Promise(r => setTimeout(r, 250));
|
|
3013
3150
|
}
|
|
3014
|
-
|
|
3151
|
+
if (sent) {
|
|
3152
|
+
this.logger.info({ backend, quit: quitCmd ?? quitKey, reason, caller }, "Backend quit sequence sent");
|
|
3153
|
+
if (opts?.watchRelaunch)
|
|
3154
|
+
this.armQuitRelaunchWatch(reason, caller, opts.watchMs);
|
|
3155
|
+
}
|
|
3156
|
+
else {
|
|
3157
|
+
this.logger.warn({ backend, quit: quitCmd ?? quitKey, reason, caller, step }, "Backend quit sequence failed to send");
|
|
3158
|
+
}
|
|
3159
|
+
return sent;
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Codex names its own resume command on its exit screen — the wedge
|
|
3163
|
+
* signature. Backend-generic shell, codex-specific match (#1030 ask 4):
|
|
3164
|
+
* other backends' panes simply never match.
|
|
3165
|
+
*/
|
|
3166
|
+
isCodexResumeScreen(pane) {
|
|
3167
|
+
return /codex\s+resume\b/i.test(pane);
|
|
3168
|
+
}
|
|
3169
|
+
clearQuitRelaunchWatch() {
|
|
3170
|
+
if (this.quitWatchTimer) {
|
|
3171
|
+
clearTimeout(this.quitWatchTimer);
|
|
3172
|
+
this.quitWatchTimer = null;
|
|
3173
|
+
}
|
|
3174
|
+
// Invalidates any already-started checkQuitRelaunch whose pane reads are
|
|
3175
|
+
// still pending: it must bail on return, not warn from stale results.
|
|
3176
|
+
this.quitWatchToken = null;
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* After a non-stop quit, warn if the backend exited but nothing relaunched
|
|
3180
|
+
* within the bound while this daemon still considers itself active.
|
|
3181
|
+
* Skipped when paused/pausing/waking/stopped or a relaunch (new spawn
|
|
3182
|
+
* generation) already happened — only the wedged state warns.
|
|
3183
|
+
*/
|
|
3184
|
+
armQuitRelaunchWatch(reason, caller, delayMs = QUIT_RELAUNCH_WATCH_MS) {
|
|
3185
|
+
this.clearQuitRelaunchWatch();
|
|
3186
|
+
const generation = this.spawnGeneration;
|
|
3187
|
+
const quitAt = Date.now();
|
|
3188
|
+
const token = {};
|
|
3189
|
+
this.quitWatchToken = token;
|
|
3190
|
+
this.quitWatchTimer = setTimeout(() => {
|
|
3191
|
+
this.quitWatchTimer = null;
|
|
3192
|
+
void this.checkQuitRelaunch({ reason, caller, generation, quitAt, token }).catch(() => { });
|
|
3193
|
+
}, delayMs);
|
|
3194
|
+
}
|
|
3195
|
+
async checkQuitRelaunch(opts) {
|
|
3196
|
+
if (this.pauseWakeState !== "active")
|
|
3197
|
+
return;
|
|
3198
|
+
if (opts.generation !== this.spawnGeneration)
|
|
3199
|
+
return;
|
|
3200
|
+
if (!this.tmux)
|
|
3201
|
+
return;
|
|
3202
|
+
// TOCTOU guard: the token is captured at arm time, so a beginSpawn/stop
|
|
3203
|
+
// that lands while the pane reads below are pending invalidates this
|
|
3204
|
+
// check. Re-validate after every await before using the results.
|
|
3205
|
+
const token = opts.token ?? this.quitWatchToken;
|
|
3206
|
+
let pane;
|
|
3207
|
+
let alive = true;
|
|
3208
|
+
try {
|
|
3209
|
+
const status = await this.tmux.getPaneStatus();
|
|
3210
|
+
alive = !!status?.alive;
|
|
3211
|
+
pane = await this.tmux.capturePane().catch(() => undefined);
|
|
3212
|
+
}
|
|
3213
|
+
catch {
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
if (this.pauseWakeState !== "active")
|
|
3217
|
+
return;
|
|
3218
|
+
if (opts.generation !== this.spawnGeneration)
|
|
3219
|
+
return;
|
|
3220
|
+
if (token !== this.quitWatchToken)
|
|
3221
|
+
return;
|
|
3222
|
+
const resumeScreen = !!pane && this.isCodexResumeScreen(pane);
|
|
3223
|
+
if (alive && !resumeScreen)
|
|
3224
|
+
return;
|
|
3225
|
+
this.logger.warn({ backend: this.backend?.binaryName, reason: opts.reason, caller: opts.caller, quitAt: opts.quitAt, paneAlive: alive, resumeScreen }, "codex-exited-without-relaunch: backend quit but no relaunch followed while this daemon is still active");
|
|
3015
3226
|
}
|
|
3016
3227
|
/**
|
|
3017
3228
|
* Kiro drops `/quit` while a turn is running. Stop that turn first, then use
|
|
@@ -3035,6 +3246,7 @@ export class Daemon extends EventEmitter {
|
|
|
3035
3246
|
}
|
|
3036
3247
|
async stop() {
|
|
3037
3248
|
this.logger.info("Stopping daemon instance");
|
|
3249
|
+
this.clearQuitRelaunchWatch();
|
|
3038
3250
|
this.fenceDeliveryWritesForStop();
|
|
3039
3251
|
this.turnReplyGuard.reset();
|
|
3040
3252
|
// Invalidate any bounded pre-Enter wait from the process generation being
|
|
@@ -3061,7 +3273,7 @@ export class Daemon extends EventEmitter {
|
|
|
3061
3273
|
let killed = false;
|
|
3062
3274
|
const windowId = this.tmux.getWindowId();
|
|
3063
3275
|
const kiroReady = windowId ? await this.drainBusyKiroForStop(windowId) : true;
|
|
3064
|
-
const quitSent = kiroReady && await this.sendQuitSequence("graceful stop");
|
|
3276
|
+
const quitSent = kiroReady && await this.sendQuitSequence("graceful stop", "stop");
|
|
3065
3277
|
const quitGraceMs = this.backend?.binaryName === "kiro-cli"
|
|
3066
3278
|
? KIRO_STOP_QUIT_GRACE_MS : 3_000;
|
|
3067
3279
|
const sigtermGraceMs = this.backend?.binaryName === "kiro-cli"
|
|
@@ -3147,13 +3359,16 @@ export class Daemon extends EventEmitter {
|
|
|
3147
3359
|
/**
|
|
3148
3360
|
* Answer a FleetManager state query. Most callers only need the cached state,
|
|
3149
3361
|
* but lifecycle decisions such as post-reply Cancel retirement need an
|
|
3150
|
-
* authoritative pane observation
|
|
3151
|
-
* "working" even though the CLI has since
|
|
3152
|
-
* control-mode output record.
|
|
3362
|
+
* authoritative pane observation, as do delivery idle gates: after startup
|
|
3363
|
+
* the last transition can stay "working" even though the CLI has since
|
|
3364
|
+
* settled without emitting another control-mode output record.
|
|
3153
3365
|
*/
|
|
3154
3366
|
async respondToInstanceStateQuery(msg, socket) {
|
|
3155
3367
|
try {
|
|
3156
|
-
if (msg.
|
|
3368
|
+
if (msg.deliveryIdle === true) {
|
|
3369
|
+
await this.captureAndEvaluateInstanceState("delivery_idle_gate");
|
|
3370
|
+
}
|
|
3371
|
+
else if (msg.refresh === true) {
|
|
3157
3372
|
await this.captureAndEvaluateInstanceState("state_query");
|
|
3158
3373
|
}
|
|
3159
3374
|
const snapshot = this.getInstanceStateSnapshot();
|
|
@@ -3177,12 +3392,35 @@ export class Daemon extends EventEmitter {
|
|
|
3177
3392
|
* failures: pause() alone no-ops while the CLI is busy/stuck, which is the
|
|
3178
3393
|
* usual state when the error surfaces. Cleared by a successful pause or wake.
|
|
3179
3394
|
*/
|
|
3180
|
-
requestPauseWhenIdle() {
|
|
3395
|
+
requestPauseWhenIdle(opts = {}) {
|
|
3181
3396
|
if (this.pauseWakeState === "paused")
|
|
3182
3397
|
return;
|
|
3398
|
+
const reconfirm = opts.reconfirmAuth === true;
|
|
3399
|
+
// An auth request wins over a pending non-auth one: the marker must not
|
|
3400
|
+
// later read as auto-wakeable when a login failure is among the causes.
|
|
3401
|
+
const reason = opts.reason ?? "auth";
|
|
3402
|
+
this.pendingPauseReason = this.pausePending && this.pendingPauseReason === "auth" ? "auth" : reason;
|
|
3403
|
+
// A pause already pending unconditionally stays unconditional.
|
|
3404
|
+
this.pauseReconfirmAuth = this.pausePending ? this.pauseReconfirmAuth && reconfirm : reconfirm;
|
|
3183
3405
|
this.pausePending = true;
|
|
3184
|
-
|
|
3185
|
-
|
|
3406
|
+
// A conditional request is the only pause path it has (#1044): decide on
|
|
3407
|
+
// the current pane now instead of at the next state sweep, so a pane that
|
|
3408
|
+
// is already idle is not left open to a delivery in the meantime.
|
|
3409
|
+
if (reconfirm)
|
|
3410
|
+
void this.captureAndEvaluateInstanceState("deferred_pause");
|
|
3411
|
+
}
|
|
3412
|
+
/** The backend's own auth-error pattern, within the pane's last rows. */
|
|
3413
|
+
authErrorAtPaneBottom(pane) {
|
|
3414
|
+
const rows = pane.replace(/\r/g, "").split("\n").filter(r => r.trim() !== "");
|
|
3415
|
+
const bottom = rows.slice(-Daemon.AUTH_RECONFIRM_ROWS).join("\n");
|
|
3416
|
+
return (this.backend?.getErrorPatterns?.() ?? [])
|
|
3417
|
+
.filter(ep => ep.type === "auth_error")
|
|
3418
|
+
.some(ep => new RegExp(ep.pattern.source, ep.pattern.flags.replace("g", "")).test(bottom));
|
|
3419
|
+
}
|
|
3420
|
+
static AUTH_RECONFIRM_ROWS = 15;
|
|
3421
|
+
/** Why the deferred pause was requested; what its marker records (Phase 2a). */
|
|
3422
|
+
pendingPauseReason = "auth";
|
|
3423
|
+
async pause(reason = "operator") {
|
|
3186
3424
|
if (this.pauseWakeState === "paused")
|
|
3187
3425
|
return;
|
|
3188
3426
|
if (this.pauseWakeState === "pausing" || this.pauseWakeState === "waking") {
|
|
@@ -3210,7 +3448,7 @@ export class Daemon extends EventEmitter {
|
|
|
3210
3448
|
const transition = (async () => {
|
|
3211
3449
|
try {
|
|
3212
3450
|
this.saveSessionId();
|
|
3213
|
-
await this.sendQuitSequence(pauseReason);
|
|
3451
|
+
await this.sendQuitSequence(pauseReason, "pause", { watchRelaunch: true });
|
|
3214
3452
|
let exited = false;
|
|
3215
3453
|
for (let i = 0; i < 15; i++) {
|
|
3216
3454
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -3235,7 +3473,7 @@ export class Daemon extends EventEmitter {
|
|
|
3235
3473
|
}
|
|
3236
3474
|
this.pauseWakeState = "paused";
|
|
3237
3475
|
this.autoPauseController.markPaused();
|
|
3238
|
-
writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now());
|
|
3476
|
+
writePausedMarker(this.instanceDir, this.lastPausedAt ?? Date.now(), reason);
|
|
3239
3477
|
this.logger.info({ pausedAt: this.lastPausedAt }, "Instance auto-paused");
|
|
3240
3478
|
this.ipcServer?.broadcast({
|
|
3241
3479
|
type: "instance_state", instanceName: this.name, state: "paused", pausedAt: this.lastPausedAt,
|
|
@@ -3264,6 +3502,7 @@ export class Daemon extends EventEmitter {
|
|
|
3264
3502
|
// An explicit wake (e.g. after the user re-logs in) cancels a deferred
|
|
3265
3503
|
// auth pause — otherwise the instance would pause again the moment it idles.
|
|
3266
3504
|
this.pausePending = false;
|
|
3505
|
+
this.pauseReconfirmAuth = false;
|
|
3267
3506
|
this.authFailureUnresolved = false;
|
|
3268
3507
|
this.loginScreenReported = false;
|
|
3269
3508
|
this.fatalStartupReported = false;
|
|
@@ -3391,8 +3630,20 @@ export class Daemon extends EventEmitter {
|
|
|
3391
3630
|
// live, kept its warm slot, and re-raised a hang alert on every scan.
|
|
3392
3631
|
if (this.pausePending && (snapshot.state === "idle" || snapshot.state === "stuck") && this.pasteQueueDepth === 0) {
|
|
3393
3632
|
this.pausePending = false;
|
|
3633
|
+
const reconfirm = this.pauseReconfirmAuth;
|
|
3634
|
+
this.pauseReconfirmAuth = false;
|
|
3635
|
+
// An unreadable pane keeps the pause: when in doubt, stop feeding a CLI
|
|
3636
|
+
// that may be unable to answer (42512a46).
|
|
3637
|
+
if (reconfirm && pane !== undefined && !this.authErrorAtPaneBottom(pane)) {
|
|
3638
|
+
this.authFailureUnresolved = false;
|
|
3639
|
+
this.logger.warn({ state: snapshot.state }, "Deferred auth pause dropped: the auth-error text is no longer at the bottom of the pane at the end of the turn, "
|
|
3640
|
+
+ "and this backend has no auth check to confirm it (likely conversation text)");
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3394
3643
|
this.pauseAllowStuck = snapshot.state === "stuck";
|
|
3395
|
-
|
|
3644
|
+
const reason = this.pendingPauseReason;
|
|
3645
|
+
this.pendingPauseReason = "auth";
|
|
3646
|
+
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason });
|
|
3396
3647
|
return;
|
|
3397
3648
|
}
|
|
3398
3649
|
// A restart deferred by a dead MCP server: the turn it must not interrupt is
|
|
@@ -3401,9 +3652,14 @@ export class Daemon extends EventEmitter {
|
|
|
3401
3652
|
this.fireMcpRestartRequest("idle_edge");
|
|
3402
3653
|
return;
|
|
3403
3654
|
}
|
|
3404
|
-
|
|
3655
|
+
// A work lease (Phase 2a) holds only this idle-timeout pause: a target the
|
|
3656
|
+
// fleet just woke for queued work, or one with a submission in flight, must
|
|
3657
|
+
// not be put back to sleep before that work reaches it. The auth-deferred
|
|
3658
|
+
// pause above and operator pauses never consult it.
|
|
3659
|
+
if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)
|
|
3660
|
+
&& !this.workLeaseActive()) {
|
|
3405
3661
|
this.pauseRequested = true;
|
|
3406
|
-
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
|
|
3662
|
+
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt, reason: "idle" });
|
|
3407
3663
|
}
|
|
3408
3664
|
}
|
|
3409
3665
|
/**
|
|
@@ -3822,14 +4078,39 @@ export class Daemon extends EventEmitter {
|
|
|
3822
4078
|
}
|
|
3823
4079
|
this.statePollInFlight = true;
|
|
3824
4080
|
const captureStartedAt = Date.now();
|
|
4081
|
+
const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
|
|
4082
|
+
const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
|
|
4083
|
+
&& !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
|
|
3825
4084
|
try {
|
|
3826
4085
|
const pane = await this.tmux.capturePane();
|
|
4086
|
+
// An old capture must not retire a new launch's transient guard either.
|
|
4087
|
+
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4088
|
+
this.resetFooterFallback();
|
|
4089
|
+
return;
|
|
4090
|
+
}
|
|
4091
|
+
// Delivery's unknown-footer proof also awaits the TTY mode. Validate
|
|
4092
|
+
// output and launch freshness AFTER both awaits, before accepting it.
|
|
4093
|
+
const deliveryCandidate = reason === "delivery_idle_gate"
|
|
4094
|
+
? await this.probeDeliveryIdleFallback(pane) : null;
|
|
4095
|
+
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4096
|
+
this.resetFooterFallback();
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
3827
4099
|
this.updateInputBlockedState(pane);
|
|
4100
|
+
// A monitor/lifecycle capture can see a busy moment between fleet polls.
|
|
4101
|
+
// Even a stale capture of changed content disproves continuous stability;
|
|
4102
|
+
// invalidate it before the output-freshness check can return early.
|
|
4103
|
+
if (currentDeliveryCapture() && this.footerFallbackPaneKey !== null
|
|
4104
|
+
&& pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "") !== this.footerFallbackPaneKey) {
|
|
4105
|
+
this.resetFooterFallback();
|
|
4106
|
+
}
|
|
3828
4107
|
// Output received while capture-pane was in flight makes this snapshot
|
|
3829
4108
|
// stale. Its output handler has already armed a new debounce.
|
|
3830
4109
|
const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
|
|
3831
4110
|
|| (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
|
|
3832
4111
|
if (outputMovedDuringCapture) {
|
|
4112
|
+
if (reason === "delivery_idle_gate")
|
|
4113
|
+
this.resetFooterFallback();
|
|
3833
4114
|
if (reason === "output_probe") {
|
|
3834
4115
|
// A second burst overtook the probe. Do not bless its stale capture as
|
|
3835
4116
|
// idle: continuous output is the safer working signal, while the next
|
|
@@ -3840,6 +4121,9 @@ export class Daemon extends EventEmitter {
|
|
|
3840
4121
|
}
|
|
3841
4122
|
return;
|
|
3842
4123
|
}
|
|
4124
|
+
// Advance the shared 10s timer only for a fresh capture of this launch.
|
|
4125
|
+
const deliveryFallback = deliveryCandidate === true
|
|
4126
|
+
? this.deliveryInputReadyPane(pane) : deliveryCandidate;
|
|
3843
4127
|
const observedChangeAt = expectedOutputAt || captureStartedAt;
|
|
3844
4128
|
// One observation per capture. This used to call observe() twice with the
|
|
3845
4129
|
// same pane: the second call always saw an unchanged pane, so the
|
|
@@ -3855,7 +4139,17 @@ export class Daemon extends EventEmitter {
|
|
|
3855
4139
|
// observe twice, which silently disabled the "content moved" branch.
|
|
3856
4140
|
const structuredPeriodicIdle = this.backend?.isPeriodicRedrawIdlePane;
|
|
3857
4141
|
let snapshot;
|
|
3858
|
-
if (
|
|
4142
|
+
if (deliveryFallback !== null) {
|
|
4143
|
+
// A missing footer cannot fall through to a broad ready match. All
|
|
4144
|
+
// of the same #1032 conditions must hold continuously for 10 seconds.
|
|
4145
|
+
snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
|
|
4146
|
+
settled: true,
|
|
4147
|
+
changeAt: observedChangeAt,
|
|
4148
|
+
forceReady: deliveryFallback,
|
|
4149
|
+
forceBusy: !deliveryFallback || paneActivity !== null,
|
|
4150
|
+
});
|
|
4151
|
+
}
|
|
4152
|
+
else if (structuredPeriodicIdle && reason === "output_probe") {
|
|
3859
4153
|
const idlePane = structuredPeriodicIdle.call(this.backend, pane);
|
|
3860
4154
|
if (idlePane) {
|
|
3861
4155
|
if (this.instanceState === "idle") {
|
|
@@ -3996,6 +4290,8 @@ export class Daemon extends EventEmitter {
|
|
|
3996
4290
|
}
|
|
3997
4291
|
}
|
|
3998
4292
|
catch (err) {
|
|
4293
|
+
if (reason === "delivery_idle_gate")
|
|
4294
|
+
this.resetFooterFallback();
|
|
3999
4295
|
this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
|
|
4000
4296
|
}
|
|
4001
4297
|
finally {
|
|
@@ -4945,19 +5241,39 @@ export class Daemon extends EventEmitter {
|
|
|
4945
5241
|
if (this.inputTransientGuardGeneration === null
|
|
4946
5242
|
|| this.inputTransientGuardGeneration !== this.spawnGeneration)
|
|
4947
5243
|
return [];
|
|
5244
|
+
if (this.inputTransientRetiredAttempt === this.launchAttempt)
|
|
5245
|
+
return [];
|
|
4948
5246
|
return this.backend?.getInputUnavailableTransients?.() ?? [];
|
|
4949
5247
|
}
|
|
5248
|
+
/** Stop honouring passive transients for the current spawn. */
|
|
5249
|
+
retireInputTransients(reason) {
|
|
5250
|
+
if (this.inputTransientRetiredAttempt === this.launchAttempt)
|
|
5251
|
+
return;
|
|
5252
|
+
this.inputTransientRetiredAttempt = this.launchAttempt;
|
|
5253
|
+
this.logger.debug({ reason, generation: this.spawnGeneration, attempt: this.launchAttempt }, "Startup input transient retired for this launch");
|
|
5254
|
+
}
|
|
4950
5255
|
/** Match a passive transient against the current interactive screen. */
|
|
4951
5256
|
inputTransientInPane(pane) {
|
|
4952
|
-
|
|
5257
|
+
const transients = this.guardedInputTransients();
|
|
5258
|
+
if (transients.length === 0)
|
|
5259
|
+
return null;
|
|
5260
|
+
for (const transient of transients) {
|
|
4953
5261
|
// `pattern` is only a cheap pre-filter. `isActive` is deliberately the
|
|
4954
5262
|
// authority because users and agents routinely quote diagnostic text.
|
|
4955
5263
|
transient.pattern.lastIndex = 0;
|
|
4956
5264
|
if (!transient.pattern.test(pane))
|
|
4957
5265
|
continue;
|
|
4958
|
-
if (transient.isActive(pane))
|
|
5266
|
+
if (transient.isActive(pane)) {
|
|
5267
|
+
this.inputTransientSeenAttempt = this.launchAttempt;
|
|
4959
5268
|
return transient;
|
|
5269
|
+
}
|
|
4960
5270
|
}
|
|
5271
|
+
// The phase is over once it has been seen to end; from then on the same
|
|
5272
|
+
// screen is a quote. A settled-looking pane before that proves nothing:
|
|
5273
|
+
// codex 0.154 painted the prompt and footer first and "Resuming session…"
|
|
5274
|
+
// a moment later — the reason this guard outlives the startup scan.
|
|
5275
|
+
if (this.inputTransientSeenAttempt === this.launchAttempt)
|
|
5276
|
+
this.retireInputTransients("transient ended");
|
|
4961
5277
|
return null;
|
|
4962
5278
|
}
|
|
4963
5279
|
/** Capture failure is unknown, never evidence that input is available. */
|
|
@@ -4993,7 +5309,12 @@ export class Daemon extends EventEmitter {
|
|
|
4993
5309
|
return budget;
|
|
4994
5310
|
}
|
|
4995
5311
|
async probeInputTransient() {
|
|
4996
|
-
|
|
5312
|
+
// A retired transient still leaves the startup guard armed: an unreadable
|
|
5313
|
+
// pane keeps answering "unknown", so it stalls out on the same budget
|
|
5314
|
+
// instead of letting a delivery poll a dark pane for ever.
|
|
5315
|
+
const armed = this.inputTransientGuardGeneration !== null && this.inputTransientGuardGeneration === this.spawnGeneration
|
|
5316
|
+
&& (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0;
|
|
5317
|
+
if (!armed || !this.tmux)
|
|
4997
5318
|
return { state: "clear" };
|
|
4998
5319
|
try {
|
|
4999
5320
|
const pane = await this.tmux.capturePane();
|
|
@@ -5001,10 +5322,13 @@ export class Daemon extends EventEmitter {
|
|
|
5001
5322
|
// The pane text comes back with the verdict: a transient that is still
|
|
5002
5323
|
// repainting is still working, and that is the only thing separating a
|
|
5003
5324
|
// slow resume from a wedged one.
|
|
5325
|
+
if (transient)
|
|
5326
|
+
this.resetFooterFallback();
|
|
5004
5327
|
return transient ? { state: "active", transient, pane } : { state: "clear" };
|
|
5005
5328
|
}
|
|
5006
5329
|
catch (err) {
|
|
5007
5330
|
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
5331
|
+
this.resetFooterFallback();
|
|
5008
5332
|
return { state: "unknown" };
|
|
5009
5333
|
}
|
|
5010
5334
|
}
|
|
@@ -5059,6 +5383,11 @@ export class Daemon extends EventEmitter {
|
|
|
5059
5383
|
stalledForMs: budget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
5060
5384
|
ceilingMs: timeoutMs,
|
|
5061
5385
|
}, "CLI input stayed unavailable — refusing to send Enter");
|
|
5386
|
+
// A screen that did not change for the whole stall window is not a
|
|
5387
|
+
// load in progress. This delivery still fails, but the same screen
|
|
5388
|
+
// (possibly a quote) must not fail every later one too.
|
|
5389
|
+
if (budget.stalled && observedDescription !== null)
|
|
5390
|
+
this.retireInputTransients("transient stalled");
|
|
5062
5391
|
return false;
|
|
5063
5392
|
}
|
|
5064
5393
|
await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
|
|
@@ -5201,6 +5530,35 @@ export class Daemon extends EventEmitter {
|
|
|
5201
5530
|
return !!this.backend?.isDeliveryInputReadyPane
|
|
5202
5531
|
&& this.inputTransientGuardGeneration === this.spawnGeneration;
|
|
5203
5532
|
}
|
|
5533
|
+
/**
|
|
5534
|
+
* null preserves native readiness; false is an ineligible unknown layout.
|
|
5535
|
+
* true means the caller may advance the timer after checking freshness.
|
|
5536
|
+
* Share #1032's candidate/timer with the later paste gate, rather than
|
|
5537
|
+
* reporting idle after 10s only to make that gate wait another 10s (#1035).
|
|
5538
|
+
*/
|
|
5539
|
+
async probeDeliveryIdleFallback(pane) {
|
|
5540
|
+
const inputReady = this.backend?.isDeliveryInputReadyPane;
|
|
5541
|
+
const stableUnknown = this.backend?.isStableUnknownLayoutIdlePane;
|
|
5542
|
+
if (!inputReady || !stableUnknown || inputReady.call(this.backend, pane)) {
|
|
5543
|
+
this.resetFooterFallback();
|
|
5544
|
+
return null;
|
|
5545
|
+
}
|
|
5546
|
+
const busy = this.backend?.getBusyPattern?.();
|
|
5547
|
+
if (busy)
|
|
5548
|
+
busy.lastIndex = 0;
|
|
5549
|
+
if (!stableUnknown.call(this.backend, pane) || busy?.test(pane)
|
|
5550
|
+
|| this.backend?.getPaneActivity?.(pane)
|
|
5551
|
+
|| this.deliveryBlockingDialogs().some(dialog => Daemon.dialogMatches(dialog, pane))
|
|
5552
|
+
|| this.inputTransientInPane(pane)) {
|
|
5553
|
+
this.resetFooterFallback();
|
|
5554
|
+
return false;
|
|
5555
|
+
}
|
|
5556
|
+
if (await this.tmux?.getPaneInputMode?.() !== "raw") {
|
|
5557
|
+
this.resetFooterFallback();
|
|
5558
|
+
return false;
|
|
5559
|
+
}
|
|
5560
|
+
return true;
|
|
5561
|
+
}
|
|
5204
5562
|
/** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
|
|
5205
5563
|
async hasPositiveDeliveryInput() {
|
|
5206
5564
|
if (!this.needsStartupInputProof())
|
|
@@ -5210,13 +5568,77 @@ export class Daemon extends EventEmitter {
|
|
|
5210
5568
|
return !check;
|
|
5211
5569
|
try {
|
|
5212
5570
|
const mode = await this.tmux.getPaneInputMode?.();
|
|
5213
|
-
if (mode !== "raw")
|
|
5571
|
+
if (mode !== "raw") {
|
|
5572
|
+
this.resetFooterFallback();
|
|
5214
5573
|
return false;
|
|
5215
|
-
|
|
5574
|
+
}
|
|
5575
|
+
return this.deliveryInputReadyPane(await this.tmux.capturePane());
|
|
5216
5576
|
}
|
|
5217
5577
|
catch {
|
|
5578
|
+
this.resetFooterFallback();
|
|
5579
|
+
return false;
|
|
5580
|
+
}
|
|
5581
|
+
}
|
|
5582
|
+
/** Pane text of the current footer-fallback candidate, and since when it has been unchanged. */
|
|
5583
|
+
footerFallbackPaneKey = null;
|
|
5584
|
+
footerFallbackStableSince = 0;
|
|
5585
|
+
/** The launch the candidate was seen in: a new spawn or launch attempt starts over. */
|
|
5586
|
+
footerFallbackEpoch = null;
|
|
5587
|
+
/**
|
|
5588
|
+
* Forget the footer-fallback candidate. Called whenever the pane is seen
|
|
5589
|
+
* outside the candidate state (busy, a dialog, a transient, unreadable) and
|
|
5590
|
+
* on every spawn: "stable for UNKNOWN_LAYOUT_STABLE_MS" must mean this screen,
|
|
5591
|
+
* continuously, not time accumulated across an interruption.
|
|
5592
|
+
*/
|
|
5593
|
+
resetFooterFallback() {
|
|
5594
|
+
this.footerFallbackPaneKey = null;
|
|
5595
|
+
this.footerFallbackEpoch = null;
|
|
5596
|
+
}
|
|
5597
|
+
footerFallbackWarnedGeneration = null;
|
|
5598
|
+
/**
|
|
5599
|
+
* The input-row proof used before a paste (#931/#947), with one fallback
|
|
5600
|
+
* (#1031). Codex sometimes paints an idle composer without its Context
|
|
5601
|
+
* status item even though tui.status_line asks for it: after four fleet
|
|
5602
|
+
* restarts, sol's resumed session showed only `⚠ 2 warnings · f2 to view`.
|
|
5603
|
+
* The proof needs a recognised footer, so the first delivery after each
|
|
5604
|
+
* restart waited 30 minutes, failed, and waited again, for seven hours.
|
|
5605
|
+
*
|
|
5606
|
+
* When the footer is the only thing missing, the #978 structural evidence
|
|
5607
|
+
* stands in for it, all of it required: the backend's own stable-unknown
|
|
5608
|
+
* idle check (an empty live composer; no busy row, esc-to-interrupt row,
|
|
5609
|
+
* queued ↳ input or known picker), no input transient such as the resume
|
|
5610
|
+
* load, and the same screen for UNKNOWN_LAYOUT_STABLE_MS. The raw-tty
|
|
5611
|
+
* requirement stays with the caller.
|
|
5612
|
+
*/
|
|
5613
|
+
deliveryInputReadyPane(pane) {
|
|
5614
|
+
const check = this.backend?.isDeliveryInputReadyPane;
|
|
5615
|
+
if (!check)
|
|
5616
|
+
return true;
|
|
5617
|
+
if (check.call(this.backend, pane)) {
|
|
5618
|
+
this.footerFallbackPaneKey = null;
|
|
5619
|
+
return true;
|
|
5620
|
+
}
|
|
5621
|
+
const stableUnknown = this.backend?.isStableUnknownLayoutIdlePane;
|
|
5622
|
+
if (!stableUnknown?.call(this.backend, pane) || this.inputTransientInPane(pane)) {
|
|
5623
|
+
this.footerFallbackPaneKey = null;
|
|
5218
5624
|
return false;
|
|
5219
5625
|
}
|
|
5626
|
+
const key = pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "");
|
|
5627
|
+
const epoch = `${this.spawnGeneration}:${this.launchAttempt}`;
|
|
5628
|
+
const now = Date.now();
|
|
5629
|
+
if (key !== this.footerFallbackPaneKey || epoch !== this.footerFallbackEpoch) {
|
|
5630
|
+
this.footerFallbackPaneKey = key;
|
|
5631
|
+
this.footerFallbackEpoch = epoch;
|
|
5632
|
+
this.footerFallbackStableSince = now;
|
|
5633
|
+
return false;
|
|
5634
|
+
}
|
|
5635
|
+
if (now - this.footerFallbackStableSince < UNKNOWN_LAYOUT_STABLE_MS)
|
|
5636
|
+
return false;
|
|
5637
|
+
if (this.footerFallbackWarnedGeneration !== this.spawnGeneration) {
|
|
5638
|
+
this.footerFallbackWarnedGeneration = this.spawnGeneration;
|
|
5639
|
+
this.logger.warn({ backend: this.backend?.binaryName, stableForMs: now - this.footerFallbackStableSince }, "Idle footer not recognised — delivering on the #978 fallback (stable empty composer, no busy/queued/dialog, raw tty, no transient)");
|
|
5640
|
+
}
|
|
5641
|
+
return true;
|
|
5220
5642
|
}
|
|
5221
5643
|
/**
|
|
5222
5644
|
* Why a pane is not deliverable matters. "busy" may be handed to a native
|
|
@@ -5226,16 +5648,26 @@ export class Daemon extends EventEmitter {
|
|
|
5226
5648
|
* pane proves nothing. The silence gate comes first and costs no capture.
|
|
5227
5649
|
*/
|
|
5228
5650
|
async paneReadinessForDelivery(windowId) {
|
|
5229
|
-
|
|
5651
|
+
// Each early return below is the pane seen outside the footer-fallback
|
|
5652
|
+
// candidate state, and never reaches deliveryInputReadyPane: reset here.
|
|
5653
|
+
if (!this.isPaneIdleForDelivery(windowId)) {
|
|
5654
|
+
this.resetFooterFallback();
|
|
5230
5655
|
return "busy";
|
|
5656
|
+
}
|
|
5231
5657
|
const probe = await this.probeBlockingDialog();
|
|
5232
|
-
if (probe.state !== "clear")
|
|
5658
|
+
if (probe.state !== "clear") {
|
|
5659
|
+
this.resetFooterFallback();
|
|
5233
5660
|
return probe.state;
|
|
5661
|
+
}
|
|
5234
5662
|
const transient = await this.probeInputTransient();
|
|
5235
|
-
if (transient.state === "active")
|
|
5663
|
+
if (transient.state === "active") {
|
|
5664
|
+
this.resetFooterFallback();
|
|
5236
5665
|
return "transient";
|
|
5237
|
-
|
|
5666
|
+
}
|
|
5667
|
+
if (transient.state === "unknown") {
|
|
5668
|
+
this.resetFooterFallback();
|
|
5238
5669
|
return "unknown";
|
|
5670
|
+
}
|
|
5239
5671
|
if (this.needsStartupInputProof()) {
|
|
5240
5672
|
return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
|
|
5241
5673
|
}
|
|
@@ -5306,6 +5738,11 @@ export class Daemon extends EventEmitter {
|
|
|
5306
5738
|
stalledForMs: transientBudget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
5307
5739
|
ceilingMs: INPUT_TRANSIENT_WAIT_MS,
|
|
5308
5740
|
}, "CLI input stayed unavailable during the delivery-readiness wait");
|
|
5741
|
+
// Same rule as the pre-Enter wait: a screen frozen for the whole
|
|
5742
|
+
// stall window is not a load in progress, so it holds this delivery
|
|
5743
|
+
// only.
|
|
5744
|
+
if (transientBudget.stalled)
|
|
5745
|
+
this.retireInputTransients("transient stalled");
|
|
5309
5746
|
return false;
|
|
5310
5747
|
}
|
|
5311
5748
|
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
@@ -5407,7 +5844,7 @@ export class Daemon extends EventEmitter {
|
|
|
5407
5844
|
return "busy";
|
|
5408
5845
|
if (this.backend?.getBusyPattern?.()?.test(pane))
|
|
5409
5846
|
return "busy";
|
|
5410
|
-
if (
|
|
5847
|
+
if (!this.deliveryInputReadyPane(pane))
|
|
5411
5848
|
return "busy";
|
|
5412
5849
|
if (strandedAgendMessageInInput(pane, prompt))
|
|
5413
5850
|
return "stranded";
|
|
@@ -6447,6 +6884,18 @@ export class Daemon extends EventEmitter {
|
|
|
6447
6884
|
}
|
|
6448
6885
|
return undefined;
|
|
6449
6886
|
}
|
|
6887
|
+
/** The fleet's answers to forwarded tool calls: settle the pending request they name. */
|
|
6888
|
+
routeFleetResponse(msg) {
|
|
6889
|
+
const type = msg.type;
|
|
6890
|
+
if (!type || !FLEET_RESPONSE_TYPES.has(type) || !msg.fleetRequestId)
|
|
6891
|
+
return;
|
|
6892
|
+
const key = String(msg.fleetRequestId);
|
|
6893
|
+
const handler = this.pendingIpcRequests.get(key);
|
|
6894
|
+
if (!handler)
|
|
6895
|
+
return;
|
|
6896
|
+
this.pendingIpcRequests.delete(key);
|
|
6897
|
+
handler(msg);
|
|
6898
|
+
}
|
|
6450
6899
|
/**
|
|
6451
6900
|
* Handle a tool call from the MCP server (forwarded by Claude).
|
|
6452
6901
|
* Routes to the channel adapter via MessageBus.
|
|
@@ -6495,9 +6944,12 @@ export class Daemon extends EventEmitter {
|
|
|
6495
6944
|
this.handleReleaseRepo(args, respond);
|
|
6496
6945
|
return;
|
|
6497
6946
|
}
|
|
6498
|
-
if (tool === "set_display_name" || tool === "set_description") {
|
|
6499
|
-
const type = tool === "set_display_name" ? "fleet_set_display_name"
|
|
6500
|
-
|
|
6947
|
+
if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis") {
|
|
6948
|
+
const type = tool === "set_display_name" ? "fleet_set_display_name"
|
|
6949
|
+
: tool === "set_description" ? "fleet_set_description"
|
|
6950
|
+
: tool === "list_emojis" ? "fleet_list_emojis"
|
|
6951
|
+
: tool === "preview_emojis" ? "fleet_preview_emojis" : "fleet_set_persona_emoji";
|
|
6952
|
+
const fleetReqId = `${tool === "set_display_name" ? "dn" : tool === "set_description" ? "desc" : "emoji"}_${++this.fleetRequestSeq}_${requestId}`;
|
|
6501
6953
|
const timeout = setTimeout(() => {
|
|
6502
6954
|
this.pendingIpcRequests.delete(fleetReqId);
|
|
6503
6955
|
respond(null, `${tool} timed out`);
|
|
@@ -6912,6 +7364,8 @@ export class Daemon extends EventEmitter {
|
|
|
6912
7364
|
* into exactly the window this exists to close.
|
|
6913
7365
|
*/
|
|
6914
7366
|
beginSpawn() {
|
|
7367
|
+
// A relaunch settles any outstanding quit watch: the CLI came back.
|
|
7368
|
+
this.clearQuitRelaunchWatch();
|
|
6915
7369
|
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
6916
7370
|
// v1 deliberately does not persist obligations across restarts: missing one
|
|
6917
7371
|
// warning is safer than treating the replacement's startup idle as the old
|
|
@@ -7570,6 +8024,13 @@ export class Daemon extends EventEmitter {
|
|
|
7570
8024
|
}
|
|
7571
8025
|
}
|
|
7572
8026
|
const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
|
|
8027
|
+
// Every launched command re-arms the passive-transient check, including a
|
|
8028
|
+
// retry inside the same spawn: its load is a new one.
|
|
8029
|
+
this.launchAttempt++;
|
|
8030
|
+
// A launch that cannot paint a passive transient (Codex: a fresh start
|
|
8031
|
+
// never shows "Resuming session…") must not honour one quoted on screen.
|
|
8032
|
+
if (this.backend.launchMayShowInputTransient?.() === false)
|
|
8033
|
+
this.retireInputTransients("launch shows none");
|
|
7573
8034
|
// e.g. Codex could not read its session DB and chose a fallback (#984):
|
|
7574
8035
|
// the operator should know which conversation this launch continues.
|
|
7575
8036
|
const launchWarning = this.backend.consumeLaunchWarning?.() ?? null;
|
|
@@ -7655,6 +8116,29 @@ export class Daemon extends EventEmitter {
|
|
|
7655
8116
|
this.firstDeliveryDelay.recordReady();
|
|
7656
8117
|
return ready;
|
|
7657
8118
|
}
|
|
8119
|
+
/**
|
|
8120
|
+
* Report the CLI's logged-out startup screen as an auth incident. Returns true
|
|
8121
|
+
* when this call reported it (the scan is then over: only re-login ends the
|
|
8122
|
+
* state); false when the screen is absent or was already reported this spawn.
|
|
8123
|
+
* `loginScreenActive`, when the backend flow has one, replaces the bare
|
|
8124
|
+
* pattern test so a transcript that merely mentions the screen cannot trip it.
|
|
8125
|
+
*/
|
|
8126
|
+
reportLoginScreen(pane) {
|
|
8127
|
+
const flow = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
|
|
8128
|
+
const onScreen = flow?.loginScreenActive ? flow.loginScreenActive(pane) : flow?.loginScreenPattern?.test(pane) === true;
|
|
8129
|
+
if (!onScreen || this.loginScreenReported)
|
|
8130
|
+
return false;
|
|
8131
|
+
this.loginScreenReported = true;
|
|
8132
|
+
this.authFailureUnresolved = true;
|
|
8133
|
+
this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
|
|
8134
|
+
this.emit("pty_error", {
|
|
8135
|
+
name: this.name,
|
|
8136
|
+
type: "auth_error",
|
|
8137
|
+
action: "pause",
|
|
8138
|
+
message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
|
|
8139
|
+
});
|
|
8140
|
+
return true;
|
|
8141
|
+
}
|
|
7658
8142
|
/**
|
|
7659
8143
|
* Poll the pane within a wall-clock budget, dismiss the confirmation dialogs
|
|
7660
8144
|
* the backend knows, and return true once the CLI shows a ready prompt with
|
|
@@ -7830,6 +8314,11 @@ export class Daemon extends EventEmitter {
|
|
|
7830
8314
|
}
|
|
7831
8315
|
if (this.dialogParkedSince !== 0 && !this.deliveryBlockingDialogs().some(d => Daemon.dialogMatches(d, pane)))
|
|
7832
8316
|
this.trackDialogParked(null);
|
|
8317
|
+
// A sign-in screen that carries the ready pattern's own glyph (Claude's
|
|
8318
|
+
// `❯ 1. …` login menu) must be recognised BEFORE the ready check, or the
|
|
8319
|
+
// ready check swallows it and the incident is never reported (#1074).
|
|
8320
|
+
if (LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenBeforeReady && this.reportLoginScreen(pane))
|
|
8321
|
+
return true;
|
|
7833
8322
|
// CLI is ready (pattern defined by each backend). Require it on two
|
|
7834
8323
|
// consecutive polls: the first ready frame is also the moment a late
|
|
7835
8324
|
// dialog is about to be painted over it.
|
|
@@ -7860,19 +8349,8 @@ export class Daemon extends EventEmitter {
|
|
|
7860
8349
|
// an auth error (the lifecycle double-checks with the token-free probe)
|
|
7861
8350
|
// instead of letting it decay into crash/hang/MCP-died noise, and stop
|
|
7862
8351
|
// the retry loop — only re-login (see /login) ends this state.
|
|
7863
|
-
|
|
7864
|
-
if (loginScreen?.test(pane) && !this.loginScreenReported) {
|
|
7865
|
-
this.loginScreenReported = true;
|
|
7866
|
-
this.authFailureUnresolved = true;
|
|
7867
|
-
this.logger.warn({ backend: this.config.backend }, "CLI is waiting at its sign-in screen — reporting auth error");
|
|
7868
|
-
this.emit("pty_error", {
|
|
7869
|
-
name: this.name,
|
|
7870
|
-
type: "auth_error",
|
|
7871
|
-
action: "pause",
|
|
7872
|
-
message: "CLI is waiting at its sign-in screen — credentials are missing or expired",
|
|
7873
|
-
});
|
|
8352
|
+
if (this.reportLoginScreen(pane))
|
|
7874
8353
|
return true;
|
|
7875
|
-
}
|
|
7876
8354
|
// Fatal: command not found (must match full phrase to avoid false positives
|
|
7877
8355
|
// like Kiro's "agent X not found, using default")
|
|
7878
8356
|
if (/command not found|: not found$/m.test(pane))
|