@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.
Files changed (88) hide show
  1. package/dist/backend/antigravity.js +5 -2
  2. package/dist/backend/antigravity.js.map +1 -1
  3. package/dist/backend/claude-code.js +3 -0
  4. package/dist/backend/claude-code.js.map +1 -1
  5. package/dist/backend/codex.d.ts +16 -0
  6. package/dist/backend/codex.js +248 -56
  7. package/dist/backend/codex.js.map +1 -1
  8. package/dist/backend/grok.js +20 -0
  9. package/dist/backend/grok.js.map +1 -1
  10. package/dist/backend/kiro.js +18 -2
  11. package/dist/backend/kiro.js.map +1 -1
  12. package/dist/backend/types.d.ts +7 -0
  13. package/dist/backend/types.js +1 -1
  14. package/dist/backend/types.js.map +1 -1
  15. package/dist/channel/adapters/discord.js +4 -5
  16. package/dist/channel/adapters/discord.js.map +1 -1
  17. package/dist/channel/adapters/telegram.js +50 -15
  18. package/dist/channel/adapters/telegram.js.map +1 -1
  19. package/dist/channel/ipc-bridge.d.ts +8 -0
  20. package/dist/channel/ipc-bridge.js +41 -14
  21. package/dist/channel/ipc-bridge.js.map +1 -1
  22. package/dist/channel/mcp-server.js +25 -10
  23. package/dist/channel/mcp-server.js.map +1 -1
  24. package/dist/channel/mcp-tools.js +25 -0
  25. package/dist/channel/mcp-tools.js.map +1 -1
  26. package/dist/channel/message-queue.d.ts +1 -0
  27. package/dist/channel/message-queue.js +49 -17
  28. package/dist/channel/message-queue.js.map +1 -1
  29. package/dist/channel/reconnect-backoff.d.ts +17 -0
  30. package/dist/channel/reconnect-backoff.js +21 -0
  31. package/dist/channel/reconnect-backoff.js.map +1 -0
  32. package/dist/cli.js +71 -26
  33. package/dist/cli.js.map +1 -1
  34. package/dist/completion.d.ts +27 -0
  35. package/dist/completion.js +121 -0
  36. package/dist/completion.js.map +1 -0
  37. package/dist/config-validator.js +9 -0
  38. package/dist/config-validator.js.map +1 -1
  39. package/dist/config.js +1 -0
  40. package/dist/config.js.map +1 -1
  41. package/dist/daemon.d.ts +74 -8
  42. package/dist/daemon.js +254 -18
  43. package/dist/daemon.js.map +1 -1
  44. package/dist/fleet-manager.d.ts +7 -1
  45. package/dist/fleet-manager.js +89 -16
  46. package/dist/fleet-manager.js.map +1 -1
  47. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  48. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  49. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  50. package/dist/instance-lifecycle.d.ts +13 -1
  51. package/dist/instance-lifecycle.js +70 -3
  52. package/dist/instance-lifecycle.js.map +1 -1
  53. package/dist/instructions.d.ts +5 -0
  54. package/dist/instructions.js +9 -11
  55. package/dist/instructions.js.map +1 -1
  56. package/dist/logger.js +14 -0
  57. package/dist/logger.js.map +1 -1
  58. package/dist/mcp-liveness.d.ts +21 -0
  59. package/dist/mcp-liveness.js +27 -0
  60. package/dist/mcp-liveness.js.map +1 -0
  61. package/dist/outbound-handlers.d.ts +9 -0
  62. package/dist/outbound-handlers.js +46 -26
  63. package/dist/outbound-handlers.js.map +1 -1
  64. package/dist/outbound-schemas.js +1 -1
  65. package/dist/outbound-schemas.js.map +1 -1
  66. package/dist/tmux-control.js +4 -2
  67. package/dist/tmux-control.js.map +1 -1
  68. package/dist/tmux-manager.d.ts +14 -0
  69. package/dist/tmux-manager.js +20 -6
  70. package/dist/tmux-manager.js.map +1 -1
  71. package/dist/topic-commands.d.ts +22 -1
  72. package/dist/topic-commands.js +95 -57
  73. package/dist/topic-commands.js.map +1 -1
  74. package/dist/tui-glyphs.d.ts +25 -0
  75. package/dist/tui-glyphs.js +26 -0
  76. package/dist/tui-glyphs.js.map +1 -0
  77. package/dist/types.d.ts +6 -0
  78. package/dist/ui/view.html +114 -2
  79. package/dist/usage/providers.d.ts +26 -0
  80. package/dist/usage/providers.js +577 -0
  81. package/dist/usage/providers.js.map +1 -0
  82. package/dist/usage/usage-api.d.ts +35 -0
  83. package/dist/usage/usage-api.js +52 -0
  84. package/dist/usage/usage-api.js.map +1 -0
  85. package/dist/view-api.d.ts +3 -0
  86. package/dist/view-api.js +10 -2
  87. package/dist/view-api.js.map +1 -1
  88. 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, waiting for idle
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 — idle→busy transition observed; agent is processing
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): we never force-paste into a busy CLI and never give up on a
1767
- * busy one — we show ⏳ and wait for idle indefinitely (a genuinely hung CLI is the
1768
- * hang detector's job; a user who can't wait presses Cancel → Escape → idle). The
1769
- * pasteLock is serial, so later messages naturally queue behind this wait.
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
- // If the CLI is busy, show ⏳ and wait for it to go idle — no timeout, no force.
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
- this.logger.debug("CLI busy — queuing message until idle");
1784
- await this.controlClient.waitUntilIdle(windowId);
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
- // Confirm the CLI accepted the message by transitioning idle→busy (new output
1811
- // after Enter). If still idle after ~2s the Enter was likely swallowed while
1812
- // the TUI was redrawing re-send Enter once and re-check.
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
- decisions = all.filter(d => d.scope === "fleet" || d.project_root === workDir);
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
- if (this.config.tool_set)
2129
- mcpEnv.AGEND_TOOL_SET = this.config.tool_set;
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
- await this.tmux.pipeOutput(join(this.instanceDir, "output.log")).catch(err => {
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
  }