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

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 (48) 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.js +1 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/context-guardian.d.ts +2 -0
  12. package/dist/context-guardian.js +10 -2
  13. package/dist/context-guardian.js.map +1 -1
  14. package/dist/daemon-entry.js +2 -1
  15. package/dist/daemon-entry.js.map +1 -1
  16. package/dist/daemon.d.ts +50 -3
  17. package/dist/daemon.js +356 -31
  18. package/dist/daemon.js.map +1 -1
  19. package/dist/fleet-context.d.ts +2 -1
  20. package/dist/fleet-manager.d.ts +10 -2
  21. package/dist/fleet-manager.js +125 -56
  22. package/dist/fleet-manager.js.map +1 -1
  23. package/dist/instance-lifecycle.d.ts +6 -1
  24. package/dist/instance-lifecycle.js +45 -3
  25. package/dist/instance-lifecycle.js.map +1 -1
  26. package/dist/logger.js +6 -3
  27. package/dist/logger.js.map +1 -1
  28. package/dist/outbound-handlers.d.ts +5 -0
  29. package/dist/outbound-handlers.js +68 -16
  30. package/dist/outbound-handlers.js.map +1 -1
  31. package/dist/statusline-watcher.d.ts +1 -1
  32. package/dist/statusline-watcher.js +3 -2
  33. package/dist/statusline-watcher.js.map +1 -1
  34. package/dist/tmux-manager.d.ts +2 -0
  35. package/dist/tmux-manager.js +9 -0
  36. package/dist/tmux-manager.js.map +1 -1
  37. package/dist/topic-archiver.d.ts +1 -1
  38. package/dist/topic-commands.d.ts +5 -1
  39. package/dist/topic-commands.js +60 -12
  40. package/dist/topic-commands.js.map +1 -1
  41. package/dist/transcript-monitor.js +2 -0
  42. package/dist/transcript-monitor.js.map +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/dist/view-api.d.ts +1 -1
  45. package/dist/web-api.d.ts +2 -1
  46. package/dist/web-api.js +21 -14
  47. package/dist/web-api.js.map +1 -1
  48. package/package.json +1 -1
