@songsid/agend 2.1.0-beta.18 → 2.1.0-beta.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 (71) hide show
  1. package/README.md +0 -1
  2. package/dist/access-path.js +3 -3
  3. package/dist/access-path.js.map +1 -1
  4. package/dist/agent-endpoint.d.ts +0 -8
  5. package/dist/agent-endpoint.js +8 -36
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/backend/antigravity.js +2 -3
  8. package/dist/backend/antigravity.js.map +1 -1
  9. package/dist/backend/factory.js +1 -5
  10. package/dist/backend/factory.js.map +1 -1
  11. package/dist/backend/kiro.js +2 -3
  12. package/dist/backend/kiro.js.map +1 -1
  13. package/dist/backend/types.d.ts +2 -12
  14. package/dist/backend/types.js +0 -1
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/cli.js +6 -56
  17. package/dist/cli.js.map +1 -1
  18. package/dist/config-validator.js +1 -50
  19. package/dist/config-validator.js.map +1 -1
  20. package/dist/config.d.ts +1 -3
  21. package/dist/config.js +0 -13
  22. package/dist/config.js.map +1 -1
  23. package/dist/context-guardian.d.ts +0 -2
  24. package/dist/context-guardian.js +2 -10
  25. package/dist/context-guardian.js.map +1 -1
  26. package/dist/daemon-entry.js +1 -2
  27. package/dist/daemon-entry.js.map +1 -1
  28. package/dist/daemon.d.ts +3 -51
  29. package/dist/daemon.js +36 -380
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/fleet-context.d.ts +1 -2
  32. package/dist/fleet-manager.d.ts +5 -23
  33. package/dist/fleet-manager.js +123 -229
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/instance-lifecycle.d.ts +1 -6
  36. package/dist/instance-lifecycle.js +3 -45
  37. package/dist/instance-lifecycle.js.map +1 -1
  38. package/dist/logger.d.ts +0 -6
  39. package/dist/logger.js +10 -29
  40. package/dist/logger.js.map +1 -1
  41. package/dist/outbound-handlers.d.ts +0 -5
  42. package/dist/outbound-handlers.js +16 -68
  43. package/dist/outbound-handlers.js.map +1 -1
  44. package/dist/settings-api.d.ts +2 -17
  45. package/dist/settings-api.js +8 -105
  46. package/dist/settings-api.js.map +1 -1
  47. package/dist/setup-wizard.js +0 -8
  48. package/dist/setup-wizard.js.map +1 -1
  49. package/dist/statusline-watcher.d.ts +1 -1
  50. package/dist/statusline-watcher.js +2 -3
  51. package/dist/statusline-watcher.js.map +1 -1
  52. package/dist/tmux-manager.d.ts +1 -3
  53. package/dist/tmux-manager.js +0 -9
  54. package/dist/tmux-manager.js.map +1 -1
  55. package/dist/topic-archiver.d.ts +1 -1
  56. package/dist/topic-commands.d.ts +1 -14
  57. package/dist/topic-commands.js +25 -96
  58. package/dist/topic-commands.js.map +1 -1
  59. package/dist/transcript-monitor.js +0 -2
  60. package/dist/transcript-monitor.js.map +1 -1
  61. package/dist/types.d.ts +1 -18
  62. package/dist/ui/dashboard.html +2 -2
  63. package/dist/ui/settings.html +40 -251
  64. package/dist/view-api.d.ts +1 -1
  65. package/dist/web-api.d.ts +1 -2
  66. package/dist/web-api.js +14 -23
  67. package/dist/web-api.js.map +1 -1
  68. package/package.json +1 -2
  69. package/dist/backend/grok.d.ts +0 -40
  70. package/dist/backend/grok.js +0 -213
  71. 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";
