@songsid/agend 2.1.0-beta.1 → 2.1.0-beta.11

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 (55) hide show
  1. package/dist/backend/antigravity.js +3 -1
  2. package/dist/backend/antigravity.js.map +1 -1
  3. package/dist/backend/codex.js +3 -1
  4. package/dist/backend/codex.js.map +1 -1
  5. package/dist/backend/kiro.js +3 -2
  6. package/dist/backend/kiro.js.map +1 -1
  7. package/dist/cli.js +43 -6
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config-validator.js +7 -0
  10. package/dist/config-validator.js.map +1 -1
  11. package/dist/config.d.ts +3 -1
  12. package/dist/config.js +13 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/context-guardian.d.ts +2 -0
  15. package/dist/context-guardian.js +10 -2
  16. package/dist/context-guardian.js.map +1 -1
  17. package/dist/daemon-entry.js +2 -1
  18. package/dist/daemon-entry.js.map +1 -1
  19. package/dist/daemon.d.ts +50 -3
  20. package/dist/daemon.js +356 -31
  21. package/dist/daemon.js.map +1 -1
  22. package/dist/fleet-context.d.ts +2 -1
  23. package/dist/fleet-manager.d.ts +21 -4
  24. package/dist/fleet-manager.js +213 -122
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/instance-lifecycle.d.ts +6 -1
  27. package/dist/instance-lifecycle.js +45 -3
  28. package/dist/instance-lifecycle.js.map +1 -1
  29. package/dist/logger.js +6 -3
  30. package/dist/logger.js.map +1 -1
  31. package/dist/outbound-handlers.d.ts +5 -0
  32. package/dist/outbound-handlers.js +68 -16
  33. package/dist/outbound-handlers.js.map +1 -1
  34. package/dist/settings-api.d.ts +2 -1
  35. package/dist/settings-api.js +10 -1
  36. package/dist/settings-api.js.map +1 -1
  37. package/dist/statusline-watcher.d.ts +1 -1
  38. package/dist/statusline-watcher.js +3 -2
  39. package/dist/statusline-watcher.js.map +1 -1
  40. package/dist/tmux-manager.d.ts +2 -0
  41. package/dist/tmux-manager.js +9 -0
  42. package/dist/tmux-manager.js.map +1 -1
  43. package/dist/topic-archiver.d.ts +1 -1
  44. package/dist/topic-commands.d.ts +5 -1
  45. package/dist/topic-commands.js +60 -12
  46. package/dist/topic-commands.js.map +1 -1
  47. package/dist/transcript-monitor.js +2 -0
  48. package/dist/transcript-monitor.js.map +1 -1
  49. package/dist/types.d.ts +15 -0
  50. package/dist/ui/settings.html +25 -3
  51. package/dist/view-api.d.ts +1 -1
  52. package/dist/web-api.d.ts +2 -1
  53. package/dist/web-api.js +21 -14
  54. package/dist/web-api.js.map +1 -1
  55. 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,
@@ -1954,6 +2014,9 @@ export class FleetManager {
1954
2014
  this.trackInboundMsg(generalInstance, msg);
1955
2015
  void this.sendCancelButton(generalInstance);
1956
2016
  }
2017
+ catch (err) {
2018
+ this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
2019
+ }
1957
2020
  }
1958
2021
  return;
1959
2022
  }
@@ -2016,27 +2079,32 @@ export class FleetManager {
2016
2079
  this.setTopicIcon(instanceName, "blue");
2017
2080
  this.warnIfRateLimited(instanceName, msg);
2018
2081
  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");
2082
+ try {
2083
+ await this.deliverToInstance(instanceName, {
2084
+ type: "fleet_inbound",
2085
+ content: text,
2086
+ targetSession: instanceName, // Channel messages → instance's own session
2087
+ meta: {
2088
+ chat_id: msg.chatId,
2089
+ message_id: msg.messageId,
2090
+ user: msg.username,
2091
+ user_id: msg.userId,
2092
+ ts: msg.timestamp.toISOString(),
2093
+ thread_id: msg.threadId ?? "",
2094
+ source: msg.source,
2095
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2096
+ ...extraMeta,
2097
+ },
2098
+ });
2099
+ }
2100
+ catch (err) {
2101
+ this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
2102
+ if (msg.chatId && msg.messageId) {
2103
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
2104
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "❌").catch(() => { });
2105
+ }
2022
2106
  return;
2023
2107
  }
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
2108
  this.lastInboundUser.set(instanceName, msg.username);
