@suzuke/agend 1.1.1 → 1.2.0

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/access-path.d.ts +1 -1
  2. package/dist/access-path.js +2 -2
  3. package/dist/access-path.js.map +1 -1
  4. package/dist/backend/claude-code.d.ts +1 -0
  5. package/dist/backend/claude-code.js +6 -1
  6. package/dist/backend/claude-code.js.map +1 -1
  7. package/dist/backend/codex.d.ts +1 -0
  8. package/dist/backend/codex.js +16 -6
  9. package/dist/backend/codex.js.map +1 -1
  10. package/dist/backend/gemini-cli.d.ts +1 -0
  11. package/dist/backend/gemini-cli.js +13 -1
  12. package/dist/backend/gemini-cli.js.map +1 -1
  13. package/dist/backend/opencode.d.ts +1 -0
  14. package/dist/backend/opencode.js +12 -4
  15. package/dist/backend/opencode.js.map +1 -1
  16. package/dist/backend/types.d.ts +2 -0
  17. package/dist/backend/types.js.map +1 -1
  18. package/dist/channel/access-manager.d.ts +5 -5
  19. package/dist/channel/access-manager.js.map +1 -1
  20. package/dist/channel/adapters/discord.d.ts +3 -2
  21. package/dist/channel/adapters/discord.js +11 -4
  22. package/dist/channel/adapters/discord.js.map +1 -1
  23. package/dist/channel/adapters/telegram.d.ts +4 -3
  24. package/dist/channel/adapters/telegram.js +11 -5
  25. package/dist/channel/adapters/telegram.js.map +1 -1
  26. package/dist/channel/ipc-bridge.js +30 -3
  27. package/dist/channel/ipc-bridge.js.map +1 -1
  28. package/dist/channel/types.d.ts +8 -7
  29. package/dist/cli.js +3 -3
  30. package/dist/cli.js.map +1 -1
  31. package/dist/daemon.js +15 -8
  32. package/dist/daemon.js.map +1 -1
  33. package/dist/fleet-context.d.ts +2 -2
  34. package/dist/fleet-manager.d.ts +3 -3
  35. package/dist/fleet-manager.js +59 -78
  36. package/dist/fleet-manager.js.map +1 -1
  37. package/dist/safe-async.d.ts +6 -0
  38. package/dist/safe-async.js +20 -0
  39. package/dist/safe-async.js.map +1 -0
  40. package/dist/setup-wizard.js +3 -3
  41. package/dist/setup-wizard.js.map +1 -1
  42. package/dist/tmux-manager.js +1 -1
  43. package/dist/tmux-manager.js.map +1 -1
  44. package/dist/topic-commands.d.ts +2 -2
  45. package/dist/topic-commands.js +1 -1
  46. package/dist/topic-commands.js.map +1 -1
  47. package/dist/types.d.ts +2 -2
  48. package/package.json +1 -1
