@songsid/agend 2.1.6-beta.1 → 2.1.6-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-endpoint.d.ts +18 -0
- package/dist/agent-endpoint.js +53 -2
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/apply-job.d.ts +114 -0
- package/dist/apply-job.js +214 -0
- package/dist/apply-job.js.map +1 -0
- package/dist/backend/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +127 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +41 -0
- package/dist/backend/codex.js +133 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +159 -0
- package/dist/backend/credential-profile.js +340 -0
- package/dist/backend/credential-profile.js.map +1 -0
- package/dist/backend/factory.js +4 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/kiro-auth-store.d.ts +25 -0
- package/dist/backend/kiro-auth-store.js +61 -0
- package/dist/backend/kiro-auth-store.js.map +1 -0
- package/dist/backend/kiro.d.ts +8 -0
- package/dist/backend/kiro.js +31 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +90 -0
- package/dist/backend/muse.js +437 -0
- package/dist/backend/muse.js.map +1 -0
- package/dist/backend/types.d.ts +41 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/access-manager.d.ts +20 -0
- package/dist/channel/access-manager.js +25 -0
- package/dist/channel/access-manager.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +17 -0
- package/dist/channel/adapters/discord.js +72 -11
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/markdown-chunk.d.ts +8 -3
- package/dist/channel/markdown-chunk.js +12 -3
- package/dist/channel/markdown-chunk.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -12
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +6 -2
- package/dist/channel/mcp-tools.js +6 -36
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +32 -3
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/cli.js +113 -0
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +45 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +61 -0
- package/dist/daemon.js +359 -88
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.d.ts +9 -0
- package/dist/event-log.js +21 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-level-config.d.ts +42 -0
- package/dist/fleet-level-config.js +78 -0
- package/dist/fleet-level-config.js.map +1 -0
- package/dist/fleet-lock.d.ts +21 -0
- package/dist/fleet-lock.js +42 -8
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +417 -2
- package/dist/fleet-manager.js +1981 -182
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +145 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +11 -1
- package/dist/instance-config-impact.d.ts +55 -0
- package/dist/instance-config-impact.js +152 -0
- package/dist/instance-config-impact.js.map +1 -0
- package/dist/instance-lifecycle.js +8 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +24 -0
- package/dist/locale.js.map +1 -1
- package/dist/logger.js +42 -25
- package/dist/logger.js.map +1 -1
- package/dist/muse-usage-relay.d.ts +72 -0
- package/dist/muse-usage-relay.js +419 -0
- package/dist/muse-usage-relay.js.map +1 -0
- package/dist/outbound-handlers.d.ts +5 -1
- package/dist/outbound-handlers.js +199 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -5
- package/dist/outbound-schemas.js +3 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +48 -0
- package/dist/provider-probe.js +121 -0
- package/dist/provider-probe.js.map +1 -0
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +164 -0
- package/dist/quickstart-api.js +351 -0
- package/dist/quickstart-api.js.map +1 -0
- package/dist/quickstart.js +23 -50
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.js +17 -5
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/db.test.js +34 -1
- package/dist/scheduler/db.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +4 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/self-restart-limit.d.ts +21 -0
- package/dist/self-restart-limit.js +129 -0
- package/dist/self-restart-limit.js.map +1 -0
- package/dist/settings-api.d.ts +116 -0
- package/dist/settings-api.js +530 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-auth.d.ts +113 -0
- package/dist/setup-auth.js +181 -0
- package/dist/setup-auth.js.map +1 -0
- package/dist/setup-form.d.ts +18 -0
- package/dist/setup-form.js +315 -0
- package/dist/setup-form.js.map +1 -0
- package/dist/setup-host.d.ts +162 -0
- package/dist/setup-host.js +496 -0
- package/dist/setup-host.js.map +1 -0
- package/dist/setup-marker.d.ts +8 -0
- package/dist/setup-marker.js +39 -0
- package/dist/setup-marker.js.map +1 -0
- package/dist/setup-tunnel-consent.d.ts +53 -0
- package/dist/setup-tunnel-consent.js +82 -0
- package/dist/setup-tunnel-consent.js.map +1 -0
- package/dist/setup-wizard.js +4 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/steer-capability.js +4 -1
- package/dist/steer-capability.js.map +1 -1
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/tmux-manager.js +29 -4
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions-notice.d.ts +38 -0
- package/dist/tool-permissions-notice.js +92 -0
- package/dist/tool-permissions-notice.js.map +1 -0
- package/dist/tool-permissions.d.ts +120 -0
- package/dist/tool-permissions.js +277 -0
- package/dist/tool-permissions.js.map +1 -0
- package/dist/topic-commands.js +1 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +12 -1
- package/dist/transcript-sources.js +19 -3
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +71 -0
- package/dist/tunnel/cloudflared.js +447 -0
- package/dist/tunnel/cloudflared.js.map +1 -0
- package/dist/tunnel/lease.d.ts +75 -0
- package/dist/tunnel/lease.js +209 -0
- package/dist/tunnel/lease.js.map +1 -0
- package/dist/tunnel/manager.d.ts +56 -0
- package/dist/tunnel/manager.js +167 -0
- package/dist/tunnel/manager.js.map +1 -0
- package/dist/tunnel/types.d.ts +119 -0
- package/dist/tunnel/types.js +33 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/ui/dashboard.html +4 -3
- package/dist/ui/settings.html +808 -156
- package/dist/ui/view.html +2 -2
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +23 -14
- package/dist/usage/providers.js +220 -58
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +13 -0
- package/dist/usage/usage-api.js +69 -1
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-api.js +8 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +75 -0
- package/dist/web-auth.js +208 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-terminal.d.ts +30 -1
- package/dist/web-terminal.js +86 -3
- package/dist/web-terminal.js.map +1 -1
- package/package.json +2 -1
package/dist/daemon.js
CHANGED
|
@@ -9,6 +9,9 @@ import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
|
|
|
9
9
|
import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
|
|
10
10
|
import { TranscriptMonitor } from "./transcript-monitor.js";
|
|
11
11
|
import { createTranscriptSource } from "./transcript-sources.js";
|
|
12
|
+
import { credentialProfileStoreHome, resolveCredentialProfile } from "./backend/credential-profile.js";
|
|
13
|
+
import { resolveToolSet } from "./tool-permissions.js";
|
|
14
|
+
import { getAgendHome } from "./paths.js";
|
|
12
15
|
import { LOGIN_FLOWS } from "./login-flows.js";
|
|
13
16
|
import { ProgressAccumulator, summarizeProgress } from "./tool-progress.js";
|
|
14
17
|
import { ContextGuardian } from "./context-guardian.js";
|
|
@@ -26,6 +29,7 @@ import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata,
|
|
|
26
29
|
import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
|
|
27
30
|
import { TurnReplyGuard } from "./turn-reply-guard.js";
|
|
28
31
|
import { t } from "./locale.js";
|
|
32
|
+
import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
|
|
29
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
30
34
|
const __dirname = dirname(__filename);
|
|
31
35
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -40,7 +44,7 @@ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
|
40
44
|
const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
|
|
41
45
|
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
42
46
|
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
|
|
43
|
-
const source = binaryName === "codex" || binaryName === "grok"
|
|
47
|
+
const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
|
|
44
48
|
? "AGENTS.md"
|
|
45
49
|
: binaryName === "kiro-cli"
|
|
46
50
|
? `.kiro/steering/agend-${instanceName}.md`
|
|
@@ -434,7 +438,23 @@ export class BackendUnreachableStartupError extends Error {
|
|
|
434
438
|
/** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
|
|
435
439
|
const STRANDED_RETRY_READY_WAIT_MS = 30_000;
|
|
436
440
|
/** A passive startup phase must clear within this bound before an Enter is sent. */
|
|
437
|
-
|
|
441
|
+
/**
|
|
442
|
+
* How long a passive startup/resume transient may sit WITHOUT REPAINTING before
|
|
443
|
+
* delivery gives up. It is a stall budget, not a total: a CLI that is still
|
|
444
|
+
* painting its resume is making progress, and #826 is what happens when the two
|
|
445
|
+
* are confused — codex's resume is the only declared transient in the fleet
|
|
446
|
+
* (backend/codex.ts), a woken paused codex routinely takes longer than this to
|
|
447
|
+
* come back, and the flat 30s cap turned that into ❌ plus a discarded message
|
|
448
|
+
* on a pane that was seconds from ready.
|
|
449
|
+
*/
|
|
450
|
+
const INPUT_TRANSIENT_STALL_MS = 30_000;
|
|
451
|
+
/**
|
|
452
|
+
* The absolute ceiling for the same wait, so a transient that repaints forever
|
|
453
|
+
* (a spinner on a wedged resume) still ends in an honest failure. Bounded, as
|
|
454
|
+
* every other delivery wait is — just bounded by "no progress", not by a guess
|
|
455
|
+
* at how long a resume takes.
|
|
456
|
+
*/
|
|
457
|
+
const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
|
|
438
458
|
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
439
459
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
440
460
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
@@ -877,6 +897,11 @@ export class Daemon extends EventEmitter {
|
|
|
877
897
|
/** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
|
|
878
898
|
dialogParkedKey = null;
|
|
879
899
|
dialogParkedReported = false;
|
|
900
|
+
/** Current stdin-owning runtime dialog, independent from execution state. */
|
|
901
|
+
inputBlockedDialogKey = null;
|
|
902
|
+
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
903
|
+
autoResolvedDialogKey = null;
|
|
904
|
+
autoResolvedDialogGeneration = 0;
|
|
880
905
|
backgroundSessionRecoveryAttempted = false;
|
|
881
906
|
/** Whether the last spawn started a fresh session (not resumed). */
|
|
882
907
|
isNewSession = false;
|
|
@@ -946,6 +971,11 @@ export class Daemon extends EventEmitter {
|
|
|
946
971
|
fatalStartupBlocked = false;
|
|
947
972
|
/** model_error seen mid-turn: notify only if it survives to the idle screen. */
|
|
948
973
|
pendingModelErrorKey = null;
|
|
974
|
+
/** Optional per-instance localhost relay used to observe Muse usage events. */
|
|
975
|
+
museUsageRelay = null;
|
|
976
|
+
/** Set after an in-run relay failure so subsequent Muse respawns go direct. */
|
|
977
|
+
museRelayFallback = false;
|
|
978
|
+
museRelayFallbackInFlight = null;
|
|
949
979
|
/**
|
|
950
980
|
* Let the next pause() proceed from a stuck pane. Set only for the auth-deferred
|
|
951
981
|
* pause — see the pausePending consumption site for why waiting for idle there
|
|
@@ -1328,7 +1358,7 @@ export class Daemon extends EventEmitter {
|
|
|
1328
1358
|
// (statusline transcript); codex/kiro/opencode read their CLI's own
|
|
1329
1359
|
// conversation store via a pluggable source. Backends with no known
|
|
1330
1360
|
// source stay inert exactly as before.
|
|
1331
|
-
this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory));
|
|
1361
|
+
this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory, this.credentialProfileStore()));
|
|
1332
1362
|
// 5. Wire transcript events
|
|
1333
1363
|
const ackIfPending = () => {
|
|
1334
1364
|
if (!this.pendingAckMessage || !this.adapter)
|
|
@@ -2167,6 +2197,7 @@ export class Daemon extends EventEmitter {
|
|
|
2167
2197
|
if (!alive)
|
|
2168
2198
|
return;
|
|
2169
2199
|
const pane = await this.tmux.capturePane();
|
|
2200
|
+
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
2170
2201
|
const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
|
|
2171
2202
|
if (interactivePrompt) {
|
|
2172
2203
|
this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
|
|
@@ -2183,7 +2214,7 @@ export class Daemon extends EventEmitter {
|
|
|
2183
2214
|
const hasPendingWork = this.pendingWork.hasPendingWork();
|
|
2184
2215
|
if (!hasPendingWork)
|
|
2185
2216
|
this.blockingProcessDetector.reset();
|
|
2186
|
-
const blockingProcess = hasPendingWork
|
|
2217
|
+
const blockingProcess = hasPendingWork && !inputBlockedDialog
|
|
2187
2218
|
? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
|
|
2188
2219
|
: null;
|
|
2189
2220
|
if (blockingProcess) {
|
|
@@ -2205,27 +2236,80 @@ export class Daemon extends EventEmitter {
|
|
|
2205
2236
|
blockingSeen = dialog;
|
|
2206
2237
|
if (dialog.holdOnly)
|
|
2207
2238
|
break; // recognised, deliberately not answered; trackDialogParked reports it
|
|
2239
|
+
const autoKey = dialog.autoResolutionKey;
|
|
2240
|
+
if (dialog.verifyAfterKeys && autoKey
|
|
2241
|
+
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
2242
|
+
&& this.autoResolvedDialogKey === autoKey) {
|
|
2243
|
+
// A previous poll sent the safety choice but the CLI has not
|
|
2244
|
+
// repainted yet. Never send another Enter into the same screen.
|
|
2245
|
+
continue;
|
|
2246
|
+
}
|
|
2208
2247
|
// These keys go straight into the pane. Sent while a message delivery is
|
|
2209
2248
|
// mid-transaction, an `Escape` wipes the pasted text and an `Enter`
|
|
2210
2249
|
// submits it half-composed — the user sees a message that vanished. Skip
|
|
2211
2250
|
// (not queue) when the pane is busy: this poller runs every 5s, and the
|
|
2212
2251
|
// dialog will still be on screen next tick.
|
|
2252
|
+
let resolved = false;
|
|
2213
2253
|
const dismissed = await this.paneWriteLock.tryRun(async () => {
|
|
2254
|
+
// Re-read under the lock: the first capture may have gone stale
|
|
2255
|
+
// while a delivery was finishing. A stale danger menu must never
|
|
2256
|
+
// receive a blind key sequence.
|
|
2257
|
+
const currentPane = await this.tmux.capturePane();
|
|
2258
|
+
if (!Daemon.dialogMatches(dialog, currentPane)) {
|
|
2259
|
+
this.updateInputBlockedState(currentPane, dialogs);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
if (dialog.verifyAfterKeys && autoKey
|
|
2263
|
+
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
2264
|
+
&& this.autoResolvedDialogKey === autoKey)
|
|
2265
|
+
return;
|
|
2266
|
+
if (dialog.verifyAfterKeys && autoKey) {
|
|
2267
|
+
this.autoResolvedDialogGeneration = this.spawnGeneration;
|
|
2268
|
+
this.autoResolvedDialogKey = autoKey;
|
|
2269
|
+
}
|
|
2214
2270
|
this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
|
|
2215
2271
|
const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
|
|
2216
2272
|
for (const key of dialog.keys) {
|
|
2273
|
+
let sent = false;
|
|
2217
2274
|
if (SPECIAL_KEYS.has(key)) {
|
|
2218
|
-
await this.tmux.sendSpecialKey(key);
|
|
2275
|
+
sent = await this.tmux.sendSpecialKey(key);
|
|
2219
2276
|
}
|
|
2220
2277
|
else {
|
|
2221
|
-
await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
2278
|
+
sent = await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
2279
|
+
}
|
|
2280
|
+
if (!sent) {
|
|
2281
|
+
if (dialog.verifyAfterKeys && autoKey) {
|
|
2282
|
+
this.autoResolvedDialogGeneration = 0;
|
|
2283
|
+
this.autoResolvedDialogKey = null;
|
|
2284
|
+
}
|
|
2285
|
+
return;
|
|
2222
2286
|
}
|
|
2223
2287
|
await new Promise(r => setTimeout(r, 200));
|
|
2224
2288
|
}
|
|
2289
|
+
if (dialog.verifyAfterKeys) {
|
|
2290
|
+
const afterKeysPane = await this.tmux.capturePane();
|
|
2291
|
+
const dialogStillActive = dialog.inputBlocked
|
|
2292
|
+
? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
|
|
2293
|
+
: Daemon.dialogMatches(dialog, afterKeysPane);
|
|
2294
|
+
if (dialogStillActive) {
|
|
2295
|
+
this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
this.updateInputBlockedState(afterKeysPane, dialogs);
|
|
2299
|
+
if (dialog.postDismissNotice) {
|
|
2300
|
+
resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
|
|
2301
|
+
}
|
|
2302
|
+
else {
|
|
2303
|
+
resolved = true;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2225
2306
|
});
|
|
2226
2307
|
if (!dismissed) {
|
|
2227
2308
|
this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
|
|
2228
2309
|
}
|
|
2310
|
+
else if (dialog.verifyAfterKeys && !resolved) {
|
|
2311
|
+
this.logger.warn({ dialog: dialog.description }, "Safety dialog choice was sent but follow-up verification or notice submission failed");
|
|
2312
|
+
}
|
|
2229
2313
|
this.trackDialogParked(blockingSeen);
|
|
2230
2314
|
return; // Dialog handled (or deliberately deferred): skip error checks this cycle
|
|
2231
2315
|
}
|
|
@@ -2544,6 +2628,13 @@ export class Daemon extends EventEmitter {
|
|
|
2544
2628
|
this.inputTransientGuardGeneration = null;
|
|
2545
2629
|
this.freezeRuntimeMonitors();
|
|
2546
2630
|
this.pendingIpcRequests.clear();
|
|
2631
|
+
if (this.museUsageRelay) {
|
|
2632
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
2633
|
+
this.museUsageRelay = null;
|
|
2634
|
+
}
|
|
2635
|
+
else if (this.backend?.binaryName === "muse") {
|
|
2636
|
+
clearMuseUsageSnapshot(this.instanceDir);
|
|
2637
|
+
}
|
|
2547
2638
|
if (this.adapter)
|
|
2548
2639
|
await this.adapter.stop();
|
|
2549
2640
|
// Notify MCP servers of graceful shutdown (prevents reconnect attempts)
|
|
@@ -2823,6 +2914,15 @@ export class Daemon extends EventEmitter {
|
|
|
2823
2914
|
}
|
|
2824
2915
|
applyInstanceStateSnapshot(snapshot, pane) {
|
|
2825
2916
|
const previous = this.instanceState;
|
|
2917
|
+
// A live safety menu owns stdin. It may still contain Claude's persistent
|
|
2918
|
+
// `❯` ready marker, but that is not an idle turn and must not clear pending
|
|
2919
|
+
// work, retire Cancel, arm auto-pause, or enter the generic stuck path.
|
|
2920
|
+
// Keep the public execution state unchanged until the menu disappears; the
|
|
2921
|
+
// dedicated input_blocked event carries the reason to observers.
|
|
2922
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
2923
|
+
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2826
2926
|
this.instanceState = snapshot.state;
|
|
2827
2927
|
// OpenCode creates its session lazily on the first submitted message.
|
|
2828
2928
|
// Waiting until stop/pause to persist that id loses resume state when the
|
|
@@ -3296,6 +3396,7 @@ export class Daemon extends EventEmitter {
|
|
|
3296
3396
|
const captureStartedAt = Date.now();
|
|
3297
3397
|
try {
|
|
3298
3398
|
const pane = await this.tmux.capturePane();
|
|
3399
|
+
this.updateInputBlockedState(pane);
|
|
3299
3400
|
// Output received while capture-pane was in flight makes this snapshot
|
|
3300
3401
|
// stale. Its output handler has already armed a new debounce.
|
|
3301
3402
|
const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
|
|
@@ -3532,6 +3633,10 @@ export class Daemon extends EventEmitter {
|
|
|
3532
3633
|
this.instanceStateSafetyListener = null;
|
|
3533
3634
|
}
|
|
3534
3635
|
handleStuckTransition(pane, snapshot, readyPattern) {
|
|
3636
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
3637
|
+
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing stuck notification while CLI input is blocked by a dialog");
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3535
3640
|
const deterministicReadyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
|
|
3536
3641
|
const diagnostic = {
|
|
3537
3642
|
backend: this.backend?.binaryName ?? this.config.backend ?? "unknown",
|
|
@@ -3831,6 +3936,28 @@ export class Daemon extends EventEmitter {
|
|
|
3831
3936
|
}
|
|
3832
3937
|
return formatted;
|
|
3833
3938
|
}
|
|
3939
|
+
/**
|
|
3940
|
+
* Whether ONE delivery reached a verdict, carried by that delivery.
|
|
3941
|
+
*
|
|
3942
|
+
* `deliverMessage` returns false for two unrelated things: a delivery that
|
|
3943
|
+
* cannot land (window gone, a pane that never came back, an unanswered
|
|
3944
|
+
* dialog) and a delivery that was never attempted (user cancel, a tmux storm,
|
|
3945
|
+
* shutdown). Only the first is a failure to tell the sender about; reporting
|
|
3946
|
+
* the second told a sending agent its message was lost while the fleet was
|
|
3947
|
+
* merely standing down (#826).
|
|
3948
|
+
*
|
|
3949
|
+
* It is per call and not a field on the daemon because deliveries do NOT all
|
|
3950
|
+
* share one lock: an ordinary delivery is serialised on `pasteLock`, a steer
|
|
3951
|
+
* on `steerLock`, and the two run concurrently by design. A field would let a
|
|
3952
|
+
* steer's verdict decide whether a queued message's sender is told its
|
|
3953
|
+
* delivery failed — a false ❌ carrying the wrong correlation id.
|
|
3954
|
+
*/
|
|
3955
|
+
failDelivery(verdict, status) {
|
|
3956
|
+
verdict.reached = true;
|
|
3957
|
+
if (status)
|
|
3958
|
+
this.emit("message_failed", status); // ❌
|
|
3959
|
+
return false;
|
|
3960
|
+
}
|
|
3834
3961
|
/** Tell the fleet when an already-accepted cross-instance pane write failed. */
|
|
3835
3962
|
reportCrossInstanceDeliveryFailure(meta, error) {
|
|
3836
3963
|
if (!meta.from_instance)
|
|
@@ -3886,10 +4013,14 @@ export class Daemon extends EventEmitter {
|
|
|
3886
4013
|
await this.wake();
|
|
3887
4014
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3888
4015
|
return;
|
|
3889
|
-
|
|
4016
|
+
const verdict = { reached: false };
|
|
4017
|
+
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id, verdict })) {
|
|
3890
4018
|
this.markTurnStarted(meta, formatted);
|
|
3891
4019
|
}
|
|
3892
|
-
else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4020
|
+
else if (verdict.reached && this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4021
|
+
// Same rule as the queued path: a steer that never got to try is not a
|
|
4022
|
+
// delivery failure. This holder is the steer's own, which is the point
|
|
4023
|
+
// — it runs on steerLock while a queued delivery runs on pasteLock.
|
|
3893
4024
|
this.reportCrossInstanceDeliveryFailure(meta);
|
|
3894
4025
|
}
|
|
3895
4026
|
}).catch(err => {
|
|
@@ -4019,10 +4150,16 @@ export class Daemon extends EventEmitter {
|
|
|
4019
4150
|
// A fresh delivery begins a fresh turn — its bubble must not inherit
|
|
4020
4151
|
// the previous turn's tool list.
|
|
4021
4152
|
this.resetToolProgress();
|
|
4022
|
-
|
|
4153
|
+
const verdict = { reached: false };
|
|
4154
|
+
if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id, verdict })) {
|
|
4023
4155
|
this.markTurnStarted(meta, formatted);
|
|
4024
4156
|
}
|
|
4025
|
-
else if (meta.from_instance &&
|
|
4157
|
+
else if (meta.from_instance && verdict.reached
|
|
4158
|
+
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4159
|
+
// Only a delivery that reached a verdict is reported. A pane that is
|
|
4160
|
+
// not ready yet, a cancel, a storm hold or a shutdown all return
|
|
4161
|
+
// false without one, and telling the sender its message was lost
|
|
4162
|
+
// there would be the false ❌ of #826 in its other form.
|
|
4026
4163
|
this.reportCrossInstanceDeliveryFailure(meta);
|
|
4027
4164
|
}
|
|
4028
4165
|
}
|
|
@@ -4054,6 +4191,9 @@ export class Daemon extends EventEmitter {
|
|
|
4054
4191
|
* serial in both cases so separate PTY writes can never overlap.
|
|
4055
4192
|
*/
|
|
4056
4193
|
async deliverMessage(formatted, status, opts) {
|
|
4194
|
+
// The caller passes its own holder when it needs the answer; a system paste
|
|
4195
|
+
// that ignores the outcome gets a throwaway.
|
|
4196
|
+
const verdict = opts?.verdict ?? { reached: false };
|
|
4057
4197
|
const cancelled = () => opts?.deliveryEpoch !== undefined
|
|
4058
4198
|
&& !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
|
|
4059
4199
|
if (cancelled() || this.stormWindow?.isStopped())
|
|
@@ -4077,7 +4217,7 @@ export class Daemon extends EventEmitter {
|
|
|
4077
4217
|
await this.waitForSpawnToSettle();
|
|
4078
4218
|
if (cancelled())
|
|
4079
4219
|
return false;
|
|
4080
|
-
if (this.refuseFatalStartupDelivery(status))
|
|
4220
|
+
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
4081
4221
|
return false;
|
|
4082
4222
|
let windowId = this.getWindowId();
|
|
4083
4223
|
const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
|
|
@@ -4119,9 +4259,7 @@ export class Daemon extends EventEmitter {
|
|
|
4119
4259
|
// wedged CLI (where the text would sit unsubmitted and the next message
|
|
4120
4260
|
// would land on top of it) — and instead of holding the queue silently.
|
|
4121
4261
|
this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
|
|
4122
|
-
|
|
4123
|
-
this.emit("message_failed", status); // ❌
|
|
4124
|
-
return false;
|
|
4262
|
+
return this.failDelivery(verdict, status);
|
|
4125
4263
|
}
|
|
4126
4264
|
}
|
|
4127
4265
|
}
|
|
@@ -4142,9 +4280,7 @@ export class Daemon extends EventEmitter {
|
|
|
4142
4280
|
// recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
|
|
4143
4281
|
if (round >= STRANDED_INPUT_MAX_ROUNDS) {
|
|
4144
4282
|
this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
|
|
4145
|
-
|
|
4146
|
-
this.emit("message_failed", status); // ❌
|
|
4147
|
-
return false;
|
|
4283
|
+
return this.failDelivery(verdict, status);
|
|
4148
4284
|
}
|
|
4149
4285
|
const stranded = await this.submitStrandedInputIfAny(windowId);
|
|
4150
4286
|
if (cancelled())
|
|
@@ -4153,18 +4289,14 @@ export class Daemon extends EventEmitter {
|
|
|
4153
4289
|
break;
|
|
4154
4290
|
if (stranded === "failed") {
|
|
4155
4291
|
this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
|
|
4156
|
-
|
|
4157
|
-
this.emit("message_failed", status); // ❌
|
|
4158
|
-
return false;
|
|
4292
|
+
return this.failDelivery(verdict, status);
|
|
4159
4293
|
}
|
|
4160
4294
|
const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
|
|
4161
4295
|
if (cancelled())
|
|
4162
4296
|
return false;
|
|
4163
4297
|
if (!readyAgain) {
|
|
4164
4298
|
this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
|
|
4165
|
-
|
|
4166
|
-
this.emit("message_failed", status); // ❌
|
|
4167
|
-
return false;
|
|
4299
|
+
return this.failDelivery(verdict, status);
|
|
4168
4300
|
}
|
|
4169
4301
|
}
|
|
4170
4302
|
}
|
|
@@ -4176,15 +4308,13 @@ export class Daemon extends EventEmitter {
|
|
|
4176
4308
|
const outcome = await this.paneWriteLock.run(async () => {
|
|
4177
4309
|
if (cancelled())
|
|
4178
4310
|
return false;
|
|
4179
|
-
if (this.refuseFatalStartupDelivery(status))
|
|
4311
|
+
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
4180
4312
|
return false;
|
|
4181
4313
|
// A passive startup phase may paint after the outer readiness probe.
|
|
4182
4314
|
// It clears without input, so waiting under the pane lock cannot starve
|
|
4183
4315
|
// a dialog dismisser and closes the final clear→paste TOCTOU window.
|
|
4184
4316
|
if (!(await this.waitForInputTransientToClear("pre-write"))) {
|
|
4185
|
-
|
|
4186
|
-
this.emit("message_failed", status); // ❌
|
|
4187
|
-
return false;
|
|
4317
|
+
return this.failDelivery(verdict, status);
|
|
4188
4318
|
}
|
|
4189
4319
|
// TOCTOU: the probes above ran outside this lock, and the CLI repaints
|
|
4190
4320
|
// whenever it likes — a resume prompt can be painted between "clear" and
|
|
@@ -4195,7 +4325,7 @@ export class Daemon extends EventEmitter {
|
|
|
4195
4325
|
if (probe.state !== "clear")
|
|
4196
4326
|
return "dialog";
|
|
4197
4327
|
}
|
|
4198
|
-
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
|
|
4328
|
+
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
|
|
4199
4329
|
});
|
|
4200
4330
|
if (outcome !== "dialog")
|
|
4201
4331
|
return outcome;
|
|
@@ -4204,18 +4334,14 @@ export class Daemon extends EventEmitter {
|
|
|
4204
4334
|
// an honest failure rather than a message that never lands.
|
|
4205
4335
|
if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
|
|
4206
4336
|
this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
|
|
4207
|
-
|
|
4208
|
-
this.emit("message_failed", status); // ❌
|
|
4209
|
-
return false;
|
|
4337
|
+
return this.failDelivery(verdict, status);
|
|
4210
4338
|
}
|
|
4211
4339
|
this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
|
|
4212
4340
|
const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
|
|
4213
4341
|
if (cancelled())
|
|
4214
4342
|
return false;
|
|
4215
4343
|
if (!clear) {
|
|
4216
|
-
|
|
4217
|
-
this.emit("message_failed", status); // ❌
|
|
4218
|
-
return false;
|
|
4344
|
+
return this.failDelivery(verdict, status);
|
|
4219
4345
|
}
|
|
4220
4346
|
}
|
|
4221
4347
|
}
|
|
@@ -4250,13 +4376,47 @@ export class Daemon extends EventEmitter {
|
|
|
4250
4376
|
return null;
|
|
4251
4377
|
}
|
|
4252
4378
|
/** Capture failure is unknown, never evidence that input is available. */
|
|
4379
|
+
/**
|
|
4380
|
+
* The budget for waiting out a passive transient, measured in progress rather
|
|
4381
|
+
* than in wall clock.
|
|
4382
|
+
*
|
|
4383
|
+
* `observe(pane)` answers "may I keep waiting?". The stall timer restarts
|
|
4384
|
+
* every time the pane text changes, so a resume that is still painting never
|
|
4385
|
+
* runs out; a screen that has not changed for INPUT_TRANSIENT_STALL_MS, or a
|
|
4386
|
+
* transient that outlives the ceiling, does. Both ends stay bounded — the
|
|
4387
|
+
* point of #826 was never that the wait should be unlimited, it was that a
|
|
4388
|
+
* flat 30s could not tell "slow" from "stuck".
|
|
4389
|
+
*/
|
|
4390
|
+
transientProgressBudget(overallDeadline) {
|
|
4391
|
+
const ceiling = Math.min(overallDeadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
|
|
4392
|
+
let lastPane = null;
|
|
4393
|
+
let stallDeadline = 0;
|
|
4394
|
+
const budget = {
|
|
4395
|
+
stalled: false,
|
|
4396
|
+
observe(pane) {
|
|
4397
|
+
const now = Date.now();
|
|
4398
|
+
if (pane !== lastPane) {
|
|
4399
|
+
lastPane = pane;
|
|
4400
|
+
stallDeadline = now + INPUT_TRANSIENT_STALL_MS;
|
|
4401
|
+
}
|
|
4402
|
+
if (now >= ceiling)
|
|
4403
|
+
return false;
|
|
4404
|
+
budget.stalled = now >= stallDeadline;
|
|
4405
|
+
return !budget.stalled;
|
|
4406
|
+
},
|
|
4407
|
+
};
|
|
4408
|
+
return budget;
|
|
4409
|
+
}
|
|
4253
4410
|
async probeInputTransient() {
|
|
4254
4411
|
if (this.guardedInputTransients().length === 0 || !this.tmux)
|
|
4255
4412
|
return { state: "clear" };
|
|
4256
4413
|
try {
|
|
4257
4414
|
const pane = await this.tmux.capturePane();
|
|
4258
4415
|
const transient = this.inputTransientInPane(pane);
|
|
4259
|
-
|
|
4416
|
+
// The pane text comes back with the verdict: a transient that is still
|
|
4417
|
+
// repainting is still working, and that is the only thing separating a
|
|
4418
|
+
// slow resume from a wedged one.
|
|
4419
|
+
return transient ? { state: "active", transient, pane } : { state: "clear" };
|
|
4260
4420
|
}
|
|
4261
4421
|
catch (err) {
|
|
4262
4422
|
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
@@ -4272,7 +4432,12 @@ export class Daemon extends EventEmitter {
|
|
|
4272
4432
|
const generation = this.inputTransientGuardGeneration;
|
|
4273
4433
|
if (generation === null || generation !== this.spawnGeneration)
|
|
4274
4434
|
return true;
|
|
4275
|
-
|
|
4435
|
+
// Same progress rule as the outer readiness wait, for the same reason: this
|
|
4436
|
+
// runs under the pane write lock, so a flat cap here turned a resume that
|
|
4437
|
+
// repainted one second too late into a discarded message. Holding the lock
|
|
4438
|
+
// while the pane is still painting costs nothing — every other writer is
|
|
4439
|
+
// waiting on the same screen.
|
|
4440
|
+
const budget = this.transientProgressBudget(Date.now() + timeoutMs);
|
|
4276
4441
|
let observedDescription = null;
|
|
4277
4442
|
for (;;) {
|
|
4278
4443
|
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
@@ -4290,12 +4455,18 @@ export class Daemon extends EventEmitter {
|
|
|
4290
4455
|
observedDescription = probe.transient.description;
|
|
4291
4456
|
this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
|
|
4292
4457
|
}
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4458
|
+
// "unknown" (capture-pane failed) has to consume the budget too, or an
|
|
4459
|
+
// unreadable pane would loop forever now that the flat deadline is gone.
|
|
4460
|
+
// It never "changes", so it stalls out on the same rule.
|
|
4461
|
+
if (!budget.observe(probe.state === "active" ? probe.pane : "\u0000unreadable")) {
|
|
4462
|
+
this.logger.error({
|
|
4463
|
+
phase, generation, transient: observedDescription,
|
|
4464
|
+
stalledForMs: budget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
4465
|
+
ceilingMs: timeoutMs,
|
|
4466
|
+
}, "CLI input stayed unavailable — refusing to send Enter");
|
|
4296
4467
|
return false;
|
|
4297
4468
|
}
|
|
4298
|
-
await new Promise(r => setTimeout(r,
|
|
4469
|
+
await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
|
|
4299
4470
|
}
|
|
4300
4471
|
}
|
|
4301
4472
|
static dialogKey(dialog) {
|
|
@@ -4303,8 +4474,33 @@ export class Daemon extends EventEmitter {
|
|
|
4303
4474
|
}
|
|
4304
4475
|
/** A dialog counts only when it is the CURRENT interactive region, if the backend can tell; else by pattern. */
|
|
4305
4476
|
static dialogMatches(dialog, pane) {
|
|
4477
|
+
dialog.pattern.lastIndex = 0;
|
|
4306
4478
|
return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
|
|
4307
4479
|
}
|
|
4480
|
+
/** Refresh the separate stdin-blocked observation from one pane capture. */
|
|
4481
|
+
updateInputBlockedState(pane, dialogs = this.backend?.getRuntimeDialogs?.() ?? []) {
|
|
4482
|
+
const active = dialogs.find(dialog => dialog.inputBlocked && Daemon.dialogMatches(dialog, pane)) ?? null;
|
|
4483
|
+
const nextKey = active ? Daemon.dialogKey(active) : null;
|
|
4484
|
+
if (nextKey !== this.inputBlockedDialogKey) {
|
|
4485
|
+
this.inputBlockedDialogKey = nextKey;
|
|
4486
|
+
this.emit("input_blocked", {
|
|
4487
|
+
name: this.name,
|
|
4488
|
+
blocked: active !== null,
|
|
4489
|
+
description: active?.description,
|
|
4490
|
+
});
|
|
4491
|
+
}
|
|
4492
|
+
if (!active && this.autoResolvedDialogGeneration === this.spawnGeneration) {
|
|
4493
|
+
// The old screen is gone. A later dangerous command in the same spawn
|
|
4494
|
+
// must get its own one-shot answer.
|
|
4495
|
+
this.autoResolvedDialogKey = null;
|
|
4496
|
+
this.autoResolvedDialogGeneration = 0;
|
|
4497
|
+
}
|
|
4498
|
+
return active;
|
|
4499
|
+
}
|
|
4500
|
+
/** Whether a runtime prompt currently owns the pane's stdin. */
|
|
4501
|
+
isInputBlocked() {
|
|
4502
|
+
return this.inputBlockedDialogKey !== null;
|
|
4503
|
+
}
|
|
4308
4504
|
/**
|
|
4309
4505
|
* Tri-state liveness. tmux's own isWindowAlive folds every query failure
|
|
4310
4506
|
* into `false`, so a transient tmux hiccup would read as "the CLI died" —
|
|
@@ -4470,7 +4666,7 @@ export class Daemon extends EventEmitter {
|
|
|
4470
4666
|
const deadline = Date.now() + timeoutMs;
|
|
4471
4667
|
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
|
|
4472
4668
|
let unknownStreak = 0;
|
|
4473
|
-
let
|
|
4669
|
+
let transientBudget = null;
|
|
4474
4670
|
for (;;) {
|
|
4475
4671
|
const remaining = deadline - Date.now();
|
|
4476
4672
|
if (remaining <= 0)
|
|
@@ -4483,15 +4679,19 @@ export class Daemon extends EventEmitter {
|
|
|
4483
4679
|
const transient = await this.probeInputTransient();
|
|
4484
4680
|
if (transient.state === "active") {
|
|
4485
4681
|
unknownStreak = 0;
|
|
4486
|
-
|
|
4487
|
-
if (
|
|
4488
|
-
this.logger.error({
|
|
4682
|
+
transientBudget ||= this.transientProgressBudget(deadline);
|
|
4683
|
+
if (!transientBudget.observe(transient.pane)) {
|
|
4684
|
+
this.logger.error({
|
|
4685
|
+
transient: transient.transient.description,
|
|
4686
|
+
stalledForMs: transientBudget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
4687
|
+
ceilingMs: INPUT_TRANSIENT_WAIT_MS,
|
|
4688
|
+
}, "CLI input stayed unavailable during the delivery-readiness wait");
|
|
4489
4689
|
return false;
|
|
4490
4690
|
}
|
|
4491
4691
|
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4492
4692
|
continue;
|
|
4493
4693
|
}
|
|
4494
|
-
|
|
4694
|
+
transientBudget = null;
|
|
4495
4695
|
if (transient.state === "unknown") {
|
|
4496
4696
|
if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
|
|
4497
4697
|
this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
|
|
@@ -4637,12 +4837,11 @@ export class Daemon extends EventEmitter {
|
|
|
4637
4837
|
* what to fix, and holding would only build a queue that floods the pane the
|
|
4638
4838
|
* moment the flag clears.
|
|
4639
4839
|
*/
|
|
4640
|
-
refuseFatalStartupDelivery(status) {
|
|
4840
|
+
refuseFatalStartupDelivery(verdict, status) {
|
|
4641
4841
|
if (!this.fatalStartupBlocked)
|
|
4642
4842
|
return false;
|
|
4643
4843
|
this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
|
|
4644
|
-
|
|
4645
|
-
this.emit("message_failed", status); // ❌
|
|
4844
|
+
this.failDelivery(verdict, status);
|
|
4646
4845
|
return true;
|
|
4647
4846
|
}
|
|
4648
4847
|
/**
|
|
@@ -4721,7 +4920,10 @@ export class Daemon extends EventEmitter {
|
|
|
4721
4920
|
}
|
|
4722
4921
|
return sent;
|
|
4723
4922
|
}
|
|
4724
|
-
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId
|
|
4923
|
+
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
|
|
4924
|
+
// Last and defaulted so the positional callers that ignore the outcome stay
|
|
4925
|
+
// readable; deliverMessage, the only one that reports, always passes its own.
|
|
4926
|
+
verdict = { reached: false }) {
|
|
4725
4927
|
const signature = this.submissionSignature(formatted, submissionId);
|
|
4726
4928
|
let windowId = initialWindowId;
|
|
4727
4929
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
@@ -4740,9 +4942,9 @@ export class Daemon extends EventEmitter {
|
|
|
4740
4942
|
? "pasteBuffer failed — recovering window and backing off"
|
|
4741
4943
|
: "pasteBuffer failed — non-retryable tmux error");
|
|
4742
4944
|
if (!recoverable) {
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
return
|
|
4945
|
+
// A tmux error the window cannot be recovered from: the text will
|
|
4946
|
+
// never reach this pane, so it is a verdict like the others.
|
|
4947
|
+
return this.failDelivery(verdict, status);
|
|
4746
4948
|
}
|
|
4747
4949
|
windowId = (await this.recoverWindow()) ?? windowId;
|
|
4748
4950
|
if (attempt < maxAttempts)
|
|
@@ -4772,9 +4974,7 @@ export class Daemon extends EventEmitter {
|
|
|
4772
4974
|
}
|
|
4773
4975
|
let enterAt = Date.now();
|
|
4774
4976
|
if (!(await this.sendDeliveryEnter("initial-submit"))) {
|
|
4775
|
-
|
|
4776
|
-
this.emit("message_failed", status); // ❌
|
|
4777
|
-
return false;
|
|
4977
|
+
return this.failDelivery(verdict, status);
|
|
4778
4978
|
}
|
|
4779
4979
|
// Kiro's legacy TUI can swallow Enter while it is still processing a large
|
|
4780
4980
|
// paste — not only during the post-ready redraw (#479): on slower hosts it
|
|
@@ -4857,9 +5057,7 @@ export class Daemon extends EventEmitter {
|
|
|
4857
5057
|
this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
|
|
4858
5058
|
const strandedAt = Date.now();
|
|
4859
5059
|
if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
|
|
4860
|
-
|
|
4861
|
-
this.emit("message_failed", status); // ❌
|
|
4862
|
-
return false;
|
|
5060
|
+
return this.failDelivery(verdict, status);
|
|
4863
5061
|
}
|
|
4864
5062
|
const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4865
5063
|
if (afterEnter === "submitted") {
|
|
@@ -4874,9 +5072,7 @@ export class Daemon extends EventEmitter {
|
|
|
4874
5072
|
// message nobody submitted. Output is corroboration; text sitting in
|
|
4875
5073
|
// the input row is disqualifying, and disqualifying evidence wins.
|
|
4876
5074
|
this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
|
|
4877
|
-
|
|
4878
|
-
this.emit("message_failed", status); // ❌
|
|
4879
|
-
return false;
|
|
5075
|
+
return this.failDelivery(verdict, status);
|
|
4880
5076
|
}
|
|
4881
5077
|
// "unproven": nothing of ours is on screen — the paste itself was lost,
|
|
4882
5078
|
// so pasting it again cannot duplicate anything.
|
|
@@ -4885,16 +5081,12 @@ export class Daemon extends EventEmitter {
|
|
|
4885
5081
|
this.logger.error({
|
|
4886
5082
|
tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
|
|
4887
5083
|
}, "Idle-gated redelivery paste failed after native-queue silent loss");
|
|
4888
|
-
|
|
4889
|
-
this.emit("message_failed", status); // ❌
|
|
4890
|
-
return false;
|
|
5084
|
+
return this.failDelivery(verdict, status);
|
|
4891
5085
|
}
|
|
4892
5086
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
4893
5087
|
const retryAt = Date.now();
|
|
4894
5088
|
if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
|
|
4895
|
-
|
|
4896
|
-
this.emit("message_failed", status); // ❌
|
|
4897
|
-
return false;
|
|
5089
|
+
return this.failDelivery(verdict, status);
|
|
4898
5090
|
}
|
|
4899
5091
|
if (windowId && this.controlClient) {
|
|
4900
5092
|
if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
|
|
@@ -4909,9 +5101,7 @@ export class Daemon extends EventEmitter {
|
|
|
4909
5101
|
return true;
|
|
4910
5102
|
}
|
|
4911
5103
|
this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
|
|
4912
|
-
|
|
4913
|
-
this.emit("message_failed", status); // ❌
|
|
4914
|
-
return false;
|
|
5104
|
+
return this.failDelivery(verdict, status);
|
|
4915
5105
|
}
|
|
4916
5106
|
if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
|
|
4917
5107
|
// F2: output after Enter is necessary but not sufficient — the paste
|
|
@@ -4925,9 +5115,7 @@ export class Daemon extends EventEmitter {
|
|
|
4925
5115
|
const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
|
|
4926
5116
|
const retryAt = Date.now();
|
|
4927
5117
|
if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
|
|
4928
|
-
|
|
4929
|
-
this.emit("message_failed", status); // ❌
|
|
4930
|
-
return false;
|
|
5118
|
+
return this.failDelivery(verdict, status);
|
|
4931
5119
|
}
|
|
4932
5120
|
submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
|
|
4933
5121
|
}
|
|
@@ -4937,9 +5125,7 @@ export class Daemon extends EventEmitter {
|
|
|
4937
5125
|
}
|
|
4938
5126
|
else {
|
|
4939
5127
|
this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
|
|
4940
|
-
|
|
4941
|
-
this.emit("message_failed", status); // ❌
|
|
4942
|
-
return false;
|
|
5128
|
+
return this.failDelivery(verdict, status);
|
|
4943
5129
|
}
|
|
4944
5130
|
}
|
|
4945
5131
|
else if (windowId && this.controlClient) {
|
|
@@ -4964,9 +5150,7 @@ export class Daemon extends EventEmitter {
|
|
|
4964
5150
|
// forever and the next delivery pasted on top — submitting two messages
|
|
4965
5151
|
// as one. Say so instead.
|
|
4966
5152
|
this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
|
|
4967
|
-
|
|
4968
|
-
this.emit("message_failed", status); // ❌
|
|
4969
|
-
return false;
|
|
5153
|
+
return this.failDelivery(verdict, status);
|
|
4970
5154
|
}
|
|
4971
5155
|
}
|
|
4972
5156
|
else {
|
|
@@ -4979,9 +5163,7 @@ export class Daemon extends EventEmitter {
|
|
|
4979
5163
|
return true;
|
|
4980
5164
|
}
|
|
4981
5165
|
this.logger.error("Message delivery failed after retries — window not ready");
|
|
4982
|
-
|
|
4983
|
-
this.emit("message_failed", status); // ❌
|
|
4984
|
-
return false;
|
|
5166
|
+
return this.failDelivery(verdict, status);
|
|
4985
5167
|
}
|
|
4986
5168
|
/**
|
|
4987
5169
|
* The single place a pasted message is judged submitted, shared by the
|
|
@@ -5442,7 +5624,12 @@ export class Daemon extends EventEmitter {
|
|
|
5442
5624
|
this.ipcServer?.broadcast({
|
|
5443
5625
|
type: typeMap[tool],
|
|
5444
5626
|
payload: args,
|
|
5445
|
-
meta: {
|
|
5627
|
+
meta: {
|
|
5628
|
+
chat_id: this.lastChatId,
|
|
5629
|
+
thread_id: this.lastThreadId,
|
|
5630
|
+
adapter_id: this.lastAdapterId,
|
|
5631
|
+
instance_name: this.name,
|
|
5632
|
+
},
|
|
5446
5633
|
fleetRequestId: fleetReqId,
|
|
5447
5634
|
});
|
|
5448
5635
|
return;
|
|
@@ -5632,10 +5819,17 @@ export class Daemon extends EventEmitter {
|
|
|
5632
5819
|
// profile instead of `full`. Every tool's schema is resent on every API call,
|
|
5633
5820
|
// so full (44 tools) costs a general ~22k chars per turn — the single largest
|
|
5634
5821
|
// slice of its prompt. Explicit config still wins.
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5822
|
+
// One rule for "is this a general", shared with everywhere else that asks.
|
|
5823
|
+
// The daemon used to read `general_topic` alone while `isGeneralInstance`
|
|
5824
|
+
// also accepted the name, so an instance called `general` without the flag
|
|
5825
|
+
// was a worker here and a general everywhere else.
|
|
5826
|
+
const isGeneral = this.config.general_topic === true || this.name === "general";
|
|
5827
|
+
// No third argument for an ordinary instance: the default is `worker` now,
|
|
5828
|
+
// and naming it here would be a second place to change it.
|
|
5829
|
+
const toolSet = isGeneral
|
|
5830
|
+
? resolveToolSet(this.config, this.name, "general")
|
|
5831
|
+
: resolveToolSet(this.config, this.name);
|
|
5832
|
+
mcpEnv.AGEND_TOOL_SET = toolSet;
|
|
5639
5833
|
if (this.config.display_name)
|
|
5640
5834
|
mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
|
|
5641
5835
|
if (this.config.description)
|
|
@@ -6068,6 +6262,10 @@ export class Daemon extends EventEmitter {
|
|
|
6068
6262
|
this.startupAborted = true;
|
|
6069
6263
|
this.freezeRuntimeMonitors();
|
|
6070
6264
|
this.pendingIpcRequests.clear();
|
|
6265
|
+
if (this.museUsageRelay) {
|
|
6266
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
6267
|
+
this.museUsageRelay = null;
|
|
6268
|
+
}
|
|
6071
6269
|
try {
|
|
6072
6270
|
await this.killProcessTree();
|
|
6073
6271
|
}
|
|
@@ -6123,6 +6321,32 @@ export class Daemon extends EventEmitter {
|
|
|
6123
6321
|
reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
|
|
6124
6322
|
}, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
|
|
6125
6323
|
}
|
|
6324
|
+
/**
|
|
6325
|
+
* A relay crash must not make the daemon kill a live Muse process. The
|
|
6326
|
+
* supervisor normally reclaims the same port; when that bounded recovery
|
|
6327
|
+
* fails, mark usage unavailable and leave Muse untouched. A later natural
|
|
6328
|
+
* spawn sees museRelayFallback and starts direct, without interrupting the
|
|
6329
|
+
* current conversation or destroying its session.
|
|
6330
|
+
*/
|
|
6331
|
+
switchMuseToDirect() {
|
|
6332
|
+
if (this.museRelayFallbackInFlight || this.startupAborted || this.backend?.binaryName !== "muse") {
|
|
6333
|
+
return this.museRelayFallbackInFlight ?? Promise.resolve();
|
|
6334
|
+
}
|
|
6335
|
+
this.museRelayFallbackInFlight = (async () => {
|
|
6336
|
+
this.museRelayFallback = true;
|
|
6337
|
+
this.logger.warn("Muse usage relay could not recover — usage is unavailable until the next natural Muse restart");
|
|
6338
|
+
try {
|
|
6339
|
+
if (this.museUsageRelay) {
|
|
6340
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
6341
|
+
this.museUsageRelay = null;
|
|
6342
|
+
}
|
|
6343
|
+
}
|
|
6344
|
+
catch (err) {
|
|
6345
|
+
this.logger.warn({ reason: err instanceof Error ? err.message : "relay stop failed" }, "Muse usage relay cleanup failed");
|
|
6346
|
+
}
|
|
6347
|
+
})().finally(() => { this.museRelayFallbackInFlight = null; });
|
|
6348
|
+
return this.museRelayFallbackInFlight;
|
|
6349
|
+
}
|
|
6126
6350
|
/**
|
|
6127
6351
|
* Tell the agent its instructions moved, once the CLI is idle.
|
|
6128
6352
|
*
|
|
@@ -6258,7 +6482,30 @@ export class Daemon extends EventEmitter {
|
|
|
6258
6482
|
if (backendConfig.agentMode === "cli" && backendConfig.agentPort) {
|
|
6259
6483
|
envPrefix += ` AGEND_PORT=${backendConfig.agentPort}`;
|
|
6260
6484
|
}
|
|
6261
|
-
|
|
6485
|
+
// Muse's usage relay must be listening before its command is assembled.
|
|
6486
|
+
// Preparation is best-effort: if the loopback port cannot be bound, leave
|
|
6487
|
+
// museBaseUrl unset and let Muse connect directly so usage never blocks a
|
|
6488
|
+
// conversation.
|
|
6489
|
+
let launchConfig = backendConfig;
|
|
6490
|
+
if (this.backend?.binaryName === "muse" && !this.museRelayFallback) {
|
|
6491
|
+
try {
|
|
6492
|
+
this.museUsageRelay ??= new MuseUsageRelay({
|
|
6493
|
+
instanceDir: this.instanceDir,
|
|
6494
|
+
onUnavailable: () => { void this.switchMuseToDirect(); },
|
|
6495
|
+
});
|
|
6496
|
+
const baseUrl = await this.museUsageRelay.start();
|
|
6497
|
+
launchConfig = { ...backendConfig, museBaseUrl: baseUrl };
|
|
6498
|
+
}
|
|
6499
|
+
catch (err) {
|
|
6500
|
+
this.logger.warn({ reason: err instanceof Error ? err.message : "relay unavailable" }, "Muse usage relay unavailable — using direct connection");
|
|
6501
|
+
if (this.museUsageRelay) {
|
|
6502
|
+
await this.museUsageRelay.stop().catch(() => { });
|
|
6503
|
+
this.museUsageRelay = null;
|
|
6504
|
+
}
|
|
6505
|
+
clearMuseUsageSnapshot(this.instanceDir);
|
|
6506
|
+
}
|
|
6507
|
+
}
|
|
6508
|
+
const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
|
|
6262
6509
|
// Ensure tmux session exists (may have been destroyed if all windows died)
|
|
6263
6510
|
await TmuxManager.ensureSession(this.tmuxSessionName);
|
|
6264
6511
|
if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
|
|
@@ -6694,6 +6941,30 @@ export class Daemon extends EventEmitter {
|
|
|
6694
6941
|
}, "Snapshot written");
|
|
6695
6942
|
return snapshot;
|
|
6696
6943
|
}
|
|
6944
|
+
/**
|
|
6945
|
+
* The credential profile's store this instance's CLI writes to, if any.
|
|
6946
|
+
*
|
|
6947
|
+
* The conversation lives in the same file as the login, so an instance on a
|
|
6948
|
+
* profile keeps its transcript there too — reading the shared store would
|
|
6949
|
+
* follow whichever *other* agent happens to be talking, or find nothing.
|
|
6950
|
+
* Built with the same `getAgendHome()` the launch prefix uses, or the two
|
|
6951
|
+
* would disagree about where the profile is.
|
|
6952
|
+
*/
|
|
6953
|
+
credentialProfileStore() {
|
|
6954
|
+
const backendName = this.config.backend ?? "claude-code";
|
|
6955
|
+
let profile;
|
|
6956
|
+
try {
|
|
6957
|
+
profile = resolveCredentialProfile(this.config.backend_options?.[backendName]);
|
|
6958
|
+
}
|
|
6959
|
+
catch {
|
|
6960
|
+
// A malformed name is refused where it is written; a transcript reader
|
|
6961
|
+
// must not take the instance down over it.
|
|
6962
|
+
return undefined;
|
|
6963
|
+
}
|
|
6964
|
+
if (!profile)
|
|
6965
|
+
return undefined;
|
|
6966
|
+
return credentialProfileStoreHome(getAgendHome(), backendName, profile);
|
|
6967
|
+
}
|
|
6697
6968
|
/** Collect ring buffer data for handover to a replacement instance. */
|
|
6698
6969
|
collectHandoverContext() {
|
|
6699
6970
|
const lines = [];
|