@songsid/agend 2.1.2-beta.3 → 2.1.2-beta.30

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 (157) hide show
  1. package/dist/access-path.js +15 -6
  2. package/dist/access-path.js.map +1 -1
  3. package/dist/agent-cli-instructions.md +1 -0
  4. package/dist/agent-cli.js +14 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.d.ts +2 -0
  7. package/dist/agent-endpoint.js +25 -1
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/backend/antigravity.d.ts +20 -0
  10. package/dist/backend/antigravity.js +37 -3
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/claude-code.d.ts +59 -0
  13. package/dist/backend/claude-code.js +67 -1
  14. package/dist/backend/claude-code.js.map +1 -1
  15. package/dist/backend/codex.d.ts +3 -0
  16. package/dist/backend/codex.js +30 -0
  17. package/dist/backend/codex.js.map +1 -1
  18. package/dist/backend/grok.d.ts +25 -0
  19. package/dist/backend/grok.js +51 -0
  20. package/dist/backend/grok.js.map +1 -1
  21. package/dist/backend/kiro.d.ts +22 -0
  22. package/dist/backend/kiro.js +85 -3
  23. package/dist/backend/kiro.js.map +1 -1
  24. package/dist/backend/types.d.ts +58 -0
  25. package/dist/backend/types.js +13 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/channel/adapters/discord.d.ts +15 -0
  28. package/dist/channel/adapters/discord.js +100 -2
  29. package/dist/channel/adapters/discord.js.map +1 -1
  30. package/dist/channel/adapters/telegram.d.ts +21 -0
  31. package/dist/channel/adapters/telegram.js +86 -0
  32. package/dist/channel/adapters/telegram.js.map +1 -1
  33. package/dist/channel/ipc-bridge.d.ts +9 -1
  34. package/dist/channel/ipc-bridge.js +12 -3
  35. package/dist/channel/ipc-bridge.js.map +1 -1
  36. package/dist/channel/ipc-timeouts.d.ts +46 -0
  37. package/dist/channel/ipc-timeouts.js +65 -0
  38. package/dist/channel/ipc-timeouts.js.map +1 -0
  39. package/dist/channel/mcp-server.js +30 -14
  40. package/dist/channel/mcp-server.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +30 -1
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/reconnect-backoff.d.ts +17 -0
  44. package/dist/channel/reconnect-backoff.js +21 -0
  45. package/dist/channel/reconnect-backoff.js.map +1 -0
  46. package/dist/channel/types.d.ts +29 -0
  47. package/dist/classic-channel-manager.js +1 -5
  48. package/dist/classic-channel-manager.js.map +1 -1
  49. package/dist/cli.js +155 -63
  50. package/dist/cli.js.map +1 -1
  51. package/dist/completion.d.ts +27 -0
  52. package/dist/completion.js +121 -0
  53. package/dist/completion.js.map +1 -0
  54. package/dist/config-validator.js +21 -0
  55. package/dist/config-validator.js.map +1 -1
  56. package/dist/config.js +1 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/cost-guard.d.ts +3 -1
  59. package/dist/cost-guard.js +3 -1
  60. package/dist/cost-guard.js.map +1 -1
  61. package/dist/daemon.d.ts +240 -14
  62. package/dist/daemon.js +956 -308
  63. package/dist/daemon.js.map +1 -1
  64. package/dist/event-log.d.ts +31 -0
  65. package/dist/event-log.js +96 -0
  66. package/dist/event-log.js.map +1 -1
  67. package/dist/fleet-context.d.ts +9 -0
  68. package/dist/fleet-manager.d.ts +311 -2
  69. package/dist/fleet-manager.js +1369 -108
  70. package/dist/fleet-manager.js.map +1 -1
  71. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  72. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  73. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  74. package/dist/hang-detector.d.ts +19 -13
  75. package/dist/hang-detector.js +19 -49
  76. package/dist/hang-detector.js.map +1 -1
  77. package/dist/instance-lifecycle.d.ts +43 -1
  78. package/dist/instance-lifecycle.js +155 -43
  79. package/dist/instance-lifecycle.js.map +1 -1
  80. package/dist/instructions.d.ts +5 -0
  81. package/dist/instructions.js +9 -11
  82. package/dist/instructions.js.map +1 -1
  83. package/dist/locale.js +5 -1
  84. package/dist/locale.js.map +1 -1
  85. package/dist/logger.js +14 -0
  86. package/dist/logger.js.map +1 -1
  87. package/dist/mcp-liveness.d.ts +21 -0
  88. package/dist/mcp-liveness.js +27 -0
  89. package/dist/mcp-liveness.js.map +1 -0
  90. package/dist/outbound-handlers.d.ts +16 -0
  91. package/dist/outbound-handlers.js +151 -27
  92. package/dist/outbound-handlers.js.map +1 -1
  93. package/dist/outbound-schemas.d.ts +11 -3
  94. package/dist/outbound-schemas.js +16 -2
  95. package/dist/outbound-schemas.js.map +1 -1
  96. package/dist/pane-write-lock.d.ts +48 -0
  97. package/dist/pane-write-lock.js +73 -0
  98. package/dist/pane-write-lock.js.map +1 -0
  99. package/dist/process-memory.d.ts +31 -0
  100. package/dist/process-memory.js +79 -0
  101. package/dist/process-memory.js.map +1 -0
  102. package/dist/quickstart.js +17 -16
  103. package/dist/quickstart.js.map +1 -1
  104. package/dist/reply-dedup.d.ts +41 -0
  105. package/dist/reply-dedup.js +0 -0
  106. package/dist/reply-dedup.js.map +1 -0
  107. package/dist/scheduler/db.js +3 -0
  108. package/dist/scheduler/db.js.map +1 -1
  109. package/dist/sd-notify.d.ts +27 -0
  110. package/dist/sd-notify.js +33 -1
  111. package/dist/sd-notify.js.map +1 -1
  112. package/dist/secret-file.d.ts +33 -0
  113. package/dist/secret-file.js +36 -0
  114. package/dist/secret-file.js.map +1 -0
  115. package/dist/setup-wizard.js +9 -7
  116. package/dist/setup-wizard.js.map +1 -1
  117. package/dist/tmux-control.d.ts +58 -5
  118. package/dist/tmux-control.js +102 -14
  119. package/dist/tmux-control.js.map +1 -1
  120. package/dist/tmux-manager.d.ts +31 -1
  121. package/dist/tmux-manager.js +40 -11
  122. package/dist/tmux-manager.js.map +1 -1
  123. package/dist/topic-commands.d.ts +50 -10
  124. package/dist/topic-commands.js +269 -106
  125. package/dist/topic-commands.js.map +1 -1
  126. package/dist/tui-glyphs.d.ts +25 -0
  127. package/dist/tui-glyphs.js +26 -0
  128. package/dist/tui-glyphs.js.map +1 -0
  129. package/dist/types.d.ts +12 -2
  130. package/dist/ui/view.html +123 -2
  131. package/dist/update-marker.d.ts +15 -0
  132. package/dist/update-marker.js +69 -0
  133. package/dist/update-marker.js.map +1 -0
  134. package/dist/usage/format-rich.d.ts +11 -0
  135. package/dist/usage/format-rich.js +121 -0
  136. package/dist/usage/format-rich.js.map +1 -0
  137. package/dist/usage/providers.d.ts +130 -0
  138. package/dist/usage/providers.js +1030 -0
  139. package/dist/usage/providers.js.map +1 -0
  140. package/dist/usage/statusline-usage.d.ts +20 -0
  141. package/dist/usage/statusline-usage.js +120 -0
  142. package/dist/usage/statusline-usage.js.map +1 -0
  143. package/dist/usage/usage-api.d.ts +50 -0
  144. package/dist/usage/usage-api.js +167 -0
  145. package/dist/usage/usage-api.js.map +1 -0
  146. package/dist/view-api.d.ts +3 -0
  147. package/dist/view-api.js +11 -2
  148. package/dist/view-api.js.map +1 -1
  149. package/dist/web-api.js +5 -2
  150. package/dist/web-api.js.map +1 -1
  151. package/package.json +4 -1
  152. package/dist/channel/tool-tracker.d.ts +0 -13
  153. package/dist/channel/tool-tracker.js +0 -58
  154. package/dist/channel/tool-tracker.js.map +0 -1
  155. package/dist/daemon-entry.d.ts +0 -1
  156. package/dist/daemon-entry.js +0 -30
  157. package/dist/daemon-entry.js.map +0 -1