@@ -14,7 +14,7 @@ export declare class FleetManager implements FleetContext {
14
14
  private daemons;
15
15
  fleetConfig: FleetConfig | null;
16
16
  adapter: ChannelAdapter | null;
17
- routingTable: Map<number, RouteTarget>;
17
+ routingTable: Map<string, RouteTarget>;
18
18
  instanceIpcClients: Map<string, IpcClient>;
19
19
  scheduler: Scheduler | null;
20
20
  private configPath;
@@ -40,7 +40,7 @@ export declare class FleetManager implements FleetContext {
40
40
  /** Load fleet.yaml and build routing table */
41
41
  loadConfig(configPath: string): FleetConfig;
42
42
  /** Build topic routing table: { topicId -> RouteTarget } */
43
- buildRoutingTable(): Map<number, RouteTarget>;
43
+ buildRoutingTable(): Map<string, RouteTarget>;
44
44
  getInstanceDir(name: string): string;
45
45
  getInstanceStatus(name: string): "running" | "stopped" | "crashed";
46
46
  startInstance(name: string, config: InstanceConfig, topicMode: boolean): Promise<void>;
@@ -69,7 +69,7 @@ export declare class FleetManager implements FleetContext {
69
69
  private notifyScheduleFailure;
70
70
  private handleScheduleCrud;
71
71
  /** Create a forum topic via the adapter. Returns the message_thread_id. */
72
- createForumTopic(topicName: string): Promise<number>;
72
+ createForumTopic(topicName: string): Promise<number | string>;
73
73
  private deleteForumTopic;
74
74
  private topicCleanupTimer;
75
75
  private sessionPruneTimer;
@@ -24,6 +24,7 @@ import { TopicCommands, sanitizeInstanceName } from "./topic-commands.js";
24
24
  import { DailySummary } from "./daily-summary.js";
25
25
  import { WebhookEmitter } from "./webhook-emitter.js";
26
26
  import { TmuxControlClient } from "./tmux-control.js";
27
+ import { safeHandler } from "./safe-async.js";
27
28
  const TMUX_SESSION = "agend";
28
29
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
29
30
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -82,7 +83,7 @@ export class FleetManager {
82
83
  return table;
83
84
  for (const [name, inst] of Object.entries(this.fleetConfig.instances)) {
84
85
  if (inst.topic_id != null) {
85
- table.set(inst.topic_id, {
86
+ table.set(String(inst.topic_id), {
86
87
  kind: inst.general_topic ? "general" : "instance",
87
88
  name,
88
89
  });
@@ -124,25 +125,25 @@ export class FleetManager {
124
125
  const daemon = new Daemon(name, config, instanceDir, topicMode, backend, this.controlClient ?? undefined);
125
126
  await daemon.start();
126
127
  this.daemons.set(name, daemon);
127
- daemon.on("restart_complete", (data) => {
128
+ daemon.on("restart_complete", safeHandler((data) => {
128
129
  this.eventLog?.insert(name, "context_rotation", data);
129
130
  this.logger.info({ name, ...data }, "Context restart completed");
130
- });
131
+ }, this.logger, `daemon.restart_complete[${name}]`));
131
132
  const hangDetector = daemon.getHangDetector();
132
133
  if (hangDetector) {
133
- hangDetector.on("hang", () => {
134
+ hangDetector.on("hang", safeHandler(async () => {
134
135
  this.eventLog?.insert(name, "hang_detected", {});
135
136
  this.logger.warn({ name }, "Instance appears hung");
136
- this.sendHangNotification(name);
137
+ await this.sendHangNotification(name);
137
138
  this.webhookEmitter?.emit("hang", name);
138
- });
139
+ }, this.logger, `hangDetector[${name}]`));
139
140
  }
140
- daemon.on("crash_loop", () => {
141
+ daemon.on("crash_loop", safeHandler(() => {
141
142
  this.eventLog?.insert(name, "crash_loop", {});
142
143
  this.logger.error({ name }, "Instance in crash loop — respawn paused");
143
144
  this.notifyInstanceTopic(name, `🔴 ${name} keeps crashing shortly after launch — respawn paused. Check rate limits or run \`agend fleet restart\`.`);
144
145
  this.setTopicIcon(name, "red");
145
- });
146
+ }, this.logger, `daemon.crash_loop[${name}]`));
146
147
  this.setTopicIcon(name, "green");
147
148
  this.touchActivity(name);
148
149
  }
@@ -226,16 +227,16 @@ export class FleetManager {
226
227
  this.webhookEmitter = new WebhookEmitter(webhookConfigs, this.logger);
227
228
  this.logger.info({ count: webhookConfigs.length }, "Webhook emitter initialized");
228
229
  }
229
- this.costGuard.on("warn", (instance, totalCents, limitCents) => {
230
+ this.costGuard.on("warn", safeHandler((instance, totalCents, limitCents) => {
230
231
  this.notifyInstanceTopic(instance, `⚠️ ${instance} cost: ${formatCents(totalCents)} / ${formatCents(limitCents)} (${Math.round(totalCents / limitCents * 100)}%)`);
231
232
  this.webhookEmitter?.emit("cost_warning", instance, { cost_cents: totalCents, limit_cents: limitCents });
232
- });
233
- this.costGuard.on("limit", (instance, totalCents, limitCents) => {
233
+ }, this.logger, "costGuard.warn"));
234
+ this.costGuard.on("limit", safeHandler(async (instance, totalCents, limitCents) => {
234
235
  this.notifyInstanceTopic(instance, `🛑 ${instance} daily limit ${formatCents(limitCents)} reached — pausing instance.`);
235
236
  this.eventLog?.insert(instance, "instance_paused", { reason: "cost_limit", cost_cents: totalCents });
236
237
  this.webhookEmitter?.emit("cost_limit", instance, { cost_cents: totalCents, limit_cents: limitCents });
237
- this.stopInstance(instance).catch(err => this.logger.error({ err, instance }, "Failed to pause instance on cost limit"));
238
- });
238
+ await this.stopInstance(instance);
239
+ }, this.logger, "costGuard.limit"));
239
240
  const summaryConfig = {
240
241
  ...DEFAULT_DAILY_SUMMARY,
241
242
  ...fleet.defaults?.daily_summary ?? {},
@@ -371,10 +372,10 @@ export class FleetManager {
371
372
  accessManager,
372
373
  inboxDir,
373
374
  });
374
- this.adapter.on("message", (msg) => {
375
- this.handleInboundMessage(msg);
376
- });
377
- this.adapter.on("callback_query", async (data) => {
375
+ this.adapter.on("message", safeHandler(async (msg) => {
376
+ await this.handleInboundMessage(msg);
377
+ }, this.logger, "adapter.message"));
378
+ this.adapter.on("callback_query", safeHandler(async (data) => {
378
379
  if (data.callbackData.startsWith("hang:")) {
379
380
  const parts = data.callbackData.split(":");
380
381
  const action = parts[1];
@@ -395,28 +396,27 @@ export class FleetManager {
395
396
  }
396
397
  return;
397
398
  }
398
- });
399
- this.adapter.on("topic_closed", (data) => {
400
- const tid = parseInt(data.threadId, 10);
399
+ }, this.logger, "adapter.callback_query"));
400
+ this.adapter.on("topic_closed", safeHandler(async (data) => {
401
401
  // Skip unbind if we archived this topic ourselves
402
- if (this.archivedTopics.has(tid))
402
+ if (this.archivedTopics.has(data.threadId))
403
403
  return;
404
- this.topicCommands.handleTopicDeleted(tid);
405
- });
404
+ await this.topicCommands.handleTopicDeleted(data.threadId);
405
+ }, this.logger, "adapter.topic_closed"));
406
406
  await this.topicCommands.registerBotCommands();
407
407
  await this.adapter.start();
408
408
  if (fleet.channel?.group_id) {
409
409
  this.adapter.setChatId(String(fleet.channel.group_id));
410
410
  }
411
- this.adapter.on("started", (username) => {
412
- this.logger.info(`Telegram bot @${username} polling`);
413
- });
414
- this.adapter.on("polling_conflict", ({ attempt, delay }) => {
411
+ this.adapter.on("started", safeHandler((username) => {
412
+ this.logger.info(`Bot @${username} polling`);
413
+ }, this.logger, "adapter.started"));
414
+ this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
415
415
  this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
416
- });
417
- this.adapter.on("handler_error", (err) => {
418
- this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Telegram handler error");
419
- });
416
+ }, this.logger, "adapter.polling_conflict"));
417
+ this.adapter.on("handler_error", safeHandler((err) => {
418
+ this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
419
+ }, this.logger, "adapter.handler_error"));
420
420
  this.startTopicCleanupPoller();
421
421
  // Prune stale external sessions every 5 minutes
422
422
  this.sessionPruneTimer = setInterval(() => {
@@ -438,7 +438,7 @@ export class FleetManager {
438
438
  try {
439
439
  await ipc.connect();
440
440
  this.instanceIpcClients.set(name, ipc);
441
- ipc.on("message", (msg) => {
441
+ ipc.on("message", safeHandler(async (msg) => {
442
442
  if (msg.type === "mcp_ready") {
443
443
  // Register external sessions (sessionName differs from instance name)
444
444
  const sessionName = msg.sessionName;
@@ -463,7 +463,7 @@ export class FleetManager {
463
463
  this.sessionRegistry.set(sender, name);
464
464
  this.logger.info({ sessionName: sender, instanceName: name }, "Registered external session");
465
465
  }
466
- this.handleOutboundFromInstance(name, msg).catch(err => this.logger.error({ err }, "handleOutboundFromInstance error"));
466
+ await this.handleOutboundFromInstance(name, msg);
467
467
  }
468
468
  else if (msg.type === "fleet_tool_status") {
469
469
  this.handleToolStatusFromInstance(name, msg);
@@ -472,7 +472,7 @@ export class FleetManager {
472
472
  msg.type === "fleet_schedule_update" || msg.type === "fleet_schedule_delete") {
473
473
  this.handleScheduleCrud(name, msg);
474
474
  }
475
- });
475
+ }, this.logger, `ipc.message[${name}]`));
476
476
  // Ask daemon for any sessions that registered before we connected
477
477
  // (fixes race condition where mcp_ready was broadcast before fleet manager connected)
478
478
  ipc.send({ type: "query_sessions" });
@@ -497,7 +497,7 @@ export class FleetManager {
497
497
  return undefined;
498
498
  }
499
499
  async handleInboundMessage(msg) {
500
- const threadId = msg.threadId ? parseInt(msg.threadId, 10) : undefined;
500
+ const threadId = msg.threadId || undefined;
501
501
  if (threadId == null) {
502
502
  // General topic: check for /status command
503
503
  if (await this.topicCommands.handleGeneralCommand(msg))
@@ -583,7 +583,7 @@ export class FleetManager {
583
583
  if (this.adapter && msg.chatId) {
584
584
  const threadId = msg.threadId ?? undefined;
585
585
  this.adapter.sendText(msg.chatId, `⏸ ${instanceName} has hit the weekly usage limit. Your message was not delivered. Limit resets automatically — check /status for details.`, { threadId })
586
- .catch(e => this.logger.debug({ err: e }, "Failed to send rate limit notice"));
586
+ .catch(e => this.logger.warn({ err: e }, "Failed to send rate limit notice"));
587
587
  }
588
588
  this.logger.info({ instanceName }, "Blocked inbound message — weekly rate limit at 100%");
589
589
  return true;
@@ -695,13 +695,13 @@ export class FleetManager {
695
695
  if (senderTopicId && !isExternalSender) {
696
696
  this.adapter.sendText(String(groupId), `→ ${targetName}:\n${message}`, {
697
697
  threadId: String(senderTopicId),
698
- }).catch(e => this.logger.debug({ err: e }, "Failed to post cross-instance notification"));
698
+ }).catch(e => this.logger.warn({ err: e }, "Failed to post cross-instance notification"));
699
699
  }
700
700
  // Only post to target topic if target is an instance (not external session)
701
701
  if (targetTopicId && !this.sessionRegistry.has(targetName)) {
702
702
  this.adapter.sendText(String(groupId), `← ${senderLabel}:\n${message}`, {
703
703
  threadId: String(targetTopicId),
704
- }).catch(e => this.logger.debug({ err: e }, "Failed to post cross-instance notification"));
704
+ }).catch(e => this.logger.warn({ err: e }, "Failed to post cross-instance notification"));
705
705
  }
706
706
  }
707
707
  this.logger.info(`✉ ${senderLabel} → ${targetName}: ${(message ?? "").slice(0, 100)}`);
@@ -737,14 +737,9 @@ export class FleetManager {
737
737
  const question = args.question;
738
738
  const context = args.context;
739
739
  const body = context ? `${question}\n\nContext: ${context}` : question;
740
- // Re-dispatch as send_to_instance with structured metadata
741
- args.instance_name = targetName;
742
- args.message = body;
743
- args.request_kind = "query";
744
- args.requires_reply = true;
745
- args.task_summary = question.slice(0, 120);
746
- // Recursively handle via the same switch (will hit send_to_instance case above)
747
- return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args, requestId, fleetRequestId, senderSessionName });
740
+ // Re-dispatch as send_to_instance with structured metadata (new object to avoid mutating input)
741
+ const queryArgs = { ...args, instance_name: targetName, message: body, request_kind: "query", requires_reply: true, task_summary: question.slice(0, 120) };
742
+ return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args: queryArgs, requestId, fleetRequestId, senderSessionName });
748
743
  }
749
744
  case "delegate_task": {
750
745
  const targetName = args.target_instance;
@@ -756,12 +751,8 @@ export class FleetManager {
756
751
  body += `\n\nSuccess criteria: ${criteria}`;
757
752
  if (context)
758
753
  body += `\n\nContext: ${context}`;
759
- args.instance_name = targetName;
760
- args.message = body;
761
- args.request_kind = "task";
762
- args.requires_reply = true;
763
- args.task_summary = task.slice(0, 120);
764
- return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args, requestId, fleetRequestId, senderSessionName });
754
+ const taskArgs = { ...args, instance_name: targetName, message: body, request_kind: "task", requires_reply: true, task_summary: task.slice(0, 120) };
755
+ return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args: taskArgs, requestId, fleetRequestId, senderSessionName });
765
756
  }
766
757
  case "report_result": {
767
758
  const targetName = args.target_instance;
@@ -773,12 +764,8 @@ export class FleetManager {
773
764
  let body = summary;
774
765
  if (artifacts)
775
766
  body += `\n\nArtifacts: ${artifacts}`;
776
- args.instance_name = targetName;
777
- args.message = body;
778
- args.request_kind = "report";
779
- args.requires_reply = false;
780
- args.task_summary = summary.slice(0, 120);
781
- return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args, requestId, fleetRequestId, senderSessionName });
767
+ const reportArgs = { ...args, instance_name: targetName, message: body, request_kind: "report", requires_reply: false, task_summary: summary.slice(0, 120) };
768
+ return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args: reportArgs, requestId, fleetRequestId, senderSessionName });
782
769
  }
