@songsid/agend 2.1.0-beta.23 → 2.1.0-beta.4

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 (84) hide show
  1. package/README.md +0 -1
  2. package/README.zh-TW.md +1 -2
  3. package/dist/access-path.js +3 -3
  4. package/dist/access-path.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +0 -8
  6. package/dist/agent-endpoint.js +8 -36
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/backend/antigravity.js +2 -3
  9. package/dist/backend/antigravity.js.map +1 -1
  10. package/dist/backend/factory.js +1 -5
  11. package/dist/backend/factory.js.map +1 -1
  12. package/dist/backend/kiro.js +2 -3
  13. package/dist/backend/kiro.js.map +1 -1
  14. package/dist/backend/types.d.ts +2 -12
  15. package/dist/backend/types.js +0 -1
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/channel/adapters/discord.d.ts +0 -20
  18. package/dist/channel/adapters/discord.js +11 -69
  19. package/dist/channel/adapters/discord.js.map +1 -1
  20. package/dist/channel/adapters/telegram.js +1 -4
  21. package/dist/channel/adapters/telegram.js.map +1 -1
  22. package/dist/classic-channel-manager.d.ts +1 -6
  23. package/dist/classic-channel-manager.js +3 -26
  24. package/dist/classic-channel-manager.js.map +1 -1
  25. package/dist/cli.js +4 -48
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config-validator.js +1 -50
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/config.d.ts +1 -3
  30. package/dist/config.js +1 -13
  31. package/dist/config.js.map +1 -1
  32. package/dist/context-guardian.d.ts +0 -2
  33. package/dist/context-guardian.js +2 -10
  34. package/dist/context-guardian.js.map +1 -1
  35. package/dist/daemon-entry.js +1 -2
  36. package/dist/daemon-entry.js.map +1 -1
  37. package/dist/daemon.d.ts +1 -25
  38. package/dist/daemon.js +30 -156
  39. package/dist/daemon.js.map +1 -1
  40. package/dist/fleet-context.d.ts +0 -1
  41. package/dist/fleet-manager.d.ts +5 -30
  42. package/dist/fleet-manager.js +106 -312
  43. package/dist/fleet-manager.js.map +1 -1
  44. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  45. package/dist/general-knowledge/skills/model-discovery/SKILL.md +0 -1
  46. package/dist/instance-lifecycle.d.ts +1 -2
  47. package/dist/instance-lifecycle.js +4 -15
  48. package/dist/instance-lifecycle.js.map +1 -1
  49. package/dist/locale.js +0 -2
  50. package/dist/locale.js.map +1 -1
  51. package/dist/logger.d.ts +0 -6
  52. package/dist/logger.js +9 -28
  53. package/dist/logger.js.map +1 -1
  54. package/dist/outbound-handlers.d.ts +0 -3
  55. package/dist/outbound-handlers.js +0 -25
  56. package/dist/outbound-handlers.js.map +1 -1
  57. package/dist/outbound-schemas.d.ts +1 -2
  58. package/dist/outbound-schemas.js +1 -1
  59. package/dist/outbound-schemas.js.map +1 -1
  60. package/dist/settings-api.d.ts +2 -17
  61. package/dist/settings-api.js +8 -105
  62. package/dist/settings-api.js.map +1 -1
  63. package/dist/setup-wizard.js +0 -8
  64. package/dist/setup-wizard.js.map +1 -1
  65. package/dist/statusline-watcher.d.ts +1 -1
  66. package/dist/statusline-watcher.js +2 -3
  67. package/dist/statusline-watcher.js.map +1 -1
  68. package/dist/tmux-manager.d.ts +1 -1
  69. package/dist/tmux-manager.js.map +1 -1
  70. package/dist/topic-commands.d.ts +1 -14
  71. package/dist/topic-commands.js +23 -89
  72. package/dist/topic-commands.js.map +1 -1
  73. package/dist/transcript-monitor.js +0 -2
  74. package/dist/transcript-monitor.js.map +1 -1
  75. package/dist/types.d.ts +1 -16
  76. package/dist/ui/dashboard.html +2 -2
  77. package/dist/ui/settings.html +40 -251
  78. package/dist/web-api.d.ts +0 -1
  79. package/dist/web-api.js +15 -24
  80. package/dist/web-api.js.map +1 -1
  81. package/package.json +1 -2
  82. package/dist/backend/grok.d.ts +0 -51
  83. package/dist/backend/grok.js +0 -280
  84. package/dist/backend/grok.js.map +0 -1