@@ -93,6 +93,8 @@ export class FleetManager {
93
93
  // Topic icon + auto-archive state
94
94
  topicIcons = {};
95
95
  lastActivity = new Map();
96
+ /** Latest pane-derived execution snapshot reported by each daemon. */
97
+ instanceStateCache = new Map();
96
98
  lastInboundUser = new Map(); // instanceName → last username
97
99
  // Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
98
100
  // than one-per-instance. A button is retired (deleted, with bounded retry) on
@@ -160,6 +162,7 @@ export class FleetManager {
160
162
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
161
163
  name,
162
164
  status: this.getInstanceStatus(name),
165
+ state: this.getInstanceExecutionState(name),
163
166
  ipc: this.instanceIpcClients.has(name),
164
167
  costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
165
168
  rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
@@ -265,17 +268,24 @@ export class FleetManager {
265
268
  */
266
269
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
267
270
  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;
271
+ if (fromInbound) {
272
+ // Skip inbound-derived binding for any instance that doesn't have an
273
+ // explicit channel_id those default to primary adapter deterministically.
274
+ // This prevents a non-deterministic race where whichever adapter delivers
275
+ // first after restart wins the binding.
276
+ if (cfg?.general_topic || cfg?.channel_id)
277
+ return;
278
+ if (cfg && !cfg.channel_id)
279
+ return; // fleet instance without explicit binding → use primary
280
+ // Classic instance: don't override an existing binding (authoritative from /start)
281
+ if (this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
282
+ return;
283
+ }
276
284
  this.instanceWorldBinding.set(name, adapterId);
277
285
  }
278
286
  getInstanceStatus(name) {
287
+ if (this.lifecycle.isPaused(name))
288
+ return "paused";
279
289
  const pidPath = join(this.getInstanceDir(name), "daemon.pid");
280
290
  if (!existsSync(pidPath))
281
291
  return "stopped";
@@ -288,6 +298,35 @@ export class FleetManager {
288
298
  return "crashed";
289
299
  }
290
300
  }
301
+ getInstanceExecutionState(name) {
302
+ if (this.lifecycle.isPaused(name))
303
+ return null;
304
+ return this.instanceStateCache.get(name)?.state ?? null;
305
+ }
306
+ cacheInstanceExecutionState(name, msg) {
307
+ const state = msg.state;
308
+ if (state !== "idle" && state !== "working" && state !== "stuck")
309
+ return;
310
+ const previous = this.instanceStateCache.get(name);
311
+ const now = Date.now();
312
+ const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
313
+ this.instanceStateCache.set(name, {
314
+ state,
315
+ unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
316
+ observedAt: numberOr(msg.observedAt, now),
317
+ stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
318
+ });
319
+ }
320
+ /** Single delivery facade: wake an auto-paused CLI before sending to daemon IPC. */
321
+ async deliverToInstance(instanceName, payload) {
322
+ if (this.lifecycle.isPaused(instanceName)) {
323
+ await this.lifecycle.wake(instanceName, 30_000);
324
+ }
325
+ const ipc = this.instanceIpcClients.get(instanceName);
326
+ if (!ipc?.connected)
327
+ throw new Error(`Instance '${instanceName}' IPC is unavailable`);
328
+ ipc.send(payload);
329
+ }
291
330
  async startInstance(name, config, topicMode) {
292
331
  if (config.general_topic) {
293
332
  this.ensureGeneralInstructions(config.working_directory, config.backend);
@@ -355,6 +394,7 @@ export class FleetManager {
355
394
  }
356
395
  async stopInstance(name) {
357
396
  this.failoverActive.delete(name);
397
+ this.instanceStateCache.delete(name);
358
398
  return this.lifecycle.stop(name);
359
399
  }
360
400
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
@@ -1520,10 +1560,16 @@ export class FleetManager {
1520
1560
  else if (msg.type === "fleet_set_description") {
1521
1561
  this.handleSetDescription(name, msg);
1522
1562
  }
1563
+ else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
1564
+ this.cacheInstanceExecutionState(name, msg);
1565
+ }
1523
1566
  }, this.logger, `ipc.message[${name}]`));
1524
1567
  // Ask daemon for any sessions that registered before we connected
1525
1568
  // (fixes race condition where mcp_ready was broadcast before fleet manager connected)
1526
1569
  ipc.send({ type: "query_sessions" });
1570
+ // The initial state transition may have happened before FleetManager
1571
+ // connected, so seed the cache instead of waiting for another transition.
1572
+ ipc.send({ type: "query_instance_state", requestId: `fleet-state-${Date.now()}` });
1527
1573
  this.logger.debug({ name }, "Connected to instance IPC");
1528
1574
  if (!this.statuslineWatcher.has(name)) {
1529
1575
  this.statuslineWatcher.watch(name);
@@ -1926,9 +1972,8 @@ export class FleetManager {
1926
1972
  }
1927
1973
  this.warnIfRateLimited(generalInstance, msg);
1928
1974
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
1929
- const ipc = this.instanceIpcClients.get(generalInstance);
1930
- if (ipc) {
1931
- ipc.send({
1975
+ try {
1976
+ await this.deliverToInstance(generalInstance, {
1932
1977
  type: "fleet_inbound",
1933
1978
  content: text,
1934
1979
  targetSession: generalInstance,
@@ -1954,6 +1999,9 @@ export class FleetManager {
1954
1999
  this.trackInboundMsg(generalInstance, msg);
1955
2000
  void this.sendCancelButton(generalInstance);
1956
2001
  }
2002
+ catch (err) {
2003
+ this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
2004
+ }
1957
2005
  }
1958
2006
  return;
1959
2007
  }
@@ -2016,27 +2064,32 @@ export class FleetManager {
2016
2064
  this.setTopicIcon(instanceName, "blue");
2017
2065
  this.warnIfRateLimited(instanceName, msg);
2018
2066
  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");
2067
+ try {
2068
+ await this.deliverToInstance(instanceName, {
2069
+ type: "fleet_inbound",
2070
+ content: text,
2071
+ targetSession: instanceName, // Channel messages → instance's own session
2072
+ meta: {
2073
+ chat_id: msg.chatId,
2074
+ message_id: msg.messageId,
2075
+ user: msg.username,
2076
+ user_id: msg.userId,
2077
+ ts: msg.timestamp.toISOString(),
2078
+ thread_id: msg.threadId ?? "",
2079
+ source: msg.source,
2080
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2081
+ ...extraMeta,
2082
+ },
2083
+ });
2084
+ }
2085
+ catch (err) {
2086
+ this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
2087
+ if (msg.chatId && msg.messageId) {
2088
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
2089
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "❌").catch(() => { });
2090
+ }
2022
2091
  return;
2023
2092
  }
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
2093
  this.lastInboundUser.set(instanceName, msg.username);
2041
2094
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
2042
2095
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
@@ -2197,20 +2250,23 @@ export class FleetManager {
2197
2250
  const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
2198
2251
  const retryCount = schedulerDefaults?.retry_count ?? 3;
2199
2252
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
2200
- const deliver = () => {
2201
- const ipc = this.instanceIpcClients.get(target);
2202
- if (!ipc?.connected)
2253
+ const deliver = async () => {
2254
+ try {
2255
+ await this.deliverToInstance(target, {
2256
+ type: "fleet_schedule_trigger",
2257
+ payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
2258
+ meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
2259
+ });
2260
+ // A scheduled trigger also puts the instance to work — show a cancel button.
2261
+ void this.sendCancelButton(target);
2262
+ return true;
2263
+ }
2264
+ catch (err) {
2265
+ this.logger.warn({ err: err.message, target }, "Scheduled wake/delivery attempt failed");
2203
2266
  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;
2267
+ }
2212
2268
  };
2213
- if (deliver()) {
2269
+ if (await deliver()) {
2214
2270
  this.scheduler.recordRun(id, "delivered");
2215
2271
  if (source !== target)
2216
2272
  this.notifySourceTopic(schedule);
@@ -2218,7 +2274,7 @@ export class FleetManager {
2218
2274
  }
2219
2275
  for (let i = 0; i < retryCount; i++) {
2220
2276
  await new Promise((r) => setTimeout(r, retryInterval));
2221
- if (deliver()) {
2277
+ if (await deliver()) {
2222
2278
  this.scheduler.recordRun(id, "delivered");
2223
2279
  if (source !== target)
2224
2280
  this.notifySourceTopic(schedule);
@@ -2751,6 +2807,10 @@ export class FleetManager {
2751
2807
  startStatuslineWatcher(name) {
2752
2808
  this.statuslineWatcher.watch(name);
2753
2809
  }
2810
+ stopStatuslineWatcher(name) {
2811
+ // Pausing stops I/O but retains the last observed limits for status views.
2812
+ this.statuslineWatcher.unwatch(name, true);
2813
+ }
2754
2814
  reactMessageStatus(instanceName, chatId, messageId, emoji) {
2755
2815
  // React via the adapter BOUND to this instance — NOT the first discord world.
2756
2816
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -3101,7 +3161,7 @@ export class FleetManager {
3101
3161
  return [];
3102
3162
  }
3103
3163
  }
3104
- async sendHangNotification(instanceName) {
3164
+ async sendHangNotification(instanceName, unchangedForMs) {
3105
3165
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3106
3166
  if (!adapter)
3107
3167
  return;
@@ -3110,11 +3170,18 @@ export class FleetManager {
3110
3170
  if (!groupId)
3111
3171
  return;
3112
3172
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
3173
+ const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
3174
+ const configuredMinutes = instanceHangConfig?.timeout_minutes
3175
+ ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
3176
+ ?? 15;
3177
+ const unchangedMinutes = unchangedForMs == null
3178
+ ? configuredMinutes
3179
+ : Math.max(1, Math.floor(unchangedForMs / 60_000));
3113
3180
  this.setTopicIcon(instanceName, "red");
3114
3181
  await adapter.notifyAlert(String(groupId), {
3115
3182
  type: "hang",
3116
3183
  instanceName,
3117
- message: `⚠️ ${instanceName} appears hung (no activity for 15+ minutes)`,
3184
+ message: `⚠️ ${instanceName} may be stuck pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
3118
3185
  choices: [
3119
3186
  { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
3120
3187
  { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
@@ -3615,11 +3682,6 @@ When users create specialized instances, suggest these configurations:
3615
3682
  const fullText = logContext
3616
3683
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
3617
3684
  : 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
3685
  const meta = {
3624
3686
  chat_id: msg.chatId,
3625
3687
  message_id: msg.messageId,
@@ -3641,12 +3703,18 @@ When users create specialized instances, suggest these configurations:
3641
3703
  meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3642
3704
  }
3643
3705
  }
3644
- ipc.send({
3645
- type: "fleet_inbound",
3646
- content: fullText,
3647
- targetSession: instanceName,
3648
- meta,
3649
- });
3706
+ try {
3707
+ await this.deliverToInstance(instanceName, {
3708
+ type: "fleet_inbound",
3709
+ content: fullText,
3710
+ targetSession: instanceName,
3711
+ meta,
3712
+ });
3713
+ }
3714
+ catch (err) {
3715
+ this.logger.warn({ err: err.message, instanceName }, "Classic wake/delivery failed");
3716
+ return;
3717
+ }
3650
3718
  this.lastInboundUser.set(instanceName, msg.username);
3651
3719
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
3652
3720
  this.trackInboundMsg(instanceName, msg);
@@ -4326,6 +4394,7 @@ When users create specialized instances, suggest these configurations:
4326
4394
  lastActivity: this.lastActivityMs(inst.name) || null,
4327
4395
  currentTask,
4328
4396
  idle: this.getInstanceIdle(inst.name),
4397
+ state: this.getInstanceExecutionState(inst.name),
4329
4398
  };
4330
4399
  });
4331
4400
  res.setHeader("Access-Control-Allow-Origin", "*");