@suzuke/agend 1.2.0 → 1.3.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.
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
2
- import { access } from "node:fs/promises";
3
2
  import { createServer } from "node:http";
4
- import { join, dirname, basename } from "node:path";
3
+ import { join, dirname } from "node:path";
5
4
  import { homedir } from "node:os";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import yaml from "js-yaml";
@@ -20,11 +19,16 @@ import { processAttachments } from "./channel/attachment-handler.js";
20
19
  import { routeToolCall } from "./channel/tool-router.js";
21
20
  import { Scheduler } from "./scheduler/index.js";
22
21
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
23
- import { TopicCommands, sanitizeInstanceName } from "./topic-commands.js";
22
+ import { TopicCommands } from "./topic-commands.js";
24
23
  import { DailySummary } from "./daily-summary.js";
25
24
  import { WebhookEmitter } from "./webhook-emitter.js";
26
25
  import { TmuxControlClient } from "./tmux-control.js";
27
26
  import { safeHandler } from "./safe-async.js";
27
+ import { RoutingEngine } from "./routing-engine.js";
28
+ import { InstanceLifecycle } from "./instance-lifecycle.js";
29
+ import { TopicArchiver } from "./topic-archiver.js";
30
+ import { StatuslineWatcher } from "./statusline-watcher.js";
31
+ import { outboundHandlers } from "./outbound-handlers.js";
28
32
  const TMUX_SESSION = "agend";
29
33
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
30
34
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -38,10 +42,13 @@ export function resolveReplyThreadId(argsThreadId, instanceConfig) {
38
42
  export class FleetManager {
39
43
  dataDir;
40
44
  children = new Map();
41
- daemons = new Map();
45
+ lifecycle;
46
+ /** @deprecated Use lifecycle.daemons — kept for backward compat */
47
+ get daemons() { return this.lifecycle.daemons; }
42
48
  fleetConfig = null;
43
49
  adapter = null;
44
- routingTable = new Map();
50
+ routing = new RoutingEngine();
51
+ get routingTable() { return this.routing.map; }
45
52
  instanceIpcClients = new Map();
46
53
  scheduler = null;
47
54
  configPath = "";
@@ -51,25 +58,52 @@ export class FleetManager {
51
58
  sessionRegistry = new Map();
52
59
  eventLog = null;
53
60
  costGuard = null;
54
- statuslineWatchers = new Map();
55
- instanceRateLimits = new Map();
61
+ statuslineWatcher;
56
62
  dailySummary = null;
57
63
  webhookEmitter = null;
58
64
  // Topic icon + auto-archive state
59
65
  topicIcons = {};
60
66
  lastActivity = new Map();
61
- archivedTopics = new Set();
62
- archiveTimer = null;
63
- static ARCHIVE_IDLE_MS = 24 * 60 * 60 * 1000; // 24 hours
67
+ topicArchiver;
68
+ controlClient = null;
64
69
  // Model failover state
65
70
  failoverActive = new Map(); // instance → current failover model
66
- controlClient = null;
67
71
  // Health endpoint
68
72
  healthServer = null;
69
73
  startedAt = 0;
70
74
  constructor(dataDir) {
71
75
  this.dataDir = dataDir;
76
+ this.lifecycle = new InstanceLifecycle(this);
72
77
  this.topicCommands = new TopicCommands(this);
78
+ this.topicArchiver = new TopicArchiver(this);
79
+ this.statuslineWatcher = new StatuslineWatcher(this);
80
+ }
81
+ // ── ArchiverContext bridge ────────────────────────────────────────────
82
+ lastActivityMs(name) {
83
+ return this.lastActivity.get(name) ?? 0;
84
+ }
85
+ // ── LifecycleContext bridge methods ──────────────────────────────────────
86
+ webhookEmit(event, name) {
87
+ this.webhookEmitter?.emit(event, name);
88
+ }
89
+ // ── SysInfo ────────────────────────────────────────────────────────────
90
+ getSysInfo() {
91
+ const mem = process.memoryUsage();
92
+ const toMB = (b) => Math.round(b / 1024 / 1024 * 10) / 10;
93
+ const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
94
+ name,
95
+ status: this.getInstanceStatus(name),
96
+ ipc: this.instanceIpcClients.has(name),
97
+ costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
98
+ rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
99
+ }));
100
+ return {
101
+ uptime_seconds: Math.floor((Date.now() - this.startedAt) / 1000),
102
+ memory_mb: { rss: toMB(mem.rss), heapUsed: toMB(mem.heapUsed), heapTotal: toMB(mem.heapTotal) },
103
+ instances,
104
+ fleet_cost_cents: this.costGuard?.getFleetTotalCents() ?? 0,
105
+ fleet_cost_limit_cents: this.costGuard?.getLimitCents() ?? 0,
106
+ };
73
107
  }
74
108
  /** Load fleet.yaml and build routing table */