783
770
  // Phase 4: Capability discovery
784
771
  case "describe_instance": {
@@ -939,7 +926,7 @@ export class FleetManager {
939
926
  ...(worktreePath ? { worktree_source: directory } : {}),
940
927
  };
941
928
  this.fleetConfig.instances[newInstanceName] = instanceConfig;
942
- this.routingTable.set(createdTopicId, { kind: "instance", name: newInstanceName });
929
+ this.routingTable.set(String(createdTopicId), { kind: "instance", name: newInstanceName });
943
930
  this.saveFleetConfig();
944
931
  // Step c: Start instance
945
932
  await this.startInstance(newInstanceName, instanceConfig, true);
@@ -954,12 +941,12 @@ export class FleetManager {
954
941
  catch (err) {
955
942
  // Rollback in reverse order
956
943
  if (newInstanceName && this.daemons.has(newInstanceName)) {
957
- await this.stopInstance(newInstanceName).catch(() => { });
944
+ await this.stopInstance(newInstanceName).catch(e => this.logger.error({ err: e, name: newInstanceName }, "Failed to stop instance during rollback"));
958
945
  }
959
946
  if (newInstanceName && this.fleetConfig?.instances[newInstanceName]) {
960
947
  delete this.fleetConfig.instances[newInstanceName];
961
948
  if (createdTopicId)
962
- this.routingTable.delete(createdTopicId);
949
+ this.routingTable.delete(String(createdTopicId));
963
950
  this.saveFleetConfig();
964
951
  }
965
952
  if (createdTopicId) {
@@ -1021,7 +1008,7 @@ export class FleetManager {
1021
1008
  this.adapter.sendText(chatId, text, { threadId }).then((sent) => {
1022
1009
  const ipc = this.instanceIpcClients.get(instanceName);
1023
1010
  ipc?.send({ type: "fleet_tool_status_ack", messageId: sent.messageId });
1024
- }).catch(e => this.logger.debug({ err: e }, "Failed to send tool status message"));
1011
+ }).catch(e => this.logger.warn({ err: e }, "Failed to send tool status message"));
1025
1012
  }
1026
1013
  }
1027
1014
  // ===================== Scheduler =====================
@@ -1141,18 +1128,9 @@ export class FleetManager {
1141
1128
  }
1142
1129
  async deleteForumTopic(topicId) {
1143
1130
  try {
1144
- const groupId = this.fleetConfig?.channel?.group_id;
1145
- const botTokenEnv = this.fleetConfig?.channel?.bot_token_env;
1146
- if (!groupId || !botTokenEnv)
1131
+ if (!this.adapter?.deleteTopic)
1147
1132
  return;
1148
- const botToken = process.env[botTokenEnv];
1149
- if (!botToken)
1150
- return;
1151
- await fetch(`https://api.telegram.org/bot${botToken}/deleteForumTopic`, {
1152
- method: "POST",
1153
- headers: { "Content-Type": "application/json" },
1154
- body: JSON.stringify({ chat_id: groupId, message_thread_id: topicId }),
1155
- });
1133
+ await this.adapter.deleteTopic(topicId);
1156
1134
  }
1157
1135
  catch (err) {
1158
1136
  this.logger.warn({ err, topicId }, "Failed to delete forum topic during rollback");
@@ -1276,8 +1254,8 @@ export class FleetManager {
1276
1254
  this.instanceIpcClients.delete(name);
1277
1255
  }
1278
1256
  // Remove from routing table
1279
- if (config.topic_id) {
1280
- this.routingTable.delete(config.topic_id);
1257
+ if (config.topic_id != null) {
1258
+ this.routingTable.delete(String(config.topic_id));
1281
1259
  }
1282
1260
  // Remove from fleet config and save
1283
1261
  delete this.fleetConfig.instances[name];
@@ -1289,7 +1267,9 @@ export class FleetManager {
1289
1267
  const timer = setInterval(() => {
1290
1268
  try {
1291
1269
  const data = JSON.parse(readFileSync(statusFile, "utf-8"));
1292
- this.costGuard?.updateCost(name, data.cost?.total_cost_usd ?? 0);
1270
+ if (data.cost?.total_cost_usd != null) {
1271
+ this.costGuard?.updateCost(name, data.cost.total_cost_usd);
1272
+ }
1293
1273
  const rl = data.rate_limits;
1294
1274
  if (rl) {
1295
1275
  const prev = this.instanceRateLimits.get(name);
@@ -1435,7 +1415,8 @@ export class FleetManager {
1435
1415
  const topicId = config.topic_id;
1436
1416
  if (topicId == null || config.general_topic)
1437
1417
  continue;
1438
- if (this.archivedTopics.has(topicId))
1418
+ const topicIdStr = String(topicId);
1419
+ if (this.archivedTopics.has(topicIdStr))
1439
1420
  continue;
1440
1421
  const status = this.getInstanceStatus(name);
1441
1422
  if (status !== "running")
@@ -1446,7 +1427,7 @@ export class FleetManager {
1446
1427
  if (now - last < FleetManager.ARCHIVE_IDLE_MS)
1447
1428
  continue;
1448
1429
  this.logger.info({ name, topicId, idleHours: Math.round((now - last) / 3600000) }, "Archiving idle topic");
1449
- this.archivedTopics.add(topicId);
1430
+ this.archivedTopics.add(topicIdStr);
1450
1431
  this.setTopicIcon(name, "remove");
1451
1432
  await this.adapter.closeForumTopic(topicId);
1452
1433
  }