@songsid/agend 2.1.0-beta.9 → 2.1.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.
- package/README.md +1 -0
- package/README.zh-TW.md +2 -1
- package/dist/access-path.js +3 -3
- package/dist/access-path.js.map +1 -1
- package/dist/agent-cli.js +1 -1
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +8 -0
- package/dist/agent-endpoint.js +36 -8
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +8 -0
- package/dist/backend/antigravity.js +43 -1
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +6 -0
- package/dist/backend/claude-code.js +20 -4
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +12 -1
- package/dist/backend/codex.js +55 -1
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/factory.js +4 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/grok.d.ts +59 -0
- package/dist/backend/grok.js +329 -0
- package/dist/backend/grok.js.map +1 -0
- package/dist/backend/kiro.d.ts +6 -0
- package/dist/backend/kiro.js +38 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.d.ts +5 -0
- package/dist/backend/opencode.js +18 -0
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +59 -3
- package/dist/backend/types.js +15 -2
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +20 -0
- package/dist/channel/adapters/discord.js +88 -11
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.js +4 -1
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +18 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/chat-export.js +5 -2
- package/dist/chat-export.js.map +1 -1
- package/dist/classic-channel-manager.d.ts +16 -1
- package/dist/classic-channel-manager.js +76 -4
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +82 -16
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +70 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +14 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +67 -5
- package/dist/daemon.js +489 -180
- package/dist/daemon.js.map +1 -1
- package/dist/daily-summary.js +7 -2
- package/dist/daily-summary.js.map +1 -1
- package/dist/fleet-context.d.ts +11 -0
- package/dist/fleet-manager.d.ts +128 -7
- package/dist/fleet-manager.js +1116 -140
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-system-prompt.js +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +19 -13
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +30 -51
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -1
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +19 -13
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -5
- package/dist/general-knowledge/skills/scheduling/SKILL.md +41 -0
- package/dist/general-knowledge/skills/session-management/SKILL.md +29 -116
- package/dist/instance-lifecycle.d.ts +13 -0
- package/dist/instance-lifecycle.js +57 -18
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +28 -2
- package/dist/locale.js.map +1 -1
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +23 -7
- package/dist/logger.js.map +1 -1
- package/dist/outbound-handlers.js +199 -11
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +44 -2
- package/dist/outbound-schemas.js +50 -3
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pause-marker.d.ts +5 -0
- package/dist/pause-marker.js +36 -0
- package/dist/pause-marker.js.map +1 -0
- package/dist/scheduler/db.d.ts +8 -0
- package/dist/scheduler/db.js +74 -5
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/db.test.js +50 -0
- package/dist/scheduler/db.test.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +6 -0
- package/dist/scheduler/scheduler.js +99 -16
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/scheduler.test.js +131 -1
- package/dist/scheduler/scheduler.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +9 -3
- package/dist/scheduler/types.js.map +1 -1
- package/dist/settings-api.d.ts +20 -2
- package/dist/settings-api.js +213 -9
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +8 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/tmux-control.d.ts +7 -0
- package/dist/tmux-control.js +22 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +1 -1
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +14 -0
- package/dist/topic-commands.js +107 -15
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +29 -1
- package/dist/tz-utils.d.ts +26 -0
- package/dist/tz-utils.js +46 -0
- package/dist/tz-utils.js.map +1 -0
- package/dist/ui/dashboard.html +3 -3
- package/dist/ui/settings.html +453 -74
- package/dist/web-api.js +3 -1
- package/dist/web-api.js.map +1 -1
- package/dist/workflow-templates/default.md +0 -4
- package/package.json +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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
|
|
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";
|
|
@@ -19,18 +19,20 @@ import { TmuxManager } from "./tmux-manager.js";
|
|
|
19
19
|
import { AccessManager } from "./channel/access-manager.js";
|
|
20
20
|
import { IpcClient } from "./channel/ipc-bridge.js";
|
|
21
21
|
import { createAdapter } from "./channel/factory.js";
|
|
22
|
+
import { createBackend } from "./backend/factory.js";
|
|
23
|
+
import { isModelCompatible } from "./backend/types.js";
|
|
22
24
|
import { createLogger } from "./logger.js";
|
|
23
25
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
24
26
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
25
27
|
import { Scheduler } from "./scheduler/index.js";
|
|
26
28
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
27
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
|
|
29
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
|
|
28
30
|
import { DailySummary } from "./daily-summary.js";
|
|
29
31
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
30
32
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
31
33
|
import { safeHandler } from "./safe-async.js";
|
|
32
34
|
import { RoutingEngine } from "./routing-engine.js";
|
|
33
|
-
import { InstanceLifecycle } from "./instance-lifecycle.js";
|
|
35
|
+
import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
34
36
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
35
37
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
36
38
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
@@ -39,7 +41,9 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
|
39
41
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
40
42
|
import { setLocale, detectLocale, t } from "./locale.js";
|
|
41
43
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
42
|
-
import { ClassicChannelManager } from "./classic-channel-manager.js";
|
|
44
|
+
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
45
|
+
import { readLastInboundAt } from "./daemon.js";
|
|
46
|
+
import { clearPausedMarker } from "./pause-marker.js";
|
|
43
47
|
import { getTmuxSession } from "./config.js";
|
|
44
48
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
45
49
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -50,6 +54,26 @@ export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
|
50
54
|
}
|
|
51
55
|
return instanceConfig?.topic_id != null ? String(instanceConfig.topic_id) : undefined;
|
|
52
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Pure warm-cap victim selection (extracted for testability). Given the current
|
|
59
|
+
* warm (running) instance names and a cap, return the LRU idle instances to evict
|
|
60
|
+
* so the running count returns to the cap. Skips: the `exclude` instance, any
|
|
61
|
+
* already-evicting, general instances (never evicted), and non-idle instances
|
|
62
|
+
* (working/stuck can't be evicted). Oldest last-inbound is evicted first; a
|
|
63
|
+
* missing timestamp (0) sorts oldest. cap <= 0 (or non-integer) = unlimited → [].
|
|
64
|
+
*/
|
|
65
|
+
export function selectLruEvictions(warm, cap, opts) {
|
|
66
|
+
if (!Number.isInteger(cap) || cap <= 0)
|
|
67
|
+
return [];
|
|
68
|
+
if (warm.length <= cap)
|
|
69
|
+
return [];
|
|
70
|
+
const candidates = warm.filter(name => name !== opts.exclude
|
|
71
|
+
&& !opts.isEvicting(name)
|
|
72
|
+
&& !opts.isGeneral(name)
|
|
73
|
+
&& opts.isIdle(name));
|
|
74
|
+
candidates.sort((a, b) => opts.lastInboundAt(a) - opts.lastInboundAt(b));
|
|
75
|
+
return candidates.slice(0, warm.length - cap);
|
|
76
|
+
}
|
|
53
77
|
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
54
78
|
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
55
79
|
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
@@ -58,6 +82,10 @@ const CANCEL_BTN_MAX_RETRIES = 3;
|
|
|
58
82
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
59
83
|
* reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
|
|
60
84
|
const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
|
|
85
|
+
const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
86
|
+
const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
87
|
+
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
88
|
+
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
|
|
61
89
|
export class FleetManager {
|
|
62
90
|
dataDir;
|
|
63
91
|
children = new Map();
|
|
@@ -65,6 +93,9 @@ export class FleetManager {
|
|
|
65
93
|
/** @deprecated Use lifecycle.daemons — kept for backward compat */
|
|
66
94
|
get daemons() { return this.lifecycle.daemons; }
|
|
67
95
|
fleetConfig = null;
|
|
96
|
+
rawFleetConfig = {};
|
|
97
|
+
rawFleetDocument = null;
|
|
98
|
+
savedFleetConfigSnapshot = null;
|
|
68
99
|
adapter = null;
|
|
69
100
|
worlds = new Map();
|
|
70
101
|
adapters = new Map(); // derived view for backward compat
|
|
@@ -95,6 +126,16 @@ export class FleetManager {
|
|
|
95
126
|
lastActivity = new Map();
|
|
96
127
|
/** Latest pane-derived execution snapshot reported by each daemon. */
|
|
97
128
|
instanceStateCache = new Map();
|
|
129
|
+
/** CLI pane status overrides; daemon.pid alone only proves FleetManager lives. */
|
|
130
|
+
instanceProcessStatus = new Map();
|
|
131
|
+
/** Instances currently being auto-paused by warm_cap, so concurrent checks don't double-evict. */
|
|
132
|
+
warmCapEvicting = new Set();
|
|
133
|
+
/** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
|
|
134
|
+
idleGatedDeliveryTails = new Map();
|
|
135
|
+
/** Non-user work must observe a fresh idle snapshot after the latest delivery. */
|
|
136
|
+
lastDeliveryAt = new Map();
|
|
137
|
+
/** State-cache updates wake event-driven idle waiters without busy polling. */
|
|
138
|
+
instanceIdleWaiters = new Map();
|
|
98
139
|
lastInboundUser = new Map(); // instanceName → last username
|
|
99
140
|
// Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
|
|
100
141
|
// than one-per-instance. A button is retired (deleted, with bounded retry) on
|
|
@@ -106,6 +147,9 @@ export class FleetManager {
|
|
|
106
147
|
topicArchiver;
|
|
107
148
|
controlClient = null;
|
|
108
149
|
classicChannels = null;
|
|
150
|
+
pendingClassicStarts = new Map();
|
|
151
|
+
/** In-flight /model selections, keyed by nonce (see handleModelSelection). */
|
|
152
|
+
pendingModelSelects = new Map();
|
|
109
153
|
// Model failover state
|
|
110
154
|
failoverActive = new Map(); // instance → current failover model
|
|
111
155
|
// IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
|
|
@@ -177,9 +221,21 @@ export class FleetManager {
|
|
|
177
221
|
}
|
|
178
222
|
/** Load fleet.yaml and build routing table */
|
|
179
223
|
loadConfig(configPath) {
|
|
224
|
+
this.configPath = configPath;
|
|
225
|
+
const source = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "{}\n";
|
|
226
|
+
this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
|
|
227
|
+
if (this.rawFleetDocument.errors.length > 0) {
|
|
228
|
+
throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
229
|
+
}
|
|
230
|
+
this.rawFleetConfig = loadRawFleetConfig(configPath);
|
|
180
231
|
this.fleetConfig = loadFleetConfig(configPath);
|
|
232
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
181
233
|
return this.fleetConfig;
|
|
182
234
|
}
|
|
235
|
+
/** User-authored fleet.yaml, before defaults are merged into instances. */
|
|
236
|
+
getRawFleetConfig() {
|
|
237
|
+
return structuredClone(this.rawFleetConfig);
|
|
238
|
+
}
|
|
183
239
|
/** Build topic routing table: { topicId -> RouteTarget } */
|
|
184
240
|
buildRoutingTable() {
|
|
185
241
|
if (this.fleetConfig) {
|
|
@@ -233,6 +289,52 @@ export class FleetManager {
|
|
|
233
289
|
return this.classicChannels?.getInstanceByChannel(channelId, adapterId)
|
|
234
290
|
?? this.routing.resolve(channelId)?.name;
|
|
235
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Model switching is privileged. Fleet allowlisted admins retain authority in
|
|
294
|
+
* ClassicBot channels, while ClassicBot's own admin_users may also switch the
|
|
295
|
+
* model of the channel they administer.
|
|
296
|
+
*/
|
|
297
|
+
isModelAdmin(userId, channelId, adapterId) {
|
|
298
|
+
if (this.isFleetAdmin(userId, adapterId))
|
|
299
|
+
return true;
|
|
300
|
+
const isClassic = !!this.classicChannels?.getInstanceByChannel(channelId, adapterId);
|
|
301
|
+
return isClassic && !!this.classicChannels?.isAdmin(userId);
|
|
302
|
+
}
|
|
303
|
+
async handlePauseWakeSlash(data, adapterId) {
|
|
304
|
+
const action = data.command;
|
|
305
|
+
const classicName = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
306
|
+
if (classicName) {
|
|
307
|
+
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
308
|
+
await data.respond(t("permission.denied"));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await data.respond(await this.topicCommands.runPauseWake(classicName, action));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
315
|
+
await data.respond(t("permission.denied"));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const route = this.routing.resolve(data.channelId);
|
|
319
|
+
if (!route) {
|
|
320
|
+
await data.respond(t("classic.no_agent"));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
let target = route.name;
|
|
324
|
+
if (route.kind === "general") {
|
|
325
|
+
const requested = typeof data.options?.instance === "string" ? data.options.instance : undefined;
|
|
326
|
+
if (!requested) {
|
|
327
|
+
await data.respond(t(`${action}.usage`));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!this.fleetConfig?.instances[requested]) {
|
|
331
|
+
await data.respond(t("instance.not_found", requested));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
target = requested;
|
|
335
|
+
}
|
|
336
|
+
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
337
|
+
}
|
|
236
338
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
237
339
|
getAdapterForInstance(name) {
|
|
238
340
|
const worldId = this.instanceWorldBinding.get(name);
|
|
@@ -268,19 +370,27 @@ export class FleetManager {
|
|
|
268
370
|
*/
|
|
269
371
|
bindInstanceAdapter(name, adapterId, fromInbound = false) {
|
|
270
372
|
const cfg = this.fleetConfig?.instances[name];
|
|
271
|
-
if (fromInbound
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
373
|
+
if (fromInbound) {
|
|
374
|
+
// Skip inbound-derived binding for any instance that doesn't have an
|
|
375
|
+
// explicit channel_id — those default to primary adapter deterministically.
|
|
376
|
+
// This prevents a non-deterministic race where whichever adapter delivers
|
|
377
|
+
// first after restart wins the binding.
|
|
378
|
+
if (cfg?.general_topic || cfg?.channel_id)
|
|
379
|
+
return;
|
|
380
|
+
if (cfg && !cfg.channel_id)
|
|
381
|
+
return; // fleet instance without explicit binding → use primary
|
|
382
|
+
// Classic instance: don't override an existing binding (authoritative from /start)
|
|
383
|
+
if (this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
279
386
|
this.instanceWorldBinding.set(name, adapterId);
|
|
280
387
|
}
|
|
281
388
|
getInstanceStatus(name) {
|
|
282
389
|
if (this.lifecycle.isPaused(name))
|
|
283
390
|
return "paused";
|
|
391
|
+
const processStatus = this.instanceProcessStatus.get(name);
|
|
392
|
+
if (processStatus)
|
|
393
|
+
return processStatus;
|
|
284
394
|
const pidPath = join(this.getInstanceDir(name), "daemon.pid");
|
|
285
395
|
if (!existsSync(pidPath))
|
|
286
396
|
return "stopped";
|
|
@@ -298,6 +408,9 @@ export class FleetManager {
|
|
|
298
408
|
return null;
|
|
299
409
|
return this.instanceStateCache.get(name)?.state ?? null;
|
|
300
410
|
}
|
|
411
|
+
isClassicInstance(name) {
|
|
412
|
+
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
413
|
+
}
|
|
301
414
|
cacheInstanceExecutionState(name, msg) {
|
|
302
415
|
const state = msg.state;
|
|
303
416
|
if (state !== "idle" && state !== "working" && state !== "stuck")
|
|
@@ -311,25 +424,223 @@ export class FleetManager {
|
|
|
311
424
|
observedAt: numberOr(msg.observedAt, now),
|
|
312
425
|
stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
|
|
313
426
|
});
|
|
427
|
+
for (const check of this.instanceIdleWaiters.get(name) ?? [])
|
|
428
|
+
check();
|
|
429
|
+
// warm_cap: a fresh transition into idle may free this instance for eviction,
|
|
430
|
+
// or (more usefully) reveal that the fleet is now over cap. Only fire on the
|
|
431
|
+
// edge into idle, not on every idle heartbeat.
|
|
432
|
+
if (state === "idle" && previous?.state !== "idle")
|
|
433
|
+
this.enforceWarmCap();
|
|
314
434
|
}
|
|
315
|
-
|
|
316
|
-
|
|
435
|
+
cacheInstanceProcessStatus(name, status) {
|
|
436
|
+
if (status === "running") {
|
|
437
|
+
this.instanceProcessStatus.delete(name);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (status !== "crashed" && status !== "stopped")
|
|
441
|
+
return;
|
|
442
|
+
this.instanceProcessStatus.set(name, status);
|
|
443
|
+
// Never display the last ready prompt as current execution state after its
|
|
444
|
+
// owning CLI process has exited.
|
|
445
|
+
this.instanceStateCache.delete(name);
|
|
446
|
+
for (const check of this.instanceIdleWaiters.get(name) ?? [])
|
|
447
|
+
check();
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Fleet-wide warm cap: if more than `defaults.warm_cap` instances are running,
|
|
451
|
+
* auto-pause the least-recently-active idle instances until back at the cap.
|
|
452
|
+
* Never evicts general instances (must stay warm) or working/stuck instances
|
|
453
|
+
* (only idle). 0/unset = unlimited. wake-before-deliver re-warms any evicted
|
|
454
|
+
* instance when a message next arrives.
|
|
455
|
+
*
|
|
456
|
+
* @param exclude instance to spare (e.g. one just woken to receive a delivery).
|
|
457
|
+
*/
|
|
458
|
+
enforceWarmCap(exclude) {
|
|
459
|
+
const cap = this.fleetConfig?.defaults?.warm_cap ?? 0;
|
|
460
|
+
if (!Number.isInteger(cap) || cap <= 0)
|
|
461
|
+
return; // 0/invalid = unlimited
|
|
462
|
+
const warm = [];
|
|
463
|
+
for (const name of this.daemons.keys()) {
|
|
464
|
+
if (this.getInstanceStatus(name) === "running")
|
|
465
|
+
warm.push(name);
|
|
466
|
+
}
|
|
467
|
+
if (warm.length <= cap)
|
|
468
|
+
return;
|
|
469
|
+
const victims = selectLruEvictions(warm, cap, {
|
|
470
|
+
exclude,
|
|
471
|
+
isEvicting: name => this.warmCapEvicting.has(name),
|
|
472
|
+
isGeneral: name => this.fleetConfig?.instances[name]?.general_topic === true,
|
|
473
|
+
isIdle: name => this.getInstanceExecutionState(name) === "idle",
|
|
474
|
+
lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
|
|
475
|
+
});
|
|
476
|
+
for (const victim of victims) {
|
|
477
|
+
this.warmCapEvicting.add(victim);
|
|
478
|
+
this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
|
|
479
|
+
this.lifecycle.pause(victim)
|
|
480
|
+
.catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
|
|
481
|
+
.finally(() => this.warmCapEvicting.delete(victim));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
|
|
485
|
+
const isReady = () => {
|
|
486
|
+
const snapshot = this.instanceStateCache.get(instanceName);
|
|
487
|
+
return snapshot?.state === "idle"
|
|
488
|
+
&& (idleObservedAfter === 0 || snapshot.observedAt > idleObservedAfter);
|
|
489
|
+
};
|
|
490
|
+
if (isReady())
|
|
491
|
+
return Promise.resolve(true);
|
|
492
|
+
return new Promise(resolve => {
|
|
493
|
+
let settled = false;
|
|
494
|
+
const finish = (idle) => {
|
|
495
|
+
if (settled)
|
|
496
|
+
return;
|
|
497
|
+
settled = true;
|
|
498
|
+
clearTimeout(timeout);
|
|
499
|
+
clearInterval(queryTimer);
|
|
500
|
+
const waiters = this.instanceIdleWaiters.get(instanceName);
|
|
501
|
+
waiters?.delete(check);
|
|
502
|
+
if (waiters?.size === 0)
|
|
503
|
+
this.instanceIdleWaiters.delete(instanceName);
|
|
504
|
+
resolve(idle);
|
|
505
|
+
};
|
|
506
|
+
const check = () => { if (isReady())
|
|
507
|
+
finish(true); };
|
|
508
|
+
const query = () => {
|
|
509
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
510
|
+
if (ipc?.connected) {
|
|
511
|
+
ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}` });
|
|
512
|
+
}
|
|
513
|
+
check();
|
|
514
|
+
};
|
|
515
|
+
const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
|
|
516
|
+
waiters.add(check);
|
|
517
|
+
this.instanceIdleWaiters.set(instanceName, waiters);
|
|
518
|
+
const timeout = setTimeout(() => finish(false), timeoutMs);
|
|
519
|
+
const queryTimer = setInterval(query, 1_000);
|
|
520
|
+
query();
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs) {
|
|
524
|
+
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
317
525
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
526
|
+
const wakeStartedAt = Date.now();
|
|
318
527
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
528
|
+
// Waking added one to the warm count — make room by evicting a different
|
|
529
|
+
// LRU idle instance (never this one; it's about to work).
|
|
530
|
+
this.enforceWarmCap(instanceName);
|
|
531
|
+
// Never satisfy a post-wake gate from a stale pre-pause cache entry.
|
|
532
|
+
idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
|
|
533
|
+
}
|
|
534
|
+
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
|
|
535
|
+
if (!idle) {
|
|
536
|
+
this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
|
|
319
537
|
}
|
|
320
538
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
321
539
|
if (!ipc?.connected)
|
|
322
540
|
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
323
541
|
ipc.send(payload);
|
|
542
|
+
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
543
|
+
}
|
|
544
|
+
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
545
|
+
async deliverToInstance(instanceName, payload, options = {}) {
|
|
546
|
+
const meta = payload.meta && typeof payload.meta === "object"
|
|
547
|
+
? payload.meta
|
|
548
|
+
: undefined;
|
|
549
|
+
const inferredCrossInstance = (typeof meta?.from_instance === "string" && meta.from_instance.length > 0)
|
|
550
|
+
|| meta?.is_cross_instance === true
|
|
551
|
+
|| payload.is_cross_instance === true;
|
|
552
|
+
const waitForIdle = options.waitForIdle
|
|
553
|
+
?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
|
|
554
|
+
if (!waitForIdle) {
|
|
555
|
+
if (this.lifecycle.isPaused(instanceName)) {
|
|
556
|
+
await this.lifecycle.wake(instanceName, 30_000);
|
|
557
|
+
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
558
|
+
}
|
|
559
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
560
|
+
if (!ipc?.connected)
|
|
561
|
+
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
562
|
+
ipc.send(payload);
|
|
563
|
+
// A cross-instance item arriving before the daemon observes this turn as
|
|
564
|
+
// working must not trust the stale idle snapshot from before the send.
|
|
565
|
+
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
569
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, payload, options.idleTimeoutMs ?? 60_000));
|
|
570
|
+
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
571
|
+
try {
|
|
572
|
+
await delivery;
|
|
573
|
+
}
|
|
574
|
+
finally {
|
|
575
|
+
if (this.idleGatedDeliveryTails.get(instanceName) === delivery) {
|
|
576
|
+
this.idleGatedDeliveryTails.delete(instanceName);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Fleet admin is an explicit config allowlist entry, not merely an open/paired user. */
|
|
581
|
+
isFleetAdmin(userId, adapterId) {
|
|
582
|
+
const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
|
|
583
|
+
return allowed.some(entry => String(entry) === String(userId));
|
|
584
|
+
}
|
|
585
|
+
async changeInstancePauseState(name, action) {
|
|
586
|
+
if (action === "wake") {
|
|
587
|
+
await this.lifecycle.wake(name, 30_000);
|
|
588
|
+
this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
|
|
589
|
+
return "awake";
|
|
590
|
+
}
|
|
591
|
+
await this.lifecycle.pause(name);
|
|
592
|
+
return this.lifecycle.isPaused(name) ? "paused" : "not_idle";
|
|
593
|
+
}
|
|
594
|
+
/** Apply a Settings edit to a ClassicBot channel without waiting for the poller. */
|
|
595
|
+
async restartClassicInstanceFromSettings(instanceName) {
|
|
596
|
+
if (!this.classicChannels)
|
|
597
|
+
throw new Error("Classic channel manager not initialized");
|
|
598
|
+
const wasRunning = this.daemons.has(instanceName);
|
|
599
|
+
this.classicChannels.reloadFromDisk();
|
|
600
|
+
this.reregisterClassicChannels();
|
|
601
|
+
const channel = this.classicChannels.getAll().find(item => item.instanceName === instanceName);
|
|
602
|
+
if (!channel)
|
|
603
|
+
throw new Error("Classic channel not found after reload");
|
|
604
|
+
if (!wasRunning)
|
|
605
|
+
return;
|
|
606
|
+
await this.stopInstance(instanceName);
|
|
607
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
608
|
+
await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
324
609
|
}
|
|
325
610
|
async startInstance(name, config, topicMode) {
|
|
611
|
+
if (this.lifecycle.isPaused(name)) {
|
|
612
|
+
this.logger.info({ name }, "Persisted paused instance — skipping startup");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
326
615
|
if (config.general_topic) {
|
|
616
|
+
// antigravity (agy) does not read MCP instructions — fleet context and
|
|
617
|
+
// routing instructions are not injected, so it cannot act as a dispatcher.
|
|
618
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
619
|
+
if (backend === "antigravity") {
|
|
620
|
+
this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
|
|
621
|
+
this.notifyInstanceTopic(name, "⚠️ antigravity backend is not supported for General instances (no MCP instructions injection). Switch to claude-code or kiro-cli.");
|
|
622
|
+
}
|
|
327
623
|
this.ensureGeneralInstructions(config.working_directory, config.backend);
|
|
328
624
|
}
|
|
625
|
+
this.instanceProcessStatus.delete(name);
|
|
329
626
|
await this.lifecycle.start(name, config, topicMode);
|
|
330
627
|
// Auto-connect IPC — daemon.start() ensures socket is ready before resolving
|
|
331
628
|
await this.connectIpcToInstance(name);
|
|
332
629
|
}
|
|
630
|
+
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
631
|
+
async startPersistedPausedInstance(name) {
|
|
632
|
+
const topicMode = this.fleetConfig?.channel?.mode === "topic"
|
|
633
|
+
|| !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
|
|
634
|
+
const fleetConfig = this.fleetConfig?.instances[name];
|
|
635
|
+
if (fleetConfig) {
|
|
636
|
+
await this.startInstance(name, fleetConfig, topicMode);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
|
|
640
|
+
if (!channel || !this.classicChannels)
|
|
641
|
+
throw new Error(`Paused instance '${name}' is no longer configured`);
|
|
642
|
+
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
643
|
+
}
|
|
333
644
|
/**
|
|
334
645
|
* Start instances with configurable concurrency and stagger delay.
|
|
335
646
|
* Instances sharing the same working_directory are serialized within a group
|
|
@@ -338,8 +649,21 @@ export class FleetManager {
|
|
|
338
649
|
*/
|
|
339
650
|
async startInstancesWithConcurrency(entries, topicMode) {
|
|
340
651
|
const raw = this.fleetConfig?.defaults?.startup;
|
|
341
|
-
const
|
|
652
|
+
const explicitConcurrency = raw?.concurrency;
|
|
342
653
|
const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 500));
|
|
654
|
+
// Adaptive concurrency: if not explicitly set, estimate from available RAM.
|
|
655
|
+
// Each instance uses ~300MB (tmux + CLI process + model overhead).
|
|
656
|
+
const ESTIMATED_MB_PER_INSTANCE = 300;
|
|
657
|
+
const { freemem } = await import("node:os");
|
|
658
|
+
let concurrency;
|
|
659
|
+
if (explicitConcurrency != null) {
|
|
660
|
+
concurrency = Math.max(1, Math.min(20, explicitConcurrency));
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
const freeMemMB = Math.round(freemem() / (1024 * 1024));
|
|
664
|
+
concurrency = Math.max(2, Math.min(10, Math.floor(freeMemMB / ESTIMATED_MB_PER_INSTANCE)));
|
|
665
|
+
this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances: entries.length }, "Adaptive startup concurrency");
|
|
666
|
+
}
|
|
343
667
|
const byWorkDir = new Map();
|
|
344
668
|
for (const [name, config] of entries) {
|
|
345
669
|
const dir = config.working_directory;
|
|
@@ -361,6 +685,17 @@ export class FleetManager {
|
|
|
361
685
|
if (pendingTimer)
|
|
362
686
|
return;
|
|
363
687
|
while (running < concurrency && idx < groups.length) {
|
|
688
|
+
// Re-check memory if adaptive (no explicit concurrency set)
|
|
689
|
+
if (explicitConcurrency == null && running > 0) {
|
|
690
|
+
const nowFreeMB = Math.round(freemem() / (1024 * 1024));
|
|
691
|
+
if (nowFreeMB < ESTIMATED_MB_PER_INSTANCE) {
|
|
692
|
+
this.logger.warn({ freeMemMB: nowFreeMB, remaining: groups.length - idx }, "Low memory — pausing instance startup");
|
|
693
|
+
// Wait and retry in 5s
|
|
694
|
+
pendingTimer = true;
|
|
695
|
+
setTimeout(() => { pendingTimer = false; startNext(); }, 5000);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
364
699
|
const now = Date.now();
|
|
365
700
|
const elapsed = now - lastStartAt;
|
|
366
701
|
if (lastStartAt > 0 && elapsed < staggerMs) {
|
|
@@ -390,21 +725,53 @@ export class FleetManager {
|
|
|
390
725
|
async stopInstance(name) {
|
|
391
726
|
this.failoverActive.delete(name);
|
|
392
727
|
this.instanceStateCache.delete(name);
|
|
728
|
+
this.instanceProcessStatus.delete(name);
|
|
729
|
+
this.lastDeliveryAt.delete(name);
|
|
393
730
|
return this.lifecycle.stop(name);
|
|
394
731
|
}
|
|
395
732
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
396
|
-
async restartSingleInstance(name) {
|
|
733
|
+
async restartSingleInstance(name, opts) {
|
|
397
734
|
if (this.configPath) {
|
|
398
735
|
this.loadConfig(this.configPath);
|
|
399
736
|
this.routing.rebuild(this.fleetConfig);
|
|
400
737
|
this.reregisterClassicChannels();
|
|
401
738
|
}
|
|
402
739
|
const config = this.fleetConfig?.instances[name];
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
740
|
+
if (config) {
|
|
741
|
+
await this.stopInstance(name);
|
|
742
|
+
if (opts?.freshStart)
|
|
743
|
+
this.writeFreshStartMarker(name);
|
|
744
|
+
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
745
|
+
await this.startInstance(name, config, topicMode ?? false);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Classic instance fallback
|
|
749
|
+
const channelId = this.classicChannels?.getChannelIdByInstance(name);
|
|
750
|
+
if (channelId) {
|
|
751
|
+
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
752
|
+
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
753
|
+
await this.stopInstance(name);
|
|
754
|
+
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
755
|
+
if (opts?.freshStart)
|
|
756
|
+
this.writeFreshStartMarker(name);
|
|
757
|
+
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
throw new Error(`Instance not found: ${name}`);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Mark an instance so its next daemon start skips session resume. Written AFTER
|
|
764
|
+
* stop (survives the old daemon's cleanup) and BEFORE start so the respawn reads
|
|
765
|
+
* it — reuses the crash-state → resumeDisabled path from crash-loop recovery.
|
|
766
|
+
* One-shot: the daemon deletes it on startup.
|
|
767
|
+
*/
|
|
768
|
+
writeFreshStartMarker(name) {
|
|
769
|
+
try {
|
|
770
|
+
writeFileSync(join(this.getInstanceDir(name), "crash-state.json"), JSON.stringify({ resumeDisabled: true, reason: "pty_error_restart" }));
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
|
|
774
|
+
}
|
|
408
775
|
}
|
|
409
776
|
/** Load .env file from data dir into process.env */
|
|
410
777
|
loadEnvFile() {
|
|
@@ -500,9 +867,11 @@ export class FleetManager {
|
|
|
500
867
|
const fleetModel = this.fleetConfig?.defaults?.model;
|
|
501
868
|
const oldBackends = new Map();
|
|
502
869
|
const oldModels = new Map();
|
|
870
|
+
const oldAutoPause = new Map();
|
|
503
871
|
for (const ch of this.classicChannels.getAll()) {
|
|
504
872
|
oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
|
|
505
873
|
oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
|
|
874
|
+
oldAutoPause.set(ch.instanceName, this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
506
875
|
}
|
|
507
876
|
if (!this.classicChannels.checkReload())
|
|
508
877
|
return;
|
|
@@ -510,14 +879,16 @@ export class FleetManager {
|
|
|
510
879
|
for (const ch of this.classicChannels.getAll()) {
|
|
511
880
|
const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
|
|
512
881
|
const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
|
|
882
|
+
const newAutoPause = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
|
|
513
883
|
const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
|
|
514
884
|
const modelChanged = oldModels.get(ch.instanceName) !== newModel;
|
|
515
|
-
|
|
885
|
+
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
886
|
+
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged)) {
|
|
516
887
|
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
|
|
517
888
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
518
889
|
// Small delay to let tmux window clean up
|
|
519
890
|
await new Promise(r => setTimeout(r, 2000));
|
|
520
|
-
await this.startClassicInstance(ch.instanceName, newBackend, this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), newModel).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
|
|
891
|
+
await this.startClassicInstance(ch.instanceName, newBackend, this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), newModel, newAutoPause).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
|
|
521
892
|
}
|
|
522
893
|
}
|
|
523
894
|
}
|
|
@@ -770,7 +1141,7 @@ export class FleetManager {
|
|
|
770
1141
|
let idx = 0;
|
|
771
1142
|
while (idx < channels.length) {
|
|
772
1143
|
const batch = channels.slice(idx, idx + concurrency);
|
|
773
|
-
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
1144
|
+
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
774
1145
|
idx += concurrency;
|
|
775
1146
|
}
|
|
776
1147
|
}
|
|
@@ -781,16 +1152,26 @@ export class FleetManager {
|
|
|
781
1152
|
const classicCount = this.classicChannels?.getAll().length ?? 0;
|
|
782
1153
|
const total = Object.keys(fleet.instances).length + classicCount;
|
|
783
1154
|
const started = this.daemons.size;
|
|
784
|
-
const
|
|
1155
|
+
const allNotRunning = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
|
|
1156
|
+
const pausedNames = allNotRunning.filter(n => this.lifecycle.isPaused(n));
|
|
1157
|
+
const failedNames = allNotRunning.filter(n => !this.lifecycle.isPaused(n));
|
|
785
1158
|
const generalName = this.findGeneralInstance();
|
|
786
1159
|
const generalThreadId = generalName ? fleet.instances[generalName]?.topic_id : undefined;
|
|
787
1160
|
const { createRequire } = await import("node:module");
|
|
788
1161
|
const _require = createRequire(import.meta.url);
|
|
789
1162
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
790
1163
|
if (this.adapter && fleet.channel?.group_id) {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1164
|
+
let text;
|
|
1165
|
+
if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
1166
|
+
text = t("fleet.ready", started, total, agendVersion);
|
|
1167
|
+
}
|
|
1168
|
+
else if (failedNames.length === 0) {
|
|
1169
|
+
text = t("fleet.ready", started, total, agendVersion) + `\n⏸ Paused: ${pausedNames.join(", ")}`;
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
1173
|
+
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
1174
|
+
}
|
|
794
1175
|
this.adapter.sendText(String(fleet.channel.group_id), text, {
|
|
795
1176
|
threadId: generalThreadId != null ? String(generalThreadId) : undefined,
|
|
796
1177
|
}).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
|
|
@@ -975,6 +1356,10 @@ export class FleetManager {
|
|
|
975
1356
|
await this.handleInboundMessage(msg);
|
|
976
1357
|
}, this.logger, "adapter.message"));
|
|
977
1358
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
1359
|
+
if (await this.handleClassicBackendSelection(data))
|
|
1360
|
+
return;
|
|
1361
|
+
if (await this.handleModelSelection(data))
|
|
1362
|
+
return;
|
|
978
1363
|
if (data.callbackData.startsWith("hang:")) {
|
|
979
1364
|
const parts = data.callbackData.split(":");
|
|
980
1365
|
const action = parts[1];
|
|
@@ -1013,13 +1398,15 @@ export class FleetManager {
|
|
|
1013
1398
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
1014
1399
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
1015
1400
|
if (data.command === "start") {
|
|
1016
|
-
|
|
1017
|
-
await data.respond(reply);
|
|
1401
|
+
await this.handleClassicStartSlash(data, adapterId);
|
|
1018
1402
|
}
|
|
1019
1403
|
else if (data.command === "stop") {
|
|
1020
1404
|
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
1021
1405
|
await data.respond(reply);
|
|
1022
1406
|
}
|
|
1407
|
+
else if (data.command === "pause" || data.command === "wake") {
|
|
1408
|
+
await this.handlePauseWakeSlash(data, adapterId);
|
|
1409
|
+
}
|
|
1023
1410
|
else if (data.command === "chat") {
|
|
1024
1411
|
const text = data.text ?? "";
|
|
1025
1412
|
if (!text) {
|
|
@@ -1075,6 +1462,9 @@ export class FleetManager {
|
|
|
1075
1462
|
const result = await this.topicCommands.sendCompact(name);
|
|
1076
1463
|
await data.respond(result);
|
|
1077
1464
|
}
|
|
1465
|
+
else if (data.command === "model") {
|
|
1466
|
+
await this.handleModelSlash(data, adapterId);
|
|
1467
|
+
}
|
|
1078
1468
|
else if (data.command === "cancel") {
|
|
1079
1469
|
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1080
1470
|
if (!name) {
|
|
@@ -1192,6 +1582,9 @@ export class FleetManager {
|
|
|
1192
1582
|
}
|
|
1193
1583
|
}, this.logger, "adapter.slash_command"));
|
|
1194
1584
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
1585
|
+
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
1586
|
+
// Non-blocking: /model & status views read the cache; never delays startup.
|
|
1587
|
+
this.probeCliEnvs();
|
|
1195
1588
|
this.adapter.on("started", safeHandler((username, userId) => {
|
|
1196
1589
|
this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
|
|
1197
1590
|
const w = this.worlds.values().next().value;
|
|
@@ -1258,6 +1651,10 @@ export class FleetManager {
|
|
|
1258
1651
|
await this.handleInboundMessage(msg);
|
|
1259
1652
|
}, this.logger, `adapter[${adapterId}].message`));
|
|
1260
1653
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
1654
|
+
if (await this.handleClassicBackendSelection(data))
|
|
1655
|
+
return;
|
|
1656
|
+
if (await this.handleModelSelection(data))
|
|
1657
|
+
return;
|
|
1261
1658
|
if (data.callbackData.startsWith("hang:")) {
|
|
1262
1659
|
const parts = data.callbackData.split(":");
|
|
1263
1660
|
const action = parts[1];
|
|
@@ -1292,13 +1689,15 @@ export class FleetManager {
|
|
|
1292
1689
|
// Slash commands: classic bot + admin commands
|
|
1293
1690
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
1294
1691
|
if (data.command === "start") {
|
|
1295
|
-
|
|
1296
|
-
await data.respond(reply);
|
|
1692
|
+
await this.handleClassicStartSlash(data, adapterId);
|
|
1297
1693
|
}
|
|
1298
1694
|
else if (data.command === "stop") {
|
|
1299
1695
|
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
1300
1696
|
await data.respond(reply);
|
|
1301
1697
|
}
|
|
1698
|
+
else if (data.command === "pause" || data.command === "wake") {
|
|
1699
|
+
await this.handlePauseWakeSlash(data, adapterId);
|
|
1700
|
+
}
|
|
1302
1701
|
else if (data.command === "chat") {
|
|
1303
1702
|
const text = data.text ?? "";
|
|
1304
1703
|
if (!text) {
|
|
@@ -1345,6 +1744,9 @@ export class FleetManager {
|
|
|
1345
1744
|
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
1346
1745
|
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
1347
1746
|
}
|
|
1747
|
+
else if (data.command === "model") {
|
|
1748
|
+
await this.handleModelSlash(data, adapterId);
|
|
1749
|
+
}
|
|
1348
1750
|
else if (data.command === "cancel") {
|
|
1349
1751
|
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1350
1752
|
if (!name) {
|
|
@@ -1555,8 +1957,14 @@ export class FleetManager {
|
|
|
1555
1957
|
else if (msg.type === "fleet_set_description") {
|
|
1556
1958
|
this.handleSetDescription(name, msg);
|
|
1557
1959
|
}
|
|
1960
|
+
else if (msg.type === "instance_process_state") {
|
|
1961
|
+
this.cacheInstanceProcessStatus(name, msg.status);
|
|
1962
|
+
}
|
|
1558
1963
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
1559
1964
|
this.cacheInstanceExecutionState(name, msg);
|
|
1965
|
+
if (msg.type === "instance_state_response") {
|
|
1966
|
+
this.cacheInstanceProcessStatus(name, msg.processStatus);
|
|
1967
|
+
}
|
|
1560
1968
|
}
|
|
1561
1969
|
}, this.logger, `ipc.message[${name}]`));
|
|
1562
1970
|
// Ask daemon for any sessions that registered before we connected
|
|
@@ -1827,9 +2235,21 @@ export class FleetManager {
|
|
|
1827
2235
|
}
|
|
1828
2236
|
}
|
|
1829
2237
|
const channelName = msg.username || chatId;
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
2238
|
+
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
2239
|
+
if (requestedBackend) {
|
|
2240
|
+
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
2241
|
+
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
|
|
2242
|
+
await msgAdapter?.sendText(chatId, reply);
|
|
2243
|
+
}
|
|
2244
|
+
else if (msgAdapter) {
|
|
2245
|
+
await this.beginClassicBackendSelection({
|
|
2246
|
+
command: "start",
|
|
2247
|
+
channelId: chatId,
|
|
2248
|
+
channelName,
|
|
2249
|
+
userId: msg.userId,
|
|
2250
|
+
respond: async (reply) => (await msgAdapter.sendText(chatId, reply)).messageId,
|
|
2251
|
+
}, msgAdapter);
|
|
2252
|
+
}
|
|
1833
2253
|
return;
|
|
1834
2254
|
}
|
|
1835
2255
|
// Handle /stop command
|
|
@@ -1846,6 +2266,42 @@ export class FleetManager {
|
|
|
1846
2266
|
await msgAdapter?.sendText(chatId, reply);
|
|
1847
2267
|
return;
|
|
1848
2268
|
}
|
|
2269
|
+
const pauseWake = parsePauseWakeCommand(text);
|
|
2270
|
+
if (pauseWake) {
|
|
2271
|
+
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
2272
|
+
await msgAdapter?.sendText(chatId, t("permission.denied"));
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
const name = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
2276
|
+
if (!name) {
|
|
2277
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
await msgAdapter?.sendText(chatId, await this.topicCommands.runPauseWake(name, pauseWake.action));
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
// Handle /model command (admin only)
|
|
2284
|
+
if (text === "/model" || text.startsWith("/model ") || text.startsWith("/model@")) {
|
|
2285
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
2286
|
+
await msgAdapter?.sendText(chatId, t("permission.denied"));
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
const modelName = text.replace(/^\/model(@\S+)?/, "").trim();
|
|
2290
|
+
const modelInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
2291
|
+
if (!modelInstance) {
|
|
2292
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
if (modelName) {
|
|
2296
|
+
await msgAdapter?.sendText(chatId, await this.applyModel(modelInstance, modelName));
|
|
2297
|
+
}
|
|
2298
|
+
else if (msgAdapter) {
|
|
2299
|
+
const fallback = await this.promptModelMenu(modelInstance, msg.userId, chatId, msgAdapter, chatId);
|
|
2300
|
+
if (fallback)
|
|
2301
|
+
await msgAdapter.sendText(chatId, fallback);
|
|
2302
|
+
}
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
1849
2305
|
// Handle /compact command (admin only)
|
|
1850
2306
|
if (text === "/compact" || text.startsWith("/compact@")) {
|
|
1851
2307
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
@@ -1979,6 +2435,7 @@ export class FleetManager {
|
|
|
1979
2435
|
user_id: msg.userId,
|
|
1980
2436
|
ts: msg.timestamp.toISOString(),
|
|
1981
2437
|
thread_id: "",
|
|
2438
|
+
adapter_id: msg.adapterId,
|
|
1982
2439
|
source: msg.source,
|
|
1983
2440
|
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
1984
2441
|
...extraMeta,
|
|
@@ -2071,6 +2528,7 @@ export class FleetManager {
|
|
|
2071
2528
|
user_id: msg.userId,
|
|
2072
2529
|
ts: msg.timestamp.toISOString(),
|
|
2073
2530
|
thread_id: msg.threadId ?? "",
|
|
2531
|
+
adapter_id: msg.adapterId,
|
|
2074
2532
|
source: msg.source,
|
|
2075
2533
|
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
2076
2534
|
...extraMeta,
|
|
@@ -2227,7 +2685,7 @@ export class FleetManager {
|
|
|
2227
2685
|
}
|
|
2228
2686
|
// ===================== Scheduler =====================
|
|
2229
2687
|
async handleScheduleTrigger(schedule) {
|
|
2230
|
-
const { target, reply_chat_id, reply_thread_id, message, label, id, source } = schedule;
|
|
2688
|
+
const { target, reply_chat_id, reply_thread_id, message, label, id, source, silent } = schedule;
|
|
2231
2689
|
const RATE_LIMIT_DEFER_THRESHOLD = 85;
|
|
2232
2690
|
const rl = this.statuslineWatcher.getRateLimits(target);
|
|
2233
2691
|
if (rl && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
|
|
@@ -2242,6 +2700,20 @@ export class FleetManager {
|
|
|
2242
2700
|
this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct }, "Schedule deferred due to rate limit");
|
|
2243
2701
|
return;
|
|
2244
2702
|
}
|
|
2703
|
+
// Silent mode: paste directly to tmux pane — no channel message.
|
|
2704
|
+
if (silent) {
|
|
2705
|
+
const ipc = this.instanceIpcClients.get(target);
|
|
2706
|
+
if (ipc) {
|
|
2707
|
+
ipc.send({ type: "raw_paste", content: message });
|
|
2708
|
+
this.scheduler.recordRun(id, "delivered");
|
|
2709
|
+
this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
|
|
2710
|
+
}
|
|
2711
|
+
else {
|
|
2712
|
+
this.scheduler.recordRun(id, "instance_offline", "IPC not connected");
|
|
2713
|
+
this.logger.warn({ target, scheduleId: id }, "Silent schedule: IPC not connected, skipping");
|
|
2714
|
+
}
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2245
2717
|
const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
|
|
2246
2718
|
const retryCount = schedulerDefaults?.retry_count ?? 3;
|
|
2247
2719
|
const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
|
|
@@ -2251,7 +2723,7 @@ export class FleetManager {
|
|
|
2251
2723
|
type: "fleet_schedule_trigger",
|
|
2252
2724
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
2253
2725
|
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
2254
|
-
});
|
|
2726
|
+
}, { waitForIdle: true });
|
|
2255
2727
|
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
2256
2728
|
void this.sendCancelButton(target);
|
|
2257
2729
|
return true;
|
|
@@ -2310,6 +2782,7 @@ export class FleetManager {
|
|
|
2310
2782
|
case "fleet_schedule_create": {
|
|
2311
2783
|
const params = {
|
|
2312
2784
|
cron: payload.cron,
|
|
2785
|
+
at: payload.at,
|
|
2313
2786
|
message: payload.message,
|
|
2314
2787
|
source: instanceName,
|
|
2315
2788
|
target: payload.target || instanceName,
|
|
@@ -2317,6 +2790,7 @@ export class FleetManager {
|
|
|
2317
2790
|
reply_thread_id: meta.thread_id || null,
|
|
2318
2791
|
label: payload.label,
|
|
2319
2792
|
timezone: payload.timezone,
|
|
2793
|
+
silent: !!(payload.silent),
|
|
2320
2794
|
};
|
|
2321
2795
|
result = this.scheduler.create(params);
|
|
2322
2796
|
break;
|
|
@@ -2437,7 +2911,9 @@ export class FleetManager {
|
|
|
2437
2911
|
switch (op) {
|
|
2438
2912
|
case "create":
|
|
2439
2913
|
return this.scheduler.create({
|
|
2440
|
-
cron: args.cron,
|
|
2914
|
+
cron: args.cron,
|
|
2915
|
+
at: args.at,
|
|
2916
|
+
message: args.message,
|
|
2441
2917
|
source: instance, target: args.target || instance,
|
|
2442
2918
|
reply_chat_id: "", reply_thread_id: null,
|
|
2443
2919
|
label: args.label,
|
|
@@ -2693,73 +3169,92 @@ export class FleetManager {
|
|
|
2693
3169
|
}
|
|
2694
3170
|
}, 5 * 60_000);
|
|
2695
3171
|
}
|
|
2696
|
-
/**
|
|
2697
|
-
|
|
3172
|
+
/**
|
|
3173
|
+
* Patch only values changed in the effective config into the original YAML
|
|
3174
|
+
* document. Unknown keys, explicit overrides and comments remain untouched.
|
|
3175
|
+
*/
|
|
3176
|
+
saveFleetConfig(explicitPatches = []) {
|
|
2698
3177
|
if (!this.fleetConfig || !this.configPath)
|
|
2699
3178
|
return;
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
}
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
}
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
3179
|
+
if (!this.savedFleetConfigSnapshot)
|
|
3180
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
3181
|
+
// Re-read immediately before patching so an unrelated concurrent/manual
|
|
3182
|
+
// edit is retained. Invalid concurrent YAML is never overwritten.
|
|
3183
|
+
const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
|
|
3184
|
+
this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
|
|
3185
|
+
if (this.rawFleetDocument.errors.length > 0) {
|
|
3186
|
+
throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
3187
|
+
}
|
|
3188
|
+
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
3189
|
+
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
3190
|
+
// Settings edits are expressed against the raw config. Persist them even
|
|
3191
|
+
// when the chosen override equals the inherited effective value, a case
|
|
3192
|
+
// the before/after runtime diff cannot observe.
|
|
3193
|
+
for (const patch of explicitPatches) {
|
|
3194
|
+
if (patch.remove) {
|
|
3195
|
+
this.rawFleetDocument.deleteIn(patch.path);
|
|
3196
|
+
}
|
|
3197
|
+
else {
|
|
3198
|
+
const before = this.rawFleetDocument.getIn(patch.path);
|
|
3199
|
+
this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
const output = String(this.rawFleetDocument);
|
|
3203
|
+
const tempPath = `${this.configPath}.tmp-${process.pid}`;
|
|
3204
|
+
writeFileSync(tempPath, output, "utf-8");
|
|
3205
|
+
if (existsSync(this.configPath))
|
|
3206
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
3207
|
+
renameSync(tempPath, this.configPath);
|
|
3208
|
+
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
3209
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
3210
|
+
this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
|
|
3211
|
+
}
|
|
3212
|
+
patchFleetDocument(document, path, before, after) {
|
|
3213
|
+
if (Object.is(before, after))
|
|
3214
|
+
return;
|
|
3215
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
3216
|
+
const shared = Math.min(before.length, after.length);
|
|
3217
|
+
for (let i = 0; i < shared; i++) {
|
|
3218
|
+
this.patchFleetDocument(document, [...path, i], before[i], after[i]);
|
|
3219
|
+
}
|
|
3220
|
+
// Remove from the end so YAML sequence indexes do not shift underneath us.
|
|
3221
|
+
for (let i = before.length - 1; i >= after.length; i--)
|
|
3222
|
+
document.deleteIn([...path, i]);
|
|
3223
|
+
for (let i = shared; i < after.length; i++)
|
|
3224
|
+
document.setIn([...path, i], after[i]);
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3228
|
+
if (isRecord(before) && isRecord(after)) {
|
|
3229
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
3230
|
+
for (const key of keys) {
|
|
3231
|
+
// `channel` is a derived alias when the raw file uses `channels`.
|
|
3232
|
+
if (path.length === 0 && key === "channel" && this.rawFleetConfig.channels)
|
|
3233
|
+
continue;
|
|
3234
|
+
// Conversely, `channels` is a normalized alias for a legacy `channel`.
|
|
3235
|
+
// Keep the user's original shape unless the caller explicitly removed
|
|
3236
|
+
// `channel` (the Settings channels endpoint intentionally migrates it).
|
|
3237
|
+
if (path.length === 0 && key === "channels" && this.rawFleetConfig.channel && !this.rawFleetConfig.channels && after.channel !== undefined)
|
|
3238
|
+
continue;
|
|
3239
|
+
this.patchFleetDocument(document, [...path, key], before[key], after[key]);
|
|
3240
|
+
}
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
if (after === undefined) {
|
|
3244
|
+
document.deleteIn(path);
|
|
3245
|
+
}
|
|
3246
|
+
else if (path.length === 0) {
|
|
3247
|
+
document.contents = document.createNode(after);
|
|
3248
|
+
}
|
|
3249
|
+
else {
|
|
3250
|
+
const currentNode = document.getIn(path, true);
|
|
3251
|
+
if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
|
|
3252
|
+
currentNode.value = after;
|
|
3253
|
+
}
|
|
3254
|
+
else {
|
|
3255
|
+
document.setIn(path, after);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
2763
3258
|
}
|
|
2764
3259
|
async removeInstance(name) {
|
|
2765
3260
|
// Clean up schedules (scheduler is fleet-level, not lifecycle-level)
|
|
@@ -2800,6 +3295,8 @@ export class FleetManager {
|
|
|
2800
3295
|
}
|
|
2801
3296
|
}
|
|
2802
3297
|
startStatuslineWatcher(name) {
|
|
3298
|
+
if (this.lifecycle.isPaused(name))
|
|
3299
|
+
return;
|
|
2803
3300
|
this.statuslineWatcher.watch(name);
|
|
2804
3301
|
}
|
|
2805
3302
|
stopStatuslineWatcher(name) {
|
|
@@ -3645,13 +4142,25 @@ When users create specialized instances, suggest these configurations:
|
|
|
3645
4142
|
const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
|
|
3646
4143
|
mkdirSync(inboxDir, { recursive: true });
|
|
3647
4144
|
const dest = join(inboxDir, basename(tmpPath));
|
|
4145
|
+
// Copy to destination — failure means this attachment is skipped
|
|
3648
4146
|
try {
|
|
3649
|
-
renameSync(tmpPath, dest);
|
|
3650
|
-
}
|
|
3651
|
-
catch {
|
|
3652
4147
|
copyFileSync(tmpPath, dest);
|
|
4148
|
+
}
|
|
4149
|
+
catch (copyErr) {
|
|
4150
|
+
try {
|
|
4151
|
+
unlinkSync(dest);
|
|
4152
|
+
}
|
|
4153
|
+
catch { } // clean partial
|
|
4154
|
+
this.logger.warn({ err: copyErr.message, instanceName, dest }, "Attachment copy failed — skipping");
|
|
4155
|
+
continue;
|
|
4156
|
+
}
|
|
4157
|
+
// Cleanup source — failure is non-fatal (dest already valid)
|
|
4158
|
+
try {
|
|
3653
4159
|
unlinkSync(tmpPath);
|
|
3654
4160
|
}
|
|
4161
|
+
catch (cleanupErr) {
|
|
4162
|
+
this.logger.debug({ tmpPath, err: cleanupErr.message }, "Orphan tmp not cleaned");
|
|
4163
|
+
}
|
|
3655
4164
|
const savedKind = att.kind === "sticker" ? "photo" : att.kind;
|
|
3656
4165
|
paths.push(dest);
|
|
3657
4166
|
if (paths.length === 1)
|
|
@@ -3684,6 +4193,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3684
4193
|
user_id: msg.userId,
|
|
3685
4194
|
ts: msg.timestamp.toISOString(),
|
|
3686
4195
|
thread_id: msg.threadId ?? "",
|
|
4196
|
+
...(msg.adapterId ? { adapter_id: msg.adapterId } : {}),
|
|
3687
4197
|
source: msg.source,
|
|
3688
4198
|
...extraMeta,
|
|
3689
4199
|
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
@@ -3725,23 +4235,457 @@ When users create specialized instances, suggest these configurations:
|
|
|
3725
4235
|
ipc.send({ type: "raw_paste", content: text });
|
|
3726
4236
|
this.logger.info({ instanceName, text: text.slice(0, 100) }, "Raw paste sent to classic instance");
|
|
3727
4237
|
}
|
|
4238
|
+
/** Resolve the backend name configured for an instance (fleet or classic). */
|
|
4239
|
+
backendNameForInstance(instanceName) {
|
|
4240
|
+
const fleetCfg = this.fleetConfig?.instances[instanceName];
|
|
4241
|
+
if (fleetCfg?.backend)
|
|
4242
|
+
return fleetCfg.backend;
|
|
4243
|
+
const classic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined
|
|
4244
|
+
? this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend)
|
|
4245
|
+
: undefined;
|
|
4246
|
+
return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
4247
|
+
}
|
|
4248
|
+
cliEnvPath(backend) {
|
|
4249
|
+
return join(getAgendHome(), "cli-env", `${backend.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
4250
|
+
}
|
|
4251
|
+
/** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
|
|
4252
|
+
readCliEnv(backend) {
|
|
4253
|
+
try {
|
|
4254
|
+
const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
|
|
4255
|
+
if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
|
|
4256
|
+
return env;
|
|
4257
|
+
}
|
|
4258
|
+
catch { /* missing / stale / corrupt */ }
|
|
4259
|
+
return null;
|
|
4260
|
+
}
|
|
4261
|
+
/** Resolve the model currently configured for a fleet or ClassicBot instance. */
|
|
4262
|
+
currentModelForInstance(instanceName) {
|
|
4263
|
+
const fleetInstance = this.fleetConfig?.instances[instanceName];
|
|
4264
|
+
if (fleetInstance) {
|
|
4265
|
+
const fleetModel = fleetInstance.model ?? this.fleetConfig?.defaults?.model;
|
|
4266
|
+
if (fleetModel?.trim())
|
|
4267
|
+
return fleetModel.trim();
|
|
4268
|
+
}
|
|
4269
|
+
const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
|
|
4270
|
+
if (classic) {
|
|
4271
|
+
const classicModel = this.classicChannels?.getModel(classic.channelId, classic.adapterId, this.fleetConfig?.defaults?.model);
|
|
4272
|
+
if (classicModel?.trim())
|
|
4273
|
+
return classicModel.trim();
|
|
4274
|
+
}
|
|
4275
|
+
const cachedModel = this.readCliEnv(this.backendNameForInstance(instanceName))?.currentModel;
|
|
4276
|
+
return cachedModel?.trim() || "default";
|
|
4277
|
+
}
|
|
4278
|
+
modelChoiceLabel(option, currentModel) {
|
|
4279
|
+
const label = option.description ? `${option.label} — ${option.description}` : option.label;
|
|
4280
|
+
return option.id === currentModel ? `✓ ${label}` : label;
|
|
4281
|
+
}
|
|
4282
|
+
/** Probe one backend's CLI env and cache it. Best-effort; never throws. */
|
|
4283
|
+
async probeBackend(backend) {
|
|
4284
|
+
try {
|
|
4285
|
+
const be = createBackend(backend, join(getAgendHome(), "cli-env"));
|
|
4286
|
+
if (!be.probeCLIEnv)
|
|
4287
|
+
return null;
|
|
4288
|
+
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
4289
|
+
const env = { backend, probedAt: Date.now(), ...probed };
|
|
4290
|
+
const path = this.cliEnvPath(backend);
|
|
4291
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
4292
|
+
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
4293
|
+
return env;
|
|
4294
|
+
}
|
|
4295
|
+
catch (err) {
|
|
4296
|
+
this.logger.warn({ err, backend }, "CLI env probe failed");
|
|
4297
|
+
return null;
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
/** Background-probe every distinct backend in use at startup (non-blocking). */
|
|
4301
|
+
probeCliEnvs() {
|
|
4302
|
+
const backends = new Set();
|
|
4303
|
+
if (this.fleetConfig?.defaults?.backend)
|
|
4304
|
+
backends.add(this.fleetConfig.defaults.backend);
|
|
4305
|
+
for (const inst of Object.values(this.fleetConfig?.instances ?? {}))
|
|
4306
|
+
if (inst.backend)
|
|
4307
|
+
backends.add(inst.backend);
|
|
4308
|
+
for (const ch of this.classicChannels?.getAll() ?? [])
|
|
4309
|
+
if (ch.backend)
|
|
4310
|
+
backends.add(ch.backend);
|
|
4311
|
+
if (backends.size === 0)
|
|
4312
|
+
backends.add("claude-code");
|
|
4313
|
+
for (const b of backends)
|
|
4314
|
+
void this.probeBackend(b);
|
|
4315
|
+
}
|
|
4316
|
+
/** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
|
|
4317
|
+
async getModelOptions(instanceName, refresh = false) {
|
|
4318
|
+
const backendName = this.backendNameForInstance(instanceName);
|
|
4319
|
+
if (!refresh) {
|
|
4320
|
+
const cached = this.readCliEnv(backendName);
|
|
4321
|
+
if (cached && cached.models.length)
|
|
4322
|
+
return cached.models;
|
|
4323
|
+
}
|
|
4324
|
+
// Cache miss / stale / forced refresh → probe live (also refreshes the cache).
|
|
4325
|
+
const env = await this.probeBackend(backendName);
|
|
4326
|
+
return env?.models ?? [];
|
|
4327
|
+
}
|
|
4328
|
+
/** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
|
|
4329
|
+
async handleModelSlash(data, adapterId) {
|
|
4330
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
4331
|
+
await data.respond(t("permission.denied"));
|
|
4332
|
+
return;
|
|
4333
|
+
}
|
|
4334
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4335
|
+
if (!name) {
|
|
4336
|
+
await data.respond(t("classic.no_agent"));
|
|
4337
|
+
return;
|
|
4338
|
+
}
|
|
4339
|
+
const requested = (typeof data.options?.name === "string" ? data.options.name.trim() : "")
|
|
4340
|
+
|| (data.text?.trim() ?? "");
|
|
4341
|
+
const isRefresh = requested === "--refresh" || requested === "refresh";
|
|
4342
|
+
if (requested && !isRefresh) {
|
|
4343
|
+
await data.respond(await this.applyModel(name, requested));
|
|
4344
|
+
return;
|
|
4345
|
+
}
|
|
4346
|
+
// No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
|
|
4347
|
+
if (!data.respondChoices) {
|
|
4348
|
+
await data.respond("Usage: /model <name> — e.g. /model sonnet");
|
|
4349
|
+
return;
|
|
4350
|
+
}
|
|
4351
|
+
const options = await this.getModelOptions(name, isRefresh);
|
|
4352
|
+
if (options.length === 0) {
|
|
4353
|
+
await data.respond(`No model list available for ${name}. Type \`/model <name>\` to set one directly.`);
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
4356
|
+
const currentModel = this.currentModelForInstance(name);
|
|
4357
|
+
const nonce = randomBytes(6).toString("hex");
|
|
4358
|
+
const choices = options.slice(0, 25).map(o => ({
|
|
4359
|
+
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
4360
|
+
label: this.modelChoiceLabel(o, currentModel),
|
|
4361
|
+
}));
|
|
4362
|
+
const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
4363
|
+
timer.unref?.();
|
|
4364
|
+
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
4365
|
+
try {
|
|
4366
|
+
await data.respondChoices(`Current model: **${currentModel}**\nSelect a new model:`, choices);
|
|
4367
|
+
}
|
|
4368
|
+
catch (err) {
|
|
4369
|
+
this.pendingModelSelects.delete(nonce);
|
|
4370
|
+
clearTimeout(timer);
|
|
4371
|
+
this.logger.warn({ err, instanceName: name }, "model menu failed");
|
|
4372
|
+
await data.respond("Usage: /model <name> — e.g. /model sonnet");
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Show a TG inline-keyboard model-selection menu. Reuses the same
|
|
4377
|
+
* pendingModelSelects coordinator as the DC Select Menu path.
|
|
4378
|
+
* Returns null on success (menu shown), or a fallback string to send.
|
|
4379
|
+
*/
|
|
4380
|
+
async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
4381
|
+
const options = await this.getModelOptions(instanceName);
|
|
4382
|
+
if (options.length === 0) {
|
|
4383
|
+
return `No model list available for ${instanceName}. Use \`/model <name>\` to set one directly.`;
|
|
4384
|
+
}
|
|
4385
|
+
const currentModel = this.currentModelForInstance(instanceName);
|
|
4386
|
+
const nonce = randomBytes(6).toString("hex");
|
|
4387
|
+
const choices = options.slice(0, 25).map(o => ({
|
|
4388
|
+
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
4389
|
+
label: this.modelChoiceLabel(o, currentModel),
|
|
4390
|
+
}));
|
|
4391
|
+
const respond = async (text) => {
|
|
4392
|
+
await adapter.sendText(chatId, text, { threadId });
|
|
4393
|
+
return undefined;
|
|
4394
|
+
};
|
|
4395
|
+
const timer = setTimeout(() => {
|
|
4396
|
+
const p = this.pendingModelSelects.get(nonce);
|
|
4397
|
+
if (p) {
|
|
4398
|
+
this.pendingModelSelects.delete(nonce);
|
|
4399
|
+
p.respond("⏰ Model selection expired.").catch(() => { });
|
|
4400
|
+
}
|
|
4401
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
4402
|
+
timer.unref?.();
|
|
4403
|
+
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
4404
|
+
try {
|
|
4405
|
+
const menuMessageId = await adapter.promptUser(chatId, `Current model: ${currentModel}\nSelect a new model:`, choices, { threadId });
|
|
4406
|
+
const pending = this.pendingModelSelects.get(nonce);
|
|
4407
|
+
if (pending)
|
|
4408
|
+
pending.menuMessageId = menuMessageId;
|
|
4409
|
+
return null; // menu shown
|
|
4410
|
+
}
|
|
4411
|
+
catch (err) {
|
|
4412
|
+
this.pendingModelSelects.delete(nonce);
|
|
4413
|
+
clearTimeout(timer);
|
|
4414
|
+
this.logger.warn({ err, instanceName }, "TG model menu failed");
|
|
4415
|
+
return `Usage: /model <name> — e.g. /model sonnet`;
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
/** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
|
|
4419
|
+
async handleModelSelection(data) {
|
|
4420
|
+
if (!data.callbackData.startsWith(MODEL_SELECT_CALLBACK_PREFIX))
|
|
4421
|
+
return false;
|
|
4422
|
+
const match = data.callbackData.match(/^model-select:([0-9a-f]+):(.+)$/);
|
|
4423
|
+
if (!match)
|
|
4424
|
+
return true;
|
|
4425
|
+
const pending = this.pendingModelSelects.get(match[1]);
|
|
4426
|
+
if (!pending)
|
|
4427
|
+
return true;
|
|
4428
|
+
// Only the admin who opened the menu, in the same channel, may consume it.
|
|
4429
|
+
if (data.userId && data.userId !== pending.userId)
|
|
4430
|
+
return true;
|
|
4431
|
+
const cbChannel = data.threadId ?? data.chatId;
|
|
4432
|
+
if (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
|
|
4433
|
+
return true;
|
|
4434
|
+
this.pendingModelSelects.delete(match[1]);
|
|
4435
|
+
clearTimeout(pending.timer);
|
|
4436
|
+
const model = match[2];
|
|
4437
|
+
// Send immediate "⏳ Switching..." feedback, then apply in background.
|
|
4438
|
+
const progressText = `⏳ Switching ${pending.instanceName} to \`${model}\`…`;
|
|
4439
|
+
let progressMsgId;
|
|
4440
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
4441
|
+
// TG path: turn the original menu into the progress message. This both
|
|
4442
|
+
// removes its inline keyboard and gives the final result a stable message
|
|
4443
|
+
// to edit, avoiding a stale selectable menu above a separate status post.
|
|
4444
|
+
const menuMessageId = pending.menuMessageId ?? data.messageId;
|
|
4445
|
+
if (menuMessageId && pending.adapter.editMessageRemoveButtons) {
|
|
4446
|
+
try {
|
|
4447
|
+
await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, menuMessageId, progressText, pending.adapterThreadId);
|
|
4448
|
+
progressMsgId = menuMessageId;
|
|
4449
|
+
}
|
|
4450
|
+
catch { /* fall back to a new progress message */ }
|
|
4451
|
+
}
|
|
4452
|
+
if (!progressMsgId) {
|
|
4453
|
+
try {
|
|
4454
|
+
const sent = await pending.adapter.sendText(pending.adapterChatId, progressText, { threadId: pending.adapterThreadId });
|
|
4455
|
+
progressMsgId = sent.messageId;
|
|
4456
|
+
}
|
|
4457
|
+
catch { /* non-fatal */ }
|
|
4458
|
+
}
|
|
4459
|
+
}
|
|
4460
|
+
else {
|
|
4461
|
+
// DC path: respond immediately with progress text
|
|
4462
|
+
await pending.respond(progressText).catch(() => { });
|
|
4463
|
+
}
|
|
4464
|
+
// Apply model in background — don't await here (keeps callback handler fast)
|
|
4465
|
+
void (async () => {
|
|
4466
|
+
const result = await this.applyModel(pending.instanceName, model);
|
|
4467
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
4468
|
+
if (progressMsgId) {
|
|
4469
|
+
pending.adapter.editMessage(pending.adapterChatId, progressMsgId, result, pending.adapterThreadId).catch(() => {
|
|
4470
|
+
pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
|
|
4471
|
+
});
|
|
4472
|
+
}
|
|
4473
|
+
else {
|
|
4474
|
+
pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
else {
|
|
4478
|
+
await pending.respond(result).catch(() => { });
|
|
4479
|
+
}
|
|
4480
|
+
})();
|
|
4481
|
+
return true;
|
|
4482
|
+
}
|
|
4483
|
+
/** Apply a model to an instance: runtime paste (claude-code) or persist + restart (others). */
|
|
4484
|
+
async applyModel(instanceName, model) {
|
|
4485
|
+
const backendName = this.backendNameForInstance(instanceName);
|
|
4486
|
+
let strategy = "restart";
|
|
4487
|
+
try {
|
|
4488
|
+
strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
|
|
4489
|
+
}
|
|
4490
|
+
catch { /* default restart */ }
|
|
4491
|
+
const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
|
|
4492
|
+
if (strategy === "runtime") {
|
|
4493
|
+
if (!this.instanceIpcClients.get(instanceName))
|
|
4494
|
+
return `${warn}❌ ${instanceName} is not running.`;
|
|
4495
|
+
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
4496
|
+
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).`;
|
|
4497
|
+
}
|
|
4498
|
+
// restart: persist the model so the respawned CLI launches with it.
|
|
4499
|
+
let persisted = false;
|
|
4500
|
+
if (this.fleetConfig?.instances[instanceName]) {
|
|
4501
|
+
this.fleetConfig.instances[instanceName].model = model;
|
|
4502
|
+
this.saveFleetConfig();
|
|
4503
|
+
persisted = true;
|
|
4504
|
+
}
|
|
4505
|
+
else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
|
|
4506
|
+
persisted = true;
|
|
4507
|
+
}
|
|
4508
|
+
if (!persisted)
|
|
4509
|
+
return `${warn}❌ Could not set model for ${instanceName}.`;
|
|
4510
|
+
await this.restartSingleInstance(instanceName);
|
|
4511
|
+
return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.`;
|
|
4512
|
+
}
|
|
3728
4513
|
/** Read recent chat log for agent context */
|
|
3729
4514
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
3730
4515
|
const logDir = ClassicChannelManager.chatLogDir(instanceName);
|
|
3731
|
-
|
|
4516
|
+
// Use local timezone for date — must match logMessage's write path
|
|
4517
|
+
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
4518
|
+
const today = new Date().toLocaleString("sv-SE", { timeZone: tz, hour12: false }).slice(0, 10);
|
|
3732
4519
|
const logFile = join(logDir, `${today}.log`);
|
|
3733
4520
|
try {
|
|
3734
4521
|
if (!existsSync(logFile))
|
|
3735
4522
|
return undefined;
|
|
3736
4523
|
const lines = readFileSync(logFile, "utf-8").trim().split("\n");
|
|
3737
|
-
|
|
4524
|
+
// The triggering message is written before forwardToClassicInstance runs
|
|
4525
|
+
// and is included separately under [User message]. Exclude that newest
|
|
4526
|
+
// log entry so the agent does not receive the same message twice. A chat
|
|
4527
|
+
// message may span physical lines, so remove from its timestamped entry
|
|
4528
|
+
// header rather than blindly dropping only the final continuation line.
|
|
4529
|
+
const entryHeader = /^\[\d{4}-\d{2}-\d{2}T[^\]]+\] <.*> /;
|
|
4530
|
+
let currentEntryStart = lines.length - 1;
|
|
4531
|
+
while (currentEntryStart > 0 && !entryHeader.test(lines[currentEntryStart])) {
|
|
4532
|
+
currentEntryStart--;
|
|
4533
|
+
}
|
|
4534
|
+
lines.splice(currentEntryStart);
|
|
4535
|
+
if (lines.length === 0 || maxLines <= 0)
|
|
4536
|
+
return undefined;
|
|
4537
|
+
return lines.slice(-maxLines).join("\n") || undefined;
|
|
3738
4538
|
}
|
|
3739
4539
|
catch {
|
|
3740
4540
|
return undefined;
|
|
3741
4541
|
}
|
|
3742
4542
|
}
|
|
4543
|
+
/** Return a user-facing blocker without mutating ClassicBot state. */
|
|
4544
|
+
validateClassicStart(channelId, userId, guildId, adapterId) {
|
|
4545
|
+
if (!this.classicChannels)
|
|
4546
|
+
return "Classic channel manager not initialized.";
|
|
4547
|
+
if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
|
|
4548
|
+
const generalId = this.findGeneralInstance(adapterId);
|
|
4549
|
+
if (generalId)
|
|
4550
|
+
this.notifyInstanceTopic(generalId, t("alert.unauth_guild", guildId, userId));
|
|
4551
|
+
return t("classic.not_authorized_guild");
|
|
4552
|
+
}
|
|
4553
|
+
if (this.classicChannels.isClassicChannel(channelId, adapterId))
|
|
4554
|
+
return t("classic.already_active");
|
|
4555
|
+
if (this.routing.resolve(channelId))
|
|
4556
|
+
return t("classic.topic_bound");
|
|
4557
|
+
return undefined;
|
|
4558
|
+
}
|
|
4559
|
+
isBackendInstalled(backend) {
|
|
4560
|
+
const installation = BACKEND_INSTALLATION_INFO[backend];
|
|
4561
|
+
return !!installation && checkBinaryInstalled(installation.binary);
|
|
4562
|
+
}
|
|
4563
|
+
getMissingBackendWarning(backend) {
|
|
4564
|
+
if (!backend)
|
|
4565
|
+
return undefined;
|
|
4566
|
+
const installation = BACKEND_INSTALLATION_INFO[backend];
|
|
4567
|
+
if (!installation || this.isBackendInstalled(backend))
|
|
4568
|
+
return undefined;
|
|
4569
|
+
return t("classic.backend_not_installed", backend, installation.binary, installation.install);
|
|
4570
|
+
}
|
|
4571
|
+
/** Handle Discord's required static slash choice, warning before a likely startup failure. */
|
|
4572
|
+
async handleClassicStartSlash(data, adapterId) {
|
|
4573
|
+
const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
|
|
4574
|
+
if (!requestedBackend) {
|
|
4575
|
+
// beta.31 made this option required. Discord can briefly retain the old
|
|
4576
|
+
// command schema client-side, however, so stale clients may still submit
|
|
4577
|
+
// `/start` without it. Do not resurrect the legacy 60-second component
|
|
4578
|
+
// menu in that case: fail immediately and make the user invoke the newly
|
|
4579
|
+
// registered command, which guarantees an explicit backend choice.
|
|
4580
|
+
await data.respond(t("classic.backend_required"));
|
|
4581
|
+
return;
|
|
4582
|
+
}
|
|
4583
|
+
const warning = this.getMissingBackendWarning(requestedBackend);
|
|
4584
|
+
// Keep the deferred ephemeral response useful even if daemon startup later
|
|
4585
|
+
// fails because the executable is absent. This is advisory, not a gate.
|
|
4586
|
+
if (warning)
|
|
4587
|
+
await data.respond(warning);
|
|
4588
|
+
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId, requestedBackend);
|
|
4589
|
+
await data.respond(warning ? `${warning}\n\n${reply}` : reply);
|
|
4590
|
+
}
|
|
4591
|
+
/** Present platform-native backend choices, then start on selection or timeout. */
|
|
4592
|
+
async beginClassicBackendSelection(data, adapter) {
|
|
4593
|
+
const adapterId = adapter.id;
|
|
4594
|
+
const blocker = this.validateClassicStart(data.channelId, data.userId, data.guildId, adapterId);
|
|
4595
|
+
if (blocker) {
|
|
4596
|
+
await data.respond(blocker);
|
|
4597
|
+
return;
|
|
4598
|
+
}
|
|
4599
|
+
const nonce = randomBytes(6).toString("hex");
|
|
4600
|
+
const choices = getClassicBackendChoices().map(choice => ({
|
|
4601
|
+
id: `${CLASSIC_BACKEND_CALLBACK_PREFIX}${nonce}:${choice.id}`,
|
|
4602
|
+
label: `${this.isBackendInstalled(choice.id) ? "✅" : "❌"} ${choice.label}`,
|
|
4603
|
+
}));
|
|
4604
|
+
const complete = data.respondChoices
|
|
4605
|
+
? async (text) => { await data.respond(text); }
|
|
4606
|
+
: async (text, messageId) => {
|
|
4607
|
+
if (messageId && adapter.editMessageRemoveButtons) {
|
|
4608
|
+
try {
|
|
4609
|
+
await adapter.editMessageRemoveButtons(data.channelId, messageId, text);
|
|
4610
|
+
return;
|
|
4611
|
+
}
|
|
4612
|
+
catch { /* fall back to a new message */ }
|
|
4613
|
+
}
|
|
4614
|
+
await data.respond(text);
|
|
4615
|
+
};
|
|
4616
|
+
const timer = setTimeout(() => {
|
|
4617
|
+
// Timeout: cancel the selection — do NOT fall back to default.
|
|
4618
|
+
const p = this.pendingClassicStarts.get(nonce);
|
|
4619
|
+
if (p) {
|
|
4620
|
+
this.pendingClassicStarts.delete(nonce);
|
|
4621
|
+
p.complete(t("classic.selection_expired"), p.messageId).catch(() => { });
|
|
4622
|
+
}
|
|
4623
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
4624
|
+
timer.unref?.();
|
|
4625
|
+
const pending = {
|
|
4626
|
+
channelId: data.channelId,
|
|
4627
|
+
channelName: data.channelName,
|
|
4628
|
+
userId: data.userId,
|
|
4629
|
+
guildId: data.guildId,
|
|
4630
|
+
adapterId,
|
|
4631
|
+
timer,
|
|
4632
|
+
complete,
|
|
4633
|
+
};
|
|
4634
|
+
this.pendingClassicStarts.set(nonce, pending);
|
|
4635
|
+
try {
|
|
4636
|
+
pending.messageId = data.respondChoices
|
|
4637
|
+
? await data.respondChoices(t("classic.choose_backend"), choices)
|
|
4638
|
+
: await adapter.promptUser(data.channelId, t("classic.choose_backend"), choices);
|
|
4639
|
+
}
|
|
4640
|
+
catch (err) {
|
|
4641
|
+
// A menu transport failure should not make /start unusable: consume the
|
|
4642
|
+
// pending request and immediately use the configured default.
|
|
4643
|
+
this.logger.warn({ err, channelId: data.channelId, adapterId }, "Classic backend menu failed; using default");
|
|
4644
|
+
await this.finishClassicBackendSelection(nonce);
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
/** Consume a selection callback. Returns true for all ClassicBot callback IDs, including stale ones. */
|
|
4648
|
+
async handleClassicBackendSelection(data) {
|
|
4649
|
+
if (!data.callbackData.startsWith(CLASSIC_BACKEND_CALLBACK_PREFIX))
|
|
4650
|
+
return false;
|
|
4651
|
+
const match = data.callbackData.match(/^classic-backend:([0-9a-f]+):(.+)$/);
|
|
4652
|
+
if (!match)
|
|
4653
|
+
return true;
|
|
4654
|
+
const pending = this.pendingClassicStarts.get(match[1]);
|
|
4655
|
+
if (!pending)
|
|
4656
|
+
return true;
|
|
4657
|
+
// Telegram keyboards are visible to everyone in a group. Only the user who
|
|
4658
|
+
// issued /start may consume the pending selection.
|
|
4659
|
+
if (data.userId && data.userId !== pending.userId)
|
|
4660
|
+
return true;
|
|
4661
|
+
const callbackChannelId = data.threadId ?? data.chatId;
|
|
4662
|
+
if (callbackChannelId !== pending.channelId && data.chatId !== pending.channelId)
|
|
4663
|
+
return true;
|
|
4664
|
+
await this.finishClassicBackendSelection(match[1], match[2]);
|
|
4665
|
+
return true;
|
|
4666
|
+
}
|
|
4667
|
+
/** Atomically claim one pending request so timeout/click races create at most one instance. */
|
|
4668
|
+
async finishClassicBackendSelection(nonce, backend) {
|
|
4669
|
+
const pending = this.pendingClassicStarts.get(nonce);
|
|
4670
|
+
if (!pending)
|
|
4671
|
+
return;
|
|
4672
|
+
this.pendingClassicStarts.delete(nonce);
|
|
4673
|
+
clearTimeout(pending.timer);
|
|
4674
|
+
const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
|
|
4675
|
+
const effectiveBackend = selectedBackend
|
|
4676
|
+
?? this.classicChannels?.getDefaults().backend
|
|
4677
|
+
?? this.fleetConfig?.defaults?.backend
|
|
4678
|
+
?? "claude-code";
|
|
4679
|
+
const warning = this.getMissingBackendWarning(effectiveBackend);
|
|
4680
|
+
// Show the warning before starting so it survives a missing-binary startup
|
|
4681
|
+
// failure. The selected backend is still attempted as requested.
|
|
4682
|
+
if (warning)
|
|
4683
|
+
await pending.complete(warning, pending.messageId);
|
|
4684
|
+
const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
|
|
4685
|
+
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
4686
|
+
}
|
|
3743
4687
|
/** Start a classic channel instance with lightweight config */
|
|
3744
|
-
async startClassicInstance(instanceName, backend, preTaskCommand, model) {
|
|
4688
|
+
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
|
|
3745
4689
|
if (this.daemons.has(instanceName))
|
|
3746
4690
|
return;
|
|
3747
4691
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
@@ -3753,41 +4697,34 @@ When users create specialized instances, suggest these configurations:
|
|
|
3753
4697
|
lightweight: true,
|
|
3754
4698
|
...(backend ? { backend } : {}),
|
|
3755
4699
|
...(model ? { model } : {}),
|
|
4700
|
+
...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
|
|
3756
4701
|
...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
|
|
3757
4702
|
};
|
|
3758
4703
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
3759
4704
|
await this.startInstance(instanceName, config, topicMode);
|
|
3760
4705
|
}
|
|
3761
4706
|
/** Handle /start slash command — register classic channel */
|
|
3762
|
-
async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
|
|
3763
|
-
|
|
4707
|
+
async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
|
|
4708
|
+
const blocker = this.validateClassicStart(channelId, userId, guildId, adapterId);
|
|
4709
|
+
if (blocker)
|
|
4710
|
+
return blocker;
|
|
4711
|
+
const classicChannels = this.classicChannels;
|
|
4712
|
+
if (!classicChannels)
|
|
3764
4713
|
return "Classic channel manager not initialized.";
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
}
|
|
3770
|
-
return t("classic.not_authorized_guild");
|
|
3771
|
-
}
|
|
3772
|
-
// Per-bot check: a second bot may /start in the same channel (own agent).
|
|
3773
|
-
if (this.classicChannels.isClassicChannel(channelId, adapterId))
|
|
3774
|
-
return t("classic.already_active");
|
|
3775
|
-
// Classic no longer lives in the routing engine, so this only guards against
|
|
3776
|
-
// a fleet topic-mode instance colliding with the channel.
|
|
3777
|
-
if (this.routing.resolve(channelId))
|
|
3778
|
-
return t("classic.topic_bound");
|
|
3779
|
-
const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
|
|
3780
|
-
this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
|
|
4714
|
+
const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
|
|
4715
|
+
clearPausedMarker(this.getInstanceDir(instanceName));
|
|
4716
|
+
const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
|
|
4717
|
+
classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId, selectedBackend);
|
|
3781
4718
|
// Bind this classic instance to the bot that started it (authoritative), so
|
|
3782
4719
|
// replies/cancel go out through that bot even though every same-guild bot
|
|
3783
4720
|
// also sees the channel's messages.
|
|
3784
4721
|
if (adapterId)
|
|
3785
4722
|
this.bindInstanceAdapter(instanceName, adapterId);
|
|
3786
|
-
await this.startClassicInstance(instanceName,
|
|
4723
|
+
await this.startClassicInstance(instanceName, classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), classicChannels.getPreTaskCommand(channelId, adapterId), classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
3787
4724
|
this.reregisterClassicChannels();
|
|
3788
4725
|
// Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
|
|
3789
|
-
if (guildId && !
|
|
3790
|
-
|
|
4726
|
+
if (guildId && !classicChannels.isCollab(channelId, adapterId)) {
|
|
4727
|
+
classicChannels.toggleCollab(channelId, adapterId);
|
|
3791
4728
|
}
|
|
3792
4729
|
this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
|
|
3793
4730
|
return t("classic.started");
|
|
@@ -3801,6 +4738,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3801
4738
|
return t("classic.no_agent");
|
|
3802
4739
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
3803
4740
|
await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
|
|
4741
|
+
clearPausedMarker(this.getInstanceDir(ch.instanceName));
|
|
3804
4742
|
this.reregisterClassicChannels();
|
|
3805
4743
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
3806
4744
|
return t("classic.stopped");
|
|
@@ -3844,6 +4782,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
3844
4782
|
clearInterval(this.classicReloadTimer);
|
|
3845
4783
|
this.classicReloadTimer = null;
|
|
3846
4784
|
}
|
|
4785
|
+
for (const pending of this.pendingClassicStarts.values())
|
|
4786
|
+
clearTimeout(pending.timer);
|
|
4787
|
+
this.pendingClassicStarts.clear();
|
|
3847
4788
|
this.topicArchiver.stop();
|
|
3848
4789
|
this.scheduler?.shutdown();
|
|
3849
4790
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
@@ -4138,7 +5079,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
4138
5079
|
let idx = 0;
|
|
4139
5080
|
while (idx < channels.length) {
|
|
4140
5081
|
const batch = channels.slice(idx, idx + concurrency);
|
|
4141
|
-
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
5082
|
+
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
4142
5083
|
idx += concurrency;
|
|
4143
5084
|
}
|
|
4144
5085
|
}
|
|
@@ -4150,13 +5091,23 @@ When users create specialized instances, suggest these configurations:
|
|
|
4150
5091
|
if (groupId && this.adapter) {
|
|
4151
5092
|
const total = Object.keys(fleet.instances).length;
|
|
4152
5093
|
const started = this.daemons.size;
|
|
4153
|
-
const
|
|
5094
|
+
const allNotRunning2 = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
|
|
5095
|
+
const pausedNames2 = allNotRunning2.filter(n => this.lifecycle.isPaused(n));
|
|
5096
|
+
const failedNames = allNotRunning2.filter(n => !this.lifecycle.isPaused(n));
|
|
4154
5097
|
const { createRequire } = await import("node:module");
|
|
4155
5098
|
const _require2 = createRequire(import.meta.url);
|
|
4156
5099
|
const agendVersion2 = _require2("../package.json").version ?? "unknown";
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
5100
|
+
let restartText;
|
|
5101
|
+
if (failedNames.length === 0 && pausedNames2.length === 0) {
|
|
5102
|
+
restartText = t("fleet.ready", started, total, agendVersion2);
|
|
5103
|
+
}
|
|
5104
|
+
else if (failedNames.length === 0) {
|
|
5105
|
+
restartText = t("fleet.ready", started, total, agendVersion2) + `\n⏸ Paused: ${pausedNames2.join(", ")}`;
|
|
5106
|
+
}
|
|
5107
|
+
else {
|
|
5108
|
+
restartText = t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "))
|
|
5109
|
+
+ (pausedNames2.length > 0 ? `\n⏸ Paused: ${pausedNames2.join(", ")}` : "");
|
|
5110
|
+
}
|
|
4160
5111
|
await this.adapter.sendText(String(groupId), restartText, notifyOpts)
|
|
4161
5112
|
.catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
|
|
4162
5113
|
// Notify each instance's channel — staggered to avoid rate limit storm
|
|
@@ -4368,8 +5319,31 @@ When users create specialized instances, suggest these configurations:
|
|
|
4368
5319
|
// Fleet API (enriched for agent board)
|
|
4369
5320
|
if (req.method === "GET" && req.url === "/api/fleet") {
|
|
4370
5321
|
const sysInfo = this.getSysInfo();
|
|
4371
|
-
const
|
|
5322
|
+
const fleetInstances = sysInfo.instances.map(inst => ({ ...inst, classic: false }));
|
|
5323
|
+
const fleetNames = new Set(fleetInstances.map(inst => inst.name));
|
|
5324
|
+
const classicInstances = (this.classicChannels?.getAll() ?? [])
|
|
5325
|
+
.filter(channel => !fleetNames.has(channel.instanceName))
|
|
5326
|
+
.map(channel => ({
|
|
5327
|
+
name: channel.instanceName,
|
|
5328
|
+
status: this.getInstanceStatus(channel.instanceName),
|
|
5329
|
+
state: this.getInstanceExecutionState(channel.instanceName),
|
|
5330
|
+
ipc: this.instanceIpcClients.has(channel.instanceName),
|
|
5331
|
+
costCents: this.costGuard?.getDailyCostCents(channel.instanceName) ?? 0,
|
|
5332
|
+
rateLimits: this.statuslineWatcher.getRateLimits(channel.instanceName) ?? null,
|
|
5333
|
+
classic: true,
|
|
5334
|
+
classicName: channel.name,
|
|
5335
|
+
channelId: channel.channelId,
|
|
5336
|
+
adapterId: channel.adapterId ?? null,
|
|
5337
|
+
}));
|
|
5338
|
+
const enriched = [...fleetInstances, ...classicInstances].map(inst => {
|
|
4372
5339
|
const config = this.fleetConfig?.instances[inst.name];
|
|
5340
|
+
const persistedInboundAt = readLastInboundAt(this.getInstanceDir(inst.name));
|
|
5341
|
+
const lastActivity = inst.classic
|
|
5342
|
+
? Math.max(persistedInboundAt ?? 0, readClassicLastActivityAt(this.dataDir, inst.name) ?? 0) || null
|
|
5343
|
+
: (persistedInboundAt ?? this.lastActivityMs(inst.name)) || null;
|
|
5344
|
+
const backend = inst.classic
|
|
5345
|
+
? this.classicChannels?.getBackendByInstance(inst.name, this.fleetConfig?.defaults.backend) ?? "claude-code"
|
|
5346
|
+
: config?.backend ?? "claude-code";
|
|
4373
5347
|
// Find claimed tasks for this instance
|
|
4374
5348
|
let currentTask = null;
|
|
4375
5349
|
try {
|
|
@@ -4382,11 +5356,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
4382
5356
|
}
|
|
4383
5357
|
return {
|
|
4384
5358
|
...inst,
|
|
4385
|
-
description: config?.description ?? null,
|
|
4386
|
-
backend
|
|
5359
|
+
description: config?.description ?? ("classicName" in inst ? inst.classicName : null),
|
|
5360
|
+
backend,
|
|
4387
5361
|
tool_set: config?.tool_set ?? "full",
|
|
4388
5362
|
general_topic: config?.general_topic ?? false,
|
|
4389
|
-
|
|
5363
|
+
// User activity is persisted by the daemon, so both the board and
|
|
5364
|
+
// auto-pause retain an accurate age across fleet restarts.
|
|
5365
|
+
lastActivity,
|
|
4390
5366
|
currentTask,
|
|
4391
5367
|
idle: this.getInstanceIdle(inst.name),
|
|
4392
5368
|
state: this.getInstanceExecutionState(inst.name),
|