@songsid/agend 2.1.1-beta.11 → 2.1.1-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/antigravity.js +5 -2
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +3 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +16 -0
- package/dist/backend/codex.js +248 -56
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.js +20 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.js +18 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +7 -0
- package/dist/backend/types.js +1 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +4 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.js +50 -15
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +8 -0
- package/dist/channel/ipc-bridge.js +41 -14
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/mcp-server.js +25 -10
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +25 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.d.ts +1 -0
- package/dist/channel/message-queue.js +49 -17
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/reconnect-backoff.d.ts +17 -0
- package/dist/channel/reconnect-backoff.js +21 -0
- package/dist/channel/reconnect-backoff.js.map +1 -0
- package/dist/cli.js +71 -26
- package/dist/cli.js.map +1 -1
- package/dist/completion.d.ts +27 -0
- package/dist/completion.js +121 -0
- package/dist/completion.js.map +1 -0
- package/dist/config-validator.js +9 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +74 -8
- package/dist/daemon.js +254 -18
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +7 -1
- package/dist/fleet-manager.js +89 -16
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
- package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
- package/dist/instance-lifecycle.d.ts +13 -1
- package/dist/instance-lifecycle.js +70 -3
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +5 -0
- package/dist/instructions.js +9 -11
- package/dist/instructions.js.map +1 -1
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -1
- package/dist/mcp-liveness.d.ts +21 -0
- package/dist/mcp-liveness.js +27 -0
- package/dist/mcp-liveness.js.map +1 -0
- package/dist/outbound-handlers.d.ts +9 -0
- package/dist/outbound-handlers.js +46 -26
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/tmux-control.js +4 -2
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +14 -0
- package/dist/tmux-manager.js +20 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +22 -1
- package/dist/topic-commands.js +95 -57
- package/dist/topic-commands.js.map +1 -1
- package/dist/tui-glyphs.d.ts +25 -0
- package/dist/tui-glyphs.js +26 -0
- package/dist/tui-glyphs.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/ui/view.html +114 -2
- package/dist/usage/providers.d.ts +26 -0
- package/dist/usage/providers.js +577 -0
- package/dist/usage/providers.js.map +1 -0
- package/dist/usage/usage-api.d.ts +35 -0
- package/dist/usage/usage-api.js +52 -0
- package/dist/usage/usage-api.js.map +1 -0
- package/dist/view-api.d.ts +3 -0
- package/dist/view-api.js +10 -2
- package/dist/view-api.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -3,6 +3,8 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync,
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
|
+
import { rotateLogIfNeeded } from "./logger.js";
|
|
7
|
+
import { mcpServerState } from "./mcp-liveness.js";
|
|
6
8
|
import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
|
|
7
9
|
import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
|
|
8
10
|
import { TranscriptMonitor } from "./transcript-monitor.js";
|
|
@@ -40,6 +42,51 @@ export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
|
|
|
40
42
|
/** @deprecated State detection is event-driven; this now aliases the safety sweep. */
|
|
41
43
|
export const DEFAULT_STATE_POLL_INTERVAL_MS = DEFAULT_STATE_SAFETY_SWEEP_MS;
|
|
42
44
|
const LAST_INBOUND_FILE = "last-inbound-at";
|
|
45
|
+
/**
|
|
46
|
+
* Whether two working directories belong to the same project, so a fleet-scoped
|
|
47
|
+
* decision recorded in one reaches the other. Covers the worktree/checkout
|
|
48
|
+
* layout AgEnD fleets actually use — `AgEnD`, `AgEnD-dev1`, `AgEnD-dev2`,
|
|
49
|
+
* `AgEnD-reviewer`, `AgEnD-main` are one project; `DouPo_Server` is not.
|
|
50
|
+
* Nesting counts too (a subdirectory of a project belongs to it).
|
|
51
|
+
*/
|
|
52
|
+
export function sameProjectFamily(a, b) {
|
|
53
|
+
const norm = (p) => p.replace(/\/+$/, "");
|
|
54
|
+
const x = norm(a), y = norm(b);
|
|
55
|
+
if (x === y)
|
|
56
|
+
return true;
|
|
57
|
+
if (x.startsWith(y + "/") || y.startsWith(x + "/"))
|
|
58
|
+
return true;
|
|
59
|
+
// Strip a trailing worktree/role suffix: "AgEnD-dev2" → "agend".
|
|
60
|
+
const family = (p) => {
|
|
61
|
+
const base = p.slice(p.lastIndexOf("/") + 1).toLowerCase();
|
|
62
|
+
return base.replace(/[-_](dev\d*|main|reviewer|tester|leader|sol|codex|worktree|wt\d*)$/i, "");
|
|
63
|
+
};
|
|
64
|
+
const fx = family(x), fy = family(y);
|
|
65
|
+
return fx.length > 0 && fx === fy;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Pick the decisions worth injecting into one instance's system prompt.
|
|
69
|
+
*
|
|
70
|
+
* `scope: "fleet"` previously bypassed the project check outright, so every
|
|
71
|
+
* instance carried every other project's playbook — measured at 8 of 14 here,
|
|
72
|
+
* resent on every API call. A fleet decision must now also be relevant: global
|
|
73
|
+
* (no project_root), the same project, or the same project family.
|
|
74
|
+
*
|
|
75
|
+
* `isDispatcher` (a general) opts out of that narrowing on purpose: it routes
|
|
76
|
+
* work across all projects, so cross-project rules ("X 文件操作由 Y 負責") are
|
|
77
|
+
* precisely what it must know. Narrowing a general would cause misrouting.
|
|
78
|
+
*/
|
|
79
|
+
export function selectRelevantDecisions(all, workDir, isDispatcher = false) {
|
|
80
|
+
return all.filter(d => {
|
|
81
|
+
if (d.project_root === workDir)
|
|
82
|
+
return true; // own project, any scope
|
|
83
|
+
if (d.scope !== "fleet")
|
|
84
|
+
return false; // project-scoped, elsewhere
|
|
85
|
+
if (!d.project_root)
|
|
86
|
+
return true; // truly global
|
|
87
|
+
return isDispatcher || sameProjectFamily(d.project_root, workDir);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
43
90
|
/** Read the last real channel inbound timestamp persisted across daemon restarts. */
|
|
44
91
|
export function readLastInboundAt(instanceDir, now = Date.now()) {
|
|
45
92
|
try {
|
|
@@ -193,6 +240,8 @@ export class PendingWorkTracker {
|
|
|
193
240
|
const NORMAL_ENTER_SETTLE_MS = 500;
|
|
194
241
|
const FIRST_ENTER_SETTLE_MS = 1_750;
|
|
195
242
|
const FIRST_DELIVERY_WINDOW_MS = 5_000;
|
|
243
|
+
/** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
|
|
244
|
+
const NATIVE_QUEUE_PASTE_VERIFY_MS = 2_000;
|
|
196
245
|
/**
|
|
197
246
|
* One-shot timing gate for the first paste after a CLI reaches its ready
|
|
198
247
|
* prompt. Recent tmux versions expose a short interval where the prompt is
|
|
@@ -244,6 +293,7 @@ export class Daemon extends EventEmitter {
|
|
|
244
293
|
topicMode;
|
|
245
294
|
backend;
|
|
246
295
|
controlClient;
|
|
296
|
+
runtimeIdentity;
|
|
247
297
|
logger;
|
|
248
298
|
tmuxSessionName;
|
|
249
299
|
tmux = null;
|
|
@@ -305,6 +355,13 @@ export class Daemon extends EventEmitter {
|
|
|
305
355
|
statePollInFlight = false;
|
|
306
356
|
autoPauseController;
|
|
307
357
|
pauseRequested = false;
|
|
358
|
+
/**
|
|
359
|
+
* Sticky "pause as soon as possible" (auth failure). Unlike pauseRequested —
|
|
360
|
+
* which the state monitor clears whenever the pane is not idle — this survives
|
|
361
|
+
* busy/stuck states, because an auth error fires mid-turn and a plain pause()
|
|
362
|
+
* would silently no-op exactly when we most need to stop feeding the CLI.
|
|
363
|
+
*/
|
|
364
|
+
pausePending = false;
|
|
308
365
|
pauseWakeState = "active";
|
|
309
366
|
pauseWakeTransition = null;
|
|
310
367
|
// Model failover: override model on next spawn when rate-limited
|
|
@@ -327,6 +384,10 @@ export class Daemon extends EventEmitter {
|
|
|
327
384
|
firstDeliveryDelay = new FirstDeliveryDelay();
|
|
328
385
|
// PTY error pattern monitoring
|
|
329
386
|
errorMonitorTimer = null;
|
|
387
|
+
/** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
|
|
388
|
+
static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
|
|
389
|
+
lastMcpDeathNotifiedAt = 0;
|
|
390
|
+
mcpDeathNotifiedForPid = null;
|
|
330
391
|
/** Prevent in-flight monitor callbacks from re-arming after a pause. */
|
|
331
392
|
runtimeMonitorsFrozen = false;
|
|
332
393
|
errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern
|
|
@@ -371,7 +432,7 @@ export class Daemon extends EventEmitter {
|
|
|
371
432
|
this.errorRecoveryDeadlineAt = 0;
|
|
372
433
|
this.activeErrorPatternKey = null;
|
|
373
434
|
}
|
|
374
|
-
constructor(name, config, instanceDir, topicMode = false, backend, controlClient, rootLogger) {
|
|
435
|
+
constructor(name, config, instanceDir, topicMode = false, backend, controlClient, rootLogger, runtimeIdentity) {
|
|
375
436
|
super();
|
|
376
437
|
this.name = name;
|
|
377
438
|
this.config = config;
|
|
@@ -379,8 +440,14 @@ export class Daemon extends EventEmitter {
|
|
|
379
440
|
this.topicMode = topicMode;
|
|
380
441
|
this.backend = backend;
|
|
381
442
|
this.controlClient = controlClient;
|
|
443
|
+
this.runtimeIdentity = runtimeIdentity;
|
|
382
444
|
if (!rootLogger)
|
|
383
445
|
throw new Error("Daemon requires a shared root logger");
|
|
446
|
+
this.runtimeIdentity ??= {
|
|
447
|
+
kind: "fleet-topic",
|
|
448
|
+
backend: config.backend ?? backend?.binaryName ?? "unknown",
|
|
449
|
+
model: config.model ?? "default",
|
|
450
|
+
};
|
|
384
451
|
this.logger = rootLogger.child({ instance: name }, { level: config.log_level });
|
|
385
452
|
this.tmuxSessionName = getTmuxSession();
|
|
386
453
|
this.messageBus = new MessageBus();
|
|
@@ -612,8 +679,12 @@ export class Daemon extends EventEmitter {
|
|
|
612
679
|
catch { /* non-fatal */ }
|
|
613
680
|
})();
|
|
614
681
|
if (!this.config.lightweight) {
|
|
615
|
-
// 3. Pipe-pane for prompt detection
|
|
682
|
+
// 3. Pipe-pane for prompt detection. Rotate first so a ballooned log from a
|
|
683
|
+
// previous stuck splash (hundreds of MB of ANSI frames) is truncated before
|
|
684
|
+
// we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
|
|
685
|
+
// keeps the writer attached after size resets.
|
|
616
686
|
const outputLog = join(this.instanceDir, "output.log");
|
|
687
|
+
rotateLogIfNeeded(outputLog);
|
|
617
688
|
await this.tmux.pipeOutput(outputLog).catch(() => { });
|
|
618
689
|
// 4. Transcript monitor
|
|
619
690
|
this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger);
|
|
@@ -677,6 +748,40 @@ export class Daemon extends EventEmitter {
|
|
|
677
748
|
this.startInstanceStateMonitor();
|
|
678
749
|
this.logger.info(`${this.name} ready`);
|
|
679
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* Detect a CRASHED MCP server and report it once.
|
|
753
|
+
*
|
|
754
|
+
* The MCP server writes its pid to channel.mcp.pid at startup and unlinks it on
|
|
755
|
+
* a clean exit, so "file present + pid dead" specifically means it died without
|
|
756
|
+
* cleaning up (crash / OOM / SIGKILL). A missing file is either "not started
|
|
757
|
+
* yet" or "exited cleanly" — neither is an incident, and an orphan exit implies
|
|
758
|
+
* the CLI itself died, which the crash detector already covers.
|
|
759
|
+
*
|
|
760
|
+
* The daemon deliberately does NOT try to respawn it: MCP is a stdio protocol
|
|
761
|
+
* where the CLI spawns the server and owns its pipes, so a daemon-spawned
|
|
762
|
+
* process would have no client reading it. Only the CLI can restore its own
|
|
763
|
+
* tools — hence notify, and let the operator decide about restarting.
|
|
764
|
+
*/
|
|
765
|
+
checkMcpServerAlive() {
|
|
766
|
+
if (this.isPaused)
|
|
767
|
+
return;
|
|
768
|
+
const status = mcpServerState(this.instanceDir);
|
|
769
|
+
if (status.state === "unknown")
|
|
770
|
+
return;
|
|
771
|
+
if (status.state === "alive") {
|
|
772
|
+
this.mcpDeathNotifiedForPid = null; // the CLI respawned it — re-arm
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// Dead: report once per pid, and at most once per cooldown window.
|
|
776
|
+
if (this.mcpDeathNotifiedForPid === status.pid)
|
|
777
|
+
return;
|
|
778
|
+
if (Date.now() - this.lastMcpDeathNotifiedAt < Daemon.MCP_DEATH_COOLDOWN_MS)
|
|
779
|
+
return;
|
|
780
|
+
this.mcpDeathNotifiedForPid = status.pid;
|
|
781
|
+
this.lastMcpDeathNotifiedAt = Date.now();
|
|
782
|
+
this.logger.error({ pid: status.pid }, "MCP server process is gone — instance has no agend tools");
|
|
783
|
+
this.emit("mcp_died", { name: this.name, pid: status.pid });
|
|
784
|
+
}
|
|
680
785
|
startHealthCheck() {
|
|
681
786
|
if (this.runtimeMonitorsFrozen || this.healthCheckTimer)
|
|
682
787
|
return;
|
|
@@ -706,6 +811,8 @@ export class Daemon extends EventEmitter {
|
|
|
706
811
|
scheduleNext();
|
|
707
812
|
return;
|
|
708
813
|
}
|
|
814
|
+
// The CLI owns the MCP server process, so the daemon can only observe it.
|
|
815
|
+
this.checkMcpServerAlive();
|
|
709
816
|
// Human-readable backend label for logs (e.g. "claude", "kiro-cli")
|
|
710
817
|
const cliLabel = this.backend?.binaryName ?? "CLI";
|
|
711
818
|
let paneStatus = await this.tmux.getPaneStatus();
|
|
@@ -716,6 +823,11 @@ export class Daemon extends EventEmitter {
|
|
|
716
823
|
return;
|
|
717
824
|
}
|
|
718
825
|
if (paneStatus?.alive) {
|
|
826
|
+
// Instance output.log is fed by tmux pipe-pane and was previously never
|
|
827
|
+
// rotated (only fleet.log / daemon.log were). Cap growth every tick.
|
|
828
|
+
if (!this.config.lightweight) {
|
|
829
|
+
rotateLogIfNeeded(join(this.instanceDir, "output.log"));
|
|
830
|
+
}
|
|
719
831
|
scheduleNext();
|
|
720
832
|
return;
|
|
721
833
|
}
|
|
@@ -1241,6 +1353,16 @@ export class Daemon extends EventEmitter {
|
|
|
1241
1353
|
};
|
|
1242
1354
|
}
|
|
1243
1355
|
/** Gracefully stop the CLI while keeping its remain-on-exit tmux window. */
|
|
1356
|
+
/**
|
|
1357
|
+
* Mark the instance to pause as soon as its pane is idle. Used for auth
|
|
1358
|
+
* failures: pause() alone no-ops while the CLI is busy/stuck, which is the
|
|
1359
|
+
* usual state when the error surfaces. Cleared by a successful pause or wake.
|
|
1360
|
+
*/
|
|
1361
|
+
requestPauseWhenIdle() {
|
|
1362
|
+
if (this.pauseWakeState === "paused")
|
|
1363
|
+
return;
|
|
1364
|
+
this.pausePending = true;
|
|
1365
|
+
}
|
|
1244
1366
|
async pause() {
|
|
1245
1367
|
if (this.pauseWakeState === "paused")
|
|
1246
1368
|
return;
|
|
@@ -1310,6 +1432,9 @@ export class Daemon extends EventEmitter {
|
|
|
1310
1432
|
}
|
|
1311
1433
|
/** Respawn the CLI in the preserved window and block until its prompt is ready. */
|
|
1312
1434
|
async wake(timeoutMs = 30_000) {
|
|
1435
|
+
// An explicit wake (e.g. after the user re-logs in) cancels a deferred
|
|
1436
|
+
// auth pause — otherwise the instance would pause again the moment it idles.
|
|
1437
|
+
this.pausePending = false;
|
|
1313
1438
|
if (this.pauseWakeState === "active")
|
|
1314
1439
|
return;
|
|
1315
1440
|
if (this.pauseWakeState === "pausing")
|
|
@@ -1389,6 +1514,12 @@ export class Daemon extends EventEmitter {
|
|
|
1389
1514
|
}
|
|
1390
1515
|
if (snapshot.state !== "idle")
|
|
1391
1516
|
this.pauseRequested = false;
|
|
1517
|
+
// A pause deferred by an auth failure: retry the moment the pane settles.
|
|
1518
|
+
if (this.pausePending && snapshot.state === "idle" && this.pasteQueueDepth === 0) {
|
|
1519
|
+
this.pausePending = false;
|
|
1520
|
+
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1392
1523
|
if (!this.pauseRequested && this.pasteQueueDepth === 0 && this.autoPauseController.observe(snapshot.state)) {
|
|
1393
1524
|
this.pauseRequested = true;
|
|
1394
1525
|
this.emit("auto_pause_requested", { name: this.name, idleSince: snapshot.stateChangedAt });
|
|
@@ -1755,18 +1886,19 @@ export class Daemon extends EventEmitter {
|
|
|
1755
1886
|
}
|
|
1756
1887
|
/**
|
|
1757
1888
|
* Deliver a single message and drive its status reactions:
|
|
1758
|
-
* ⏳ message_queued — CLI busy; queued
|
|
1889
|
+
* ⏳ message_queued — CLI busy; queued locally or by the backend
|
|
1759
1890
|
* 👀 message_delivered — pasted + Enter sent; agent now has it
|
|
1760
|
-
* ✅ message_confirmed —
|
|
1891
|
+
* ✅ message_confirmed — processing observed, or native queue handoff succeeded
|
|
1761
1892
|
* ❌ message_failed — tmux window gone, paste retries exhausted
|
|
1762
1893
|
* Returns true once the message is in the CLI, false only on real delivery failure.
|
|
1763
1894
|
*
|
|
1764
1895
|
* Bug A (silent message loss): paste failures retry with backoff (window recovery)
|
|
1765
1896
|
* and emit `message_failed` if all attempts fail.
|
|
1766
|
-
* Busy handling (UX):
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1897
|
+
* Busy handling (UX): backends without a native input queue show ⏳ and wait for
|
|
1898
|
+
* idle indefinitely (a genuinely hung CLI is the hang detector's job). Backends
|
|
1899
|
+
* that explicitly support queued input receive one complete paste+Enter transaction
|
|
1900
|
+
* immediately and own the application-level queue themselves. The pasteLock remains
|
|
1901
|
+
* serial in both cases so separate PTY writes can never overlap.
|
|
1770
1902
|
*/
|
|
1771
1903
|
async deliverMessage(formatted, status) {
|
|
1772
1904
|
// Sanitize unclosed code fences — they cause CLI to wait for closure on Enter
|
|
@@ -1776,12 +1908,22 @@ export class Daemon extends EventEmitter {
|
|
|
1776
1908
|
formatted = formatted.replace(/```/g, "");
|
|
1777
1909
|
}
|
|
1778
1910
|
let windowId = this.getWindowId();
|
|
1779
|
-
|
|
1911
|
+
const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
|
|
1912
|
+
let handingOffToNativeQueue = false;
|
|
1913
|
+
// If the CLI is busy, either hand the complete submission to its native input
|
|
1914
|
+
// queue or wait for idle. Native queue support is an explicit backend capability:
|
|
1915
|
+
// normal Enter input has steering/interrupt semantics in several other CLIs.
|
|
1780
1916
|
if (windowId && this.controlClient && !this.controlClient.isIdle(windowId)) {
|
|
1781
1917
|
if (status)
|
|
1782
1918
|
this.emit("message_queued", status);
|
|
1783
|
-
|
|
1784
|
-
|
|
1919
|
+
if (supportsQueuedInput) {
|
|
1920
|
+
handingOffToNativeQueue = true;
|
|
1921
|
+
this.logger.debug("CLI busy — handing message to backend-native input queue");
|
|
1922
|
+
}
|
|
1923
|
+
else {
|
|
1924
|
+
this.logger.debug("CLI busy — queuing message until idle");
|
|
1925
|
+
await this.controlClient.waitUntilIdle(windowId);
|
|
1926
|
+
}
|
|
1785
1927
|
}
|
|
1786
1928
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
1787
1929
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
@@ -1807,9 +1949,56 @@ export class Daemon extends EventEmitter {
|
|
|
1807
1949
|
await this.tmux.sendSpecialKey("Enter");
|
|
1808
1950
|
if (status)
|
|
1809
1951
|
this.emit("message_delivered", status); // 👀
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
// the
|
|
1952
|
+
// Busy queue-capable CLIs (codex) may accept paste without an idle→busy
|
|
1953
|
+
// transition. Do NOT probe for busy — a second bare Enter can mutate the
|
|
1954
|
+
// queue. Instead verify the paste actually landed in the pane (TUI redraw
|
|
1955
|
+
// can silently swallow it). Idle submissions keep the swallowed-Enter path.
|
|
1956
|
+
if (handingOffToNativeQueue) {
|
|
1957
|
+
await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
|
|
1958
|
+
if (await this.nativeQueuePasteVisible(formatted)) {
|
|
1959
|
+
if (status)
|
|
1960
|
+
this.emit("message_confirmed", status); // ✅ native queue accepted
|
|
1961
|
+
return true;
|
|
1962
|
+
}
|
|
1963
|
+
// Silent loss: fall back once to the normal idle-gated path.
|
|
1964
|
+
this.logger.warn("Native-queue paste not visible in pane — retrying via idle-gated delivery");
|
|
1965
|
+
if (windowId && this.controlClient) {
|
|
1966
|
+
await this.controlClient.waitUntilIdle(windowId);
|
|
1967
|
+
}
|
|
1968
|
+
const repasted = await this.tmux.pasteBuffer(formatted);
|
|
1969
|
+
if (!repasted) {
|
|
1970
|
+
this.logger.error("Idle-gated redelivery paste failed after native-queue silent loss");
|
|
1971
|
+
if (status)
|
|
1972
|
+
this.emit("message_failed", status); // ❌
|
|
1973
|
+
return false;
|
|
1974
|
+
}
|
|
1975
|
+
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
1976
|
+
const retryAt = Date.now();
|
|
1977
|
+
await this.tmux.sendSpecialKey("Enter");
|
|
1978
|
+
if (windowId && this.controlClient) {
|
|
1979
|
+
let becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
|
|
1980
|
+
if (!becameBusy) {
|
|
1981
|
+
this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
|
|
1982
|
+
const retry2At = Date.now();
|
|
1983
|
+
await this.tmux.sendSpecialKey("Enter");
|
|
1984
|
+
becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
|
|
1985
|
+
}
|
|
1986
|
+
if (becameBusy) {
|
|
1987
|
+
if (status)
|
|
1988
|
+
this.emit("message_confirmed", status); // ✅
|
|
1989
|
+
return true;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
else if (await this.nativeQueuePasteVisible(formatted)) {
|
|
1993
|
+
if (status)
|
|
1994
|
+
this.emit("message_confirmed", status); // ✅
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
|
|
1998
|
+
if (status)
|
|
1999
|
+
this.emit("message_failed", status); // ❌
|
|
2000
|
+
return false;
|
|
2001
|
+
}
|
|
1813
2002
|
if (windowId && this.controlClient) {
|
|
1814
2003
|
let becameBusy = await this.confirmBusyAfterEnter(windowId, enterAt);
|
|
1815
2004
|
if (!becameBusy) {
|
|
@@ -1835,6 +2024,33 @@ export class Daemon extends EventEmitter {
|
|
|
1835
2024
|
this.emit("message_failed", status); // ❌
|
|
1836
2025
|
return false;
|
|
1837
2026
|
}
|
|
2027
|
+
/**
|
|
2028
|
+
* True when a busy native-queue paste appears to have landed: Codex shows a
|
|
2029
|
+
* `↳` queue marker, or a distinctive slice of the pasted text is on screen.
|
|
2030
|
+
* Used only to detect silent paste loss — not as a general ready check.
|
|
2031
|
+
*/
|
|
2032
|
+
async nativeQueuePasteVisible(formatted) {
|
|
2033
|
+
if (!this.tmux)
|
|
2034
|
+
return false;
|
|
2035
|
+
try {
|
|
2036
|
+
const pane = await this.tmux.capturePane();
|
|
2037
|
+
if (pane.includes("↳"))
|
|
2038
|
+
return true;
|
|
2039
|
+
for (const line of formatted.split(/\r?\n/)) {
|
|
2040
|
+
const t = line.trim();
|
|
2041
|
+
if (t.length >= 8 && pane.includes(t))
|
|
2042
|
+
return true;
|
|
2043
|
+
}
|
|
2044
|
+
const compact = formatted.replace(/\s+/g, " ").trim();
|
|
2045
|
+
if (compact.length >= 8 && pane.includes(compact.slice(0, Math.min(80, compact.length)))) {
|
|
2046
|
+
return true;
|
|
2047
|
+
}
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
catch {
|
|
2051
|
+
return false;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
1838
2054
|
/** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
|
|
1839
2055
|
async recoverWindow() {
|
|
1840
2056
|
try {
|
|
@@ -2110,7 +2326,14 @@ export class Daemon extends EventEmitter {
|
|
|
2110
2326
|
try {
|
|
2111
2327
|
const all = JSON.parse(process.env.AGEND_DECISIONS);
|
|
2112
2328
|
const workDir = this.config.working_directory;
|
|
2113
|
-
|
|
2329
|
+
// `scope: "fleet"` used to bypass the project check entirely, so a
|
|
2330
|
+
// single-project worker inherited every other project's playbook (8 of
|
|
2331
|
+
// 14 injected here were foreign) — resent on every API call. Now a
|
|
2332
|
+
// fleet decision must also be RELEVANT: global (no project_root), the
|
|
2333
|
+
// same project, or the same project family (worktrees/checkouts of one
|
|
2334
|
+
// repo). A general is exempt: it dispatches across all projects, so
|
|
2335
|
+
// cross-project routing rules are exactly what it needs.
|
|
2336
|
+
decisions = selectRelevantDecisions(all, workDir, this.config.general_topic === true);
|
|
2114
2337
|
// Stable ordering so identical decision sets always build byte-identical
|
|
2115
2338
|
// instructions — otherwise source ordering jitter flips the warmup hash.
|
|
2116
2339
|
decisions.sort((a, b) => a.title.localeCompare(b.title));
|
|
@@ -2124,9 +2347,18 @@ export class Daemon extends EventEmitter {
|
|
|
2124
2347
|
AGEND_SOCKET_PATH: sockPath,
|
|
2125
2348
|
AGEND_INSTANCE_NAME: this.name,
|
|
2126
2349
|
AGEND_WORKING_DIR: this.config.working_directory,
|
|
2350
|
+
AGEND_INSTANCE_KIND: this.runtimeIdentity?.kind ?? "fleet-topic",
|
|
2351
|
+
AGEND_BACKEND: this.runtimeIdentity?.backend ?? this.config.backend ?? this.backend?.binaryName ?? "unknown",
|
|
2352
|
+
AGEND_MODEL: this.runtimeIdentity?.model ?? this.config.model ?? "default",
|
|
2127
2353
|
};
|
|
2128
|
-
|
|
2129
|
-
|
|
2354
|
+
// A general is a dispatcher, not a worker: default it to the `general` tool
|
|
2355
|
+
// profile instead of `full`. Every tool's schema is resent on every API call,
|
|
2356
|
+
// so full (44 tools) costs a general ~22k chars per turn — the single largest
|
|
2357
|
+
// slice of its prompt. Explicit config still wins.
|
|
2358
|
+
const defaultToolSet = this.config.general_topic ? "general" : undefined;
|
|
2359
|
+
const toolSet = this.config.tool_set ?? defaultToolSet;
|
|
2360
|
+
if (toolSet)
|
|
2361
|
+
mcpEnv.AGEND_TOOL_SET = toolSet;
|
|
2130
2362
|
if (this.config.display_name)
|
|
2131
2363
|
mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
|
|
2132
2364
|
if (this.config.description)
|
|
@@ -2159,6 +2391,7 @@ export class Daemon extends EventEmitter {
|
|
|
2159
2391
|
instructions = buildFleetInstructions({
|
|
2160
2392
|
instanceName: this.name,
|
|
2161
2393
|
workingDirectory: this.config.working_directory,
|
|
2394
|
+
runtimeIdentity: this.runtimeIdentity,
|
|
2162
2395
|
displayName: this.config.display_name,
|
|
2163
2396
|
description: this.config.description,
|
|
2164
2397
|
customPrompt: resolvedCustomPrompt,
|
|
@@ -2171,6 +2404,7 @@ export class Daemon extends EventEmitter {
|
|
|
2171
2404
|
instructions = buildFleetInstructions({
|
|
2172
2405
|
instanceName: this.name,
|
|
2173
2406
|
workingDirectory: this.config.working_directory,
|
|
2407
|
+
runtimeIdentity: this.runtimeIdentity,
|
|
2174
2408
|
displayName: this.config.display_name,
|
|
2175
2409
|
description: this.config.description,
|
|
2176
2410
|
customPrompt: resolvedCustomPrompt,
|
|
@@ -2366,7 +2600,9 @@ export class Daemon extends EventEmitter {
|
|
|
2366
2600
|
this.logger.warn({ err }, "Failed to set remain-on-exit — exit codes will not be captured");
|
|
2367
2601
|
});
|
|
2368
2602
|
if (reuseWindow && !this.config.lightweight) {
|
|
2369
|
-
|
|
2603
|
+
const outputLog = join(this.instanceDir, "output.log");
|
|
2604
|
+
rotateLogIfNeeded(outputLog);
|
|
2605
|
+
await this.tmux.pipeOutput(outputLog).catch(err => {
|
|
2370
2606
|
this.logger.warn({ err }, "Failed to restore pipe-pane after wake");
|
|
2371
2607
|
});
|
|
2372
2608
|
}
|