@songsid/agend 2.1.0-beta.2 → 2.1.0-beta.21

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 (78) hide show
  1. package/README.md +1 -0
  2. package/README.zh-TW.md +2 -1
  3. package/dist/access-path.js +3 -3
  4. package/dist/access-path.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +8 -0
  6. package/dist/agent-endpoint.js +36 -8
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/backend/antigravity.js +3 -2
  9. package/dist/backend/antigravity.js.map +1 -1
  10. package/dist/backend/factory.js +5 -1
  11. package/dist/backend/factory.js.map +1 -1
  12. package/dist/backend/grok.d.ts +41 -0
  13. package/dist/backend/grok.js +256 -0
  14. package/dist/backend/grok.js.map +1 -0
  15. package/dist/backend/kiro.js +3 -2
  16. package/dist/backend/kiro.js.map +1 -1
  17. package/dist/backend/types.d.ts +12 -2
  18. package/dist/backend/types.js +1 -0
  19. package/dist/backend/types.js.map +1 -1
  20. package/dist/classic-channel-manager.js +1 -1
  21. package/dist/cli.js +56 -6
  22. package/dist/cli.js.map +1 -1
  23. package/dist/config-validator.js +50 -1
  24. package/dist/config-validator.js.map +1 -1
  25. package/dist/config.d.ts +3 -1
  26. package/dist/config.js +13 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/context-guardian.d.ts +2 -0
  29. package/dist/context-guardian.js +10 -2
  30. package/dist/context-guardian.js.map +1 -1
  31. package/dist/daemon-entry.js +2 -1
  32. package/dist/daemon-entry.js.map +1 -1
  33. package/dist/daemon.d.ts +51 -3
  34. package/dist/daemon.js +380 -36
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/fleet-context.d.ts +2 -1
  37. package/dist/fleet-manager.d.ts +23 -5
  38. package/dist/fleet-manager.js +229 -123
  39. package/dist/fleet-manager.js.map +1 -1
  40. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
  41. package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -0
  42. package/dist/instance-lifecycle.d.ts +6 -1
  43. package/dist/instance-lifecycle.js +46 -3
  44. package/dist/instance-lifecycle.js.map +1 -1
  45. package/dist/logger.d.ts +6 -0
  46. package/dist/logger.js +29 -10
  47. package/dist/logger.js.map +1 -1
  48. package/dist/outbound-handlers.d.ts +5 -0
  49. package/dist/outbound-handlers.js +68 -16
  50. package/dist/outbound-handlers.js.map +1 -1
  51. package/dist/outbound-schemas.d.ts +1 -0
  52. package/dist/outbound-schemas.js +1 -1
  53. package/dist/outbound-schemas.js.map +1 -1
  54. package/dist/settings-api.d.ts +17 -2
  55. package/dist/settings-api.js +105 -8
  56. package/dist/settings-api.js.map +1 -1
  57. package/dist/setup-wizard.js +8 -0
  58. package/dist/setup-wizard.js.map +1 -1
  59. package/dist/statusline-watcher.d.ts +1 -1
  60. package/dist/statusline-watcher.js +3 -2
  61. package/dist/statusline-watcher.js.map +1 -1
  62. package/dist/tmux-manager.d.ts +3 -1
  63. package/dist/tmux-manager.js +9 -0
  64. package/dist/tmux-manager.js.map +1 -1
  65. package/dist/topic-archiver.d.ts +1 -1
  66. package/dist/topic-commands.d.ts +14 -1
  67. package/dist/topic-commands.js +96 -25
  68. package/dist/topic-commands.js.map +1 -1
  69. package/dist/transcript-monitor.js +2 -0
  70. package/dist/transcript-monitor.js.map +1 -1
  71. package/dist/types.d.ts +18 -1
  72. package/dist/ui/dashboard.html +2 -2
  73. package/dist/ui/settings.html +251 -40
  74. package/dist/view-api.d.ts +1 -1
  75. package/dist/web-api.d.ts +2 -1
  76. package/dist/web-api.js +24 -15
  77. package/dist/web-api.js.map +1 -1
  78. package/package.json +2 -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 yaml from "js-yaml";