package/dist/cli.js CHANGED
@@ -13,7 +13,10 @@ import { spawn, execSync, execFileSync } from "node:child_process";
13
13
  import { getAgendHome, getTmuxSocketName } from "./paths.js";
14
14
  import { readClassicLastActivityAt } from "./classic-channel-manager.js";
15
15
  import { hasPausedMarker } from "./pause-marker.js";
16
+ import { PIE_PERCENT_RE } from "./tui-glyphs.js";
17
+ import { COMPLETION_SHELLS, completionScript } from "./completion.js";
16
18
  import { getUpdateSelector, lookupTargetVersion, reportUpdateRestart, shouldSkipUpdate, } from "./update-check.js";
19
+ import { clearUpdateMarker, markUpdateInProgress } from "./update-marker.js";
17
20
  /** Prefix tmux args with -L when socket isolation is active. */
18
21
  function tmuxArgs(args) {
19
22
  const socket = getTmuxSocketName();
@@ -67,8 +70,14 @@ fleet
67
70
  const fleet = loadFleetConfig(FLEET_CONFIG_PATH);
68
71
  const port = fleet.health_port ?? 19280;
69
72
  try {
70
- const healthResp = await fetch(`http://127.0.0.1:${port}/health`);
71
- if (healthResp.ok) {
73
+ // Any HTTP answer means a fleet daemon is listening — including a 503,
74
+ // which /health now returns when the fleet is degraded (no adapter
75
+ // connected, an instance crashed). Checking `.ok` here would treat a
76
+ // degraded fleet as "not running" and start a SECOND fleet on the same
77
+ // data dir, whose startup cleanup kills the first one's tmux windows.
78
+ // Only a connection error means nothing is there (the catch below).
79
+ await fetch(`http://127.0.0.1:${port}/health`);
80
+ {
72
81
  try {
73
82
  const resp = await fetch(`http://127.0.0.1:${port}/api/instance/${encodeURIComponent(instance)}/start`, { method: "POST" });
74
83
  const body = await resp.json();
@@ -91,6 +100,59 @@ fleet
91
100
  }
92
101
  const { FleetManager } = await import("./fleet-manager.js");
93
102
  const fm = new FleetManager(DATA_DIR);
103
+ // Register crash/signal handlers BEFORE startAll(). Startup routinely takes
104
+ // minutes (sequential general spawn, staggered instances, warmup waits), and
105
+ // without these a Ctrl-C or `systemctl stop` in that window got Node's default
106
+ // behaviour: instant death with fleet.pid already written, databases unclosed,
107
+ // and every tmux window orphaned.
108
+ let stopping = false;
109
+ const shutdown = async () => {
110
+ if (stopping)
111
+ return; // SIGINT and SIGTERM share this, and crash paths call stopAll too
112
+ stopping = true;
113
+ console.log("\nStopping fleet...");
114
+ await fm.stopAll().catch(err => console.error("Shutdown error:", err));
115
+ process.exit(0);
116
+ };
117
+ process.on("SIGINT", shutdown);
118
+ process.on("SIGTERM", shutdown);
119
+ process.on("uncaughtException", (err) => {
120
+ // An uncaught exception leaves unknown state — still the one case worth
121
+ // tearing down for.
122
+ console.error("Uncaught exception:", err);
123
+ if (stopping)
124
+ return;
125
+ stopping = true;
126
+ fm.stopAll().catch(() => { }).finally(() => process.exit(1));
127
+ });
128
+ process.on("unhandledRejection", (err) => {
129
+ const msg = err instanceof Error ? err.message : String(err);
130
+ // 409 = another bot poller exists — the adapter retries, don't crash.
131
+ if (msg.includes("409") && msg.includes("getUpdates")) {
132
+ console.error("Bot polling conflict (409) — retrying...");
133
+ return;
134
+ }
135
+ // Do NOT take the fleet down. A rejected promise in one instance's tmux
136
+ // call, health tick, or model switch used to stop every other working
137
+ // instance and exit(1) — the widest blast radius in the process. The
138
+ // per-instance health check and crash recovery handle a genuinely broken
139
+ // instance on their own.
140
+ //
141
+ // Surviving quietly would be its own failure mode, so this reports to the
142
+ // General topic as well as the log: the operator must not have to read
143
+ // daemon.log to learn the fleet swallowed a fault. notifyFleetError()
144
+ // throttles repeats, since these usually arrive from a loop.
145
+ console.error("Unhandled rejection (fleet continues):", err);
146
+ if (err instanceof Error && err.stack)
147
+ console.error(err.stack);
148
+ try {
149
+ fm.notifyFleetError(`⚠️ Fleet caught an unhandled error and kept running:\n\`${msg}\`\nSee daemon.log for the stack trace.`);
150
+ }
151
+ catch (notifyErr) {
152
+ // Never let the reporting path itself become an unhandled rejection.
153
+ console.error("Failed to report unhandled rejection:", notifyErr);
154
+ }
155
+ });
94
156
  if (instance) {
95
157
  const config = fm.loadConfig(FLEET_CONFIG_PATH);
96
158
  const inst = config.instances[instance];
@@ -108,30 +170,6 @@ fleet
108
170
  await fm.startAll(FLEET_CONFIG_PATH);
109
171
  }
110
172
  console.log("Fleet started");
111
- // Keep process alive + clean shutdown on Ctrl+C
112
- const shutdown = async () => {
113
- console.log("\nStopping fleet...");
114
- await fm.stopAll();
115
- process.exit(0);
116
- };
117
- process.on("SIGINT", shutdown);
118
- process.on("SIGTERM", shutdown);
119
- process.on("uncaughtException", async (err) => {
120
- console.error("Uncaught exception:", err);
121
- await fm.stopAll().catch(() => { });
122
- process.exit(1);
123
- });
124
- process.on("unhandledRejection", async (err) => {
125
- const msg = err instanceof Error ? err.message : String(err);
126
- // 409 = another bot poller exists — adapter handles retry, don't crash
127
- if (msg.includes("409") && msg.includes("getUpdates")) {
128
- console.error("Bot polling conflict (409) — retrying...");
129
- return;
130
- }
131
- console.error("Unhandled rejection:", err);
132
- await fm.stopAll().catch(() => { });
133
- process.exit(1);
134
- });
135
173
  });
136
174
  fleet
137
175
  .command("stop")
@@ -1105,6 +1143,18 @@ program
1105
1143
  else {
1106
1144
  console.log(`\n Updating AgEnD to ${tag}...\n`);
1107
1145
  }
1146
+ // Tell the running fleet that what happens next is planned. `npm install -g`
1147
+ // replaces the package directory underneath the live daemon — MCP servers
1148
+ // die, then the restart stops every instance — and without this marker the
1149
+ // old daemon reports each one as a crash. The new fleet deletes the marker
1150
+ // once it finishes starting; it also expires on its own, so an update that
1151
+ // dies here does not silence real alerts.
1152
+ markUpdateInProgress(DATA_DIR);
1153
+ const failUpdate = (message) => {
1154
+ clearUpdateMarker(DATA_DIR);
1155
+ console.error(message);
1156
+ process.exit(1);
1157
+ };
1108
1158
  // Detect and remove stale npm link (local build that shadows global install)
1109
1159
  try {
1110
1160
  const agendPath = execSync("which agend", { encoding: "utf-8", stdio: "pipe" }).trim();
@@ -1146,8 +1196,7 @@ program
1146
1196
  execSync("curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash", { stdio: "inherit" });
1147
1197
  }
1148
1198
  catch {
1149
- console.error(" Failed to install nvm. Install manually: https://github.com/nvm-sh/nvm");
1150
- process.exit(1);
1199
+ failUpdate(" Failed to install nvm. Install manually: https://github.com/nvm-sh/nvm");
1151
1200
  }
1152
1201
  }
1153
1202
  console.log(" Using nvm to install Node 22...");
@@ -1156,8 +1205,7 @@ program
1156
1205
  execSync(`bash -c '${nvmPrefix} && npm install -g ${pkg}'`, { stdio: "inherit" });
1157
1206
  }
