@songsid/agend 2.1.0-beta.2 → 2.1.0-beta.20
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-endpoint.d.ts +8 -0
- package/dist/agent-endpoint.js +36 -8
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.js +3 -2
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/factory.js +5 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/grok.d.ts +41 -0
- package/dist/backend/grok.js +246 -0
- package/dist/backend/grok.js.map +1 -0
- package/dist/backend/kiro.js +3 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +12 -2
- package/dist/backend/types.js +1 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/classic-channel-manager.js +1 -1
- package/dist/cli.js +56 -6
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +50 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/context-guardian.d.ts +2 -0
- package/dist/context-guardian.js +10 -2
- package/dist/context-guardian.js.map +1 -1
- package/dist/daemon-entry.js +2 -1
- package/dist/daemon-entry.js.map +1 -1
- package/dist/daemon.d.ts +51 -3
- package/dist/daemon.js +380 -36
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -1
- package/dist/fleet-manager.d.ts +23 -5
- package/dist/fleet-manager.js +229 -123
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -0
- package/dist/instance-lifecycle.d.ts +6 -1
- package/dist/instance-lifecycle.js +46 -3
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +29 -10
- package/dist/logger.js.map +1 -1
- package/dist/outbound-handlers.d.ts +5 -0
- package/dist/outbound-handlers.js +68 -16
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/settings-api.d.ts +17 -2
- package/dist/settings-api.js +105 -8
- 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/statusline-watcher.d.ts +1 -1
- package/dist/statusline-watcher.js +3 -2
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/tmux-manager.d.ts +3 -1
- package/dist/tmux-manager.js +9 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-archiver.d.ts +1 -1
- package/dist/topic-commands.d.ts +14 -1
- package/dist/topic-commands.js +96 -25
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +2 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/ui/dashboard.html +2 -2
- package/dist/ui/settings.html +251 -40
- package/dist/view-api.d.ts +1 -1
- package/dist/web-api.d.ts +2 -1
- package/dist/web-api.js +24 -15
- package/dist/web-api.js.map +1 -1
- 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";
|
|
@@ -65,6 +65,9 @@ export class FleetManager {
|
|
|
65
65
|
/** @deprecated Use lifecycle.daemons — kept for backward compat */
|
|
66
66
|
get daemons() { return this.lifecycle.daemons; }
|
|
67
67
|
fleetConfig = null;
|
|
68
|
+
rawFleetConfig = {};
|
|
69
|
+
rawFleetDocument = null;
|
|
70
|
+
savedFleetConfigSnapshot = null;
|
|
68
71
|
adapter = null;
|
|
69
72
|
worlds = new Map();
|
|
70
73
|
adapters = new Map(); // derived view for backward compat
|
|
@@ -93,6 +96,8 @@ export class FleetManager {
|
|
|
93
96
|
// Topic icon + auto-archive state
|
|
94
97
|
topicIcons = {};
|
|
95
98
|
lastActivity = new Map();
|
|
99
|
+
/** Latest pane-derived execution snapshot reported by each daemon. */
|
|
100
|
+
instanceStateCache = new Map();
|
|
96
101
|
lastInboundUser = new Map(); // instanceName → last username
|
|
97
102
|
// Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
|
|
98
103
|
// than one-per-instance. A button is retired (deleted, with bounded retry) on
|
|
@@ -160,6 +165,7 @@ export class FleetManager {
|
|
|
160
165
|
const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
|
|
161
166
|
name,
|
|
162
167
|
status: this.getInstanceStatus(name),
|
|
168
|
+
state: this.getInstanceExecutionState(name),
|
|
163
169
|
ipc: this.instanceIpcClients.has(name),
|
|
164
170
|
costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
|
|
165
171
|
rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
|
|
@@ -174,9 +180,21 @@ export class FleetManager {
|
|
|
174
180
|
}
|
|
175
181
|
/** Load fleet.yaml and build routing table */
|
|
176
182
|
loadConfig(configPath) {
|
|
183
|
+
this.configPath = configPath;
|
|
184
|
+
const source = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "{}\n";
|
|
185
|
+
this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
|
|
186
|
+
if (this.rawFleetDocument.errors.length > 0) {
|
|
187
|
+
throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
188
|
+
}
|
|
189
|
+
this.rawFleetConfig = loadRawFleetConfig(configPath);
|
|
177
190
|
this.fleetConfig = loadFleetConfig(configPath);
|
|
191
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
178
192
|
return this.fleetConfig;
|
|
179
193
|
}
|
|
194
|
+
/** User-authored fleet.yaml, before defaults are merged into instances. */
|
|
195
|
+
getRawFleetConfig() {
|
|
196
|
+
return structuredClone(this.rawFleetConfig);
|
|
197
|
+
}
|
|
180
198
|
/** Build topic routing table: { topicId -> RouteTarget } */
|
|
181
199
|
buildRoutingTable() {
|
|
182
200
|
if (this.fleetConfig) {
|
|
@@ -265,17 +283,24 @@ export class FleetManager {
|
|
|
265
283
|
*/
|
|
266
284
|
bindInstanceAdapter(name, adapterId, fromInbound = false) {
|
|
267
285
|
const cfg = this.fleetConfig?.instances[name];
|
|
268
|
-
if (fromInbound
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
286
|
+
if (fromInbound) {
|
|
287
|
+
// Skip inbound-derived binding for any instance that doesn't have an
|
|
288
|
+
// explicit channel_id — those default to primary adapter deterministically.
|
|
289
|
+
// This prevents a non-deterministic race where whichever adapter delivers
|
|
290
|
+
// first after restart wins the binding.
|
|
291
|
+
if (cfg?.general_topic || cfg?.channel_id)
|
|
292
|
+
return;
|
|
293
|
+
if (cfg && !cfg.channel_id)
|
|
294
|
+
return; // fleet instance without explicit binding → use primary
|
|
295
|
+
// Classic instance: don't override an existing binding (authoritative from /start)
|
|
296
|
+
if (this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
276
299
|
this.instanceWorldBinding.set(name, adapterId);
|
|
277
300
|
}
|
|
278
301
|
getInstanceStatus(name) {
|
|
302
|
+
if (this.lifecycle.isPaused(name))
|
|
303
|
+
return "paused";
|
|
279
304
|
const pidPath = join(this.getInstanceDir(name), "daemon.pid");
|
|
280
305
|
if (!existsSync(pidPath))
|
|
281
306
|
return "stopped";
|
|
@@ -288,6 +313,35 @@ export class FleetManager {
|
|
|
288
313
|
return "crashed";
|
|
289
314
|
}
|
|
290
315
|
}
|
|
316
|
+
getInstanceExecutionState(name) {
|
|
317
|
+
if (this.lifecycle.isPaused(name))
|
|
318
|
+
return null;
|
|
319
|
+
return this.instanceStateCache.get(name)?.state ?? null;
|
|
320
|
+
}
|
|
321
|
+
cacheInstanceExecutionState(name, msg) {
|
|
322
|
+
const state = msg.state;
|
|
323
|
+
if (state !== "idle" && state !== "working" && state !== "stuck")
|
|
324
|
+
return;
|
|
325
|
+
const previous = this.instanceStateCache.get(name);
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
328
|
+
this.instanceStateCache.set(name, {
|
|
329
|
+
state,
|
|
330
|
+
unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
|
|
331
|
+
observedAt: numberOr(msg.observedAt, now),
|
|
332
|
+
stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/** Single delivery facade: wake an auto-paused CLI before sending to daemon IPC. */
|
|
336
|
+
async deliverToInstance(instanceName, payload) {
|
|
337
|
+
if (this.lifecycle.isPaused(instanceName)) {
|
|
338
|
+
await this.lifecycle.wake(instanceName, 30_000);
|
|
339
|
+
}
|
|
340
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
341
|
+
if (!ipc?.connected)
|
|
342
|
+
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
343
|
+
ipc.send(payload);
|
|
344
|
+
}
|
|
291
345
|
async startInstance(name, config, topicMode) {
|
|
292
346
|
if (config.general_topic) {
|
|
293
347
|
this.ensureGeneralInstructions(config.working_directory, config.backend);
|
|
@@ -355,6 +409,7 @@ export class FleetManager {
|
|
|
355
409
|
}
|
|
356
410
|
async stopInstance(name) {
|
|
357
411
|
this.failoverActive.delete(name);
|
|
412
|
+
this.instanceStateCache.delete(name);
|
|
358
413
|
return this.lifecycle.stop(name);
|
|
359
414
|
}
|
|
360
415
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
@@ -1520,10 +1575,16 @@ export class FleetManager {
|
|
|
1520
1575
|
else if (msg.type === "fleet_set_description") {
|
|
1521
1576
|
this.handleSetDescription(name, msg);
|
|
1522
1577
|
}
|
|
1578
|
+
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
1579
|
+
this.cacheInstanceExecutionState(name, msg);
|
|
1580
|
+
}
|
|
1523
1581
|
}, this.logger, `ipc.message[${name}]`));
|
|
1524
1582
|
// Ask daemon for any sessions that registered before we connected
|
|
1525
1583
|
// (fixes race condition where mcp_ready was broadcast before fleet manager connected)
|
|
1526
1584
|
ipc.send({ type: "query_sessions" });
|
|
1585
|
+
// The initial state transition may have happened before FleetManager
|
|
1586
|
+
// connected, so seed the cache instead of waiting for another transition.
|
|
1587
|
+
ipc.send({ type: "query_instance_state", requestId: `fleet-state-${Date.now()}` });
|
|
1527
1588
|
this.logger.debug({ name }, "Connected to instance IPC");
|
|
1528
1589
|
if (!this.statuslineWatcher.has(name)) {
|
|
1529
1590
|
this.statuslineWatcher.watch(name);
|
|
@@ -1926,9 +1987,8 @@ export class FleetManager {
|
|
|
1926
1987
|
}
|
|
1927
1988
|
this.warnIfRateLimited(generalInstance, msg);
|
|
1928
1989
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
ipc.send({
|
|
1990
|
+
try {
|
|
1991
|
+
await this.deliverToInstance(generalInstance, {
|
|
1932
1992
|
type: "fleet_inbound",
|
|
1933
1993
|
content: text,
|
|
1934
1994
|
targetSession: generalInstance,
|
|
@@ -1939,6 +1999,7 @@ export class FleetManager {
|
|
|
1939
1999
|
user_id: msg.userId,
|
|
1940
2000
|
ts: msg.timestamp.toISOString(),
|
|
1941
2001
|
thread_id: "",
|
|
2002
|
+
adapter_id: msg.adapterId,
|
|
1942
2003
|
source: msg.source,
|
|
1943
2004
|
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
1944
2005
|
...extraMeta,
|
|
@@ -1954,6 +2015,9 @@ export class FleetManager {
|
|
|
1954
2015
|
this.trackInboundMsg(generalInstance, msg);
|
|
1955
2016
|
void this.sendCancelButton(generalInstance);
|
|
1956
2017
|
}
|
|
2018
|
+
catch (err) {
|
|
2019
|
+
this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
|
|
2020
|
+
}
|
|
1957
2021
|
}
|
|
1958
2022
|
return;
|
|
1959
2023
|
}
|
|
@@ -2016,27 +2080,33 @@ export class FleetManager {
|
|
|
2016
2080
|
this.setTopicIcon(instanceName, "blue");
|
|
2017
2081
|
this.warnIfRateLimited(instanceName, msg);
|
|
2018
2082
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2083
|
+
try {
|
|
2084
|
+
await this.deliverToInstance(instanceName, {
|
|
2085
|
+
type: "fleet_inbound",
|
|
2086
|
+
content: text,
|
|
2087
|
+
targetSession: instanceName, // Channel messages → instance's own session
|
|
2088
|
+
meta: {
|
|
2089
|
+
chat_id: msg.chatId,
|
|
2090
|
+
message_id: msg.messageId,
|
|
2091
|
+
user: msg.username,
|
|
2092
|
+
user_id: msg.userId,
|
|
2093
|
+
ts: msg.timestamp.toISOString(),
|
|
2094
|
+
thread_id: msg.threadId ?? "",
|
|
2095
|
+
adapter_id: msg.adapterId,
|
|
2096
|
+
source: msg.source,
|
|
2097
|
+
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
2098
|
+
...extraMeta,
|
|
2099
|
+
},
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
catch (err) {
|
|
2103
|
+
this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
|
|
2104
|
+
if (msg.chatId && msg.messageId) {
|
|
2105
|
+
const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
|
|
2106
|
+
reactAdapter.react(this.reactTarget(msg), msg.messageId, "❌").catch(() => { });
|
|
2107
|
+
}
|
|
2022
2108
|
return;
|
|
2023
2109
|
}
|
|
2024
|
-
ipc.send({
|
|
2025
|
-
type: "fleet_inbound",
|
|
2026
|
-
content: text,
|
|
2027
|
-
targetSession: instanceName, // Channel messages → instance's own session
|
|
2028
|
-
meta: {
|
|
2029
|
-
chat_id: msg.chatId,
|
|
2030
|
-
message_id: msg.messageId,
|
|
2031
|
-
user: msg.username,
|
|
2032
|
-
user_id: msg.userId,
|
|
2033
|
-
ts: msg.timestamp.toISOString(),
|
|
2034
|
-
thread_id: msg.threadId ?? "",
|
|
2035
|
-
source: msg.source,
|
|
2036
|
-
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
2037
|
-
...extraMeta,
|
|
2038
|
-
},
|
|
2039
|
-
});
|
|
2040
2110
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
2041
2111
|
this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
|
|
2042
2112
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
|
|
@@ -2197,20 +2267,23 @@ export class FleetManager {
|
|
|
2197
2267
|
const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
|
|
2198
2268
|
const retryCount = schedulerDefaults?.retry_count ?? 3;
|
|
2199
2269
|
const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
|
|
2200
|
-
const deliver = () => {
|
|
2201
|
-
|
|
2202
|
-
|
|
2270
|
+
const deliver = async () => {
|
|
2271
|
+
try {
|
|
2272
|
+
await this.deliverToInstance(target, {
|
|
2273
|
+
type: "fleet_schedule_trigger",
|
|
2274
|
+
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
2275
|
+
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
2276
|
+
});
|
|
2277
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
2278
|
+
void this.sendCancelButton(target);
|
|
2279
|
+
return true;
|
|
2280
|
+
}
|
|
2281
|
+
catch (err) {
|
|
2282
|
+
this.logger.warn({ err: err.message, target }, "Scheduled wake/delivery attempt failed");
|
|
2203
2283
|
return false;
|
|
2204
|
-
|
|
2205
|
-
type: "fleet_schedule_trigger",
|
|
2206
|
-
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
2207
|
-
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
2208
|
-
});
|
|
2209
|
-
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
2210
|
-
void this.sendCancelButton(target);
|
|
2211
|
-
return true;
|
|
2284
|
+
}
|
|
2212
2285
|
};
|
|
2213
|
-
if (deliver()) {
|
|
2286
|
+
if (await deliver()) {
|
|
2214
2287
|
this.scheduler.recordRun(id, "delivered");
|
|
2215
2288
|
if (source !== target)
|
|
2216
2289
|
this.notifySourceTopic(schedule);
|
|
@@ -2218,7 +2291,7 @@ export class FleetManager {
|
|
|
2218
2291
|
}
|
|
2219
2292
|
for (let i = 0; i < retryCount; i++) {
|
|
2220
2293
|
await new Promise((r) => setTimeout(r, retryInterval));
|
|
2221
|
-
if (deliver()) {
|
|
2294
|
+
if (await deliver()) {
|
|
2222
2295
|
this.scheduler.recordRun(id, "delivered");
|
|
2223
2296
|
if (source !== target)
|
|
2224
2297
|
this.notifySourceTopic(schedule);
|
|
@@ -2642,73 +2715,92 @@ export class FleetManager {
|
|
|
2642
2715
|
}
|
|
2643
2716
|
}, 5 * 60_000);
|
|
2644
2717
|
}
|
|
2645
|
-
/**
|
|
2646
|
-
|
|
2718
|
+
/**
|
|
2719
|
+
* Patch only values changed in the effective config into the original YAML
|
|
2720
|
+
* document. Unknown keys, explicit overrides and comments remain untouched.
|
|
2721
|
+
*/
|
|
2722
|
+
saveFleetConfig(explicitPatches = []) {
|
|
2647
2723
|
if (!this.fleetConfig || !this.configPath)
|
|
2648
2724
|
return;
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2725
|
+
if (!this.savedFleetConfigSnapshot)
|
|
2726
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2727
|
+
// Re-read immediately before patching so an unrelated concurrent/manual
|
|
2728
|
+
// edit is retained. Invalid concurrent YAML is never overwritten.
|
|
2729
|
+
const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
|
|
2730
|
+
this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
|
|
2731
|
+
if (this.rawFleetDocument.errors.length > 0) {
|
|
2732
|
+
throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
2733
|
+
}
|
|
2734
|
+
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
2735
|
+
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
2736
|
+
// Settings edits are expressed against the raw config. Persist them even
|
|
2737
|
+
// when the chosen override equals the inherited effective value, a case
|
|
2738
|
+
// the before/after runtime diff cannot observe.
|
|
2739
|
+
for (const patch of explicitPatches) {
|
|
2740
|
+
if (patch.remove) {
|
|
2741
|
+
this.rawFleetDocument.deleteIn(patch.path);
|
|
2742
|
+
}
|
|
2743
|
+
else {
|
|
2744
|
+
const before = this.rawFleetDocument.getIn(patch.path);
|
|
2745
|
+
this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
const output = String(this.rawFleetDocument);
|
|
2749
|
+
const tempPath = `${this.configPath}.tmp-${process.pid}`;
|
|
2750
|
+
writeFileSync(tempPath, output, "utf-8");
|
|
2751
|
+
if (existsSync(this.configPath))
|
|
2752
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
2753
|
+
renameSync(tempPath, this.configPath);
|
|
2754
|
+
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
2755
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2756
|
+
this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
|
|
2757
|
+
}
|
|
2758
|
+
patchFleetDocument(document, path, before, after) {
|
|
2759
|
+
if (Object.is(before, after))
|
|
2760
|
+
return;
|
|
2761
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
2762
|
+
const shared = Math.min(before.length, after.length);
|
|
2763
|
+
for (let i = 0; i < shared; i++) {
|
|
2764
|
+
this.patchFleetDocument(document, [...path, i], before[i], after[i]);
|
|
2765
|
+
}
|
|
2766
|
+
// Remove from the end so YAML sequence indexes do not shift underneath us.
|
|
2767
|
+
for (let i = before.length - 1; i >= after.length; i--)
|
|
2768
|
+
document.deleteIn([...path, i]);
|
|
2769
|
+
for (let i = shared; i < after.length; i++)
|
|
2770
|
+
document.setIn([...path, i], after[i]);
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2774
|
+
if (isRecord(before) && isRecord(after)) {
|
|
2775
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
2776
|
+
for (const key of keys) {
|
|
2777
|
+
// `channel` is a derived alias when the raw file uses `channels`.
|
|
2778
|
+
if (path.length === 0 && key === "channel" && this.rawFleetConfig.channels)
|
|
2779
|
+
continue;
|
|
2780
|
+
// Conversely, `channels` is a normalized alias for a legacy `channel`.
|
|
2781
|
+
// Keep the user's original shape unless the caller explicitly removed
|
|
2782
|
+
// `channel` (the Settings channels endpoint intentionally migrates it).
|
|
2783
|
+
if (path.length === 0 && key === "channels" && this.rawFleetConfig.channel && !this.rawFleetConfig.channels && after.channel !== undefined)
|
|
2784
|
+
continue;
|
|
2785
|
+
this.patchFleetDocument(document, [...path, key], before[key], after[key]);
|
|
2786
|
+
}
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
if (after === undefined) {
|
|
2790
|
+
document.deleteIn(path);
|
|
2791
|
+
}
|
|
2792
|
+
else if (path.length === 0) {
|
|
2793
|
+
document.contents = document.createNode(after);
|
|
2794
|
+
}
|
|
2795
|
+
else {
|
|
2796
|
+
const currentNode = document.getIn(path, true);
|
|
2797
|
+
if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
|
|
2798
|
+
currentNode.value = after;
|
|
2799
|
+
}
|
|
2800
|
+
else {
|
|
2801
|
+
document.setIn(path, after);
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2712
2804
|
}
|
|
2713
2805
|
async removeInstance(name) {
|
|
2714
2806
|
// Clean up schedules (scheduler is fleet-level, not lifecycle-level)
|
|
@@ -2751,6 +2843,10 @@ export class FleetManager {
|
|
|
2751
2843
|
startStatuslineWatcher(name) {
|
|
2752
2844
|
this.statuslineWatcher.watch(name);
|
|
2753
2845
|
}
|
|
2846
|
+
stopStatuslineWatcher(name) {
|
|
2847
|
+
// Pausing stops I/O but retains the last observed limits for status views.
|
|
2848
|
+
this.statuslineWatcher.unwatch(name, true);
|
|
2849
|
+
}
|
|
2754
2850
|
reactMessageStatus(instanceName, chatId, messageId, emoji) {
|
|
2755
2851
|
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
2756
2852
|
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
@@ -3101,7 +3197,7 @@ export class FleetManager {
|
|
|
3101
3197
|
return [];
|
|
3102
3198
|
}
|
|
3103
3199
|
}
|
|
3104
|
-
async sendHangNotification(instanceName) {
|
|
3200
|
+
async sendHangNotification(instanceName, unchangedForMs) {
|
|
3105
3201
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
3106
3202
|
if (!adapter)
|
|
3107
3203
|
return;
|
|
@@ -3110,11 +3206,18 @@ export class FleetManager {
|
|
|
3110
3206
|
if (!groupId)
|
|
3111
3207
|
return;
|
|
3112
3208
|
const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
3209
|
+
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
3210
|
+
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
3211
|
+
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
3212
|
+
?? 15;
|
|
3213
|
+
const unchangedMinutes = unchangedForMs == null
|
|
3214
|
+
? configuredMinutes
|
|
3215
|
+
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
3113
3216
|
this.setTopicIcon(instanceName, "red");
|
|
3114
3217
|
await adapter.notifyAlert(String(groupId), {
|
|
3115
3218
|
type: "hang",
|
|
3116
3219
|
instanceName,
|
|
3117
|
-
message: `⚠️ ${instanceName}
|
|
3220
|
+
message: `⚠️ ${instanceName} may be stuck — pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
|
|
3118
3221
|
choices: [
|
|
3119
3222
|
{ id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
|
|
3120
3223
|
{ id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
|
|
@@ -3615,11 +3718,6 @@ When users create specialized instances, suggest these configurations:
|
|
|
3615
3718
|
const fullText = logContext
|
|
3616
3719
|
? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
|
|
3617
3720
|
: text;
|
|
3618
|
-
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3619
|
-
if (!ipc) {
|
|
3620
|
-
this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
|
|
3621
|
-
return;
|
|
3622
|
-
}
|
|
3623
3721
|
const meta = {
|
|
3624
3722
|
chat_id: msg.chatId,
|
|
3625
3723
|
message_id: msg.messageId,
|
|
@@ -3627,6 +3725,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3627
3725
|
user_id: msg.userId,
|
|
3628
3726
|
ts: msg.timestamp.toISOString(),
|
|
3629
3727
|
thread_id: msg.threadId ?? "",
|
|
3728
|
+
...(msg.adapterId ? { adapter_id: msg.adapterId } : {}),
|
|
3630
3729
|
source: msg.source,
|
|
3631
3730
|
...extraMeta,
|
|
3632
3731
|
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
@@ -3641,12 +3740,18 @@ When users create specialized instances, suggest these configurations:
|
|
|
3641
3740
|
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
3642
3741
|
}
|
|
3643
3742
|
}
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3743
|
+
try {
|
|
3744
|
+
await this.deliverToInstance(instanceName, {
|
|
3745
|
+
type: "fleet_inbound",
|
|
3746
|
+
content: fullText,
|
|
3747
|
+
targetSession: instanceName,
|
|
3748
|
+
meta,
|
|
3749
|
+
});
|
|
3750
|
+
}
|
|
3751
|
+
catch (err) {
|
|
3752
|
+
this.logger.warn({ err: err.message, instanceName }, "Classic wake/delivery failed");
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3650
3755
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
3651
3756
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
3652
3757
|
this.trackInboundMsg(instanceName, msg);
|
|
@@ -4326,6 +4431,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
4326
4431
|
lastActivity: this.lastActivityMs(inst.name) || null,
|
|
4327
4432
|
currentTask,
|
|
4328
4433
|
idle: this.getInstanceIdle(inst.name),
|
|
4434
|
+
state: this.getInstanceExecutionState(inst.name),
|
|
4329
4435
|
};
|
|
4330
4436
|
});
|
|
4331
4437
|
res.setHeader("Access-Control-Allow-Origin", "*");
|