8
+ import { isScalar, parseDocument } from "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, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
14
+ import { loadFleetConfig, loadRawFleetConfig, 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,6 +65,9 @@ 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;
68
71
  adapter = null;
69
72
  worlds = new Map();
70
73
  adapters = new Map(); // derived view for backward compat
@@ -93,6 +96,8 @@ export class FleetManager {
93
96
  // Topic icon + auto-archive state
94
97
  topicIcons = {};
95
98
  lastActivity = new Map();
99
+ /** Latest pane-derived execution snapshot reported by each daemon. */
100
+ instanceStateCache = new Map();
96
101
  lastInboundUser = new Map(); // instanceName → last username
97
102
  // Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
98
103
  // than one-per-instance. A button is retired (deleted, with bounded retry) on
@@ -160,6 +165,7 @@ export class FleetManager {
160
165
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
161
166
  name,
162
167
  status: this.getInstanceStatus(name),
168
+ state: this.getInstanceExecutionState(name),
163
169
  ipc: this.instanceIpcClients.has(name),
164
170
  costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
165
171
  rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
@@ -174,9 +180,21 @@ export class FleetManager {
174
180
  }
175
181
  /** Load fleet.yaml and build routing table */
176
182
  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);
177
190
  this.fleetConfig = loadFleetConfig(configPath);
191
+ this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
178
192
  return this.fleetConfig;
179
193
  }
194
+ /** User-authored fleet.yaml, before defaults are merged into instances. */
195
+ getRawFleetConfig() {
196
+ return structuredClone(this.rawFleetConfig);
197
+ }
180
198
  /** Build topic routing table: { topicId -> RouteTarget } */
181
199
  buildRoutingTable() {
182
200
  if (this.fleetConfig) {
@@ -265,17 +283,24 @@ export class FleetManager {
265
283
  */
266
284
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
267
285
  const cfg = this.fleetConfig?.instances[name];
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;
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
+ }
276
299
  this.instanceWorldBinding.set(name, adapterId);
277
300
  }
278
301
  getInstanceStatus(name) {
302
+ if (this.lifecycle.isPaused(name))
303
+ return "paused";
279
304
  const pidPath = join(this.getInstanceDir(name), "daemon.pid");
280
305
  if (!existsSync(pidPath))
281
306
  return "stopped";
@@ -288,6 +313,35 @@ export class FleetManager {
288
313
  return "crashed";
289
314
  }
290
315
  }
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
+ }
291
345
  async startInstance(name, config, topicMode) {
292
346
  if (config.general_topic) {
293
347
  this.ensureGeneralInstructions(config.working_directory, config.backend);
@@ -355,6 +409,7 @@ export class FleetManager {
355
409
  }
356
410
  async stopInstance(name) {
357
411
  this.failoverActive.delete(name);
412
+ this.instanceStateCache.delete(name);
358
413
  return this.lifecycle.stop(name);
359
414
  }
360
415
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
@@ -1520,10 +1575,16 @@ export class FleetManager {
1520
1575
  else if (msg.type === "fleet_set_description") {
1521
1576
  this.handleSetDescription(name, msg);
1522
1577
  }
1578
+ else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
1579
+ this.cacheInstanceExecutionState(name, msg);
1580
+ }
1523
1581
  }, this.logger, `ipc.message[${name}]`));
1524
1582
  // Ask daemon for any sessions that registered before we connected
1525
1583
  // (fixes race condition where mcp_ready was broadcast before fleet manager connected)