1158
1207
  catch {
1159
- console.error(" Failed to install via nvm.");
1160
- process.exit(1);
1208
+ failUpdate(" Failed to install via nvm.");
1161
1209
  }
1162
1210
  // Try to remove old system binary
1163
1211
  console.log(" Note: removing old system install (may require sudo)...");
@@ -1169,8 +1217,7 @@ program
1169
1217
  execSync(`npm install -g ${pkg}`, { stdio: "inherit" });
1170
1218
  }
1171
1219
  catch {
1172
- console.error(` Failed to update. Try: npm install -g ${pkg}`);
1173
- process.exit(1);
1220
+ failUpdate(` Failed to update. Try: npm install -g ${pkg}`);
1174
1221
  }
1175
1222
  }
1176
1223
  // ── Verify installation ──
@@ -1180,15 +1227,13 @@ program
1180
1227
  ? spawnSync("bash", ["-c", `source ${nvmSh2} && nvm use 22 > /dev/null 2>&1 && which agend`], { encoding: "utf-8" }).stdout?.trim()
1181
1228
  : spawnSync("which", ["agend"], { encoding: "utf-8" }).stdout?.trim();
1182
1229
  if (!agendPath) {
1183
- console.error(" ✗ Verification failed: agend not found in PATH after install.");
1184
1230
  if (needsSudo)
1185
1231
  console.error(" You may need to add nvm to your shell profile and restart.");
1186
- process.exit(1);
1232
+ failUpdate(" ✗ Verification failed: agend not found in PATH after install.");
1187
1233
  }
