@songsid/agend 2.1.2-beta.8 → 2.1.2

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 (174) 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 +51 -4
  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 +26 -0
  16. package/dist/backend/codex.js +100 -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 +23 -0
  22. package/dist/backend/kiro.js +120 -2
  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 +120 -8
  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 +25 -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 +242 -91
  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 +2 -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 +299 -7
  62. package/dist/daemon.js +1152 -311
  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 +14 -0
  68. package/dist/fleet-lock.d.ts +28 -0
  69. package/dist/fleet-lock.js +130 -0
  70. package/dist/fleet-lock.js.map +1 -0
  71. package/dist/fleet-manager.d.ts +348 -6
  72. package/dist/fleet-manager.js +1860 -262
  73. package/dist/fleet-manager.js.map +1 -1
  74. package/dist/general-instance.d.ts +9 -0
  75. package/dist/general-instance.js +11 -0
  76. package/dist/general-instance.js.map +1 -0
  77. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  78. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  79. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  80. package/dist/hang-detector.d.ts +19 -13
  81. package/dist/hang-detector.js +19 -49
  82. package/dist/hang-detector.js.map +1 -1
  83. package/dist/instance-lifecycle.d.ts +58 -2
  84. package/dist/instance-lifecycle.js +231 -46
  85. package/dist/instance-lifecycle.js.map +1 -1
  86. package/dist/locale.js +7 -1
  87. package/dist/locale.js.map +1 -1
  88. package/dist/logger.js +14 -0
  89. package/dist/logger.js.map +1 -1
  90. package/dist/mcp-liveness.d.ts +21 -0
  91. package/dist/mcp-liveness.js +27 -0
  92. package/dist/mcp-liveness.js.map +1 -0
  93. package/dist/outbound-handlers.d.ts +9 -0
  94. package/dist/outbound-handlers.js +194 -31
  95. package/dist/outbound-handlers.js.map +1 -1
  96. package/dist/outbound-schemas.d.ts +11 -3
  97. package/dist/outbound-schemas.js +16 -2
  98. package/dist/outbound-schemas.js.map +1 -1
  99. package/dist/pane-write-lock.d.ts +48 -0
  100. package/dist/pane-write-lock.js +73 -0
  101. package/dist/pane-write-lock.js.map +1 -0
  102. package/dist/process-memory.d.ts +31 -0
  103. package/dist/process-memory.js +79 -0
  104. package/dist/process-memory.js.map +1 -0
  105. package/dist/quickstart.js +17 -16
  106. package/dist/quickstart.js.map +1 -1
  107. package/dist/reply-dedup.d.ts +41 -0
  108. package/dist/reply-dedup.js +0 -0
  109. package/dist/reply-dedup.js.map +1 -0
  110. package/dist/restart-progress.d.ts +41 -0
  111. package/dist/restart-progress.js +97 -0
  112. package/dist/restart-progress.js.map +1 -0
  113. package/dist/scheduler/db.js +3 -0
  114. package/dist/scheduler/db.js.map +1 -1
  115. package/dist/sd-notify.d.ts +27 -0
  116. package/dist/sd-notify.js +33 -1
  117. package/dist/sd-notify.js.map +1 -1
  118. package/dist/secret-file.d.ts +33 -0
  119. package/dist/secret-file.js +36 -0
  120. package/dist/secret-file.js.map +1 -0
  121. package/dist/service-installer.d.ts +28 -0
  122. package/dist/service-installer.js +72 -1
  123. package/dist/service-installer.js.map +1 -1
  124. package/dist/settings-api.js +5 -3
  125. package/dist/settings-api.js.map +1 -1
  126. package/dist/setup-wizard.js +9 -7
  127. package/dist/setup-wizard.js.map +1 -1
  128. package/dist/tmux-control.d.ts +58 -5
  129. package/dist/tmux-control.js +102 -14
  130. package/dist/tmux-control.js.map +1 -1
  131. package/dist/tmux-manager.d.ts +31 -1
  132. package/dist/tmux-manager.js +40 -11
  133. package/dist/tmux-manager.js.map +1 -1
  134. package/dist/topic-commands.d.ts +49 -9
  135. package/dist/topic-commands.js +270 -109
  136. package/dist/topic-commands.js.map +1 -1
  137. package/dist/tui-glyphs.d.ts +5 -1
  138. package/dist/tui-glyphs.js +6 -2
  139. package/dist/tui-glyphs.js.map +1 -1
  140. package/dist/types.d.ts +19 -2
  141. package/dist/ui/settings.html +1 -1
  142. package/dist/ui/view.html +123 -2
  143. package/dist/update-marker.d.ts +15 -0
  144. package/dist/update-marker.js +69 -0
  145. package/dist/update-marker.js.map +1 -0
  146. package/dist/usage/format-rich.d.ts +11 -0
  147. package/dist/usage/format-rich.js +121 -0
  148. package/dist/usage/format-rich.js.map +1 -0
  149. package/dist/usage/provider-alerts.d.ts +33 -0
  150. package/dist/usage/provider-alerts.js +57 -0
  151. package/dist/usage/provider-alerts.js.map +1 -0
  152. package/dist/usage/providers.d.ts +130 -0
  153. package/dist/usage/providers.js +1063 -0
  154. package/dist/usage/providers.js.map +1 -0
  155. package/dist/usage/statusline-usage.d.ts +20 -0
  156. package/dist/usage/statusline-usage.js +120 -0
  157. package/dist/usage/statusline-usage.js.map +1 -0
  158. package/dist/usage/usage-api.d.ts +50 -0
  159. package/dist/usage/usage-api.js +197 -0
  160. package/dist/usage/usage-api.js.map +1 -0
  161. package/dist/view-api.js +8 -3
  162. package/dist/view-api.js.map +1 -1
  163. package/dist/web-api.js +7 -3
  164. package/dist/web-api.js.map +1 -1
  165. package/dist/web-auth.d.ts +6 -0
  166. package/dist/web-auth.js +54 -0
  167. package/dist/web-auth.js.map +1 -0
  168. package/package.json +4 -1
  169. package/dist/channel/tool-tracker.d.ts +0 -13
  170. package/dist/channel/tool-tracker.js +0 -58
  171. package/dist/channel/tool-tracker.js.map +0 -1
  172. package/dist/daemon-entry.d.ts +0 -1
  173. package/dist/daemon-entry.js +0 -30
  174. package/dist/daemon-entry.js.map +0 -1