1526
1584
  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()}` });
1527
1588
  this.logger.debug({ name }, "Connected to instance IPC");
1528
1589
  if (!this.statuslineWatcher.has(name)) {
1529
1590
  this.statuslineWatcher.watch(name);
@@ -1926,9 +1987,8 @@ export class FleetManager {
1926
1987
  }
1927
1988
  this.warnIfRateLimited(generalInstance, msg);
1928
1989
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
1929
- const ipc = this.instanceIpcClients.get(generalInstance);
1930
- if (ipc) {
1931
- ipc.send({
1990
+ try {
1991
+ await this.deliverToInstance(generalInstance, {
1932
1992
  type: "fleet_inbound",
1933
1993
  content: text,
1934
1994
  targetSession: generalInstance,
@@ -1939,6 +1999,7 @@ export class FleetManager {
1939
1999
  user_id: msg.userId,
1940
2000
  ts: msg.timestamp.toISOString(),
1941
2001
  thread_id: "",
2002
+ adapter_id: msg.adapterId,
1942
2003
  source: msg.source,
1943
2004
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
1944
2005
  ...extraMeta,
@@ -1954,6 +2015,9 @@ export class FleetManager {
1954
2015
  this.trackInboundMsg(generalInstance, msg);
1955
2016
  void this.sendCancelButton(generalInstance);
1956
2017
  }
2018
+ catch (err) {
2019
+ this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
2020
+ }
1957
2021
  }
1958
2022
  return;
1959
2023
  }
@@ -2016,27 +2080,33 @@ export class FleetManager {
2016
2080
  this.setTopicIcon(instanceName, "blue");
2017
2081
  this.warnIfRateLimited(instanceName, msg);
2018
2082
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
2019
- const ipc = this.instanceIpcClients.get(instanceName);
2020
- if (!ipc) {
2021
- this.logger.warn({ instanceName }, "No IPC connection to instance");
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
+ }
2022
2108
  return;
2023
2109
  }
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
- });
2040
2110
  this.lastInboundUser.set(instanceName, msg.username);
2041
2111
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
2042
2112
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
@@ -2197,20 +2267,23 @@ export class FleetManager {
2197
2267
  const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
2198
2268
  const retryCount = schedulerDefaults?.retry_count ?? 3;
2199
2269
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
2200
- const deliver = () => {
2201
- const ipc = this.instanceIpcClients.get(target);
2202
- if (!ipc?.connected)
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");
2203
2283
  return false;
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;
2284
+ }
2212
2285
  };
2213
- if (deliver()) {
2286
+ if (await deliver()) {
2214
2287
  this.scheduler.recordRun(id, "delivered");
2215
2288
  if (source !== target)
2216
2289
  this.notifySourceTopic(schedule);
@@ -2218,7 +2291,7 @@ export class FleetManager {
2218
2291
  }
2219
2292
  for (let i = 0; i < retryCount; i++) {
2220
2293
  await new Promise((r) => setTimeout(r, retryInterval));
2221
- if (deliver()) {
2294
+ if (await deliver()) {
2222
2295
  this.scheduler.recordRun(id, "delivered");
2223
2296
  if (source !== target)
2224
2297
  this.notifySourceTopic(schedule);
@@ -2642,73 +2715,92 @@ export class FleetManager {
2642
2715
  }
2643
2716
  }, 5 * 60_000);
2644
2717
  }
2645
- /** Save fleet config back to fleet.yaml */
2646
- saveFleetConfig() {
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 = []) {
2647
2723
  if (!this.fleetConfig || !this.configPath)
2648
2724
  return;
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");
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
+ }
2712
2804
  }
2713
2805
  async removeInstance(name) {
2714
2806
  // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
@@ -2751,6 +2843,10 @@ export class FleetManager {
2751
2843
  startStatuslineWatcher(name) {
2752
2844
  this.statuslineWatcher.watch(name);
2753
2845
  }
2846
+ stopStatuslineWatcher(name) {
2847
+ // Pausing stops I/O but retains the last observed limits for status views.
2848
+ this.statuslineWatcher.unwatch(name, true);
2849
+ }
2754
2850
  reactMessageStatus(instanceName, chatId, messageId, emoji) {
2755
2851
  // React via the adapter BOUND to this instance — NOT the first discord world.
2756
2852
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -3101,7 +3197,7 @@ export class FleetManager {
3101
3197
  return [];
3102
3198
  }
3103
3199
  }
3104
- async sendHangNotification(instanceName) {
3200
+ async sendHangNotification(instanceName, unchangedForMs) {
3105
3201
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3106
3202
  if (!adapter)
3107
3203
  return;
@@ -3110,11 +3206,18 @@ export class FleetManager {
3110
3206
  if (!groupId)
3111
3207
  return;
3112
3208
  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));
3113
3216
  this.setTopicIcon(instanceName, "red");
3114
3217
  await adapter.notifyAlert(String(groupId), {
3115
3218
  type: "hang",
3116
3219
  instanceName,
3117
- message: `⚠️ ${instanceName} appears hung (no activity for 15+ minutes)`,
3220
+ message: `⚠️ ${instanceName} may be stuck pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
3118
3221
  choices: [
3119
3222
  { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
3120
3223
  { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
@@ -3615,11 +3718,6 @@ When users create specialized instances, suggest these configurations:
3615
3718
  const fullText = logContext
3616
3719
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
3617
3720
  : 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
- }
3623
3721
  const meta = {
3624
3722
  chat_id: msg.chatId,
3625
3723
  message_id: msg.messageId,
@@ -3627,6 +3725,7 @@ When users create specialized instances, suggest these configurations:
3627
3725
  user_id: msg.userId,
3628
3726
  ts: msg.timestamp.toISOString(),
3629
3727
  thread_id: msg.threadId ?? "",
3728
+ ...(msg.adapterId ? { adapter_id: msg.adapterId } : {}),
3630
3729
  source: msg.source,
3631
3730
  ...extraMeta,
3632
3731
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
@@ -3641,12 +3740,18 @@ When users create specialized instances, suggest these configurations:
3641
3740
  meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3642
3741
  }
3643
3742
  }
3644
- ipc.send({
3645
- type: "fleet_inbound",
3646
- content: fullText,
3647
- targetSession: instanceName,
3648
- meta,
3649
- });
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
+ }
3650
3755
  this.lastInboundUser.set(instanceName, msg.username);
3651
3756
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
3652
3757
  this.trackInboundMsg(instanceName, msg);
@@ -4326,6 +4431,7 @@ When users create specialized instances, suggest these configurations:
4326
4431
  lastActivity: this.lastActivityMs(inst.name) || null,
4327
4432
  currentTask,
4328
4433
  idle: this.getInstanceIdle(inst.name),
4434
+ state: this.getInstanceExecutionState(inst.name),
4329
4435
  };
4330
4436
  });
4331
4437
  res.setHeader("Access-Control-Allow-Origin", "*");