1188
1234
  const verifyResult = spawnSync(agendPath, ["--version"], { encoding: "utf-8", timeout: 5000 });
1189
1235
  if (verifyResult.status !== 0) {
1190
- console.error(" ✗ Verification failed: agend --version returned error.");
1191
- process.exit(1);
1236
+ failUpdate(" ✗ Verification failed: agend --version returned error.");
1192
1237
  }
1193
1238
  const newVersion = (verifyResult.stdout ?? "").trim();
1194
1239
  console.log(` ✓ Installed: ${newVersion}`);
@@ -1216,8 +1261,12 @@ program
1216
1261
  // systemd → user systemd → launchd → detached pid).
1217
1262
  console.log(" Restarting fleet...");
1218
1263
  const restartResult = spawnSync(agendPath, ["restart"], { encoding: "utf-8", timeout: 30000, stdio: "inherit" });
1219
- if (!reportUpdateRestart(restartResult.status))
1264
+ if (!reportUpdateRestart(restartResult.status)) {
1265
+ // No new fleet is coming up to clear the marker — do it here, or the next
1266
+ // 15 minutes of genuine crashes would go unreported.
1267
+ clearUpdateMarker(DATA_DIR);
1220
1268
  process.exitCode = 1;
1269
+ }
1221
1270
  });