@@ -5,13 +5,13 @@ import { join, dirname, basename } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
7
7
  import { sdNotify } from "./sd-notify.js";
8
- import { isScalar, parseDocument } from "yaml";
8
+ import yaml from "js-yaml";
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
11
11
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
12
12
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
13
13
  import { isProbeableRouteTarget } from "./fleet-context.js";
14
- import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
14
+ import { loadFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
15
15
  import { EventLog } from "./event-log.js";
16
16
  import { AdapterWorld } from "./adapter-world.js";
17
17
  import { CostGuard, formatCents } from "./cost-guard.js";
@@ -39,7 +39,7 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
39
39
  import { handleSettingsRequest } from "./settings-api.js";
40
40
  import { setLocale, detectLocale, t } from "./locale.js";
41
41
  import { handleAgentRequest } from "./agent-endpoint.js";
42
- import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend } from "./classic-channel-manager.js";
42
+ import { ClassicChannelManager } from "./classic-channel-manager.js";
43
43
  import { getTmuxSession } from "./config.js";
44
44
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
45
45
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -58,8 +58,6 @@ const CANCEL_BTN_MAX_RETRIES = 3;
58
58
  * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
59
59
  * reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
60
60
  const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
61
- const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
62
- const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
63
61
  export class FleetManager {
64
62
  dataDir;
65
63
  children = new Map();
@@ -67,9 +65,6 @@ export class FleetManager {
67
65
  /** @deprecated Use lifecycle.daemons — kept for backward compat */
68
66
  get daemons() { return this.lifecycle.daemons; }
69
67
  fleetConfig = null;
70
- rawFleetConfig = {};
71
- rawFleetDocument = null;
72
- savedFleetConfigSnapshot = null;
73
68
  adapter = null;
74
69
  worlds = new Map();
75
70
  adapters = new Map(); // derived view for backward compat
@@ -98,8 +93,6 @@ export class FleetManager {
98
93
  // Topic icon + auto-archive state
99
94
  topicIcons = {};
100
95
  lastActivity = new Map();
101
- /** Latest pane-derived execution snapshot reported by each daemon. */
102
- instanceStateCache = new Map();
103
96
  lastInboundUser = new Map(); // instanceName → last username
104
97
  // Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
105
98
  // than one-per-instance. A button is retired (deleted, with bounded retry) on
@@ -111,7 +104,6 @@ export class FleetManager {
111
104
  topicArchiver;
112
105
  controlClient = null;
113
106
  classicChannels = null;
114
- pendingClassicStarts = new Map();
115
107
  // Model failover state
116
108
  failoverActive = new Map(); // instance → current failover model
117
109
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
@@ -168,7 +160,6 @@ export class FleetManager {
168
160
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
169
161
  name,
170
162
  status: this.getInstanceStatus(name),
171
- state: this.getInstanceExecutionState(name),
172
163
  ipc: this.instanceIpcClients.has(name),
173
164
  costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
174
165
  rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
@@ -183,21 +174,9 @@ export class FleetManager {
183
174
  }
184
175
  /** Load fleet.yaml and build routing table */
185
176
  loadConfig(configPath) {
186
- this.configPath = configPath;
187
- const source = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "{}\n";
188
- this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
189
- if (this.rawFleetDocument.errors.length > 0) {
190
- throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
191
- }
192
- this.rawFleetConfig = loadRawFleetConfig(configPath);
193
177
  this.fleetConfig = loadFleetConfig(configPath);
194
- this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
195
178
  return this.fleetConfig;
196
179
  }
197
- /** User-authored fleet.yaml, before defaults are merged into instances. */
198
- getRawFleetConfig() {
199
- return structuredClone(this.rawFleetConfig);
200
- }
201
180
  /** Build topic routing table: { topicId -> RouteTarget } */
202
181
  buildRoutingTable() {
203
182
  if (this.fleetConfig) {
@@ -286,19 +265,14 @@ export class FleetManager {
286
265
  */
287
266
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
288
267
  const cfg = this.fleetConfig?.instances[name];
289
- if (fromInbound) {
290
- // Skip inbound-derived binding for any instance that doesn't have an
291
- // explicit channel_id those default to primary adapter deterministically.
292
- // This prevents a non-deterministic race where whichever adapter delivers
293
- // first after restart wins the binding.
294
- if (cfg?.general_topic || cfg?.channel_id)
295
- return;
296
- if (cfg && !cfg.channel_id)
297
- return; // fleet instance without explicit binding → use primary
298
- // Classic instance: don't override an existing binding (authoritative from /start)
299
- if (this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
300
- return;
301
- }
268
+ if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
269
+ return;
270
+ // A classic instance is bound authoritatively at /start. Don't let an inbound
271
+ // (seen by every same-guild bot) override an existing binding — but if there
272
+ // is none yet (e.g. after a restart, before v2.1 persistence), allow inbound
273
+ // to re-establish it so replies don't fall back to the primary bot forever.
274
+ if (fromInbound && this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
275
+ return;
302
276
  this.instanceWorldBinding.set(name, adapterId);
303
277
  }
304
278
  getInstanceStatus(name) {
@@ -316,25 +290,6 @@ export class FleetManager {
316
290
  return "crashed";
317
291
  }
318
292
  }
319
- getInstanceExecutionState(name) {
320
- if (this.lifecycle.isPaused(name))
321
- return null;
322
- return this.instanceStateCache.get(name)?.state ?? null;
323
- }
324
- cacheInstanceExecutionState(name, msg) {
325
- const state = msg.state;
326
- if (state !== "idle" && state !== "working" && state !== "stuck")
327
- return;
328
- const previous = this.instanceStateCache.get(name);
329
- const now = Date.now();
330
- const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
331
- this.instanceStateCache.set(name, {
332
- state,
333
- unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
334
- observedAt: numberOr(msg.observedAt, now),
335
- stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
336
- });
337
- }
338
293
  /** Single delivery facade: wake an auto-paused CLI before sending to daemon IPC. */
339
294
  async deliverToInstance(instanceName, payload) {
340
295
  if (this.lifecycle.isPaused(instanceName)) {
@@ -412,7 +367,6 @@ export class FleetManager {
412
367
  }
413
368
  async stopInstance(name) {
414
369
  this.failoverActive.delete(name);
415
- this.instanceStateCache.delete(name);
416
370
  return this.lifecycle.stop(name);
417
371
  }
418
372
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
@@ -998,8 +952,6 @@ export class FleetManager {
998
952
  await this.handleInboundMessage(msg);
999
953
  }, this.logger, "adapter.message"));
1000
954
  this.adapter.on("callback_query", safeHandler(async (data) => {
1001
- if (await this.handleClassicBackendSelection(data))
1002
- return;
1003
955
  if (data.callbackData.startsWith("hang:")) {
1004
956
  const parts = data.callbackData.split(":");
1005
957
  const action = parts[1];
@@ -1038,14 +990,8 @@ export class FleetManager {
1038
990
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
1039
991
  this.adapter.on("slash_command", safeHandler(async (data) => {
1040
992
  if (data.command === "start") {
1041
- const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
1042
- if (requestedBackend) {
1043
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId, requestedBackend);
1044
- await data.respond(reply);
1045
- }
1046
- else {
1047
- await this.beginClassicBackendSelection(data, this.adapter);
1048
- }
993
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
994
+ await data.respond(reply);
1049
995
  }
1050
996
  else if (data.command === "stop") {
1051
997
  const reply = await this.handleClassicStop(data.channelId, adapterId);
@@ -1289,8 +1235,6 @@ export class FleetManager {
1289
1235
  await this.handleInboundMessage(msg);
1290
1236
  }, this.logger, `adapter[${adapterId}].message`));
1291
1237
  adapter.on("callback_query", safeHandler(async (data) => {
1292
- if (await this.handleClassicBackendSelection(data))
1293
- return;
1294
1238
  if (data.callbackData.startsWith("hang:")) {
1295
1239
  const parts = data.callbackData.split(":");
1296
1240
  const action = parts[1];
@@ -1325,14 +1269,8 @@ export class FleetManager {
1325
1269
  // Slash commands: classic bot + admin commands
1326
1270
  adapter.on("slash_command", safeHandler(async (data) => {
1327
1271
  if (data.command === "start") {
1328
- const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
1329
- if (requestedBackend) {
1330
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId, requestedBackend);
1331
- await data.respond(reply);
1332
- }
1333
- else {
1334
- await this.beginClassicBackendSelection(data, adapter);
1335
- }
1272
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
1273
+ await data.respond(reply);
1336
1274
  }
1337
1275
  else if (data.command === "stop") {
1338
1276
  const reply = await this.handleClassicStop(data.channelId, adapterId);
@@ -1594,16 +1532,10 @@ export class FleetManager {
1594
1532
  else if (msg.type === "fleet_set_description") {
1595
1533
  this.handleSetDescription(name, msg);
1596
1534
  }
1597
- else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
1598
- this.cacheInstanceExecutionState(name, msg);
1599
- }
1600
1535
  }, this.logger, `ipc.message[${name}]`));
1601
1536
  // Ask daemon for any sessions that registered before we connected
1602
1537
  // (fixes race condition where mcp_ready was broadcast before fleet manager connected)
1603
1538
  ipc.send({ type: "query_sessions" });
1604
- // The initial state transition may have happened before FleetManager
1605
- // connected, so seed the cache instead of waiting for another transition.
1606
- ipc.send({ type: "query_instance_state", requestId: `fleet-state-${Date.now()}` });
1607
1539
  this.logger.debug({ name }, "Connected to instance IPC");
1608
1540
  if (!this.statuslineWatcher.has(name)) {
1609
1541
  this.statuslineWatcher.watch(name);
@@ -1866,21 +1798,9 @@ export class FleetManager {
1866
1798
  }
1867
1799
  }
1868
1800
  const channelName = msg.username || chatId;
1869
- const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
1870
- if (requestedBackend) {
1871
- // handleClassicStart binds the instance to this adapter authoritatively.
1872
- const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
1873
- await msgAdapter?.sendText(chatId, reply);
1874
- }
1875
- else if (msgAdapter) {
1876
- await this.beginClassicBackendSelection({
1877
- command: "start",
1878
- channelId: chatId,
1879
- channelName,
1880
- userId: msg.userId,
1881
- respond: async (reply) => (await msgAdapter.sendText(chatId, reply)).messageId,
1882
- }, msgAdapter);
1883
- }
1801
+ // handleClassicStart binds the instance to this adapter authoritatively.
1802
+ const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId);
1803
+ await msgAdapter?.sendText(chatId, reply);
1884
1804
  return;
1885
1805
  }
1886
1806
  // Handle /stop command
@@ -2030,7 +1950,6 @@ export class FleetManager {
2030
1950
  user_id: msg.userId,
2031
1951
  ts: msg.timestamp.toISOString(),
2032
1952
  thread_id: "",
2033
- adapter_id: msg.adapterId,
2034
1953
  source: msg.source,
2035
1954
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2036
1955
  ...extraMeta,
@@ -2123,7 +2042,6 @@ export class FleetManager {
2123
2042
  user_id: msg.userId,
2124
2043
  ts: msg.timestamp.toISOString(),
2125
2044
  thread_id: msg.threadId ?? "",
2126
- adapter_id: msg.adapterId,
2127
2045
  source: msg.source,
2128
2046
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2129
2047
  ...extraMeta,
@@ -2746,92 +2664,73 @@ export class FleetManager {
2746
2664
  }
2747
2665
  }, 5 * 60_000);
2748
2666
  }
2749
- /**
2750
- * Patch only values changed in the effective config into the original YAML
2751
- * document. Unknown keys, explicit overrides and comments remain untouched.
2752
- */
2753
- saveFleetConfig(explicitPatches = []) {
2667
+ /** Save fleet config back to fleet.yaml */
2668
+ saveFleetConfig() {
2754
2669
  if (!this.fleetConfig || !this.configPath)
2755
2670
  return;
2756
- if (!this.savedFleetConfigSnapshot)
2757
- this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2758
- // Re-read immediately before patching so an unrelated concurrent/manual
2759
- // edit is retained. Invalid concurrent YAML is never overwritten.
2760
- const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
2761
- this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
2762
- if (this.rawFleetDocument.errors.length > 0) {
2763
- throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
2764
- }
2765
- this.rawFleetConfig = loadRawFleetConfig(this.configPath);
2766
- this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
2767
- // Settings edits are expressed against the raw config. Persist them even
2768
- // when the chosen override equals the inherited effective value, a case
2769
- // the before/after runtime diff cannot observe.
2770
- for (const patch of explicitPatches) {
2771
- if (patch.remove) {
2772
- this.rawFleetDocument.deleteIn(patch.path);
2773
- }
2774
- else {
2775
- const before = this.rawFleetDocument.getIn(patch.path);
2776
- this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
2777
- }
2778
- }
2779
- const output = String(this.rawFleetDocument);
2780
- const tempPath = `${this.configPath}.tmp-${process.pid}`;
2781
- writeFileSync(tempPath, output, "utf-8");
2782
- if (existsSync(this.configPath))
2783
- chmodSync(tempPath, statSync(this.configPath).mode);
2784
- renameSync(tempPath, this.configPath);
2785
- this.rawFleetConfig = loadRawFleetConfig(this.configPath);
2786
- this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2787
- this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
2788
- }
2789
- patchFleetDocument(document, path, before, after) {
2790
- if (Object.is(before, after))
2791
- return;
2792
- if (Array.isArray(before) && Array.isArray(after)) {
2793
- const shared = Math.min(before.length, after.length);
2794
- for (let i = 0; i < shared; i++) {
2795
- this.patchFleetDocument(document, [...path, i], before[i], after[i]);
2796
- }
2797
- // Remove from the end so YAML sequence indexes do not shift underneath us.
2798
- for (let i = before.length - 1; i >= after.length; i--)
2799
- document.deleteIn([...path, i]);
2800
- for (let i = shared; i < after.length; i++)
2801
- document.setIn([...path, i], after[i]);
2802
- return;
2803
- }
2804
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2805
- if (isRecord(before) && isRecord(after)) {
2806
- const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
2807
- for (const key of keys) {
2808
- // `channel` is a derived alias when the raw file uses `channels`.
2809
- if (path.length === 0 && key === "channel" && this.rawFleetConfig.channels)
2810
- continue;
2811
- // Conversely, `channels` is a normalized alias for a legacy `channel`.
2812
- // Keep the user's original shape unless the caller explicitly removed
2813
- // `channel` (the Settings channels endpoint intentionally migrates it).
2814
- if (path.length === 0 && key === "channels" && this.rawFleetConfig.channel && !this.rawFleetConfig.channels && after.channel !== undefined)
2815
- continue;
2816
- this.patchFleetDocument(document, [...path, key], before[key], after[key]);
2817
- }
2818
- return;
2819
- }
2820
- if (after === undefined) {
2821
- document.deleteIn(path);
2822
- }
2823
- else if (path.length === 0) {
2824
- document.contents = document.createNode(after);
2825
- }
2826
- else {
2827
- const currentNode = document.getIn(path, true);
2828
- if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
2829
- currentNode.value = after;
2830
- }
2831
- else {
2832
- document.setIn(path, after);
2833
- }
2834
- }
2671
+ const toSave = {};
2672
+ if (this.fleetConfig.project_roots)
2673
+ toSave.project_roots = this.fleetConfig.project_roots;
2674
+ if (this.fleetConfig.channels && this.fleetConfig.channels.length > 0) {
2675
+ toSave.channels = this.fleetConfig.channels;
2676
+ }
2677
+ else if (this.fleetConfig.channel) {
2678
+ toSave.channel = this.fleetConfig.channel;
2679
+ }
2680
+ if (this.fleetConfig.health_port)
2681
+ toSave.health_port = this.fleetConfig.health_port;
2682
+ if (Object.keys(this.fleetConfig.defaults).length > 0)
2683
+ toSave.defaults = this.fleetConfig.defaults;
2684
+ if (this.fleetConfig.teams && Object.keys(this.fleetConfig.teams).length > 0) {
2685
+ toSave.teams = this.fleetConfig.teams;
2686
+ }
2687
+ if (this.fleetConfig.templates && Object.keys(this.fleetConfig.templates).length > 0) {
2688
+ toSave.templates = this.fleetConfig.templates;
2689
+ }
2690
+ if (this.fleetConfig.profiles && Object.keys(this.fleetConfig.profiles).length > 0) {
2691
+ toSave.profiles = this.fleetConfig.profiles;
2692
+ }
2693
+ toSave.instances = {};
2694
+ for (const [name, inst] of Object.entries(this.fleetConfig.instances)) {
2695
+ const serialized = {
2696
+ working_directory: inst.working_directory,
2697
+ topic_id: inst.topic_id,
2698
+ };
2699
+ // Preserve all optional user-configured fields so saveFleetConfig() never silently drops them
2700
+ if (inst.general_topic)
2701
+ serialized.general_topic = true;
2702
+ if (inst.display_name)
2703
+ serialized.display_name = inst.display_name;
2704
+ if (inst.channel_id)
2705
+ serialized.channel_id = inst.channel_id;
2706
+ if (inst.description)
2707
+ serialized.description = inst.description;
2708
+ if (inst.tags?.length)
2709
+ serialized.tags = inst.tags;
2710
+ if (inst.model)
2711
+ serialized.model = inst.model;
2712
+ if (inst.model_failover?.length)
2713
+ serialized.model_failover = inst.model_failover;
2714
+ if (inst.worktree_source)
2715
+ serialized.worktree_source = inst.worktree_source;
2716
+ if (inst.backend)
2717
+ serialized.backend = inst.backend;
2718
+ if (inst.systemPrompt)
2719
+ serialized.systemPrompt = inst.systemPrompt;
2720
+ if (inst.skipPermissions)
2721
+ serialized.skipPermissions = inst.skipPermissions;
2722
+ if (inst.lightweight)
2723
+ serialized.lightweight = inst.lightweight;
2724
+ if (inst.cost_guard)
2725
+ serialized.cost_guard = inst.cost_guard;
2726
+ if (inst.workflow !== undefined)
2727
+ serialized.workflow = inst.workflow;
2728
+ if (inst.agent_mode)
2729
+ serialized.agent_mode = inst.agent_mode;
2730
+ toSave.instances[name] = serialized;
2731
+ }
2732
+ writeFileSync(this.configPath, yaml.dump(toSave, { lineWidth: 120 }));
2733
+ this.logger.info({ path: this.configPath }, "Saved fleet config");
2835
2734
  }
2836
2735
  async removeInstance(name) {
2837
2736
  // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
@@ -2874,10 +2773,6 @@ export class FleetManager {
2874
2773
  startStatuslineWatcher(name) {
2875
2774
  this.statuslineWatcher.watch(name);
2876
2775
  }
2877
- stopStatuslineWatcher(name) {
2878
- // Pausing stops I/O but retains the last observed limits for status views.
2879
- this.statuslineWatcher.unwatch(name, true);
2880
- }
2881
2776
  reactMessageStatus(instanceName, chatId, messageId, emoji) {
2882
2777
  // React via the adapter BOUND to this instance — NOT the first discord world.
2883
2778
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -3228,7 +3123,7 @@ export class FleetManager {
3228
3123
  return [];
3229
3124
  }
3230
3125
  }
3231
- async sendHangNotification(instanceName, unchangedForMs) {
3126
+ async sendHangNotification(instanceName) {
3232
3127
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3233
3128
  if (!adapter)
3234
3129
  return;
@@ -3237,18 +3132,11 @@ export class FleetManager {
3237
3132
  if (!groupId)
3238
3133
  return;
3239
3134
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
3240
- const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
3241
- const configuredMinutes = instanceHangConfig?.timeout_minutes
3242
- ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
3243
- ?? 15;
3244
- const unchangedMinutes = unchangedForMs == null
3245
- ? configuredMinutes
3246
- : Math.max(1, Math.floor(unchangedForMs / 60_000));
3247
3135
  this.setTopicIcon(instanceName, "red");
3248
3136
  await adapter.notifyAlert(String(groupId), {
3249
3137
  type: "hang",
3250
3138
  instanceName,
3251
- message: `⚠️ ${instanceName} may be stuck pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
3139
+ message: `⚠️ ${instanceName} appears hung (no activity for 15+ minutes)`,
3252
3140
  choices: [
3253
3141
  { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
3254
3142
  { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
@@ -3756,7 +3644,6 @@ When users create specialized instances, suggest these configurations:
3756
3644
  user_id: msg.userId,
3757
3645
  ts: msg.timestamp.toISOString(),
3758
3646
  thread_id: msg.threadId ?? "",
3759
- ...(msg.adapterId ? { adapter_id: msg.adapterId } : {}),
3760
3647
  source: msg.source,
3761
3648
  ...extraMeta,
3762
3649
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
@@ -3813,104 +3700,6 @@ When users create specialized instances, suggest these configurations:
3813
3700
  return undefined;
3814
3701
  }
3815
3702
  }
3816
- /** Return a user-facing blocker without mutating ClassicBot state. */
3817
- validateClassicStart(channelId, userId, guildId, adapterId) {
3818
- if (!this.classicChannels)
3819
- return "Classic channel manager not initialized.";
3820
- if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
3821
- const generalId = this.findGeneralInstance(adapterId);
3822
- if (generalId)
3823
- this.notifyInstanceTopic(generalId, t("alert.unauth_guild", guildId, userId));
3824
- return t("classic.not_authorized_guild");
3825
- }
3826
- if (this.classicChannels.isClassicChannel(channelId, adapterId))
3827
- return t("classic.already_active");
3828
- if (this.routing.resolve(channelId))
3829
- return t("classic.topic_bound");
3830
- return undefined;
3831
- }
3832
- /** Present platform-native backend choices, then start on selection or timeout. */
3833
- async beginClassicBackendSelection(data, adapter) {
3834
- const adapterId = adapter.id;
3835
- const blocker = this.validateClassicStart(data.channelId, data.userId, data.guildId, adapterId);
3836
- if (blocker) {
3837
- await data.respond(blocker);
3838
- return;
3839
- }
3840
- const nonce = randomBytes(6).toString("hex");
3841
- const choices = getClassicBackendChoices().map(choice => ({
3842
- id: `${CLASSIC_BACKEND_CALLBACK_PREFIX}${nonce}:${choice.id}`,
3843
- label: choice.label,
3844
- }));
3845
- const complete = data.respondChoices
3846
- ? async (text) => { await data.respond(text); }
3847
- : async (text, messageId) => {
3848
- if (messageId && adapter.editMessageRemoveButtons) {
3849
- try {
3850
- await adapter.editMessageRemoveButtons(data.channelId, messageId, text);
3851
- return;
3852
- }
3853
- catch { /* fall back to a new message */ }
3854
- }
3855
- await data.respond(text);
3856
- };
3857
- const timer = setTimeout(() => {
3858
- void this.finishClassicBackendSelection(nonce).catch(err => this.logger.warn({ err, channelId: data.channelId, adapterId }, "Classic backend selection timeout fallback failed"));
3859
- }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
3860
- timer.unref?.();
3861
- const pending = {
3862
- channelId: data.channelId,
3863
- channelName: data.channelName,
3864
- userId: data.userId,
3865
- guildId: data.guildId,
3866
- adapterId,
3867
- timer,
3868
- complete,
3869
- };
3870
- this.pendingClassicStarts.set(nonce, pending);
3871
- try {
3872
- pending.messageId = data.respondChoices
3873
- ? await data.respondChoices(t("classic.choose_backend"), choices)
3874
- : await adapter.promptUser(data.channelId, t("classic.choose_backend"), choices);
3875
- }
3876
- catch (err) {
3877
- // A menu transport failure should not make /start unusable: consume the
3878
- // pending request and immediately use the configured default.
3879
- this.logger.warn({ err, channelId: data.channelId, adapterId }, "Classic backend menu failed; using default");
3880
- await this.finishClassicBackendSelection(nonce);
3881
- }
3882
- }
3883
- /** Consume a selection callback. Returns true for all ClassicBot callback IDs, including stale ones. */
3884
- async handleClassicBackendSelection(data) {
3885
- if (!data.callbackData.startsWith(CLASSIC_BACKEND_CALLBACK_PREFIX))
3886
- return false;
3887
- const match = data.callbackData.match(/^classic-backend:([0-9a-f]+):(.+)$/);
3888
- if (!match)
3889
- return true;
3890
- const pending = this.pendingClassicStarts.get(match[1]);
3891
- if (!pending)
3892
- return true;
3893
- // Telegram keyboards are visible to everyone in a group. Only the user who
3894
- // issued /start may consume the pending selection.
3895
- if (data.userId && data.userId !== pending.userId)
3896
- return true;
3897
- const callbackChannelId = data.threadId ?? data.chatId;
3898
- if (callbackChannelId !== pending.channelId && data.chatId !== pending.channelId)
3899
- return true;
3900
- await this.finishClassicBackendSelection(match[1], match[2]);
3901
- return true;
3902
- }
3903
- /** Atomically claim one pending request so timeout/click races create at most one instance. */
3904
- async finishClassicBackendSelection(nonce, backend) {
3905
- const pending = this.pendingClassicStarts.get(nonce);
3906
- if (!pending)
3907
- return;
3908
- this.pendingClassicStarts.delete(nonce);
3909
- clearTimeout(pending.timer);
3910
- const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
3911
- const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
3912
- await pending.complete(reply, pending.messageId);
3913
- }
3914
3703
  /** Start a classic channel instance with lightweight config */
3915
3704
  async startClassicInstance(instanceName, backend, preTaskCommand, model) {
3916
3705
  if (this.daemons.has(instanceName))
@@ -3930,26 +3719,35 @@ When users create specialized instances, suggest these configurations:
3930
3719
  await this.startInstance(instanceName, config, topicMode);
3931
3720
  }
3932
3721
  /** Handle /start slash command — register classic channel */
3933
- async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
3934
- const blocker = this.validateClassicStart(channelId, userId, guildId, adapterId);
3935
- if (blocker)
3936
- return blocker;
3937
- const classicChannels = this.classicChannels;
3938
- if (!classicChannels)
3722
+ async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
3723
+ if (!this.classicChannels)
3939
3724
  return "Classic channel manager not initialized.";
3940
- const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3941
- const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
3942
- classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId, selectedBackend);
3725
+ if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
3726
+ const generalId = this.findGeneralInstance();
3727
+ if (generalId) {
3728
+ this.notifyInstanceTopic(generalId, t("alert.unauth_guild", guildId, userId));
3729
+ }
3730
+ return t("classic.not_authorized_guild");
3731
+ }
3732
+ // Per-bot check: a second bot may /start in the same channel (own agent).
3733
+ if (this.classicChannels.isClassicChannel(channelId, adapterId))
3734
+ return t("classic.already_active");
3735
+ // Classic no longer lives in the routing engine, so this only guards against
3736
+ // a fleet topic-mode instance colliding with the channel.
3737
+ if (this.routing.resolve(channelId))
3738
+ return t("classic.topic_bound");
3739
+ const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3740
+ this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
3943
3741
  // Bind this classic instance to the bot that started it (authoritative), so
3944
3742
  // replies/cancel go out through that bot even though every same-guild bot
3945
3743
  // also sees the channel's messages.
3946
3744
  if (adapterId)
3947
3745
  this.bindInstanceAdapter(instanceName, adapterId);
3948
- await this.startClassicInstance(instanceName, classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), classicChannels.getPreTaskCommand(channelId, adapterId), classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model));
3746
+ await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model));
3949
3747
  this.reregisterClassicChannels();
3950
3748
  // Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
3951
- if (guildId && !classicChannels.isCollab(channelId, adapterId)) {
3952
- classicChannels.toggleCollab(channelId, adapterId);
3749
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3750
+ this.classicChannels.toggleCollab(channelId, adapterId);
3953
3751
  }
3954
3752
  this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
3955
3753
  return t("classic.started");
@@ -4006,9 +3804,6 @@ When users create specialized instances, suggest these configurations:
4006
3804
  clearInterval(this.classicReloadTimer);
4007
3805
  this.classicReloadTimer = null;
4008
3806
  }
4009
- for (const pending of this.pendingClassicStarts.values())
4010
- clearTimeout(pending.timer);
4011
- this.pendingClassicStarts.clear();
4012
3807
  this.topicArchiver.stop();
4013
3808
  this.scheduler?.shutdown();
4014
3809
  // Stop instances in parallel batches to avoid long sequential waits.
@@ -4554,7 +4349,6 @@ When users create specialized instances, suggest these configurations:
4554
4349
  lastActivity: this.lastActivityMs(inst.name) || null,
4555
4350
  currentTask,
4556
4351
  idle: this.getInstanceIdle(inst.name),
4557
- state: this.getInstanceExecutionState(inst.name),
4558
4352
  };
4559
4353
  });
4560
4354
  res.setHeader("Access-Control-Allow-Origin", "*");