package/dist/cli.js CHANGED
@@ -14,7 +14,11 @@ import { getAgendHome, getTmuxSocketName } from "./paths.js";
14
14
  import { readClassicLastActivityAt } from "./classic-channel-manager.js";
15
15
  import { hasPausedMarker } from "./pause-marker.js";
16
16
  import { PIE_PERCENT_RE } from "./tui-glyphs.js";
17
+ import { COMPLETION_SHELLS, completionScript } from "./completion.js";
17
18
  import { getUpdateSelector, lookupTargetVersion, reportUpdateRestart, shouldSkipUpdate, } from "./update-check.js";
19
+ import { clearUpdateMarker, markUpdateInProgress } from "./update-marker.js";
20
+ import { acquireFleetLock, releaseProcessFleetLock, setProcessFleetLock } from "./fleet-lock.js";
21
+ import { SYSTEMD_RESTART_TIMEOUT_MS } from "./service-installer.js";
18
22
  /** Prefix tmux args with -L when socket isolation is active. */
19
23
  function tmuxArgs(args) {
20
24
  const socket = getTmuxSocketName();
@@ -25,6 +29,18 @@ const __dirname = dirname(__filename);
25
29
  const DATA_DIR = getAgendHome();
26
30
  const FLEET_CONFIG_PATH = join(DATA_DIR, "fleet.yaml");
27
31
  const program = new Command();
32
+ function claimFleetSingleton() {
33
+ try {
34
+ setProcessFleetLock(acquireFleetLock(DATA_DIR));
35
+ process.once("exit", () => { releaseProcessFleetLock(); });
36
+ return true;
37
+ }
38
+ catch (err) {
39
+ console.error(err.message);
40
+ process.exitCode = 1;
41
+ return false;
42
+ }
43
+ }
28
44
  // Read version from package.json at build time
29
45
  const pkgVersion = (() => {
30
46
  try {
@@ -68,8 +84,14 @@ fleet
68
84
  const fleet = loadFleetConfig(FLEET_CONFIG_PATH);
69
85
  const port = fleet.health_port ?? 19280;
70
86
  try {
71
- const healthResp = await fetch(`http://127.0.0.1:${port}/health`);
72
- if (healthResp.ok) {
87
+ // Any HTTP answer means a fleet daemon is listening — including a 503,
88
+ // which /health now returns when the fleet is degraded (no adapter
89
+ // connected, an instance crashed). Checking `.ok` here would treat a
90
+ // degraded fleet as "not running" and start a SECOND fleet on the same
91
+ // data dir, whose startup cleanup kills the first one's tmux windows.
92
+ // Only a connection error means nothing is there (the catch below).
93
+ await fetch(`http://127.0.0.1:${port}/health`);
94
+ {
73
95
  try {
74
96
  const resp = await fetch(`http://127.0.0.1:${port}/api/instance/${encodeURIComponent(instance)}/start`, { method: "POST" });
75
97
  const body = await resp.json();
@@ -90,8 +112,65 @@ fleet
90
112
  }
91
113
  catch { /* fleet not running, fall through */ }
92
114
  }
115
+ // Claim the singleton before constructing FleetManager: its constructor and
116
+ // startAll path touch shared tmux, sockets, state files and adapters.
117
+ if (!claimFleetSingleton())
118
+ return;
93
119
  const { FleetManager } = await import("./fleet-manager.js");
94
120
  const fm = new FleetManager(DATA_DIR);
121
+ // Register crash/signal handlers BEFORE startAll(). Startup routinely takes
122
+ // minutes (sequential general spawn, staggered instances, warmup waits), and
123
+ // without these a Ctrl-C or `systemctl stop` in that window got Node's default
124
+ // behaviour: instant death with fleet.pid already written, databases unclosed,
125
+ // and every tmux window orphaned.
126
+ let stopping = false;
127
+ const shutdown = async () => {
128
+ if (stopping)
129
+ return; // SIGINT and SIGTERM share this, and crash paths call stopAll too
130
+ stopping = true;
131
+ console.log("\nStopping fleet...");
132
+ await fm.stopAll().catch(err => console.error("Shutdown error:", err));
133
+ process.exit(0);
134
+ };
135
+ process.on("SIGINT", shutdown);
136
+ process.on("SIGTERM", shutdown);
137
+ process.on("uncaughtException", (err) => {
138
+ // An uncaught exception leaves unknown state — still the one case worth
139
+ // tearing down for.
140
+ console.error("Uncaught exception:", err);
141
+ if (stopping)
142
+ return;
143
+ stopping = true;
144
+ fm.stopAll().catch(() => { }).finally(() => process.exit(1));
145
+ });
146
+ process.on("unhandledRejection", (err) => {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ // 409 = another bot poller exists — the adapter retries, don't crash.
149
+ if (msg.includes("409") && msg.includes("getUpdates")) {
150
+ console.error("Bot polling conflict (409) — retrying...");
151
+ return;
152
+ }
153
+ // Do NOT take the fleet down. A rejected promise in one instance's tmux
154
+ // call, health tick, or model switch used to stop every other working
155
+ // instance and exit(1) — the widest blast radius in the process. The
156
+ // per-instance health check and crash recovery handle a genuinely broken
157
+ // instance on their own.
158
+ //
159
+ // Surviving quietly would be its own failure mode, so this reports to the
160
+ // General topic as well as the log: the operator must not have to read
161
+ // daemon.log to learn the fleet swallowed a fault. notifyFleetError()
162
+ // throttles repeats, since these usually arrive from a loop.
163
+ console.error("Unhandled rejection (fleet continues):", err);
164
+ if (err instanceof Error && err.stack)
165
+ console.error(err.stack);
166
+ try {
167
+ fm.notifyFleetError(`⚠️ Fleet caught an unhandled error and kept running:\n\`${msg}\`\nSee daemon.log for the stack trace.`);
168
+ }
169
+ catch (notifyErr) {
170
+ // Never let the reporting path itself become an unhandled rejection.
171
+ console.error("Failed to report unhandled rejection:", notifyErr);
172
+ }
173
+ });
95
174
  if (instance) {
96
175
  const config = fm.loadConfig(FLEET_CONFIG_PATH);
97
176
  const inst = config.instances[instance];
@@ -100,7 +179,10 @@ fleet
100
179
  process.exit(1);
101
180
  }
102
181
  const topicMode = config.channel?.mode === "topic";
103
- await fm.startInstance(instance, inst, topicMode);
182
+ // An explicit `agend fleet start <name>` is a resume request when the
183
+ // instance was auto/manual-paused. Fleet startup itself still leaves
184
+ // persisted pauses alone.
185
+ await fm.startInstance(instance, inst, topicMode, "fleet-topic", true);
104
186
  }
105
187
  else {
106
188
  if (process.env.AGEND_HOME) {
@@ -109,30 +191,6 @@ fleet
109
191
  await fm.startAll(FLEET_CONFIG_PATH);
110
192
  }
111
193
  console.log("Fleet started");
112
- // Keep process alive + clean shutdown on Ctrl+C
113
- const shutdown = async () => {
114
- console.log("\nStopping fleet...");
115
- await fm.stopAll();
116
- process.exit(0);
117
- };
118
- process.on("SIGINT", shutdown);
119
- process.on("SIGTERM", shutdown);
120
- process.on("uncaughtException", async (err) => {
121
- console.error("Uncaught exception:", err);
122
- await fm.stopAll().catch(() => { });
123
- process.exit(1);
124
- });
125
- process.on("unhandledRejection", async (err) => {
126
- const msg = err instanceof Error ? err.message : String(err);
127
- // 409 = another bot poller exists — adapter handles retry, don't crash
128
- if (msg.includes("409") && msg.includes("getUpdates")) {
129
- console.error("Bot polling conflict (409) — retrying...");
130
- return;
131
- }
132
- console.error("Unhandled rejection:", err);
133
- await fm.stopAll().catch(() => { });
134
- process.exit(1);
135
- });
136
194
  });
137
195
  fleet
138
196
  .command("stop")
@@ -319,6 +377,8 @@ fleet
319
377
  }
320
378
  console.log("Old fleet stopped. Starting with new code...");
321
379
  // Start new fleet in this process (new Node.js process = new code loaded)
380
+ if (!claimFleetSingleton())
381
+ return;
322
382
  const { FleetManager } = await import("./fleet-manager.js");
323
383
  const fm = new FleetManager(DATA_DIR);
324
384
  await fm.startAll(FLEET_CONFIG_PATH);
@@ -1106,11 +1166,23 @@ program
1106
1166
  else {
1107
1167
  console.log(`\n Updating AgEnD to ${tag}...\n`);
1108
1168
  }
1169
+ // Tell the running fleet that what happens next is planned. `npm install -g`
1170
+ // replaces the package directory underneath the live daemon — MCP servers
1171
+ // die, then the restart stops every instance — and without this marker the
1172
+ // old daemon reports each one as a crash. The new fleet deletes the marker
1173
+ // once it finishes starting; it also expires on its own, so an update that
1174
+ // dies here does not silence real alerts.
1175
+ markUpdateInProgress(DATA_DIR);
1176
+ const failUpdate = (message) => {
1177
+ clearUpdateMarker(DATA_DIR);
1178
+ console.error(message);
1179
+ process.exit(1);
1180
+ };
1109
1181
  // Detect and remove stale npm link (local build that shadows global install)
1110
1182
  try {
1111
1183
  const agendPath = execSync("which agend", { encoding: "utf-8", stdio: "pipe" }).trim();
1112
1184
  const resolved = execSync(`readlink -f "${agendPath}"`, { encoding: "utf-8", stdio: "pipe" }).trim();
1113
- if (resolved.includes("/Projects/") || resolved.includes("@suzuke") || resolved.includes("/src/")) {
1185
+ if (resolved.includes("/Projects/") || resolved.includes("@songsid") || resolved.includes("@suzuke") || resolved.includes("/src/")) {
1114
1186
  console.log(` ⚠️ Detected local npm link: ${resolved}`);
1115
1187
  console.log(" Removing link to allow global install...\n");
1116
1188
  try {
@@ -1147,8 +1219,7 @@ program
1147
1219
  execSync("curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash", { stdio: "inherit" });
1148
1220
  }
1149
1221
  catch {
1150
- console.error(" Failed to install nvm. Install manually: https://github.com/nvm-sh/nvm");
1151
- process.exit(1);
1222
+ failUpdate(" Failed to install nvm. Install manually: https://github.com/nvm-sh/nvm");
1152
1223
  }
1153
1224
  }
1154
1225
  console.log(" Using nvm to install Node 22...");
@@ -1157,12 +1228,16 @@ program
1157
1228
  execSync(`bash -c '${nvmPrefix} && npm install -g ${pkg}'`, { stdio: "inherit" });
1158
1229
  }
1159
1230
  catch {
1160
- console.error(" Failed to install via nvm.");
1161
- process.exit(1);
1231
+ failUpdate(" Failed to install via nvm.");
1162
1232
  }
1163
1233
  // Try to remove old system binary
1164
1234
  console.log(" Note: removing old system install (may require sudo)...");
1165
- spawnSync("sudo", ["npm", "uninstall", "-g", "@songsid/agend"], { stdio: "inherit" });
1235
+ // Never wait for a password in an agent/non-interactive terminal. This is
1236
+ // best-effort cleanup; the new nvm install already succeeded.
1237
+ spawnSync("sudo", ["-n", "npm", "uninstall", "-g", "@songsid/agend"], {
1238
+ stdio: "inherit",
1239
+ timeout: 10_000,
1240
+ });
1166
1241
  }
1167
1242
  else {
1168
1243
  // ── Direct install ──
@@ -1170,8 +1245,7 @@ program
1170
1245
  execSync(`npm install -g ${pkg}`, { stdio: "inherit" });
1171
1246
  }
1172
1247
  catch {
1173
- console.error(` Failed to update. Try: npm install -g ${pkg}`);
1174
- process.exit(1);
1248
+ failUpdate(` Failed to update. Try: npm install -g ${pkg}`);
1175
1249
  }
1176
1250
  }
1177
1251
  // ── Verify installation ──
@@ -1181,15 +1255,13 @@ program
1181
1255
  ? spawnSync("bash", ["-c", `source ${nvmSh2} && nvm use 22 > /dev/null 2>&1 && which agend`], { encoding: "utf-8" }).stdout?.trim()
1182
1256
  : spawnSync("which", ["agend"], { encoding: "utf-8" }).stdout?.trim();
1183
1257
  if (!agendPath) {
1184
- console.error(" ✗ Verification failed: agend not found in PATH after install.");
1185
1258
  if (needsSudo)
1186
1259
  console.error(" You may need to add nvm to your shell profile and restart.");
1187
- process.exit(1);
1260
+ failUpdate(" ✗ Verification failed: agend not found in PATH after install.");
1188
1261
  }
1189
1262
  const verifyResult = spawnSync(agendPath, ["--version"], { encoding: "utf-8", timeout: 5000 });
1190
1263
  if (verifyResult.status !== 0) {
1191
- console.error(" ✗ Verification failed: agend --version returned error.");
1192
- process.exit(1);
1264
+ failUpdate(" ✗ Verification failed: agend --version returned error.");
1193
1265
  }
1194
1266
  const newVersion = (verifyResult.stdout ?? "").trim();
1195
1267
  console.log(` ✓ Installed: ${newVersion}`);
@@ -1216,9 +1288,20 @@ program
1216
1288
  // restart` (new binary) does the 4-environment service detection (system
1217
1289
  // systemd → user systemd → launchd → detached pid).
1218
1290
  console.log(" Restarting fleet...");
1219
- const restartResult = spawnSync(agendPath, ["restart"], { encoding: "utf-8", timeout: 30000, stdio: "inherit" });
1220
- if (!reportUpdateRestart(restartResult.status))
1291
+ // `agend restart` may synchronously wait for a Type=notify service to finish
1292
+ // a multi-minute stop/start and send READY=1. Keep this parent wrapper alive
1293
+ // longer than the restart command's own bounded wait.
1294
+ const restartResult = spawnSync(agendPath, ["restart"], {
1295
+ encoding: "utf-8",
1296
+ timeout: SYSTEMD_RESTART_TIMEOUT_MS + 60_000,
1297
+ stdio: "inherit",
1298
+ });
1299
+ if (!reportUpdateRestart(restartResult.status)) {
1300
+ // No new fleet is coming up to clear the marker — do it here, or the next
1301
+ // 15 minutes of genuine crashes would go unreported.
1302
+ clearUpdateMarker(DATA_DIR);
1221
1303
  process.exitCode = 1;
1304
+ }
1222
1305
  });