1222
1271
  program
1223
1272
  .command("reload")
@@ -1834,28 +1883,7 @@ async function fuzzyMatch(query, names) {
1834
1883
  return null;
1835
1884
  }
1836
1885
  async function resolveInstance(query, config) {
1837
- const names = Object.keys(config.instances);
1838
- // Include classic instances from classicBot.yaml
1839
- try {
1840
- const classicPath = join(DATA_DIR, "classicBot.yaml");
1841
- if (existsSync(classicPath)) {
1842
- const yamlMod = (await import("js-yaml")).default;
1843
- const classic = yamlMod.load(readFileSync(classicPath, "utf-8"));
1844
- if (classic?.channels) {
1845
- for (const [key, val] of Object.entries(classic.channels)) {
1846
- // New format persists instanceName; old format keyed by channelId.
1847
- if (val.instanceName) {
1848
- names.push(val.instanceName);
1849
- continue;
1850
- }
1851
- const channelId = val.channelId ?? key;
1852
- const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
1853
- names.push(`classic-${chName}-${channelId.slice(-4)}`);
1854
- }
1855
- }
1856
- }
1857
- }
1858
- catch { /* ignore */ }
1886
+ const names = await listInstanceNames(config);
1859
1887
  const match = await fuzzyMatch(query, names);
1860
1888
  if (!match) {
1861
1889
  console.error(`No instance matching "${query}". Available: ${names.join(", ")}`);
@@ -1950,7 +1978,7 @@ function formatTimeSince(isoStr) {
1950
1978
  * Universal context-% parser for `agend ls` (all backends). Kept in sync with
1951
1979
  * parseContextPercent() in topic-commands.ts — same formats, bottom-up so the
1952
1980
  * most recent prompt wins:
1953
- * kiro classic "8% !>" / TUI " 1%" / bracket "[8%]" / prompt "8% ❯"
1981
+ * kiro classic "8% !>" / TUI " 1%" (any pie glyph) / bracket "[8%]" / prompt "8% ❯"
1954
1982
  * codex "Context N% left" (remaining → 100-N) or "Context N% used"
1955
1983
  * opencode "1.2K (6%)"
1956
1984
  * grok "12K / 500K" (used tokens / context window)
@@ -1977,7 +2005,7 @@ const defaultParser = (output) => {
1977
2005
  return used / total * 100;
1978
2006
  }
1979
2007
  const m = line.match(/(\d+)%.*[!❯>]/)
1980
- || line.match(/◔\s*(\d+)%/)
2008
+ || line.match(PIE_PERCENT_RE)
1981
2009
  || line.match(/\[(\d+)%\]/)
1982
2010
  || line.match(/Context\s+(\d+)%\s+used/i)
1983
2011
  || line.match(/\d+(?:\.\d+)?[KM]?\s*\((\d+)%\)/);
@@ -1988,10 +2016,48 @@ const defaultParser = (output) => {
1988
2016
  };
1989
2017
  /** Optional backend-specific overrides; none needed — defaultParser covers all. */
1990
2018
  const contextParsers = {};
2019
+ /**
2020
+ * Every instance name the CLI accepts: fleet.yaml instances plus the
2021
+ * dynamically-created ClassicBot ones. Shared by `resolveInstance` (fuzzy
2022
+ * matching for `attach`) and `ls --names-only` (shell completion), so the
2023
+ * candidates offered by tab-completion are exactly the ones `attach` resolves.
2024
+ */
2025
+ async function listInstanceNames(config) {
2026
+ const names = Object.keys(config.instances);
2027
+ try {
2028
+ const classicPath = join(DATA_DIR, "classicBot.yaml");
2029
+ if (existsSync(classicPath)) {
2030
+ const yamlMod = (await import("js-yaml")).default;
2031
+ const classic = yamlMod.load(readFileSync(classicPath, "utf-8"));
2032
+ if (classic?.channels) {
2033
+ for (const [key, val] of Object.entries(classic.channels)) {
2034
+ // New format persists instanceName; old format keyed by channelId.
2035
+ if (val.instanceName) {
2036
+ names.push(val.instanceName);
2037
+ continue;
2038
+ }
2039
+ const channelId = val.channelId ?? key;
2040
+ const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
2041
+ names.push(`classic-${chName}-${channelId.slice(-4)}`);
2042
+ }
2043
+ }
2044
+ }
2045
+ }
2046
+ catch { /* ignore */ }
2047
+ return names;
2048
+ }
1991
2049
  async function lsAction(opts) {
1992
2050
  const yaml = (await import("js-yaml")).default;
1993
2051
  const config = yaml.load(readFileSync(FLEET_CONFIG_PATH, "utf-8"));
1994
2052
  const names = Object.keys(config.instances);
2053
+ // --names-only feeds shell completion, so it must return before any tmux or
2054
+ // status work (a tab press should not wait on the fleet) and must print
2055
+ // nothing but names — a "no instances" notice would become a candidate.
2056
+ if (opts.namesOnly) {
2057
+ for (const n of await listInstanceNames(config))
2058
+ console.log(n);
2059
+ return;
2060
+ }
1995
2061
  // Load classic channels from classicBot.yaml (keyed by channelId)
1996
2062
  const classicPath = join(DATA_DIR, "classicBot.yaml");
1997
2063
  let classicConfig = null;
@@ -2277,12 +2343,38 @@ program
2277
2343
  .command("ls")
2278
2344
  .description("List all instances with status, backend, team, and last activity")
2279
2345
  .option("--json", "Output as JSON")
2346
+ .option("--names-only", "Print instance names one per line (for shell completion)")
2280
2347
  .action(async (opts) => {
2281
2348
  await lsAction(opts);
2282
2349
  });
2283
2350
  program
2284
- .command("health")
2285
- .description("Fleet health check shows problems and diagnostics")
2351
+ .command("completion")
2352
+ .description("Print a shell completion script (bash or zsh)")
2353
+ .argument("<shell>", `Shell to generate for (${COMPLETION_SHELLS.join(", ")})`)
2354
+ .addHelpText("after", `
2355
+ Install:
2356
+ bash echo 'eval "$(agend completion bash)"' >> ~/.bashrc
2357
+ zsh echo 'eval "$(agend completion zsh)"' >> ~/.zshrc
2358
+
2359
+ Then reload the shell. Tab-completes instance names for \`agend attach\` and
2360
+ \`agend fleet start|stop|restart\`, and subcommands elsewhere.`)
2361
+ .action((shell) => {
2362
+ if (!COMPLETION_SHELLS.includes(shell)) {
2363
+ console.error(`Unsupported shell: ${shell}. Supported: ${COMPLETION_SHELLS.join(", ")}`);
2364
+ process.exit(2);
2365
+ }
2366
+ // Subcommand names come from commander itself, so the script can't drift
2367
+ // out of sync with the CLI the way a hardcoded list would.
2368
+ const fleetCmd = program.commands.find(c => c.name() === "fleet");
2369
+ console.log(completionScript(shell, {
2370
+ topLevel: program.commands.map(c => c.name()),
2371
+ fleetSub: fleetCmd?.commands.map(c => c.name()) ?? [],
2372
+ instanceCommands: ["attach"],
2373
+ fleetInstanceCommands: ["start", "stop", "restart"],
2374
+ }));
2375
+ });
2376
+ program
2377
+ .command("health").description("Fleet health check — shows problems and diagnostics")
2286
2378
  .option("--json", "Output as JSON")
2287
2379
  .option("-q, --quiet", "One-line summary only")
2288
2380
  .action(async (opts) => {