2041
2109
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
2042
2110
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
@@ -2197,20 +2265,23 @@ export class FleetManager {
2197
2265
  const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
2198
2266
  const retryCount = schedulerDefaults?.retry_count ?? 3;
2199
2267
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
2200
- const deliver = () => {
2201
- const ipc = this.instanceIpcClients.get(target);
2202
- if (!ipc?.connected)
2268
+ const deliver = async () => {
2269
+ try {
2270
+ await this.deliverToInstance(target, {
2271
+ type: "fleet_schedule_trigger",
2272
+ payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
2273
+ meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
2274
+ });
2275
+ // A scheduled trigger also puts the instance to work — show a cancel button.
2276
+ void this.sendCancelButton(target);
2277
+ return true;
2278
+ }
2279
+ catch (err) {
2280
+ this.logger.warn({ err: err.message, target }, "Scheduled wake/delivery attempt failed");
2203
2281
  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;
2282
+ }
2212
2283
  };
2213
- if (deliver()) {
2284
+ if (await deliver()) {
2214
2285
  this.scheduler.recordRun(id, "delivered");
2215
2286
  if (source !== target)
2216
2287
  this.notifySourceTopic(schedule);
@@ -2218,7 +2289,7 @@ export class FleetManager {
2218
2289
  }
2219
2290
  for (let i = 0; i < retryCount; i++) {
2220
2291
  await new Promise((r) => setTimeout(r, retryInterval));
2221
- if (deliver()) {
2292
+ if (await deliver()) {
2222
2293
  this.scheduler.recordRun(id, "delivered");
2223
2294
  if (source !== target)
2224
2295
  this.notifySourceTopic(schedule);
@@ -2642,73 +2713,80 @@ export class FleetManager {
2642
2713
  }
2643
2714
  }, 5 * 60_000);
2644
2715
  }
2645
- /** Save fleet config back to fleet.yaml */
2716
+ /**
2717
+ * Patch only values changed in the effective config into the original YAML
2718
+ * document. Unknown keys, explicit overrides and comments remain untouched.
2719
+ */
2646
2720
  saveFleetConfig() {
2647
2721
  if (!this.fleetConfig || !this.configPath)
2648
2722
  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");
2723
+ if (!this.savedFleetConfigSnapshot)
2724
+ this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2725
+ // Re-read immediately before patching so an unrelated concurrent/manual
2726
+ // edit is retained. Invalid concurrent YAML is never overwritten.
2727
+ const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
2728
+ this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
2729
+ if (this.rawFleetDocument.errors.length > 0) {
2730
+ throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
2731
+ }
2732
+ this.rawFleetConfig = loadRawFleetConfig(this.configPath);
2733
+ this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
2734
+ const output = String(this.rawFleetDocument);
2735
+ const tempPath = `${this.configPath}.tmp-${process.pid}`;
2736
+ writeFileSync(tempPath, output, "utf-8");
2737
+ if (existsSync(this.configPath))
2738
+ chmodSync(tempPath, statSync(this.configPath).mode);
2739
+ renameSync(tempPath, this.configPath);
2740
+ this.rawFleetConfig = loadRawFleetConfig(this.configPath);
2741
+ this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2742
+ this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
2743
+ }
2744
+ patchFleetDocument(document, path, before, after) {
2745
+ if (Object.is(before, after))
2746
+ return;
2747
+ if (Array.isArray(before) && Array.isArray(after)) {
2748
+ const shared = Math.min(before.length, after.length);
2749
+ for (let i = 0; i < shared; i++) {
2750
+ this.patchFleetDocument(document, [...path, i], before[i], after[i]);
2751
+ }
2752
+ // Remove from the end so YAML sequence indexes do not shift underneath us.
2753
+ for (let i = before.length - 1; i >= after.length; i--)
2754
+ document.deleteIn([...path, i]);
2755
+ for (let i = shared; i < after.length; i++)
2756
+ document.setIn([...path, i], after[i]);
2757
+ return;
2758
+ }
2759
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2760
+ if (isRecord(before) && isRecord(after)) {
2761
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
2762
+ for (const key of keys) {
2763
+ // `channel` is a derived alias when the raw file uses `channels`.
2764
+ if (path.length === 0 && key === "channel" && this.rawFleetConfig.channels)
2765
+ continue;
2766
+ // Conversely, `channels` is a normalized alias for a legacy `channel`.
2767
+ // Keep the user's original shape unless the caller explicitly removed
2768
+ // `channel` (the Settings channels endpoint intentionally migrates it).
2769
+ if (path.length === 0 && key === "channels" && this.rawFleetConfig.channel && !this.rawFleetConfig.channels && after.channel !== undefined)
2770
+ continue;
2771
+ this.patchFleetDocument(document, [...path, key], before[key], after[key]);
2772
+ }
2773
+ return;
2774
+ }
2775
+ if (after === undefined) {
2776
+ document.deleteIn(path);
2777
+ }
2778
+ else if (path.length === 0) {
2779
+ document.contents = document.createNode(after);
2780
+ }
2781
+ else {
2782
+ const currentNode = document.getIn(path, true);
2783
+ if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
2784
+ currentNode.value = after;
2785
+ }
2786
+ else {
2787
+ document.setIn(path, after);
2788
+ }
2789
+ }
2712
2790
  }
2713
2791
  async removeInstance(name) {
2714
2792
  // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
@@ -2751,6 +2829,10 @@ export class FleetManager {
2751
2829
  startStatuslineWatcher(name) {
2752
2830
  this.statuslineWatcher.watch(name);
2753
2831
  }
2832
+ stopStatuslineWatcher(name) {
2833
+ // Pausing stops I/O but retains the last observed limits for status views.
2834
+ this.statuslineWatcher.unwatch(name, true);
2835
+ }
2754
2836
  reactMessageStatus(instanceName, chatId, messageId, emoji) {
2755
2837
  // React via the adapter BOUND to this instance — NOT the first discord world.
2756
2838
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -3101,7 +3183,7 @@ export class FleetManager {
3101
3183
  return [];
3102
3184
  }
3103
3185
  }
3104
- async sendHangNotification(instanceName) {
3186
+ async sendHangNotification(instanceName, unchangedForMs) {
3105
3187
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3106
3188
  if (!adapter)
3107
3189
  return;
@@ -3110,11 +3192,18 @@ export class FleetManager {
3110
3192
  if (!groupId)
3111
3193
  return;
3112
3194
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
3195
+ const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
3196
+ const configuredMinutes = instanceHangConfig?.timeout_minutes
3197
+ ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
3198
+ ?? 15;
3199
+ const unchangedMinutes = unchangedForMs == null
3200
+ ? configuredMinutes
3201
+ : Math.max(1, Math.floor(unchangedForMs / 60_000));
3113
3202
  this.setTopicIcon(instanceName, "red");
3114
3203
  await adapter.notifyAlert(String(groupId), {
3115
3204
  type: "hang",
3116
3205
  instanceName,
3117
- message: `⚠️ ${instanceName} appears hung (no activity for 15+ minutes)`,
3206
+ message: `⚠️ ${instanceName} may be stuck pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
3118
3207
  choices: [
3119
3208
  { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
3120
3209
  { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
@@ -3615,11 +3704,6 @@ When users create specialized instances, suggest these configurations:
3615
3704
  const fullText = logContext
3616
3705
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
3617
3706
  : 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
3707
  const meta = {
3624
3708
  chat_id: msg.chatId,
3625
3709
  message_id: msg.messageId,
@@ -3641,12 +3725,18 @@ When users create specialized instances, suggest these configurations:
3641
3725
  meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3642
3726
  }
3643
3727
  }
3644
- ipc.send({
3645
- type: "fleet_inbound",
3646
- content: fullText,
3647
- targetSession: instanceName,
3648
- meta,
3649
- });
3728
+ try {
3729
+ await this.deliverToInstance(instanceName, {
3730
+ type: "fleet_inbound",
3731
+ content: fullText,
3732
+ targetSession: instanceName,
3733
+ meta,
3734
+ });
3735
+ }
3736
+ catch (err) {
3737
+ this.logger.warn({ err: err.message, instanceName }, "Classic wake/delivery failed");
3738
+ return;
3739
+ }
3650
3740
  this.lastInboundUser.set(instanceName, msg.username);
3651
3741
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
3652
3742
  this.trackInboundMsg(instanceName, msg);
@@ -4326,6 +4416,7 @@ When users create specialized instances, suggest these configurations:
4326
4416
  lastActivity: this.lastActivityMs(inst.name) || null,
4327
4417
  currentTask,
4328
4418
  idle: this.getInstanceIdle(inst.name),
4419
+ state: this.getInstanceExecutionState(inst.name),
4329
4420
  };
4330
4421
  });
4331
4422
  res.setHeader("Access-Control-Allow-Origin", "*");