@@ -65,9 +65,6 @@ export class FleetManager {
65
65
  /** @deprecated Use lifecycle.daemons — kept for backward compat */
66
66
  get daemons() { return this.lifecycle.daemons; }
67
67
  fleetConfig = null;
68
- rawFleetConfig = {};
69
- rawFleetDocument = null;
70
- savedFleetConfigSnapshot = null;
71
68
  adapter = null;
72
69
  worlds = new Map();
73
70
  adapters = new Map(); // derived view for backward compat
@@ -96,8 +93,6 @@ export class FleetManager {
96
93
  // Topic icon + auto-archive state
97
94
  topicIcons = {};
98
95
  lastActivity = new Map();
99
- /** Latest pane-derived execution snapshot reported by each daemon. */
100
- instanceStateCache = new Map();
101
96
  lastInboundUser = new Map(); // instanceName → last username
102
97
  // Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
103
98
  // than one-per-instance. A button is retired (deleted, with bounded retry) on
@@ -165,7 +160,6 @@ export class FleetManager {
165
160
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
166
161
  name,
167
162
  status: this.getInstanceStatus(name),
168
- state: this.getInstanceExecutionState(name),
169
163
  ipc: this.instanceIpcClients.has(name),
170
164
  costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
171
165
  rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
@@ -180,21 +174,9 @@ export class FleetManager {
180
174
  }
181
175
  /** Load fleet.yaml and build routing table */
182
176
  loadConfig(configPath) {
183
- this.configPath = configPath;
184
- const source = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "{}\n";
185
- this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
186
- if (this.rawFleetDocument.errors.length > 0) {
187
- throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
188
- }
189
- this.rawFleetConfig = loadRawFleetConfig(configPath);
190
177
  this.fleetConfig = loadFleetConfig(configPath);
191
- this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
192
178
  return this.fleetConfig;
193
179
  }
194
- /** User-authored fleet.yaml, before defaults are merged into instances. */
195
- getRawFleetConfig() {
196
- return structuredClone(this.rawFleetConfig);
197
- }
198
180
  /** Build topic routing table: { topicId -> RouteTarget } */
199
181
  buildRoutingTable() {
200
182
  if (this.fleetConfig) {
@@ -283,24 +265,17 @@ export class FleetManager {
283
265
  */
284
266
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
285
267
  const cfg = this.fleetConfig?.instances[name];
286
- if (fromInbound) {
287
- // Skip inbound-derived binding for any instance that doesn't have an
288
- // explicit channel_id those default to primary adapter deterministically.
289
- // This prevents a non-deterministic race where whichever adapter delivers
290
- // first after restart wins the binding.
291
- if (cfg?.general_topic || cfg?.channel_id)
292
- return;
293
- if (cfg && !cfg.channel_id)
294
- return; // fleet instance without explicit binding → use primary
295
- // Classic instance: don't override an existing binding (authoritative from /start)
296
- if (this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
297
- return;
298
- }
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;
299
276
  this.instanceWorldBinding.set(name, adapterId);
300
277
  }
301
278
  getInstanceStatus(name) {
302
- if (this.lifecycle.isPaused(name))
303
- return "paused";
304
279
  const pidPath = join(this.getInstanceDir(name), "daemon.pid");
305
280
  if (!existsSync(pidPath))
306
281
  return "stopped";
@@ -313,35 +288,6 @@ export class FleetManager {
313
288
  return "crashed";
314
289
  }
315
290
  }
316
- getInstanceExecutionState(name) {
317
- if (this.lifecycle.isPaused(name))
318
- return null;
319
- return this.instanceStateCache.get(name)?.state ?? null;
320
- }
321
- cacheInstanceExecutionState(name, msg) {
322
- const state = msg.state;
323
- if (state !== "idle" && state !== "working" && state !== "stuck")
324
- return;
325
- const previous = this.instanceStateCache.get(name);
326
- const now = Date.now();
327
- const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
328
- this.instanceStateCache.set(name, {
329
- state,
330
- unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
331
- observedAt: numberOr(msg.observedAt, now),
332
- stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
333
- });
334
- }
335
- /** Single delivery facade: wake an auto-paused CLI before sending to daemon IPC. */
336
- async deliverToInstance(instanceName, payload) {
337
- if (this.lifecycle.isPaused(instanceName)) {
338
- await this.lifecycle.wake(instanceName, 30_000);
339
- }
340
- const ipc = this.instanceIpcClients.get(instanceName);
341
- if (!ipc?.connected)
342
- throw new Error(`Instance '${instanceName}' IPC is unavailable`);
343
- ipc.send(payload);
344
- }
345
291
  async startInstance(name, config, topicMode) {
346
292
  if (config.general_topic) {
347
293
  this.ensureGeneralInstructions(config.working_directory, config.backend);
@@ -409,7 +355,6 @@ export class FleetManager {
409
355
  }
410
356
  async stopInstance(name) {
411
357
  this.failoverActive.delete(name);
412
- this.instanceStateCache.delete(name);
413
358
  return this.lifecycle.stop(name);
414
359
  }
415
360
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
@@ -1575,16 +1520,10 @@ export class FleetManager {
1575
1520
  else if (msg.type === "fleet_set_description") {
1576
1521
  this.handleSetDescription(name, msg);
1577
1522
  }
1578
- else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
1579
- this.cacheInstanceExecutionState(name, msg);
1580
- }
1581
1523
  }, this.logger, `ipc.message[${name}]`));
1582
1524
  // Ask daemon for any sessions that registered before we connected
1583
1525
  // (fixes race condition where mcp_ready was broadcast before fleet manager connected)
1584
1526
  ipc.send({ type: "query_sessions" });
1585
- // The initial state transition may have happened before FleetManager
1586
- // connected, so seed the cache instead of waiting for another transition.
1587
- ipc.send({ type: "query_instance_state", requestId: `fleet-state-${Date.now()}` });
1588
1527
  this.logger.debug({ name }, "Connected to instance IPC");
1589
1528
  if (!this.statuslineWatcher.has(name)) {
1590
1529
  this.statuslineWatcher.watch(name);
@@ -1987,8 +1926,9 @@ export class FleetManager {
1987
1926
  }
1988
1927
  this.warnIfRateLimited(generalInstance, msg);
1989
1928
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
1990
- try {
1991
- await this.deliverToInstance(generalInstance, {
1929
+ const ipc = this.instanceIpcClients.get(generalInstance);
1930
+ if (ipc) {
1931
+ ipc.send({
1992
1932
  type: "fleet_inbound",
1993
1933
  content: text,
1994
1934
  targetSession: generalInstance,
@@ -1999,7 +1939,6 @@ export class FleetManager {
1999
1939
  user_id: msg.userId,
2000
1940
  ts: msg.timestamp.toISOString(),
2001
1941
  thread_id: "",
2002
- adapter_id: msg.adapterId,
2003
1942
  source: msg.source,
2004
1943
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2005
1944
  ...extraMeta,
@@ -2015,9 +1954,6 @@ export class FleetManager {
2015
1954
  this.trackInboundMsg(generalInstance, msg);
2016
1955
  void this.sendCancelButton(generalInstance);
2017
1956
  }
2018
- catch (err) {
2019
- this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
2020
- }
2021
1957
  }
2022
1958
  return;
2023
1959
  }
@@ -2080,33 +2016,27 @@ export class FleetManager {
2080
2016
  this.setTopicIcon(instanceName, "blue");
2081
2017
  this.warnIfRateLimited(instanceName, msg);
2082
2018
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
2083
- try {
2084
- await this.deliverToInstance(instanceName, {
2085
- type: "fleet_inbound",
2086
- content: text,
2087
- targetSession: instanceName, // Channel messages → instance's own session
2088
- meta: {
2089
- chat_id: msg.chatId,
2090
- message_id: msg.messageId,
2091
- user: msg.username,
2092
- user_id: msg.userId,
2093
- ts: msg.timestamp.toISOString(),
2094
- thread_id: msg.threadId ?? "",
2095
- adapter_id: msg.adapterId,
2096
- source: msg.source,
2097
- ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2098
- ...extraMeta,
2099
- },
2100
- });
2101
- }
2102
- catch (err) {
2103
- this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
2104
- if (msg.chatId && msg.messageId) {
2105
- const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
2106
- reactAdapter.react(this.reactTarget(msg), msg.messageId, "❌").catch(() => { });
2107
- }
2019
+ const ipc = this.instanceIpcClients.get(instanceName);
2020
+ if (!ipc) {
2021
+ this.logger.warn({ instanceName }, "No IPC connection to instance");
2108
2022
  return;
2109
2023
  }
2024
+ ipc.send({
2025
+ type: "fleet_inbound",
2026
+ content: text,
2027
+ targetSession: instanceName, // Channel messages → instance's own session
2028
+ meta: {
2029
+ chat_id: msg.chatId,
2030
+ message_id: msg.messageId,
2031
+ user: msg.username,
2032
+ user_id: msg.userId,
2033
+ ts: msg.timestamp.toISOString(),
2034
+ thread_id: msg.threadId ?? "",
2035
+ source: msg.source,
2036
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2037
+ ...extraMeta,
2038
+ },
2039
+ });
2110
2040
  this.lastInboundUser.set(instanceName, msg.username);
2111
2041
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
2112
2042
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
@@ -2267,23 +2197,20 @@ export class FleetManager {
2267
2197
  const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
2268
2198
  const retryCount = schedulerDefaults?.retry_count ?? 3;
2269
2199
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
2270
- const deliver = async () => {
2271
- try {
2272
- await this.deliverToInstance(target, {
2273
- type: "fleet_schedule_trigger",
2274
- payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
2275
- meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
2276
- });
2277
- // A scheduled trigger also puts the instance to work — show a cancel button.
2278
- void this.sendCancelButton(target);
2279
- return true;
2280
- }
2281
- catch (err) {
2282
- this.logger.warn({ err: err.message, target }, "Scheduled wake/delivery attempt failed");
2200
+ const deliver = () => {
2201
+ const ipc = this.instanceIpcClients.get(target);
2202
+ if (!ipc?.connected)
2283
2203
  return false;
2284
- }
2204
+ ipc.send({
2205
+ type: "fleet_schedule_trigger",
2206
+ payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
2207
+ meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
2208
+ });
2209
+ // A scheduled trigger also puts the instance to work — show a cancel button.
2210
+ void this.sendCancelButton(target);
2211
+ return true;
2285
2212
  };
2286
- if (await deliver()) {
2213
+ if (deliver()) {
2287
2214
  this.scheduler.recordRun(id, "delivered");
2288
2215
  if (source !== target)
2289
2216
  this.notifySourceTopic(schedule);
@@ -2291,7 +2218,7 @@ export class FleetManager {
2291
2218
  }
2292
2219
  for (let i = 0; i < retryCount; i++) {
2293
2220
  await new Promise((r) => setTimeout(r, retryInterval));
2294
- if (await deliver()) {
2221
+ if (deliver()) {
2295
2222
  this.scheduler.recordRun(id, "delivered");
2296
2223
  if (source !== target)
2297
2224
  this.notifySourceTopic(schedule);
@@ -2715,92 +2642,73 @@ export class FleetManager {
2715
2642
  }
2716
2643
  }, 5 * 60_000);
2717
2644
  }
2718
- /**
2719
- * Patch only values changed in the effective config into the original YAML
2720
- * document. Unknown keys, explicit overrides and comments remain untouched.
2721
- */
2722
- saveFleetConfig(explicitPatches = []) {
2645
+ /** Save fleet config back to fleet.yaml */
2646
+ saveFleetConfig() {
2723
2647
  if (!this.fleetConfig || !this.configPath)
2724
2648
  return;
2725
- if (!this.savedFleetConfigSnapshot)
2726
- this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2727
- // Re-read immediately before patching so an unrelated concurrent/manual
2728
- // edit is retained. Invalid concurrent YAML is never overwritten.
2729
- const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
2730
- this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
2731
- if (this.rawFleetDocument.errors.length > 0) {
2732
- throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
2733
- }
2734
- this.rawFleetConfig = loadRawFleetConfig(this.configPath);
2735
- this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
2736
- // Settings edits are expressed against the raw config. Persist them even
2737
- // when the chosen override equals the inherited effective value, a case
2738
- // the before/after runtime diff cannot observe.
2739
- for (const patch of explicitPatches) {
2740
- if (patch.remove) {
2741
- this.rawFleetDocument.deleteIn(patch.path);
2742
- }
2743
- else {
2744
- const before = this.rawFleetDocument.getIn(patch.path);
2745
- this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
2746
- }
2747
- }
2748
- const output = String(this.rawFleetDocument);
2749
- const tempPath = `${this.configPath}.tmp-${process.pid}`;
2750
- writeFileSync(tempPath, output, "utf-8");
2751
- if (existsSync(this.configPath))
2752
- chmodSync(tempPath, statSync(this.configPath).mode);
2753
- renameSync(tempPath, this.configPath);
2754
- this.rawFleetConfig = loadRawFleetConfig(this.configPath);
2755
- this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2756
- this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
2757
- }
2758
- patchFleetDocument(document, path, before, after) {
2759
- if (Object.is(before, after))
2760
- return;
2761
- if (Array.isArray(before) && Array.isArray(after)) {
2762
- const shared = Math.min(before.length, after.length);
2763
- for (let i = 0; i < shared; i++) {
2764
- this.patchFleetDocument(document, [...path, i], before[i], after[i]);
2765
- }
2766
- // Remove from the end so YAML sequence indexes do not shift underneath us.
2767
- for (let i = before.length - 1; i >= after.length; i--)
2768
- document.deleteIn([...path, i]);
2769
- for (let i = shared; i < after.length; i++)
2770
- document.setIn([...path, i], after[i]);
2771
- return;
2772
- }
2773
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2774
- if (isRecord(before) && isRecord(after)) {
2775
- const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
2776
- for (const key of keys) {
2777
- // `channel` is a derived alias when the raw file uses `channels`.
2778
- if (path.length === 0 && key === "channel" && this.rawFleetConfig.channels)
2779
- continue;
2780
- // Conversely, `channels` is a normalized alias for a legacy `channel`.
2781
- // Keep the user's original shape unless the caller explicitly removed
2782
- // `channel` (the Settings channels endpoint intentionally migrates it).
2783
- if (path.length === 0 && key === "channels" && this.rawFleetConfig.channel && !this.rawFleetConfig.channels && after.channel !== undefined)
2784
- continue;
2785
- this.patchFleetDocument(document, [...path, key], before[key], after[key]);
2786
- }
2787
- return;
2788
- }
2789
- if (after === undefined) {
2790
- document.deleteIn(path);
2791
- }
2792
- else if (path.length === 0) {
2793
- document.contents = document.createNode(after);
2794
- }
2795
- else {
2796
- const currentNode = document.getIn(path, true);
2797
- if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
2798
- currentNode.value = after;
2799
- }
2800
- else {
2801
- document.setIn(path, after);
2802
- }
2803
- }
2649
+ const toSave = {};
2650
+ if (this.fleetConfig.project_roots)
2651
+ toSave.project_roots = this.fleetConfig.project_roots;
2652
+ if (this.fleetConfig.channels && this.fleetConfig.channels.length > 0) {
2653
+ toSave.channels = this.fleetConfig.channels;
2654
+ }
2655
+ else if (this.fleetConfig.channel) {
2656
+ toSave.channel = this.fleetConfig.channel;
2657
+ }
2658
+ if (this.fleetConfig.health_port)
2659
+ toSave.health_port = this.fleetConfig.health_port;
2660
+ if (Object.keys(this.fleetConfig.defaults).length > 0)
2661
+ toSave.defaults = this.fleetConfig.defaults;
2662
+ if (this.fleetConfig.teams && Object.keys(this.fleetConfig.teams).length > 0) {
2663
+ toSave.teams = this.fleetConfig.teams;
2664
+ }
2665
+ if (this.fleetConfig.templates && Object.keys(this.fleetConfig.templates).length > 0) {
2666
+ toSave.templates = this.fleetConfig.templates;
2667
+ }
2668
+ if (this.fleetConfig.profiles && Object.keys(this.fleetConfig.profiles).length > 0) {
2669
+ toSave.profiles = this.fleetConfig.profiles;
2670
+ }
2671
+ toSave.instances = {};
2672
+ for (const [name, inst] of Object.entries(this.fleetConfig.instances)) {
2673
+ const serialized = {
2674
+ working_directory: inst.working_directory,
2675
+ topic_id: inst.topic_id,
2676
+ };
2677
+ // Preserve all optional user-configured fields so saveFleetConfig() never silently drops them
2678
+ if (inst.general_topic)
2679
+ serialized.general_topic = true;
2680
+ if (inst.display_name)
2681
+ serialized.display_name = inst.display_name;
2682
+ if (inst.channel_id)
2683
+ serialized.channel_id = inst.channel_id;
2684
+ if (inst.description)
2685
+ serialized.description = inst.description;
2686
+ if (inst.tags?.length)
2687
+ serialized.tags = inst.tags;
2688
+ if (inst.model)
2689
+ serialized.model = inst.model;
2690
+ if (inst.model_failover?.length)
2691
+ serialized.model_failover = inst.model_failover;
2692
+ if (inst.worktree_source)
2693
+ serialized.worktree_source = inst.worktree_source;
2694
+ if (inst.backend)
2695
+ serialized.backend = inst.backend;
2696
+ if (inst.systemPrompt)
2697
+ serialized.systemPrompt = inst.systemPrompt;
2698
+ if (inst.skipPermissions)
2699
+ serialized.skipPermissions = inst.skipPermissions;
2700
+ if (inst.lightweight)
2701
+ serialized.lightweight = inst.lightweight;
2702
+ if (inst.cost_guard)
2703
+ serialized.cost_guard = inst.cost_guard;
2704
+ if (inst.workflow !== undefined)
2705
+ serialized.workflow = inst.workflow;
2706
+ if (inst.agent_mode)
2707
+ serialized.agent_mode = inst.agent_mode;
2708
+ toSave.instances[name] = serialized;
2709
+ }
2710
+ writeFileSync(this.configPath, yaml.dump(toSave, { lineWidth: 120 }));
2711
+ this.logger.info({ path: this.configPath }, "Saved fleet config");
2804
2712
  }
2805
2713
  async removeInstance(name) {
2806
2714
  // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
@@ -2843,10 +2751,6 @@ export class FleetManager {
2843
2751
  startStatuslineWatcher(name) {
2844
2752
  this.statuslineWatcher.watch(name);
2845
2753
  }
2846
- stopStatuslineWatcher(name) {
2847
- // Pausing stops I/O but retains the last observed limits for status views.
2848
- this.statuslineWatcher.unwatch(name, true);
2849
- }
2850
2754
  reactMessageStatus(instanceName, chatId, messageId, emoji) {
2851
2755
  // React via the adapter BOUND to this instance — NOT the first discord world.
2852
2756
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -3197,7 +3101,7 @@ export class FleetManager {
3197
3101
  return [];
3198
3102
  }
3199
3103
  }
3200
- async sendHangNotification(instanceName, unchangedForMs) {
3104
+ async sendHangNotification(instanceName) {
3201
3105
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3202
3106
  if (!adapter)
3203
3107
  return;
@@ -3206,18 +3110,11 @@ export class FleetManager {
3206
3110
  if (!groupId)
3207
3111
  return;
3208
3112
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
3209
- const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
3210
- const configuredMinutes = instanceHangConfig?.timeout_minutes
3211
- ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
3212
- ?? 15;
3213
- const unchangedMinutes = unchangedForMs == null
3214
- ? configuredMinutes
3215
- : Math.max(1, Math.floor(unchangedForMs / 60_000));
3216
3113
  this.setTopicIcon(instanceName, "red");
3217
3114
  await adapter.notifyAlert(String(groupId), {
3218
3115
  type: "hang",
3219
3116
  instanceName,
3220
- message: `⚠️ ${instanceName} may be stuck pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
3117
+ message: `⚠️ ${instanceName} appears hung (no activity for 15+ minutes)`,
3221
3118
  choices: [
3222
3119
  { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
3223
3120
  { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
@@ -3718,6 +3615,11 @@ When users create specialized instances, suggest these configurations:
3718
3615
  const fullText = logContext
3719
3616
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
3720
3617
  : text;
3618
+ const ipc = this.instanceIpcClients.get(instanceName);
3619
+ if (!ipc) {
3620
+ this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
3621
+ return;
3622
+ }
3721
3623
  const meta = {
3722
3624
  chat_id: msg.chatId,
3723
3625
  message_id: msg.messageId,
@@ -3725,7 +3627,6 @@ When users create specialized instances, suggest these configurations:
3725
3627
  user_id: msg.userId,
3726
3628
  ts: msg.timestamp.toISOString(),
3727
3629
  thread_id: msg.threadId ?? "",
3728
- ...(msg.adapterId ? { adapter_id: msg.adapterId } : {}),
3729
3630
  source: msg.source,
3730
3631
  ...extraMeta,
3731
3632
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
@@ -3740,18 +3641,12 @@ When users create specialized instances, suggest these configurations:
3740
3641
  meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3741
3642
  }
3742
3643
  }
3743
- try {
3744
- await this.deliverToInstance(instanceName, {
3745
- type: "fleet_inbound",
3746
- content: fullText,
3747
- targetSession: instanceName,
3748
- meta,
3749
- });
3750
- }
3751
- catch (err) {
3752
- this.logger.warn({ err: err.message, instanceName }, "Classic wake/delivery failed");
3753
- return;
3754
- }
3644
+ ipc.send({
3645
+ type: "fleet_inbound",
3646
+ content: fullText,
3647
+ targetSession: instanceName,
3648
+ meta,
3649
+ });
3755
3650
  this.lastInboundUser.set(instanceName, msg.username);
3756
3651
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
3757
3652
  this.trackInboundMsg(instanceName, msg);
@@ -4431,7 +4326,6 @@ When users create specialized instances, suggest these configurations:
4431
4326
  lastActivity: this.lastActivityMs(inst.name) || null,
4432
4327
  currentTask,
4433
4328
  idle: this.getInstanceIdle(inst.name),
4434
- state: this.getInstanceExecutionState(inst.name),
4435
4329
  };
4436
4330
  });
4437
4331
  res.setHeader("Access-Control-Allow-Origin", "*");