75
109
  loadConfig(configPath) {
@@ -78,18 +112,10 @@ export class FleetManager {
78
112
  }
79
113
  /** Build topic routing table: { topicId -> RouteTarget } */
80
114
  buildRoutingTable() {
81
- const table = new Map();
82
- if (!this.fleetConfig)
83
- return table;
84
- for (const [name, inst] of Object.entries(this.fleetConfig.instances)) {
85
- if (inst.topic_id != null) {
86
- table.set(String(inst.topic_id), {
87
- kind: inst.general_topic ? "general" : "instance",
88
- name,
89
- });
90
- }
115
+ if (this.fleetConfig) {
116
+ this.routing.rebuild(this.fleetConfig);
91
117
  }
92
- return table;
118
+ return this.routing.map;
93
119
  }
94
120
  getInstanceDir(name) {
95
121
  return join(this.dataDir, "instances", name);
@@ -108,65 +134,11 @@ export class FleetManager {
108
134
  }
109
135
  }
110
136
  async startInstance(name, config, topicMode) {
111
- if (this.daemons.has(name)) {
112
- this.logger.info({ name }, "Instance already running, skipping");
113
- return;
114
- }
115
- if (!existsSync(config.working_directory)) {
116
- this.logger.error({ name, working_directory: config.working_directory }, "Working directory does not exist — skipping instance");
117
- return;
118
- }
119
- const instanceDir = this.getInstanceDir(name);
120
- mkdirSync(instanceDir, { recursive: true });
121
- const { Daemon } = await import("./daemon.js");
122
- const { createBackend } = await import("./backend/factory.js");
123
- const backendName = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
124
- const backend = createBackend(backendName, instanceDir);
125
- const daemon = new Daemon(name, config, instanceDir, topicMode, backend, this.controlClient ?? undefined);
126
- await daemon.start();
127
- this.daemons.set(name, daemon);
128
- daemon.on("restart_complete", safeHandler((data) => {
129
- this.eventLog?.insert(name, "context_rotation", data);
130
- this.logger.info({ name, ...data }, "Context restart completed");
131
- }, this.logger, `daemon.restart_complete[${name}]`));
132
- const hangDetector = daemon.getHangDetector();
133
- if (hangDetector) {
134
- hangDetector.on("hang", safeHandler(async () => {
135
- this.eventLog?.insert(name, "hang_detected", {});
136
- this.logger.warn({ name }, "Instance appears hung");
137
- await this.sendHangNotification(name);
138
- this.webhookEmitter?.emit("hang", name);
139
- }, this.logger, `hangDetector[${name}]`));
140
- }
141
- daemon.on("crash_loop", safeHandler(() => {
142
- this.eventLog?.insert(name, "crash_loop", {});
143
- this.logger.error({ name }, "Instance in crash loop — respawn paused");
144
- this.notifyInstanceTopic(name, `🔴 ${name} keeps crashing shortly after launch — respawn paused. Check rate limits or run \`agend fleet restart\`.`);
145
- this.setTopicIcon(name, "red");
146
- }, this.logger, `daemon.crash_loop[${name}]`));
147
- this.setTopicIcon(name, "green");
148
- this.touchActivity(name);
137
+ return this.lifecycle.start(name, config, topicMode);
149
138
  }
150
139
  async stopInstance(name) {
151
- this.setTopicIcon(name, "remove");
152
140
  this.failoverActive.delete(name);
153
- const daemon = this.daemons.get(name);
154
- if (daemon) {
155
- await daemon.stop();
156
- this.daemons.delete(name);
157
- }
158
- else {
159
- const pidPath = join(this.getInstanceDir(name), "daemon.pid");
160
- if (existsSync(pidPath)) {
161
- const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
162
- try {
163
- process.kill(pid, "SIGTERM");
164
- }
165
- catch (e) {
166
- this.logger.debug({ err: e, pid }, "SIGTERM failed for stale process");
167
- }
168
- }
169
- }
141
+ return this.lifecycle.stop(name);
170
142
  }
171
143
  /** Load .env file from data dir into process.env */
172
144
  loadEnvFile() {
@@ -310,12 +282,11 @@ export class FleetManager {
310
282
  await this.startSharedAdapter(fleet);
311
283
  // Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
312
284
  await this.topicCommands.autoCreateTopics();
313
- this.routingTable = this.buildRoutingTable();
314
- const routeSummary = [...this.routingTable.entries()].map(([tid, target]) => `#${tid}→${target.name}`).join(", ");
285
+ const routeSummary = this.routing.rebuild(this.fleetConfig);
315
286
  this.logger.info(`Routes: ${routeSummary}`);
316
287
  // Resolve topic icon emoji IDs and start idle archive poller
317
288
  await this.resolveTopicIcons();
318
- this.startArchivePoller();
289
+ this.topicArchiver.startPoller();
319
290
  await new Promise(r => setTimeout(r, 3000));
320
291
  await this.connectToInstances(fleet);
321
292
  for (const name of Object.keys(fleet.instances)) {
@@ -353,7 +324,7 @@ export class FleetManager {
353
324
  };
354
325
  process.once("SIGUSR1", onFullRestart);
355
326
  }
356
- /** Start the shared Telegram adapter for topic mode */
327
+ /** Start the shared channel adapter for topic mode */
357
328
  async startSharedAdapter(fleet) {
358
329
  const channelConfig = fleet.channel;
359
330
  const botToken = process.env[channelConfig.bot_token_env];
@@ -399,7 +370,7 @@ export class FleetManager {
399
370
  }, this.logger, "adapter.callback_query"));
400
371
  this.adapter.on("topic_closed", safeHandler(async (data) => {
401
372
  // Skip unbind if we archived this topic ourselves
402
- if (this.archivedTopics.has(data.threadId))
373
+ if (this.topicArchiver.isArchived(data.threadId))
403
374
  return;
404
375
  await this.topicCommands.handleTopicDeleted(data.threadId);
405
376
  }, this.logger, "adapter.topic_closed"));
@@ -477,8 +448,8 @@ export class FleetManager {
477
448
  // (fixes race condition where mcp_ready was broadcast before fleet manager connected)
478
449
  ipc.send({ type: "query_sessions" });
479
450
  this.logger.debug({ name }, "Connected to instance IPC");
480
- if (!this.statuslineWatchers.has(name)) {
481
- this.startStatuslineWatcher(name);
451
+ if (!this.statuslineWatcher.has(name)) {
452
+ this.statuslineWatcher.watch(name);
482
453
  }
483
454
  }
484
455
  catch (err) {
@@ -534,15 +505,15 @@ export class FleetManager {
534
505
  }
535
506
  return;
536
507
  }
537
- const target = this.routingTable.get(threadId);
508
+ const target = this.routing.resolve(threadId);
538
509
  if (!target) {
539
510
  this.topicCommands.handleUnboundTopic(msg);
540
511
  return;
541
512
  }
542
513
  const instanceName = target.name;
543
514
  // Reopen archived topic before routing
544
- if (this.archivedTopics.has(threadId)) {
545
- await this.reopenArchivedTopic(threadId, instanceName);
515
+ if (this.topicArchiver.isArchived(threadId)) {
516
+ await this.topicArchiver.reopen(threadId, instanceName);
546
517
  }
547
518
  this.touchActivity(instanceName);
548
519
  this.setTopicIcon(instanceName, "blue");
@@ -561,7 +532,7 @@ export class FleetManager {
561
532
  ipc.send({
562
533
  type: "fleet_inbound",
563
534
  content: text,
564
- targetSession: instanceName, // Telegram messages → instance's own session
535
+ targetSession: instanceName, // Channel messages → instance's own session
565
536
  meta: {
566
537
  chat_id: msg.chatId,
567
538
  message_id: msg.messageId,
@@ -577,7 +548,7 @@ export class FleetManager {
577
548
  }
578
549
  /** Handle outbound tool calls from a daemon instance */
579
550
  replyIfRateLimited(instanceName, msg) {
580
- const rl = this.instanceRateLimits.get(instanceName);
551
+ const rl = this.statuslineWatcher.getRateLimits(instanceName);
581
552
  if (!rl || rl.seven_day_pct < 100)
582
553
  return false;
583
554
  if (this.adapter && msg.chatId) {
@@ -618,376 +589,13 @@ export class FleetManager {
618
589
  }
619
590
  return;
620
591
  }
621
- // Fleet-specific tools
622
- switch (tool) {
623
- case "send_to_instance": {
624
- const targetName = args.instance_name;
625
- const message = args.message;
626
- if (!targetName) {
627
- respond(null, "send_to_instance: missing required argument 'instance_name'");
628
- break;
629
- }
630
- if (!message) {
631
- respond(null, "send_to_instance: missing required argument 'message'");
632
- break;
633
- }
634
- const senderLabel = senderSessionName ?? instanceName;
635
- const isExternalSender = senderSessionName != null && senderSessionName !== instanceName;
636
- // Resolve target: could be an instance name or an external session name
637
- let targetIpc = this.instanceIpcClients.get(targetName);
638
- let targetSession = targetName; // default: target is the instance itself
639
- let targetInstanceName = targetName;
640
- if (!targetIpc) {
641
- // Check if target is an external session
642
- const hostInstance = this.sessionRegistry.get(targetName);
643
- if (hostInstance) {
644
- targetIpc = this.instanceIpcClients.get(hostInstance);
645
- targetSession = targetName; // deliver to the external session
646
- targetInstanceName = hostInstance;
647
- }
648
- }
649
- if (!targetIpc) {
650
- // Check if instance exists in config but is stopped
651
- const existsInConfig = targetName in (this.fleetConfig?.instances ?? {});
652
- if (existsInConfig) {
653
- respond(null, `Instance '${targetName}' is stopped. Use start_instance('${targetName}') to start it first.`);
654
- }
655
- else {
656
- respond(null, `Instance or session not found: ${targetName}`);
657
- }
658
- break;
659
- }
660
- // Build structured metadata (Phase 2)
661
- const correlationId = args.correlation_id || `cid-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
662
- const meta = {
663
- chat_id: "",
664
- message_id: `xmsg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
665
- user: `instance:${senderLabel}`,
666
- user_id: `instance:${senderLabel}`,
667
- ts: new Date().toISOString(),
668
- thread_id: "",
669
- from_instance: senderLabel,
670
- correlation_id: correlationId,
671
- };
672
- if (args.request_kind)
673
- meta.request_kind = args.request_kind;
674
- if (args.requires_reply != null)
675
- meta.requires_reply = String(args.requires_reply);
676
- if (args.task_summary)
677
- meta.task_summary = args.task_summary;
678
- if (args.working_directory)
679
- meta.working_directory = args.working_directory;
680
- if (args.branch)
681
- meta.branch = args.branch;
682
- targetIpc.send({
683
- type: "fleet_inbound",
684
- targetSession,
685
- content: message,
686
- meta,
687
- });
688
- // Post to Telegram topics for visibility
689
- const groupId = this.fleetConfig?.channel?.group_id;
690
- if (groupId && this.adapter) {
691
- const senderTopicId = this.fleetConfig?.instances[instanceName]?.topic_id;
692
- const targetTopicId = this.fleetConfig?.instances[targetInstanceName]?.topic_id;
693
- // Post full message to topics — adapter handles 4096-char chunking
694
- // Only post to sender topic if sender is the instance itself (not external)
695
- if (senderTopicId && !isExternalSender) {
696
- this.adapter.sendText(String(groupId), `→ ${targetName}:\n${message}`, {
697
- threadId: String(senderTopicId),
698
- }).catch(e => this.logger.warn({ err: e }, "Failed to post cross-instance notification"));
699
- }
700
- // Only post to target topic if target is an instance (not external session)
701
- if (targetTopicId && !this.sessionRegistry.has(targetName)) {
702
- this.adapter.sendText(String(groupId), `← ${senderLabel}:\n${message}`, {
703
- threadId: String(targetTopicId),
704
- }).catch(e => this.logger.warn({ err: e }, "Failed to post cross-instance notification"));
705
- }
706
- }
707
- this.logger.info(`✉ ${senderLabel} → ${targetName}: ${(message ?? "").slice(0, 100)}`);
708
- respond({ sent: true, target: targetName, correlation_id: correlationId });
709
- break;
710
- }
711
- case "list_instances": {
712
- const senderLabel = senderSessionName ?? instanceName;
713
- const allInstances = Object.entries(this.fleetConfig?.instances ?? {})
714
- .filter(([name]) => name !== instanceName && name !== senderLabel)
715
- .map(([name, config]) => ({
716
- name,
717
- type: "instance",
718
- status: this.daemons.has(name) ? "running" : "stopped",
719
- working_directory: config.working_directory,
720
- topic_id: config.topic_id ?? null,
721
- description: config.description ?? null,
722
- tags: config.tags ?? [],
723
- last_activity: this.lastActivity.get(name) ? new Date(this.lastActivity.get(name)).toISOString() : null,
724
- }));
725
- // Include external sessions (excluding self)
726
- const externalSessions = [...this.sessionRegistry.entries()]
727
- .filter(([sessName]) => sessName !== senderLabel)
728
- .map(([sessName, hostInstance]) => ({
729
- name: sessName, type: "session", host: hostInstance,
730
- }));
731
- respond({ instances: allInstances, external_sessions: externalSessions });
732
- break;
733
- }
734
- // Phase 3: High-level collaboration tools (wrappers around send_to_instance)
735
- case "request_information": {
736
- const targetName = args.target_instance;
737
- const question = args.question;
738
- const context = args.context;
739
- const body = context ? `${question}\n\nContext: ${context}` : question;
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 });
743
- }
744
- case "delegate_task": {
745
- const targetName = args.target_instance;
746
- const task = args.task;
747
- const criteria = args.success_criteria;
748
- const context = args.context;
749
- let body = task;
750
- if (criteria)
751
- body += `\n\nSuccess criteria: ${criteria}`;
752
- if (context)
753
- body += `\n\nContext: ${context}`;
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 });
756
- }
757
- case "report_result": {
758
- const targetName = args.target_instance;
759
- const summary = args.summary;
760
- const artifacts = args.artifacts;
761
- if (!args.correlation_id) {
762
- this.logger.warn({ instanceName, targetName }, "report_result called without correlation_id — recipient cannot match this to an original request");
763
- }
764
- let body = summary;
765
- if (artifacts)
766
- body += `\n\nArtifacts: ${artifacts}`;
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 });
769
- }
770
- // Phase 4: Capability discovery
771
- case "describe_instance": {
772
- const targetName = args.name;
773
- const config = this.fleetConfig?.instances[targetName];
774
- if (config) {
775
- respond({
776
- name: targetName,
777
- type: "instance",
778
- description: config.description ?? null,
779
- tags: config.tags ?? [],
780
- working_directory: config.working_directory,
781
- status: this.daemons.has(targetName) ? "running" : "stopped",
782
- topic_id: config.topic_id ?? null,
783
- model: config.model ?? null,
784
- last_activity: this.lastActivity.get(targetName) ? new Date(this.lastActivity.get(targetName)).toISOString() : null,
785
- worktree_source: config.worktree_source ?? null,
786
- });
787
- break;
788
- }
789
- // Check if it's a known external session
790
- const hostInstance = this.sessionRegistry.get(targetName);
791
- if (hostInstance) {
792
- respond({
793
- name: targetName,
794
- type: "session",
795
- host: hostInstance,
796
- status: "running",
797
- });
798
- break;
799
- }
800
- respond(null, `Instance or session '${targetName}' not found`);
801
- break;
802
- }
803
- case "start_instance": {
804
- const targetName = args.name;
805
- // Already running?
806
- if (this.daemons.has(targetName)) {
807
- respond({ success: true, status: "already_running" });
808
- break;
809
- }
810
- // Exists in config?
811
- const targetConfig = this.fleetConfig?.instances[targetName];
812
- if (!targetConfig) {
813
- respond(null, `Instance '${targetName}' not found in fleet config`);
814
- break;
815
- }
816
- try {
817
- await this.startInstance(targetName, targetConfig, true);
818
- await this.connectIpcToInstance(targetName);
819
- respond({ success: true, status: "started" });
820
- }
821
- catch (err) {
822
- respond(null, `Failed to start instance '${targetName}': ${err.message}`);
823
- }
824
- break;
825
- }
826
- case "create_instance": {
827
- const directory = args.directory.replace(/^~/, process.env.HOME || "~");
828
- const topicName = args.topic_name || basename(directory);
829
- const description = args.description;
830
- const branch = args.branch;
831
- // Validate directory exists
832
- try {
833
- await access(directory);
834
- }
835
- catch {
836
- respond(null, `Directory does not exist: ${directory}`);
837
- break;
838
- }
839
- // Check for duplicate early (before worktree creation) — only when no branch
840
- if (!branch) {
841
- const expandHome = (p) => p.replace(/^~/, process.env.HOME || "~");
842
- const existingInstance = Object.entries(this.fleetConfig?.instances ?? {})
843
- .find(([_, config]) => expandHome(config.working_directory) === directory);
844
- if (existingInstance) {
845
- const [eName, eConfig] = existingInstance;
846
- respond({
847
- success: true,
848
- status: "already_exists",
849
- name: eName,
850
- topic_id: eConfig.topic_id,
851
- running: this.daemons.has(eName),
852
- });
853
- break;
854
- }
855
- }
856
- // If branch specified, create git worktree
857
- let workDir = directory;
858
- let worktreePath;
859
- if (branch) {
860
- try {
861
- const { execFile: execFileCb } = await import("node:child_process");
862
- const { promisify } = await import("node:util");
863
- const execFileAsync = promisify(execFileCb);
864
- // Verify it's a git repo
865
- await execFileAsync("git", ["rev-parse", "--git-dir"], { cwd: directory });
866
- // Determine worktree path: sibling directory named repo-branch
867
- const repoName = basename(directory);
868
- const safeBranch = branch.replace(/\//g, "-");
869
- worktreePath = join(dirname(directory), `${repoName}-${safeBranch}`);
870
- // Check if branch exists
871
- let branchExists = false;
872
- try {
873
- await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd: directory });
874
- branchExists = true;
875
- }
876
- catch { /* branch doesn't exist */ }
877
- if (branchExists) {
878
- await execFileAsync("git", ["worktree", "add", worktreePath, branch], { cwd: directory });
879
- }
880
- else {
881
- await execFileAsync("git", ["worktree", "add", worktreePath, "-b", branch], { cwd: directory });
882
- }
883
- this.logger.info({ worktreePath, branch, repo: directory }, "Created git worktree for instance");
884
- workDir = worktreePath;
885
- }
886
- catch (err) {
887
- respond(null, `Failed to create worktree: ${err.message}`);
888
- break;
889
- }
890
- }
891
- // Check worktree path for duplicates (branch case only — non-branch already checked above)
892
- if (worktreePath) {
893
- const expandHome = (p) => p.replace(/^~/, process.env.HOME || "~");
894
- const existingInstance = Object.entries(this.fleetConfig?.instances ?? {})
895
- .find(([_, config]) => expandHome(config.working_directory) === workDir);
896
- if (existingInstance) {
897
- const [eName, eConfig] = existingInstance;
898
- respond({
899
- success: true,
900
- status: "already_exists",
901
- name: eName,
902
- topic_id: eConfig.topic_id,
903
- running: this.daemons.has(eName),
904
- });
905
- break;
906
- }
907
- }
908
- // Sequential steps with rollback
909
- let createdTopicId;
910
- let newInstanceName;
911
- try {
912
- // Step a: Create Telegram topic
913
- createdTopicId = await this.createForumTopic(topicName);
914
- // Step b: Register in config
915
- // Use topicName for worktree instances to avoid long paths (Unix socket limit 104 bytes)
916
- const nameBase = worktreePath ? topicName : basename(workDir);
917
- newInstanceName = `${sanitizeInstanceName(nameBase)}-t${createdTopicId}`;
918
- const instanceConfig = {
919
- ...DEFAULT_INSTANCE_CONFIG,
920
- ...this.fleetConfig.defaults,
921
- working_directory: workDir,
922
- topic_id: createdTopicId,
923
- ...(description ? { description } : {}),
924
- ...(args.model ? { model: args.model } : {}),
925
- ...(args.backend ? { backend: args.backend } : {}),
926
- ...(worktreePath ? { worktree_source: directory } : {}),
927
- };
928
- this.fleetConfig.instances[newInstanceName] = instanceConfig;
929
- this.routingTable.set(String(createdTopicId), { kind: "instance", name: newInstanceName });
930
- this.saveFleetConfig();
931
- // Step c: Start instance
932
- await this.startInstance(newInstanceName, instanceConfig, true);
933
- await this.connectIpcToInstance(newInstanceName);
934
- respond({
935
- success: true,
936
- name: newInstanceName,
937
- topic_id: createdTopicId,
938
- ...(worktreePath ? { worktree_path: worktreePath, branch } : {}),
939
- });
940
- }
941
- catch (err) {
942
- // Rollback in reverse order
943
- if (newInstanceName && this.daemons.has(newInstanceName)) {
944
- await this.stopInstance(newInstanceName).catch(e => this.logger.error({ err: e, name: newInstanceName }, "Failed to stop instance during rollback"));
945
- }
946
- if (newInstanceName && this.fleetConfig?.instances[newInstanceName]) {
947
- delete this.fleetConfig.instances[newInstanceName];
948
- if (createdTopicId)
949
- this.routingTable.delete(String(createdTopicId));
950
- this.saveFleetConfig();
951
- }
952
- if (createdTopicId) {
953
- await this.deleteForumTopic(createdTopicId);
954
- }
955
- // Rollback worktree
956
- if (worktreePath) {
957
- try {
958
- const { execFile: execFileCb } = await import("node:child_process");
959
- const { promisify } = await import("node:util");
960
- const execFileAsync = promisify(execFileCb);
961
- await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], { cwd: directory });
962
- }
963
- catch { /* best-effort worktree cleanup */ }
964
- }
965
- respond(null, `Failed to create instance: ${err.message}`);
966
- }
967
- break;
968
- }
969
- case "delete_instance": {
970
- const instanceName = args.name;
971
- const deleteTopic = args.delete_topic ?? false;
972
- const instanceConfig = this.fleetConfig?.instances[instanceName];
973
- if (!instanceConfig) {
974
- respond(null, `Instance not found: ${instanceName}`);
975
- break;
976
- }
977
- if (instanceConfig.general_topic) {
978
- respond(null, "Cannot delete the General instance");
979
- break;
980
- }
981
- // Delete Telegram topic if requested (before removeInstance clears config)
982
- if (deleteTopic && instanceConfig.topic_id) {
983
- await this.deleteForumTopic(instanceConfig.topic_id);
984
- }
985
- await this.removeInstance(instanceName);
986
- respond({ success: true, name: instanceName, topic_deleted: deleteTopic });
987
- break;
988
- }
989
- default:
990
- respond(null, `Unknown tool: ${tool}`);
592
+ // Dispatch fleet-specific tools via handler map
593
+ const handler = outboundHandlers.get(tool);
594
+ if (handler) {
595
+ await handler(this, args, respond, { instanceName, requestId, fleetRequestId, senderSessionName });
596
+ }
597
+ else {
598
+ respond(null, `Unknown tool: ${tool}`);
991
599
  }
992
600
  }