1223
1306
  program
1224
1307
  .command("reload")
@@ -1331,8 +1414,10 @@ program
1331
1414
  .command("start")
1332
1415
  .description("Start the AgEnD service (must be installed first)")
1333
1416
  .action(async () => {
1334
- const { getServicePath, startService } = await import("./service-installer.js");
1335
- if (!getServicePath()) {
1417
+ const { getServicePath, getSystemServicePath, startService, startSystemService } = await import("./service-installer.js");
1418
+ const userServicePath = getServicePath();
1419
+ const systemServicePath = getSystemServicePath();
1420
+ if (!userServicePath && !systemServicePath) {
1336
1421
  console.log("No service installed. Starting fleet directly...");
1337
1422
  const { spawn } = await import("node:child_process");
1338
1423
  const child = spawn("sh", ["-c", "agend fleet start"], { detached: true, stdio: "ignore" });
@@ -1340,16 +1425,16 @@ program
1340
1425
  console.log("Fleet starting in background.");
1341
1426
  return;
1342
1427
  }
1343
- if (startService()) {
1428
+ const started = systemServicePath ? startSystemService() : startService();
1429
+ if (started) {
1344
1430
  console.log("Service started.");
1345
1431
  }
1346
1432
  else {
1347
- // systemd/launchd failed fallback to direct start
1348
- console.log("Service manager unavailable. Starting fleet directly...");
1349
- const { spawn } = await import("node:child_process");
1350
- const child = spawn("sh", ["-c", "agend fleet start"], { detached: true, stdio: "ignore" });
1351
- child.unref();
1352
- console.log("Fleet starting in background.");
1433
+ // A service file means the service manager owns this fleet. Falling back
1434
+ // to a detached process here creates a second fleet when the bus recovers.
1435
+ console.error("Failed to start the installed AgEnD service; refusing to start a detached duplicate.");
1436
+ console.error("Check your service manager, then retry: systemctl --user status com.agend.fleet");
1437
+ process.exitCode = 1;
1353
1438
  }
1354
1439
  });
1355
1440
  program
@@ -1361,18 +1446,10 @@ program
1361
1446
  // service's own HOME, which may differ from what this command resolves (sudo,
1362
1447
  // AGEND_HOME) — an absent pid file does NOT mean it's stopped. Ask the service
1363
1448
  // manager first. This is the canonical restart the `update` flow spawns.
1364
- const { detectPlatform } = await import("./service-installer.js");
1449
+ const { detectPlatform, getServicePath, getSystemServicePath, getSystemdServiceState, restartSystemdService, } = await import("./service-installer.js");
1365
1450
  const { spawn, spawnSync } = await import("node:child_process");
1366
1451
  const plat = detectPlatform();
1367
1452
  const pidPath = join(DATA_DIR, "fleet.pid");
1368
- const isActive = (cmd) => {
1369
- try {
1370
- return spawnSync("sh", ["-c", cmd], { encoding: "utf-8", timeout: 5000 }).stdout?.trim() === "active";
1371
- }
1372
- catch {
1373
- return false;
1374
- }
1375
- };
1376
1453
  const run = (cmd) => {
1377
1454
  try {
1378
1455
  execSync(cmd, { stdio: "inherit", timeout: 15000 });
@@ -1383,9 +1460,17 @@ program
1383
1460
  }
1384
1461
  };
1385
1462
  // 1. System-level systemd service "agend"
1386
- if (plat !== "macos" && isActive("systemctl is-active agend 2>/dev/null")) {
1463
+ const systemServiceInstalled = plat !== "macos" && getSystemServicePath() !== null;
1464
+ const systemState = plat !== "macos" ? getSystemdServiceState("agend", false) : "stopped";
1465
+ if (systemServiceInstalled && systemState === "unavailable") {
1466
+ console.error("Cannot reach the system service manager; refusing a detached restart that could duplicate the fleet.");
1467
+ console.error("Check: systemctl status agend");
1468
+ process.exitCode = 1;
1469
+ return;
1470
+ }
1471
+ if (systemServiceInstalled && (systemState === "running" || systemState === "stopped")) {
1387
1472
  run("systemctl daemon-reload");
1388
- if (run("systemctl restart agend")) {
1473
+ if (restartSystemdService("agend", false)) {
1389
1474
  console.log("Service restarted (system).");
1390
1475
  return;
1391
1476
  }
@@ -1395,13 +1480,23 @@ program
1395
1480
  return;
1396
1481
  }
1397
1482
  // 2. User-level systemd service "com.agend.fleet"
1398
- if (plat !== "macos" && isActive("systemctl --user is-active com.agend.fleet 2>/dev/null")) {
1483
+ const userServiceInstalled = plat !== "macos" && getServicePath() !== null;
1484
+ const userState = plat !== "macos"
1485
+ ? getSystemdServiceState("com.agend.fleet", true)
1486
+ : "stopped";
1487
+ if (userServiceInstalled && userState === "unavailable") {
1488
+ console.error("Cannot reach the user service manager; refusing a detached restart that could duplicate the fleet.");
1489
+ console.error("Check: systemctl --user status com.agend.fleet");
1490
+ process.exitCode = 1;
1491
+ return;
1492
+ }
1493
+ if (userServiceInstalled && (userState === "running" || userState === "stopped")) {
1399
1494
  run("systemctl --user daemon-reload");
1400
1495
  try {
1401
1496
  execSync("systemctl --user reset-failed com.agend.fleet", { stdio: "pipe", timeout: 5000 });
1402
1497
  }
1403
1498
  catch { /* best effort */ }
1404
- if (run("systemctl --user restart com.agend.fleet")) {
1499
+ if (restartSystemdService("com.agend.fleet", true)) {
1405
1500
  console.log("Service restarted (user).");
1406
1501
  return;
1407
1502
  }
@@ -1835,28 +1930,7 @@ async function fuzzyMatch(query, names) {
1835
1930
  return null;
1836
1931
  }
1837
1932
  async function resolveInstance(query, config) {
1838
- const names = Object.keys(config.instances);
1839
- // Include classic instances from classicBot.yaml
1840
- try {
1841
- const classicPath = join(DATA_DIR, "classicBot.yaml");
1842
- if (existsSync(classicPath)) {
1843
- const yamlMod = (await import("js-yaml")).default;
1844
- const classic = yamlMod.load(readFileSync(classicPath, "utf-8"));
1845
- if (classic?.channels) {
1846
- for (const [key, val] of Object.entries(classic.channels)) {
1847
- // New format persists instanceName; old format keyed by channelId.
1848
- if (val.instanceName) {
1849
- names.push(val.instanceName);
1850
- continue;
1851
- }
1852
- const channelId = val.channelId ?? key;
1853
- const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
1854
- names.push(`classic-${chName}-${channelId.slice(-4)}`);
1855
- }
1856
- }
1857
- }
1858
- }
1859
- catch { /* ignore */ }
1933
+ const names = await listInstanceNames(config);
1860
1934
  const match = await fuzzyMatch(query, names);
1861
1935
  if (!match) {
1862
1936
  console.error(`No instance matching "${query}". Available: ${names.join(", ")}`);
@@ -1907,6 +1981,15 @@ function getTreeRssKb(pid, depth = 0) {
1907
1981
  function getInstanceStatusStandalone(name) {
1908
1982
  if (hasPausedMarker(join(DATA_DIR, "instances", name)))
1909
1983
  return "paused";
1984
+ // daemon.pid is the shared FleetManager PID, so it remains alive when one
1985
+ // CLI has entered the persisted 3-crashes circuit breaker. Consult that
1986
+ // marker before using daemon.pid as a fallback.
1987
+ try {
1988
+ const crash = JSON.parse(readFileSync(join(DATA_DIR, "instances", name, "crash-state.json"), "utf8"));
1989
+ if (Number(crash?.crashesInWindow) >= 3)
1990
+ return "crashed";
1991
+ }
1992
+ catch { /* no valid crash-loop marker */ }
1910
1993
  const pidPath = join(DATA_DIR, "instances", name, "daemon.pid");
1911
1994
  if (!existsSync(pidPath))
1912
1995
  return "stopped";
@@ -1989,10 +2072,48 @@ const defaultParser = (output) => {
1989
2072
  };
1990
2073
  /** Optional backend-specific overrides; none needed — defaultParser covers all. */
1991
2074
  const contextParsers = {};
2075
+ /**
2076
+ * Every instance name the CLI accepts: fleet.yaml instances plus the
2077
+ * dynamically-created ClassicBot ones. Shared by `resolveInstance` (fuzzy
2078
+ * matching for `attach`) and `ls --names-only` (shell completion), so the
2079
+ * candidates offered by tab-completion are exactly the ones `attach` resolves.
2080
+ */
2081
+ async function listInstanceNames(config) {
2082
+ const names = Object.keys(config.instances);
2083
+ try {
2084
+ const classicPath = join(DATA_DIR, "classicBot.yaml");
2085
+ if (existsSync(classicPath)) {
2086
+ const yamlMod = (await import("js-yaml")).default;
2087
+ const classic = yamlMod.load(readFileSync(classicPath, "utf-8"));
2088
+ if (classic?.channels) {
2089
+ for (const [key, val] of Object.entries(classic.channels)) {
2090
+ // New format persists instanceName; old format keyed by channelId.
2091
+ if (val.instanceName) {
2092
+ names.push(val.instanceName);
2093
+ continue;
2094
+ }
2095
+ const channelId = val.channelId ?? key;
2096
+ const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
2097
+ names.push(`classic-${chName}-${channelId.slice(-4)}`);
2098
+ }
2099
+ }
2100
+ }
2101
+ }
2102
+ catch { /* ignore */ }
2103
+ return names;
2104
+ }
1992
2105
  async function lsAction(opts) {
1993
2106
  const yaml = (await import("js-yaml")).default;
1994
2107
  const config = yaml.load(readFileSync(FLEET_CONFIG_PATH, "utf-8"));
1995
2108
  const names = Object.keys(config.instances);
2109
+ // --names-only feeds shell completion, so it must return before any tmux or
2110
+ // status work (a tab press should not wait on the fleet) and must print
2111
+ // nothing but names — a "no instances" notice would become a candidate.
2112
+ if (opts.namesOnly) {
2113
+ for (const n of await listInstanceNames(config))
2114
+ console.log(n);
2115
+ return;
2116
+ }
1996
2117
  // Load classic channels from classicBot.yaml (keyed by channelId)
1997
2118
  const classicPath = join(DATA_DIR, "classicBot.yaml");
1998
2119
  let classicConfig = null;
@@ -2195,6 +2316,10 @@ async function lsAction(opts) {
2195
2316
  }
2196
2317
  // Status icon — prefer tri-state 'state' field, fall back to idle boolean
2197
2318
  const statusIcon = (s, idle, state) => {
2319
+ if (s === "crashed")
2320
+ return "\x1b[31m●\x1b[0m";
2321
+ if (s === "stopped")
2322
+ return "\x1b[90m○\x1b[0m";
2198
2323
  if (state === "stuck")
2199
2324
  return "\x1b[31m●\x1b[0m";
2200
2325
  if (state === "paused" || s === "paused")
@@ -2206,11 +2331,13 @@ async function lsAction(opts) {
2206
2331
  // Fallback for when API is unreachable
2207
2332
  if (s === "running")
2208
2333
  return idle === false ? "\x1b[34m●\x1b[0m" : "\x1b[32m●\x1b[0m";
2209
- if (s === "crashed")
2210
- return "\x1b[31m●\x1b[0m";
2211
2334
  return "\x1b[90m○\x1b[0m";
2212
2335
  };
2213
2336
  const statusLabel = (s, idle, state) => {
2337
+ if (s === "crashed")
2338
+ return "Crashed";
2339
+ if (s === "stopped")
2340
+ return "Stopped";
2214
2341
  if (state === "stuck")
2215
2342
  return "Stuck";
2216
2343
  if (state === "paused" || s === "paused")
@@ -2221,8 +2348,6 @@ async function lsAction(opts) {
2221
2348
  return "Idle";
2222
2349
  if (s === "running")
2223
2350
  return idle === false ? "Busy" : "Idle";
2224
- if (s === "crashed")
2225
- return "Crashed";
2226
2351
  return "Stopped";
2227
2352
  };
2228
2353
  /** Get display width accounting for fullwidth (CJK) characters */
@@ -2278,12 +2403,38 @@ program
2278
2403
  .command("ls")
2279
2404
  .description("List all instances with status, backend, team, and last activity")
2280
2405
  .option("--json", "Output as JSON")
2406
+ .option("--names-only", "Print instance names one per line (for shell completion)")
2281
2407
  .action(async (opts) => {
2282
2408
  await lsAction(opts);
2283
2409
  });
2284
2410
  program
2285
- .command("health")
2286
- .description("Fleet health check shows problems and diagnostics")
2411
+ .command("completion")
2412
+ .description("Print a shell completion script (bash or zsh)")
2413
+ .argument("<shell>", `Shell to generate for (${COMPLETION_SHELLS.join(", ")})`)
2414
+ .addHelpText("after", `
2415
+ Install:
2416
+ bash echo 'eval "$(agend completion bash)"' >> ~/.bashrc
2417
+ zsh echo 'eval "$(agend completion zsh)"' >> ~/.zshrc
2418
+
2419
+ Then reload the shell. Tab-completes instance names for \`agend attach\` and
2420
+ \`agend fleet start|stop|restart\`, and subcommands elsewhere.`)
2421
+ .action((shell) => {
2422
+ if (!COMPLETION_SHELLS.includes(shell)) {
2423
+ console.error(`Unsupported shell: ${shell}. Supported: ${COMPLETION_SHELLS.join(", ")}`);
2424
+ process.exit(2);
2425
+ }
2426
+ // Subcommand names come from commander itself, so the script can't drift
2427
+ // out of sync with the CLI the way a hardcoded list would.
2428
+ const fleetCmd = program.commands.find(c => c.name() === "fleet");
2429
+ console.log(completionScript(shell, {
2430
+ topLevel: program.commands.map(c => c.name()),
2431
+ fleetSub: fleetCmd?.commands.map(c => c.name()) ?? [],
2432
+ instanceCommands: ["attach"],
2433
+ fleetInstanceCommands: ["start", "stop", "restart"],
2434
+ }));
2435
+ });
2436
+ program
2437
+ .command("health").description("Fleet health check — shows problems and diagnostics")
2287
2438
  .option("--json", "Output as JSON")
2288
2439
  .option("-q, --quiet", "One-line summary only")
2289
2440
  .action(async (opts) => {