993
601
  /** Handle tool status update from a daemon instance */
@@ -1015,7 +623,7 @@ export class FleetManager {
1015
623
  async handleScheduleTrigger(schedule) {
1016
624
  const { target, reply_chat_id, reply_thread_id, message, label, id, source } = schedule;
1017
625
  const RATE_LIMIT_DEFER_THRESHOLD = 85;
1018
- const rl = this.instanceRateLimits.get(target);
626
+ const rl = this.statuslineWatcher.getRateLimits(target);
1019
627
  if (rl && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
1020
628
  this.scheduler.recordRun(id, "deferred", `5hr rate limit at ${rl.five_hour_pct}%`);
1021
629
  this.eventLog?.insert(target, "schedule_deferred", {
@@ -1143,7 +751,7 @@ export class FleetManager {
1143
751
  this.topicCleanupTimer = setInterval(async () => {
1144
752
  if (!this.fleetConfig?.channel?.group_id || !this.adapter?.topicExists)
1145
753
  return;
1146
- for (const [threadId, target] of this.routingTable) {
754
+ for (const [threadId, target] of this.routing.entries()) {
1147
755
  try {
1148
756
  if (!isProbeableRouteTarget(target)) {
1149
757
  continue;
@@ -1207,87 +815,18 @@ export class FleetManager {
1207
815
  this.logger.info({ path: this.configPath }, "Saved fleet config");
1208
816
  }
1209
817
  async removeInstance(name) {
818
+ // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
1210
819
  const config = this.fleetConfig?.instances[name];
1211
- if (!config)
1212
- return;
1213
- // Never remove the General instance
1214
- if (config.general_topic) {
1215
- this.logger.warn({ name }, "Refusing to remove General instance");
1216
- return;
1217
- }
1218
- // Clean up schedules
1219
- if (this.scheduler && config.topic_id) {
820
+ if (this.scheduler && config?.topic_id) {
1220
821
  const count = this.scheduler.deleteByInstanceOrThread(name, String(config.topic_id));
1221
822
  if (count > 0) {
1222
823
  this.logger.info({ name, count }, "Cleaned up schedules for deleted instance");
1223
824
  }
1224
825
  }
1225
- // Stop daemon if running
1226
- if (this.daemons.has(name)) {
1227
- await this.stopInstance(name);
1228
- }
1229
- // Clean up git worktree if applicable
1230
- if (config.worktree_source && config.working_directory) {
1231
- const { existsSync } = await import("node:fs");
1232
- if (!existsSync(config.working_directory)) {
1233
- this.logger.info({ worktree: config.working_directory }, "Worktree directory already gone, skipping removal");
1234
- }
1235
- else {
1236
- try {
1237
- const { execFile: execFileCb } = await import("node:child_process");
1238
- const { promisify } = await import("node:util");
1239
- const execFileAsync = promisify(execFileCb);
1240
- await execFileAsync("git", ["worktree", "remove", "--force", config.working_directory], {
1241
- cwd: config.worktree_source,
1242
- });
1243
- this.logger.info({ worktree: config.working_directory }, "Removed git worktree");
1244
- }
1245
- catch (err) {
1246
- this.logger.warn({ err, worktree: config.working_directory }, "Failed to remove git worktree");
1247
- }
1248
- }
1249
- }
1250
- // Clean up IPC
1251
- const ipc = this.instanceIpcClients.get(name);
1252
- if (ipc) {
1253
- await ipc.close();
1254
- this.instanceIpcClients.delete(name);
1255
- }
1256
- // Remove from routing table
1257
- if (config.topic_id != null) {
1258
- this.routingTable.delete(String(config.topic_id));
1259
- }
1260
- // Remove from fleet config and save
1261
- delete this.fleetConfig.instances[name];
1262
- this.saveFleetConfig();
1263
- this.logger.info({ name }, "Instance removed");
826
+ return this.lifecycle.remove(name);
1264
827
  }
1265
828
  startStatuslineWatcher(name) {
1266
- const statusFile = join(this.getInstanceDir(name), "statusline.json");
1267
- const timer = setInterval(() => {
1268
- try {
1269
- const data = JSON.parse(readFileSync(statusFile, "utf-8"));
1270
- if (data.cost?.total_cost_usd != null) {
1271
- this.costGuard?.updateCost(name, data.cost.total_cost_usd);
1272
- }
1273
- const rl = data.rate_limits;
1274
- if (rl) {
1275
- const prev = this.instanceRateLimits.get(name);
1276
- const newSevenDay = rl.seven_day?.used_percentage ?? 0;
1277
- if (prev?.seven_day_pct === 100 && newSevenDay < 100) {
1278
- this.notifyInstanceTopic(name, `✅ ${name} weekly usage limit has reset — instance is available again.`);
1279
- this.logger.info({ name }, "Weekly rate limit recovered");
1280
- }
1281
- this.instanceRateLimits.set(name, {
1282
- five_hour_pct: rl.five_hour?.used_percentage ?? 0,
1283
- seven_day_pct: newSevenDay,
1284
- });
1285
- this.checkModelFailover(name, rl.five_hour?.used_percentage ?? 0);
1286
- }
1287
- }
1288
- catch { /* file may not exist yet or be mid-write */ }
1289
- }, 10_000);
1290
- this.statuslineWatchers.set(name, timer);
829
+ this.statuslineWatcher.watch(name);
1291
830
  }
1292
831
  // ── Model failover ──────────────────────────────────────────────────────
1293
832
  static FAILOVER_TRIGGER_PCT = 90;
@@ -1368,7 +907,7 @@ export class FleetManager {
1368
907
  const stickers = await this.adapter.getTopicIconStickers();
1369
908
  if (stickers.length === 0)
1370
909
  return;
1371
- // Telegram's getForumTopicIconStickers returns a fixed set.
910
+ // getForumTopicIconStickers returns a fixed set of available icons.
1372
911
  // Try to match by emoji character, fall back to positional.
1373
912
  const find = (targets) => stickers.find((s) => targets.some((t) => s.emoji.includes(t)));
1374
913
  const green = find(["🟢", "✅", "💚"]);
@@ -1401,54 +940,9 @@ export class FleetManager {
1401
940
  this.lastActivity.set(instanceName, Date.now());
1402
941
  }
1403
942
  /** Start periodic idle archive checker */
1404
- startArchivePoller() {
1405
- this.archiveTimer = setInterval(() => {
1406
- this.archiveIdleTopics().catch((err) => this.logger.debug({ err }, "Archive idle check failed"));
1407
- }, 30 * 60_000); // check every 30 minutes
1408
- }
1409
- /** Close topics that have been idle beyond threshold */
1410
- async archiveIdleTopics() {
1411
- if (!this.adapter?.closeForumTopic || !this.fleetConfig)
1412
- return;
1413
- const now = Date.now();
1414
- for (const [name, config] of Object.entries(this.fleetConfig.instances)) {
1415
- const topicId = config.topic_id;
1416
- if (topicId == null || config.general_topic)
1417
- continue;
1418
- const topicIdStr = String(topicId);
1419
- if (this.archivedTopics.has(topicIdStr))
1420
- continue;
1421
- const status = this.getInstanceStatus(name);
1422
- if (status !== "running")
1423
- continue; // only archive running-but-idle
1424
- const last = this.lastActivity.get(name) ?? 0;
1425
- if (last === 0)
1426
- continue; // never active → skip (just started)
1427
- if (now - last < FleetManager.ARCHIVE_IDLE_MS)
1428
- continue;
1429
- this.logger.info({ name, topicId, idleHours: Math.round((now - last) / 3600000) }, "Archiving idle topic");
1430
- this.archivedTopics.add(topicIdStr);
1431
- this.setTopicIcon(name, "remove");
1432
- await this.adapter.closeForumTopic(topicId);
1433
- }
1434
- }
1435
- /** Reopen an archived topic and restore icon */
1436
- async reopenArchivedTopic(topicId, instanceName) {
1437
- if (!this.archivedTopics.has(topicId))
1438
- return;
1439
- this.archivedTopics.delete(topicId);
1440
- if (this.adapter?.reopenForumTopic) {
1441
- await this.adapter.reopenForumTopic(topicId);
1442
- }
1443
- this.setTopicIcon(instanceName, "green");
1444
- this.touchActivity(instanceName);
1445
- this.logger.info({ instanceName, topicId }, "Reopened archived topic");
1446
- }
943
+ // archiveIdleTopics / reopenArchivedTopic → delegated to TopicArchiver
1447
944
  clearStatuslineWatchers() {
1448
- for (const [, timer] of this.statuslineWatchers)
1449
- clearInterval(timer);
1450
- this.statuslineWatchers.clear();
1451
- this.instanceRateLimits.clear();
945
+ this.statuslineWatcher.stopAll();
1452
946
  this.failoverActive.clear();
1453
947
  }
1454
948
  async stopAll() {
@@ -1463,10 +957,7 @@ export class FleetManager {
1463
957
  clearInterval(this.sessionPruneTimer);
1464
958
  this.sessionPruneTimer = null;
1465
959
  }
1466
- if (this.archiveTimer) {
1467
- clearInterval(this.archiveTimer);
1468
- this.archiveTimer = null;
1469
- }
960
+ this.topicArchiver.stop();
1470
961
  this.scheduler?.shutdown();
1471
962
  await Promise.allSettled([...this.daemons.entries()].map(async ([name, daemon]) => {
1472
963
  try {
@@ -1642,7 +1133,7 @@ export class FleetManager {
1642
1133
  await this.startInstance(name, config, topicMode);
1643
1134
  }
1644
1135
  if (topicMode) {
1645
- this.routingTable = this.buildRoutingTable();
1136
+ this.routing.rebuild(this.fleetConfig);
1646
1137
  await new Promise(r => setTimeout(r, 3000));
1647
1138
  await this.connectToInstances(fleet);
1648
1139
  for (const name of Object.keys(fleet.instances)) {
@@ -1658,7 +1149,7 @@ export class FleetManager {
1658
1149
  this.logger.info({ count: instances.length }, "Sending restart notification to instances");
1659
1150
  for (const [name, config] of instances) {
1660
1151
  const threadId = config.topic_id != null ? String(config.topic_id) : undefined;
1661
- // Send to Telegram topic so the message appears in the chat
1152
+ // Send to topic so the message appears in the instance's channel
1662
1153
  if (threadId) {
1663
1154
  this.adapter.sendText(String(groupId), "Fleet restart complete. Continue from where you left off.", { threadId })
1664
1155
  .catch(e => this.logger.warn({ err: e, name, threadId }, "Failed to post per-instance restart notification"));