@songsid/agend 2.1.4-beta.1 → 2.1.4-beta.11
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/dist/backend/antigravity.d.ts +1 -0
- package/dist/backend/antigravity.js +1 -0
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +1 -0
- package/dist/backend/claude-code.js +24 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +1 -0
- package/dist/backend/codex.js +43 -4
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/gemini-cli.d.ts +1 -0
- package/dist/backend/gemini-cli.js +1 -0
- package/dist/backend/gemini-cli.js.map +1 -1
- package/dist/backend/grok.d.ts +1 -0
- package/dist/backend/grok.js +1 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +1 -0
- package/dist/backend/kiro.js +1 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/mock.d.ts +1 -0
- package/dist/backend/mock.js +1 -0
- package/dist/backend/mock.js.map +1 -1
- package/dist/backend/opencode.d.ts +40 -0
- package/dist/backend/opencode.js +81 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +10 -0
- package/dist/backend/types.js +7 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +24 -7
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-server.js +6 -20
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +1 -1
- package/dist/classic-channel-manager.d.ts +34 -3
- package/dist/classic-channel-manager.js +190 -31
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +65 -13
- package/dist/cli.js.map +1 -1
- package/dist/completion-install.d.ts +70 -0
- package/dist/completion-install.js +152 -0
- package/dist/completion-install.js.map +1 -0
- package/dist/completion.d.ts +7 -1
- package/dist/completion.js +14 -4
- package/dist/completion.js.map +1 -1
- package/dist/config-validator.js +35 -7
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +53 -0
- package/dist/daemon.js +229 -40
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +8 -0
- package/dist/fleet-manager.d.ts +143 -3
- package/dist/fleet-manager.js +1109 -290
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
- package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -0
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
- package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
- package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
- package/dist/instance-lifecycle.d.ts +10 -0
- package/dist/instance-lifecycle.js +50 -33
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +33 -0
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +130 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-schemas.d.ts +2 -1
- package/dist/outbound-schemas.js +1 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/quickstart.js +25 -0
- package/dist/quickstart.js.map +1 -1
- package/dist/restart-progress.d.ts +7 -1
- package/dist/restart-progress.js +27 -6
- package/dist/restart-progress.js.map +1 -1
- package/dist/tool-progress.d.ts +40 -0
- package/dist/tool-progress.js +289 -0
- package/dist/tool-progress.js.map +1 -0
- package/dist/topic-commands.d.ts +26 -0
- package/dist/topic-commands.js +117 -1
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.d.ts +15 -2
- package/dist/transcript-monitor.js +63 -17
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +106 -0
- package/dist/transcript-sources.js +428 -0
- package/dist/transcript-sources.js.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/update-marker.d.ts +30 -0
- package/dist/update-marker.js +71 -20
- package/dist/update-marker.js.map +1 -1
- package/dist/update-progress.d.ts +6 -0
- package/dist/update-progress.js +29 -0
- package/dist/update-progress.js.map +1 -0
- package/dist/workflow-templates/default.md +2 -1
- package/package.json +2 -2
package/dist/fleet-manager.js
CHANGED
|
@@ -4,7 +4,8 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import { join, dirname, basename } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
|
-
import { clearUpdateMarker, isUpdateInProgress } from "./update-marker.js";
|
|
7
|
+
import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
|
|
8
|
+
import { formatUpdateProgress } from "./update-progress.js";
|
|
8
9
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
9
10
|
import { readFleetMemory } from "./process-memory.js";
|
|
10
11
|
import { ReplyDeduper } from "./reply-dedup.js";
|
|
@@ -29,7 +30,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
|
|
|
29
30
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
30
31
|
import { Scheduler } from "./scheduler/index.js";
|
|
31
32
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
32
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
|
|
33
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, CLEAR_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
|
|
33
34
|
import { DailySummary } from "./daily-summary.js";
|
|
34
35
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
35
36
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -134,6 +135,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
|
|
|
134
135
|
* exactly one progress message per turn, and it is the cancel button itself.
|
|
135
136
|
*/
|
|
136
137
|
const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
|
|
138
|
+
/** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
|
|
139
|
+
const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
|
|
137
140
|
/** Elapsed time is only shown once work has clearly outlasted a quick answer. */
|
|
138
141
|
/**
|
|
139
142
|
* Default delay before the button starts showing elapsed time. Configurable via
|
|
@@ -153,6 +156,12 @@ const PROGRESS_ACTIVITY_MAX_CHARS = 48;
|
|
|
153
156
|
* 🫡 passes too — it reads as a deliberate acknowledgement, not plumbing.
|
|
154
157
|
*/
|
|
155
158
|
const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
|
|
159
|
+
/**
|
|
160
|
+
* Reactions that are neither delivery plumbing nor meaningful conversational
|
|
161
|
+
* feedback. Keep this separate from DELIVERY_STATUS_EMOJIS so adding a UI-only
|
|
162
|
+
* emoji never changes the documented delivery-state protocol.
|
|
163
|
+
*/
|
|
164
|
+
const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
|
|
156
165
|
/**
|
|
157
166
|
* How long a delivery waits out a disconnected instance IPC before giving up.
|
|
158
167
|
*
|
|
@@ -165,6 +174,13 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
|
165
174
|
const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
166
175
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
167
176
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
177
|
+
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
178
|
+
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
179
|
+
const HANG_CALLBACK_PREFIX = "hang:";
|
|
180
|
+
const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
181
|
+
const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
|
|
182
|
+
/** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
|
|
183
|
+
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
168
184
|
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
|
|
169
185
|
export class FleetManager {
|
|
170
186
|
dataDir;
|
|
@@ -239,6 +255,8 @@ export class FleetManager {
|
|
|
239
255
|
replyDeduper = new ReplyDeduper();
|
|
240
256
|
/** instanceName → what it is doing right now, when the backend can tell us. */
|
|
241
257
|
instanceActivity = new Map();
|
|
258
|
+
/** instanceName → this turn's tool list (multi-line), for the bubble. */
|
|
259
|
+
instanceProgress = new Map();
|
|
242
260
|
/** instanceName → tail of deliveries waiting for its IPC to come back. */
|
|
243
261
|
ipcWaitTails = new Map();
|
|
244
262
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
@@ -253,6 +271,8 @@ export class FleetManager {
|
|
|
253
271
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
254
272
|
pendingEffortSelects = new Map();
|
|
255
273
|
pendingModelSelects = new Map();
|
|
274
|
+
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
275
|
+
pendingNonceButtons = new Map();
|
|
256
276
|
// Model failover state
|
|
257
277
|
failoverActive = new Map(); // instance → current failover model
|
|
258
278
|
// IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
|
|
@@ -274,6 +294,9 @@ export class FleetManager {
|
|
|
274
294
|
healthServer = null;
|
|
275
295
|
healthPortRetried = false;
|
|
276
296
|
updateCheckTimer = null;
|
|
297
|
+
updateProgressTimer = null;
|
|
298
|
+
updateProgressEditRunning = false;
|
|
299
|
+
lastUpdateProgressText = null;
|
|
277
300
|
eventLogPruneTimer = null;
|
|
278
301
|
logRotateTimer = null;
|
|
279
302
|
/** Days of event/activity history to keep. */
|
|
@@ -553,6 +576,45 @@ export class FleetManager {
|
|
|
553
576
|
}
|
|
554
577
|
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
555
578
|
}
|
|
579
|
+
async handleUpdateSlash(data, adapterId) {
|
|
580
|
+
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
581
|
+
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
582
|
+
await data.respond(t("not_authorized"));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const messageId = await data.respond(t("update.progress.preparing", 0));
|
|
586
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
587
|
+
if (messageId && adapter) {
|
|
588
|
+
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
589
|
+
this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
|
|
590
|
+
}
|
|
591
|
+
const { spawn } = await import("node:child_process");
|
|
592
|
+
const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
593
|
+
const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
|
|
594
|
+
const child = spawn("sh", ["-c", `sleep 2 && ${command}`], { detached: true, stdio: "ignore" });
|
|
595
|
+
child.once("error", err => this.failUpdateProgress(err.message));
|
|
596
|
+
child.unref();
|
|
597
|
+
}
|
|
598
|
+
/** Admin-only full conversation reset for fleet-topic and Classic instances. */
|
|
599
|
+
async handleClearSlash(data, adapterId) {
|
|
600
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
601
|
+
await data.respond(t("permission.denied"));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
605
|
+
if (!name) {
|
|
606
|
+
await data.respond(t("classic.no_agent"));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
610
|
+
if (!adapter) {
|
|
611
|
+
await data.respond(t("clear.prompt_unavailable"));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
615
|
+
const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
|
|
616
|
+
await data.respond(fallback ?? t("clear.confirm_posted"));
|
|
617
|
+
}
|
|
556
618
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
557
619
|
getAdapterForInstance(name) {
|
|
558
620
|
const worldId = this.getInstanceAdapterId(name);
|
|
@@ -1016,19 +1078,34 @@ export class FleetManager {
|
|
|
1016
1078
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
1017
1079
|
return;
|
|
1018
1080
|
}
|
|
1081
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
1019
1082
|
if (config.general_topic) {
|
|
1020
1083
|
// antigravity (agy) does not read MCP instructions — fleet context and
|
|
1021
1084
|
// routing instructions are not injected, so it cannot act as a dispatcher.
|
|
1022
|
-
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
1023
1085
|
if (backend === "antigravity") {
|
|
1024
1086
|
this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
|
|
1025
1087
|
this.notifyInstanceTopic(name, "⚠️ antigravity backend is not supported for General instances (no MCP instructions injection). Switch to claude-code or kiro-cli.");
|
|
1026
1088
|
}
|
|
1027
|
-
this.ensureGeneralInstructions(config.working_directory,
|
|
1089
|
+
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
1090
|
+
}
|
|
1091
|
+
else if (kind === "fleet-topic") {
|
|
1092
|
+
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
1093
|
+
// deliberately excluded: their workspace and conversation lifecycle are
|
|
1094
|
+
// managed independently from fleet-topic workers.
|
|
1095
|
+
try {
|
|
1096
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
1097
|
+
this.syncRoleSkills(skillsWorkDir, backend, "worker");
|
|
1098
|
+
}
|
|
1099
|
+
catch (err) {
|
|
1100
|
+
// Skill publishing is additive. A read-only or temporarily unavailable
|
|
1101
|
+
// workspace must not turn an otherwise valid worker startup into a
|
|
1102
|
+
// fleet outage.
|
|
1103
|
+
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
1104
|
+
}
|
|
1028
1105
|
}
|
|
1029
1106
|
await this.lifecycle.start(name, config, topicMode, {
|
|
1030
1107
|
kind,
|
|
1031
|
-
backend
|
|
1108
|
+
backend,
|
|
1032
1109
|
model: this.resolveInstanceModel(name).display,
|
|
1033
1110
|
});
|
|
1034
1111
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
@@ -1193,7 +1270,18 @@ export class FleetManager {
|
|
|
1193
1270
|
this.instanceStateCache.delete(name);
|
|
1194
1271
|
this.instanceProcessStatus.delete(name);
|
|
1195
1272
|
this.lastDeliveryAt.delete(name);
|
|
1196
|
-
|
|
1273
|
+
// A pending hang/exit/assist offer refers to the instance being torn down;
|
|
1274
|
+
// left alone it would stay clickable for the rest of its 15 minutes. Clear
|
|
1275
|
+
// again AFTER the stop completes: the teardown itself can emit hang /
|
|
1276
|
+
// interactive-prompt / clean-exit events whose handlers post fresh prompts
|
|
1277
|
+
// while the stop is still awaiting (the TOCTOU sol's review called out).
|
|
1278
|
+
this.clearNoncePromptsForInstance(name);
|
|
1279
|
+
try {
|
|
1280
|
+
return await this.lifecycle.stop(name);
|
|
1281
|
+
}
|
|
1282
|
+
finally {
|
|
1283
|
+
this.clearNoncePromptsForInstance(name);
|
|
1284
|
+
}
|
|
1197
1285
|
}
|
|
1198
1286
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
1199
1287
|
async restartSingleInstance(name, opts) {
|
|
@@ -1293,6 +1381,65 @@ export class FleetManager {
|
|
|
1293
1381
|
getDashboardAccess() {
|
|
1294
1382
|
return { ready: this.healthServerListening, token: this.webToken };
|
|
1295
1383
|
}
|
|
1384
|
+
beginUpdateProgress(adapter, chatId, threadId, messageId) {
|
|
1385
|
+
persistUpdateProgress(this.dataDir, {
|
|
1386
|
+
adapterId: adapter.id,
|
|
1387
|
+
chatId,
|
|
1388
|
+
...(threadId ? { threadId } : {}),
|
|
1389
|
+
messageId,
|
|
1390
|
+
});
|
|
1391
|
+
this.lastUpdateProgressText = null;
|
|
1392
|
+
this.startUpdateProgressMonitor(adapter);
|
|
1393
|
+
}
|
|
1394
|
+
failUpdateProgress(message) {
|
|
1395
|
+
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
1396
|
+
}
|
|
1397
|
+
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
1398
|
+
startUpdateProgressMonitor(initialAdapter) {
|
|
1399
|
+
if (this.updateProgressTimer)
|
|
1400
|
+
clearInterval(this.updateProgressTimer);
|
|
1401
|
+
const tick = async () => {
|
|
1402
|
+
if (this.updateProgressEditRunning)
|
|
1403
|
+
return;
|
|
1404
|
+
const marker = readUpdateProgress(this.dataDir);
|
|
1405
|
+
if (!marker) {
|
|
1406
|
+
if (this.updateProgressTimer)
|
|
1407
|
+
clearInterval(this.updateProgressTimer);
|
|
1408
|
+
this.updateProgressTimer = null;
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const target = marker.progress.target;
|
|
1412
|
+
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
1413
|
+
if (!adapter)
|
|
1414
|
+
return;
|
|
1415
|
+
const text = formatUpdateProgress(marker);
|
|
1416
|
+
if (text === this.lastUpdateProgressText)
|
|
1417
|
+
return;
|
|
1418
|
+
this.updateProgressEditRunning = true;
|
|
1419
|
+
try {
|
|
1420
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
1421
|
+
this.lastUpdateProgressText = text;
|
|
1422
|
+
if (marker.progress.stage === "failed" || marker.progress.stage === "complete") {
|
|
1423
|
+
clearUpdateMarker(this.dataDir);
|
|
1424
|
+
if (this.updateProgressTimer)
|
|
1425
|
+
clearInterval(this.updateProgressTimer);
|
|
1426
|
+
this.updateProgressTimer = null;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
catch (err) {
|
|
1430
|
+
this.logger.warn({ err }, "Failed to edit update progress");
|
|
1431
|
+
}
|
|
1432
|
+
finally {
|
|
1433
|
+
this.updateProgressEditRunning = false;
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
void tick();
|
|
1437
|
+
// Poll stages faster than the elapsed-time display. npm verification and
|
|
1438
|
+
// service-file refresh can be short; 200ms prevents those stages from being
|
|
1439
|
+
// skipped while the text cache still limits normal edits to once per second.
|
|
1440
|
+
this.updateProgressTimer = setInterval(() => void tick(), 200);
|
|
1441
|
+
this.updateProgressTimer.unref?.();
|
|
1442
|
+
}
|
|
1296
1443
|
/** Start all instances from fleet config */
|
|
1297
1444
|
async startAll(configPath) {
|
|
1298
1445
|
const startupStartedAt = Date.now();
|
|
@@ -1307,6 +1454,15 @@ export class FleetManager {
|
|
|
1307
1454
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
1308
1455
|
const fleet = this.loadConfig(configPath);
|
|
1309
1456
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
1457
|
+
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
1458
|
+
const pendingUpdateProgress = savedUpdateProgress
|
|
1459
|
+
&& savedUpdateProgress.progress.stage !== "failed"
|
|
1460
|
+
&& savedUpdateProgress.progress.stage !== "complete"
|
|
1461
|
+
? savedUpdateProgress
|
|
1462
|
+
: null;
|
|
1463
|
+
if (pendingUpdateProgress) {
|
|
1464
|
+
setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
|
|
1465
|
+
}
|
|
1310
1466
|
this.initializeWebAuthTokens();
|
|
1311
1467
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
1312
1468
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
@@ -1354,9 +1510,8 @@ export class FleetManager {
|
|
|
1354
1510
|
// routing does NOT go through the routing engine (single-key, can't hold two
|
|
1355
1511
|
// bots in one channel) — it resolves per-bot via getInstanceByChannel.
|
|
1356
1512
|
this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
|
|
1357
|
-
const
|
|
1358
|
-
|
|
1359
|
-
this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
|
|
1513
|
+
const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
|
|
1514
|
+
this.classicChannels.configureAdapters(classicAdapters);
|
|
1360
1515
|
// Restore the persisted bot binding so replies/cancel go through the right
|
|
1361
1516
|
// bot after a restart (before this, inbound would re-bind lazily).
|
|
1362
1517
|
for (const ch of this.classicChannels.getAll()) {
|
|
@@ -1525,7 +1680,7 @@ export class FleetManager {
|
|
|
1525
1680
|
const generalDir = join(getAgendHome(), name);
|
|
1526
1681
|
mkdirSync(generalDir, { recursive: true });
|
|
1527
1682
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
1528
|
-
this.ensureGeneralInstructions(generalDir, backendName);
|
|
1683
|
+
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
1529
1684
|
fleet.instances[name] = {
|
|
1530
1685
|
...DEFAULT_INSTANCE_CONFIG,
|
|
1531
1686
|
working_directory: generalDir,
|
|
@@ -1563,7 +1718,7 @@ export class FleetManager {
|
|
|
1563
1718
|
const allEntries = Object.entries(fleet.instances);
|
|
1564
1719
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
1565
1720
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
1566
|
-
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
|
|
1721
|
+
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingUpdateProgress ? "update" : "restart" });
|
|
1567
1722
|
if (generals.length > 0) {
|
|
1568
1723
|
for (const [name, cfg] of generals) {
|
|
1569
1724
|
try {
|
|
@@ -1602,7 +1757,19 @@ export class FleetManager {
|
|
|
1602
1757
|
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
1603
1758
|
}
|
|
1604
1759
|
})();
|
|
1605
|
-
progressStart = adapterStartup.then(() =>
|
|
1760
|
+
progressStart = adapterStartup.then(() => {
|
|
1761
|
+
if (pendingUpdateProgress) {
|
|
1762
|
+
const saved = pendingUpdateProgress.progress.target;
|
|
1763
|
+
const adapter = this.adapters.get(saved.adapterId);
|
|
1764
|
+
const target = adapter ? {
|
|
1765
|
+
adapter,
|
|
1766
|
+
chatId: saved.chatId,
|
|
1767
|
+
threadId: saved.threadId,
|
|
1768
|
+
} : null;
|
|
1769
|
+
return startupProgress.resume(target, saved.messageId);
|
|
1770
|
+
}
|
|
1771
|
+
return startupProgress.start(this.restartProgressTarget());
|
|
1772
|
+
});
|
|
1606
1773
|
}
|
|
1607
1774
|
// The systemd watchdog answers exactly one question: is this process still
|
|
1608
1775
|
// turning its event loop? Pinging from a timer proves that, and after the
|
|
@@ -1980,31 +2147,20 @@ export class FleetManager {
|
|
|
1980
2147
|
await this.handleInboundReaction(r);
|
|
1981
2148
|
}, this.logger, "adapter.reaction"));
|
|
1982
2149
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
2150
|
+
if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
|
|
2151
|
+
return;
|
|
2152
|
+
if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2153
|
+
return;
|
|
2154
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
|
|
2155
|
+
return;
|
|
1983
2156
|
if (await this.handleClassicBackendSelection(data))
|
|
1984
2157
|
return;
|
|
1985
2158
|
if (await this.handleModelSelection(data))
|
|
1986
2159
|
return;
|
|
1987
2160
|
if (await this.handleEffortSelection(data))
|
|
1988
2161
|
return;
|
|
1989
|
-
if (data.
|
|
1990
|
-
const parts = data.callbackData.split(":");
|
|
1991
|
-
const action = parts[1];
|
|
1992
|
-
const instanceName = parts[2];
|
|
1993
|
-
if (action === "restart") {
|
|
1994
|
-
await this.stopInstance(instanceName);
|
|
1995
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
1996
|
-
if (config) {
|
|
1997
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1998
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
1999
|
-
// startInstance already calls connectIpcToInstance
|
|
2000
|
-
}
|
|
2001
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2002
|
-
}
|
|
2003
|
-
else {
|
|
2004
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2005
|
-
}
|
|
2162
|
+
if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2006
2163
|
return;
|
|
2007
|
-
}
|
|
2008
2164
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2009
2165
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
|
|
2010
2166
|
return;
|
|
@@ -2083,6 +2239,29 @@ export class FleetManager {
|
|
|
2083
2239
|
const result = await this.topicCommands.sendCompact(name);
|
|
2084
2240
|
await data.respond(result);
|
|
2085
2241
|
}
|
|
2242
|
+
else if (data.command === "steer") {
|
|
2243
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2244
|
+
if (!name) {
|
|
2245
|
+
await data.respond(t("classic.no_agent"));
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2249
|
+
if (!steerText) {
|
|
2250
|
+
await data.respond(t("steer.usage"));
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2254
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2255
|
+
// rerouting the instance's replies to the slash context.
|
|
2256
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2257
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2258
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2259
|
+
});
|
|
2260
|
+
await data.respond(result);
|
|
2261
|
+
}
|
|
2262
|
+
else if (data.command === "clear") {
|
|
2263
|
+
await this.handleClearSlash(data, adapterId);
|
|
2264
|
+
}
|
|
2086
2265
|
else if (data.command === "model") {
|
|
2087
2266
|
await this.handleModelSlash(data, adapterId);
|
|
2088
2267
|
}
|
|
@@ -2135,17 +2314,7 @@ export class FleetManager {
|
|
|
2135
2314
|
: t("collab.off.classic"));
|
|
2136
2315
|
}
|
|
2137
2316
|
else if (data.command === "update") {
|
|
2138
|
-
|
|
2139
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2140
|
-
await data.respond(t("not_authorized"));
|
|
2141
|
-
return;
|
|
2142
|
-
}
|
|
2143
|
-
await data.respond(t("update.running"));
|
|
2144
|
-
const { spawn } = await import("node:child_process");
|
|
2145
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2146
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2147
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2148
|
-
child.unref();
|
|
2317
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2149
2318
|
}
|
|
2150
2319
|
else if (data.command === "doctor") {
|
|
2151
2320
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2214,6 +2383,26 @@ export class FleetManager {
|
|
|
2214
2383
|
const result = await this.topicCommands.sendCompact(name);
|
|
2215
2384
|
await data.respond(result);
|
|
2216
2385
|
}
|
|
2386
|
+
else if (data.command === "steer") {
|
|
2387
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2388
|
+
if (!name) {
|
|
2389
|
+
await data.respond(t("classic.no_agent"));
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2393
|
+
if (!steerText) {
|
|
2394
|
+
await data.respond(t("steer.usage"));
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2398
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2399
|
+
// rerouting the instance's replies to the slash context.
|
|
2400
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2401
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2402
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2403
|
+
});
|
|
2404
|
+
await data.respond(result);
|
|
2405
|
+
}
|
|
2217
2406
|
}, this.logger, "adapter.slash_command"));
|
|
2218
2407
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
2219
2408
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
@@ -2290,30 +2479,20 @@ export class FleetManager {
|
|
|
2290
2479
|
await this.handleInboundReaction(r);
|
|
2291
2480
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
2292
2481
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
2482
|
+
if (await this.handleClearConfirmation(data, adapterId, adapter))
|
|
2483
|
+
return;
|
|
2484
|
+
if (await this.handleExitRestartPrompt(data, adapterId, adapter))
|
|
2485
|
+
return;
|
|
2486
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
|
|
2487
|
+
return;
|
|
2293
2488
|
if (await this.handleClassicBackendSelection(data))
|
|
2294
2489
|
return;
|
|
2295
2490
|
if (await this.handleModelSelection(data))
|
|
2296
2491
|
return;
|
|
2297
2492
|
if (await this.handleEffortSelection(data))
|
|
2298
2493
|
return;
|
|
2299
|
-
if (
|
|
2300
|
-
const parts = data.callbackData.split(":");
|
|
2301
|
-
const action = parts[1];
|
|
2302
|
-
const instanceName = parts[2];
|
|
2303
|
-
if (action === "restart") {
|
|
2304
|
-
await this.stopInstance(instanceName);
|
|
2305
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
2306
|
-
if (config) {
|
|
2307
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2308
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
2309
|
-
}
|
|
2310
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2311
|
-
}
|
|
2312
|
-
else {
|
|
2313
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2314
|
-
}
|
|
2494
|
+
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
2315
2495
|
return;
|
|
2316
|
-
}
|
|
2317
2496
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2318
2497
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
|
|
2319
2498
|
return;
|
|
@@ -2385,6 +2564,9 @@ export class FleetManager {
|
|
|
2385
2564
|
else if (data.command === "model") {
|
|
2386
2565
|
await this.handleModelSlash(data, adapterId);
|
|
2387
2566
|
}
|
|
2567
|
+
else if (data.command === "clear") {
|
|
2568
|
+
await this.handleClearSlash(data, adapterId);
|
|
2569
|
+
}
|
|
2388
2570
|
else if (data.command === "effort") {
|
|
2389
2571
|
await this.handleEffortSlash(data, adapterId);
|
|
2390
2572
|
}
|
|
@@ -2434,17 +2616,7 @@ export class FleetManager {
|
|
|
2434
2616
|
: t("collab.off.classic"));
|
|
2435
2617
|
}
|
|
2436
2618
|
else if (data.command === "update") {
|
|
2437
|
-
|
|
2438
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2439
|
-
await data.respond(t("not_authorized"));
|
|
2440
|
-
return;
|
|
2441
|
-
}
|
|
2442
|
-
await data.respond(t("update.running"));
|
|
2443
|
-
const { spawn } = await import("node:child_process");
|
|
2444
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2445
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2446
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2447
|
-
child.unref();
|
|
2619
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2448
2620
|
}
|
|
2449
2621
|
else if (data.command === "doctor") {
|
|
2450
2622
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2513,6 +2685,26 @@ export class FleetManager {
|
|
|
2513
2685
|
const result = await this.topicCommands.sendCompact(name);
|
|
2514
2686
|
await data.respond(result);
|
|
2515
2687
|
}
|
|
2688
|
+
else if (data.command === "steer") {
|
|
2689
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2690
|
+
if (!name) {
|
|
2691
|
+
await data.respond(t("classic.no_agent"));
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2695
|
+
if (!steerText) {
|
|
2696
|
+
await data.respond(t("steer.usage"));
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2700
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2701
|
+
// rerouting the instance's replies to the slash context.
|
|
2702
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2703
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2704
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2705
|
+
});
|
|
2706
|
+
await data.respond(result);
|
|
2707
|
+
}
|
|
2516
2708
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
2517
2709
|
await adapter.start();
|
|
2518
2710
|
if (channelConfig.group_id) {
|
|
@@ -2630,6 +2822,9 @@ export class FleetManager {
|
|
|
2630
2822
|
else if (msg.type === "instance_activity") {
|
|
2631
2823
|
this.cacheInstanceActivity(name, msg.activity);
|
|
2632
2824
|
}
|
|
2825
|
+
else if (msg.type === "instance_progress") {
|
|
2826
|
+
this.cacheInstanceProgress(name, msg.progress || null);
|
|
2827
|
+
}
|
|
2633
2828
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
2634
2829
|
this.cacheInstanceExecutionState(name, msg);
|
|
2635
2830
|
if (msg.type === "instance_state_response") {
|
|
@@ -2818,6 +3013,10 @@ export class FleetManager {
|
|
|
2818
3013
|
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
|
|
2819
3014
|
return;
|
|
2820
3015
|
}
|
|
3016
|
+
if (IGNORED_REACTION_EMOJIS.has(r.emoji)) {
|
|
3017
|
+
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring non-contextual emoji reaction");
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
2821
3020
|
this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
|
|
2822
3021
|
if (r.action === "add") {
|
|
2823
3022
|
this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
|
|
@@ -3073,6 +3272,43 @@ export class FleetManager {
|
|
|
3073
3272
|
await msgAdapter?.sendText(chatId, result);
|
|
3074
3273
|
return;
|
|
3075
3274
|
}
|
|
3275
|
+
// /steer — interject into the running turn. Not admin-gated: anyone who
|
|
3276
|
+
// can talk to this agent can send it a message; steer only changes when
|
|
3277
|
+
// it lands, and it keeps the full [user:] formatting (unlike /raw).
|
|
3278
|
+
if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
|
|
3279
|
+
const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3280
|
+
if (!steerName) {
|
|
3281
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
|
|
3285
|
+
if (!steerContent) {
|
|
3286
|
+
await msgAdapter?.sendText(chatId, t("steer.usage"));
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
|
|
3290
|
+
await msgAdapter?.sendText(chatId, result);
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
// Handle /clear command (admin only) — unlike /compact this starts a
|
|
3294
|
+
// fresh conversation and intentionally discards the current history.
|
|
3295
|
+
if (text === "/clear" || text.startsWith("/clear@")) {
|
|
3296
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
3297
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/clear"));
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
const clearName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3301
|
+
if (!clearName) {
|
|
3302
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
if (!msgAdapter)
|
|
3306
|
+
return;
|
|
3307
|
+
const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
|
|
3308
|
+
if (fallback)
|
|
3309
|
+
await msgAdapter.sendText(chatId, fallback);
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3076
3312
|
// Handle /cancel command
|
|
3077
3313
|
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
3078
3314
|
const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
@@ -3463,7 +3699,7 @@ export class FleetManager {
|
|
|
3463
3699
|
this.clearCancelButton(instanceName);
|
|
3464
3700
|
}
|
|
3465
3701
|
else {
|
|
3466
|
-
void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
|
|
3702
|
+
void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
|
|
3467
3703
|
}
|
|
3468
3704
|
this.reactDone(instanceName);
|
|
3469
3705
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
@@ -4212,7 +4448,13 @@ export class FleetManager {
|
|
|
4212
4448
|
}
|
|
4213
4449
|
catch (err) {
|
|
4214
4450
|
const e = err;
|
|
4215
|
-
|
|
4451
|
+
const output = stripAnsi(e.stdout ?? "").trim();
|
|
4452
|
+
if (output)
|
|
4453
|
+
return output;
|
|
4454
|
+
if (e.code === "ENOENT") {
|
|
4455
|
+
return "Backend doctor unavailable — agend CLI not found in PATH";
|
|
4456
|
+
}
|
|
4457
|
+
return stripAnsi(e.message ?? "").trim() || "Doctor failed";
|
|
4216
4458
|
}
|
|
4217
4459
|
}
|
|
4218
4460
|
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
@@ -4364,6 +4606,371 @@ export class FleetManager {
|
|
|
4364
4606
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
4365
4607
|
}
|
|
4366
4608
|
}
|
|
4609
|
+
// ── Nonce-armed button prompts (hang / assist / exit / clear) ──
|
|
4610
|
+
//
|
|
4611
|
+
// One shared lifecycle for every "notification with decision buttons":
|
|
4612
|
+
// post with a 128-bit nonce, arm a bounded expiry, bind the click to the
|
|
4613
|
+
// exact adapter+chat+thread+message that created it, require fleet admin,
|
|
4614
|
+
// consume exactly once. The features differ only in what they post
|
|
4615
|
+
// and what a consumed click does.
|
|
4616
|
+
/**
|
|
4617
|
+
* Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
|
|
4618
|
+
* The entry is registered before the send and rolled back if the send
|
|
4619
|
+
* fails, so a nonce in the map always refers to a message that exists (or
|
|
4620
|
+
* is about to). Returns the nonce, or null when the alert could not be sent.
|
|
4621
|
+
*/
|
|
4622
|
+
async postNonceButtonPrompt(opts) {
|
|
4623
|
+
// 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
|
|
4624
|
+
// callback_data cap still holds: longest id is "interactive-assist:" (19)
|
|
4625
|
+
// + 32 hex + ":confirm" (8) = 59 bytes.
|
|
4626
|
+
const nonce = randomBytes(16).toString("hex");
|
|
4627
|
+
const entry = {
|
|
4628
|
+
prefix: opts.prefix,
|
|
4629
|
+
instanceName: opts.instanceName,
|
|
4630
|
+
adapterId: opts.adapterId,
|
|
4631
|
+
adapter: opts.adapter,
|
|
4632
|
+
chatId: opts.chatId,
|
|
4633
|
+
threadId: opts.threadId,
|
|
4634
|
+
expiredText: opts.expiredText,
|
|
4635
|
+
...opts.extra,
|
|
4636
|
+
};
|
|
4637
|
+
entry.timer = setTimeout(() => {
|
|
4638
|
+
const pending = this.pendingNonceButtons.get(nonce);
|
|
4639
|
+
if (pending !== entry)
|
|
4640
|
+
return;
|
|
4641
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4642
|
+
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
4643
|
+
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
|
|
4644
|
+
}
|
|
4645
|
+
}, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
|
|
4646
|
+
entry.timer.unref?.();
|
|
4647
|
+
this.pendingNonceButtons.set(nonce, entry);
|
|
4648
|
+
try {
|
|
4649
|
+
const sent = await opts.adapter.notifyAlert(opts.chatId, {
|
|
4650
|
+
type: opts.alertType,
|
|
4651
|
+
instanceName: opts.instanceName,
|
|
4652
|
+
message: opts.message,
|
|
4653
|
+
choices: opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label })),
|
|
4654
|
+
}, opts.threadId ? { threadId: opts.threadId } : undefined);
|
|
4655
|
+
entry.messageId = sent.messageId;
|
|
4656
|
+
return nonce;
|
|
4657
|
+
}
|
|
4658
|
+
catch (err) {
|
|
4659
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4660
|
+
if (entry.timer)
|
|
4661
|
+
clearTimeout(entry.timer);
|
|
4662
|
+
this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
|
|
4663
|
+
return null;
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
/**
|
|
4667
|
+
* Claim a nonce-armed callback exactly once.
|
|
4668
|
+
*
|
|
4669
|
+
* Returns:
|
|
4670
|
+
* - null — the callback is not for this prefix; try the next handler
|
|
4671
|
+
* - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
|
|
4672
|
+
* - the entry+action — the click is authorized and now claimed (removed
|
|
4673
|
+
* from the map before any await, so double clicks cannot act twice)
|
|
4674
|
+
*
|
|
4675
|
+
* A stale click (expired nonce, or a pre-upgrade button whose payload no
|
|
4676
|
+
* longer parses) collapses the clicked message so the dead button stops
|
|
4677
|
+
* inviting clicks — the same courtesy the cancel button extends.
|
|
4678
|
+
*/
|
|
4679
|
+
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
|
|
4680
|
+
if (!data.callbackData.startsWith(prefix))
|
|
4681
|
+
return null;
|
|
4682
|
+
const match = data.callbackData.match(actionRe);
|
|
4683
|
+
let pending = match ? this.pendingNonceButtons.get(match[1]) : undefined;
|
|
4684
|
+
// The map is shared across prompt kinds. A nonce that resolves to an entry
|
|
4685
|
+
// of a DIFFERENT kind is not a usable capability for this handler — treat
|
|
4686
|
+
// it as stale rather than acting across kinds (fail closed).
|
|
4687
|
+
if (pending && pending.prefix !== prefix)
|
|
4688
|
+
pending = undefined;
|
|
4689
|
+
if (!match || !pending) {
|
|
4690
|
+
const adapter = receivingAdapter ?? this.adapter;
|
|
4691
|
+
adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
|
|
4692
|
+
return "consumed";
|
|
4693
|
+
}
|
|
4694
|
+
// Bind the capability to the exact message/world that created it. Telegram
|
|
4695
|
+
// keyboards are visible to everyone, so the click also requires fleet admin.
|
|
4696
|
+
const isAuthorized = data.userId
|
|
4697
|
+
? pending.authChannelId
|
|
4698
|
+
? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
4699
|
+
: this.isFleetAdmin(data.userId, callbackAdapterId)
|
|
4700
|
+
: false;
|
|
4701
|
+
if (pending.adapterId !== callbackAdapterId
|
|
4702
|
+
|| data.chatId !== pending.chatId
|
|
4703
|
+
|| (pending.threadId != null && data.threadId !== pending.threadId)
|
|
4704
|
+
|| (pending.messageId != null && data.messageId !== pending.messageId)
|
|
4705
|
+
|| !isAuthorized) {
|
|
4706
|
+
// Deliberately does NOT consume the nonce: the real admin can still click.
|
|
4707
|
+
this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
|
|
4708
|
+
return "consumed";
|
|
4709
|
+
}
|
|
4710
|
+
// Claim before any await: double clicks and duplicate callback delivery can
|
|
4711
|
+
// never act twice.
|
|
4712
|
+
this.pendingNonceButtons.delete(match[1]);
|
|
4713
|
+
if (pending.timer)
|
|
4714
|
+
clearTimeout(pending.timer);
|
|
4715
|
+
return { entry: pending, action: match[2] };
|
|
4716
|
+
}
|
|
4717
|
+
/** Collapse a consumed prompt's buttons into a final status line. */
|
|
4718
|
+
async retireNonceButtons(pending, messageId, text) {
|
|
4719
|
+
if (!pending.adapter.editMessageRemoveButtons) {
|
|
4720
|
+
this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
try {
|
|
4724
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
4725
|
+
}
|
|
4726
|
+
catch (err) {
|
|
4727
|
+
// The action was already atomically consumed. An edit failure must not
|
|
4728
|
+
// undo that or make the button actionable again.
|
|
4729
|
+
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
|
|
4730
|
+
}
|
|
4731
|
+
}
|
|
4732
|
+
/** Post the destructive `/clear` confirmation in the invoking channel. */
|
|
4733
|
+
async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
|
|
4734
|
+
if (!this.topicCommands.supportsClear(instanceName))
|
|
4735
|
+
return CLEAR_UNSUPPORTED_MSG;
|
|
4736
|
+
const adapterId = adapter.id;
|
|
4737
|
+
if (!adapterId)
|
|
4738
|
+
return t("clear.prompt_unavailable");
|
|
4739
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
4740
|
+
prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
|
|
4741
|
+
alertType: "clear_confirm",
|
|
4742
|
+
instanceName,
|
|
4743
|
+
adapter,
|
|
4744
|
+
adapterId,
|
|
4745
|
+
chatId,
|
|
4746
|
+
threadId,
|
|
4747
|
+
message: t("clear.confirm_message", instanceName),
|
|
4748
|
+
choices: [
|
|
4749
|
+
{ action: "confirm", label: t("clear.confirm") },
|
|
4750
|
+
{ action: "cancel", label: t("clear.cancel") },
|
|
4751
|
+
],
|
|
4752
|
+
expiredText: t("clear.expired", instanceName),
|
|
4753
|
+
extra: { authChannelId: channelId },
|
|
4754
|
+
timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
|
|
4755
|
+
});
|
|
4756
|
+
return nonce ? null : t("clear.prompt_unavailable");
|
|
4757
|
+
}
|
|
4758
|
+
/** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
|
|
4759
|
+
async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
|
|
4760
|
+
const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
4761
|
+
if (claimed === null)
|
|
4762
|
+
return false;
|
|
4763
|
+
if (claimed === "consumed")
|
|
4764
|
+
return true;
|
|
4765
|
+
const { entry: pending, action } = claimed;
|
|
4766
|
+
this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
|
|
4767
|
+
if (action === "cancel") {
|
|
4768
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
|
|
4769
|
+
return true;
|
|
4770
|
+
}
|
|
4771
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
|
|
4772
|
+
try {
|
|
4773
|
+
const result = await this.topicCommands.sendClear(pending.instanceName);
|
|
4774
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
|
|
4775
|
+
}
|
|
4776
|
+
catch (err) {
|
|
4777
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4778
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
|
|
4779
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("clear.failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show clear command error"));
|
|
4780
|
+
}
|
|
4781
|
+
return true;
|
|
4782
|
+
}
|
|
4783
|
+
/**
|
|
4784
|
+
* Drop every pending prompt that refers to an instance being stopped or
|
|
4785
|
+
* restarted — a restart offer for an instance the operator just restarted
|
|
4786
|
+
* (or an assist for one they stopped) must not stay clickable for the rest
|
|
4787
|
+
* of its 15 minutes.
|
|
4788
|
+
*/
|
|
4789
|
+
clearNoncePromptsForInstance(instanceName) {
|
|
4790
|
+
for (const [nonce, entry] of this.pendingNonceButtons) {
|
|
4791
|
+
if (entry.instanceName !== instanceName)
|
|
4792
|
+
continue;
|
|
4793
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4794
|
+
if (entry.timer)
|
|
4795
|
+
clearTimeout(entry.timer);
|
|
4796
|
+
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
4797
|
+
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
4798
|
+
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
4803
|
+
* the operator's. Keep the instance notice passive and put the action in the
|
|
4804
|
+
* same-world General topic where an administrator can make the choice. */
|
|
4805
|
+
async notifyNormalExit(instanceName) {
|
|
4806
|
+
this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
|
|
4807
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
4808
|
+
const generalName = this.findGeneralInstance(worldId);
|
|
4809
|
+
if (!generalName) {
|
|
4810
|
+
this.logger.warn({ instanceName, worldId }, "Normal CLI exit has no General notification target");
|
|
4811
|
+
return;
|
|
4812
|
+
}
|
|
4813
|
+
const adapterId = this.getInstanceAdapterId(generalName);
|
|
4814
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
4815
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
4816
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
4817
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
4818
|
+
if (!adapter || !adapterId || !chatId) {
|
|
4819
|
+
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address normal-exit restart controls");
|
|
4820
|
+
return;
|
|
4821
|
+
}
|
|
4822
|
+
await this.postNonceButtonPrompt({
|
|
4823
|
+
prefix: EXIT_RESTART_CALLBACK_PREFIX,
|
|
4824
|
+
alertType: "exit_restart",
|
|
4825
|
+
instanceName,
|
|
4826
|
+
adapter,
|
|
4827
|
+
adapterId,
|
|
4828
|
+
chatId,
|
|
4829
|
+
threadId,
|
|
4830
|
+
message: t("exit.general_notice", instanceName),
|
|
4831
|
+
choices: [
|
|
4832
|
+
{ action: "restart", label: t("exit.restart") },
|
|
4833
|
+
{ action: "ignore", label: t("exit.ignore") },
|
|
4834
|
+
],
|
|
4835
|
+
expiredText: t("exit.expired", instanceName),
|
|
4836
|
+
});
|
|
4837
|
+
}
|
|
4838
|
+
/** Consume a clean-exit Restart/Ignore button exactly once. */
|
|
4839
|
+
async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
4840
|
+
const claimed = this.consumeNonceCallback(EXIT_RESTART_CALLBACK_PREFIX, /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
|
|
4841
|
+
if (claimed === null)
|
|
4842
|
+
return false;
|
|
4843
|
+
if (claimed === "consumed")
|
|
4844
|
+
return true;
|
|
4845
|
+
const { entry: pending, action } = claimed;
|
|
4846
|
+
this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
|
|
4847
|
+
if (action === "ignore") {
|
|
4848
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
|
|
4849
|
+
return true;
|
|
4850
|
+
}
|
|
4851
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
|
|
4852
|
+
try {
|
|
4853
|
+
await this.restartSingleInstance(pending.instanceName);
|
|
4854
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
|
|
4855
|
+
}
|
|
4856
|
+
catch (err) {
|
|
4857
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4858
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
|
|
4859
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
|
|
4860
|
+
}
|
|
4861
|
+
return true;
|
|
4862
|
+
}
|
|
4863
|
+
interactivePromptLabel(kind) {
|
|
4864
|
+
const key = `interactive.kind.${kind}`;
|
|
4865
|
+
const translated = t(key);
|
|
4866
|
+
return translated === key ? kind.replaceAll("_", " ") : translated;
|
|
4867
|
+
}
|
|
4868
|
+
/**
|
|
4869
|
+
* Surface a stable terminal prompt in both places that matter:
|
|
4870
|
+
* - the blocked instance gets a plain pointer to General;
|
|
4871
|
+
* - General gets one-shot Confirm/Cancel controls on its own bound adapter.
|
|
4872
|
+
*
|
|
4873
|
+
* Only the prompt category crosses channels. The captured terminal line can
|
|
4874
|
+
* contain usernames or secret-adjacent text and stays in daemon.log.
|
|
4875
|
+
*/
|
|
4876
|
+
async notifyInteractivePrompt(instanceName, kind) {
|
|
4877
|
+
this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
|
|
4878
|
+
// Multi-world fleets can have one General per bot/platform. Route the assist
|
|
4879
|
+
// request to the General bound to the same adapter as the blocked instance.
|
|
4880
|
+
const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
|
|
4881
|
+
if (!generalName) {
|
|
4882
|
+
this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
|
|
4883
|
+
return;
|
|
4884
|
+
}
|
|
4885
|
+
const adapterId = this.getInstanceAdapterId(generalName);
|
|
4886
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
4887
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
4888
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
4889
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
4890
|
+
if (!adapter || !adapterId || !chatId) {
|
|
4891
|
+
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
|
|
4892
|
+
return;
|
|
4893
|
+
}
|
|
4894
|
+
const label = this.interactivePromptLabel(kind);
|
|
4895
|
+
await this.postNonceButtonPrompt({
|
|
4896
|
+
prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
4897
|
+
alertType: "interactive_prompt",
|
|
4898
|
+
instanceName,
|
|
4899
|
+
adapter,
|
|
4900
|
+
adapterId,
|
|
4901
|
+
chatId,
|
|
4902
|
+
threadId,
|
|
4903
|
+
message: t("interactive.general_notice", instanceName, label),
|
|
4904
|
+
choices: [
|
|
4905
|
+
{ action: "confirm", label: t("interactive.confirm") },
|
|
4906
|
+
{ action: "cancel", label: t("interactive.cancel") },
|
|
4907
|
+
],
|
|
4908
|
+
expiredText: t("interactive.expired", instanceName),
|
|
4909
|
+
extra: { generalName, promptKind: kind },
|
|
4910
|
+
});
|
|
4911
|
+
}
|
|
4912
|
+
/** Consume a General assist button exactly once. */
|
|
4913
|
+
async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
|
|
4914
|
+
const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
4915
|
+
if (claimed === null)
|
|
4916
|
+
return false;
|
|
4917
|
+
if (claimed === "consumed")
|
|
4918
|
+
return true;
|
|
4919
|
+
const { entry: pending, action } = claimed;
|
|
4920
|
+
this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
|
|
4921
|
+
action,
|
|
4922
|
+
userId: data.userId,
|
|
4923
|
+
generalName: pending.generalName,
|
|
4924
|
+
});
|
|
4925
|
+
if (action === "cancel") {
|
|
4926
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
|
|
4927
|
+
return true;
|
|
4928
|
+
}
|
|
4929
|
+
// Fail closed on a malformed entry. generalName is set at posting time for
|
|
4930
|
+
// every interactive-assist entry; its absence means entry confusion, and
|
|
4931
|
+
// falling back to the blocked instance would type the assist text into the
|
|
4932
|
+
// very terminal prompt this feature exists to keep humans in front of.
|
|
4933
|
+
const generalName = pending.generalName;
|
|
4934
|
+
if (!generalName) {
|
|
4935
|
+
this.logger.error({ instanceName: pending.instanceName }, "Interactive-assist entry has no General target — refusing to deliver");
|
|
4936
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName));
|
|
4937
|
+
return true;
|
|
4938
|
+
}
|
|
4939
|
+
// Injecting into General while General itself is blocked would type into the
|
|
4940
|
+
// terminal prompt instead of the agent input. Fail safe and require attach.
|
|
4941
|
+
if (pending.instanceName === generalName) {
|
|
4942
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
|
|
4943
|
+
return true;
|
|
4944
|
+
}
|
|
4945
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
|
|
4946
|
+
try {
|
|
4947
|
+
await this.deliverToInstance(generalName, {
|
|
4948
|
+
type: "fleet_inbound",
|
|
4949
|
+
content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.promptKind ?? "")),
|
|
4950
|
+
targetSession: generalName,
|
|
4951
|
+
meta: {
|
|
4952
|
+
chat_id: pending.chatId,
|
|
4953
|
+
message_id: pending.messageId ?? data.messageId,
|
|
4954
|
+
user: "AgEnD",
|
|
4955
|
+
user_id: data.userId,
|
|
4956
|
+
ts: new Date().toISOString(),
|
|
4957
|
+
thread_id: pending.threadId ?? "",
|
|
4958
|
+
adapter_id: pending.adapterId,
|
|
4959
|
+
source: pending.adapter.type,
|
|
4960
|
+
},
|
|
4961
|
+
}, {
|
|
4962
|
+
// This is system-generated work, not a live user message. Queue behind
|
|
4963
|
+
// General's current turn so Kiro/Claude do not lose it while busy.
|
|
4964
|
+
isCrossInstance: true,
|
|
4965
|
+
});
|
|
4966
|
+
this.logger.info({ instanceName: pending.instanceName, generalName: pending.generalName }, "Interactive prompt assistance delivered to General");
|
|
4967
|
+
}
|
|
4968
|
+
catch (err) {
|
|
4969
|
+
this.logger.warn({ err, instanceName: pending.instanceName, generalName: pending.generalName }, "Failed to deliver interactive prompt assistance to General");
|
|
4970
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName), pending.threadId);
|
|
4971
|
+
}
|
|
4972
|
+
return true;
|
|
4973
|
+
}
|
|
4367
4974
|
// ── Cancel button ────────────────────────────────────────────────────
|
|
4368
4975
|
// Sent after delivering a user message to an instance; clicking it (or
|
|
4369
4976
|
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
@@ -4452,7 +5059,7 @@ export class FleetManager {
|
|
|
4452
5059
|
}
|
|
4453
5060
|
return false;
|
|
4454
5061
|
}
|
|
4455
|
-
async sendCancelButton(instanceName, correlationId) {
|
|
5062
|
+
async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
|
|
4456
5063
|
// Post first, retire after (see the tail of this method). Retiring up front
|
|
4457
5064
|
// meant that from the delete until the new message came back — a chat API
|
|
4458
5065
|
// round trip, and every reply goes through here — the instance had NO live
|
|
@@ -4513,6 +5120,10 @@ export class FleetManager {
|
|
|
4513
5120
|
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
4514
5121
|
// with nothing visibly changed.
|
|
4515
5122
|
lastProgressText: "👀 處理中…",
|
|
5123
|
+
// A reply can re-post the same in-flight bubble below the reply. Carry
|
|
5124
|
+
// its current list into that replacement; fresh inbound work must start
|
|
5125
|
+
// empty even if the daemon's reset broadcast is still in flight.
|
|
5126
|
+
toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
|
|
4516
5127
|
};
|
|
4517
5128
|
this.startProgressTicker(entry);
|
|
4518
5129
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
@@ -4566,16 +5177,28 @@ export class FleetManager {
|
|
|
4566
5177
|
static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS) {
|
|
4567
5178
|
if (elapsedMs < minElapsedMs)
|
|
4568
5179
|
return "👀 處理中…";
|
|
5180
|
+
const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
|
|
5181
|
+
const detail = FleetManager.sanitizeActivity(activity);
|
|
5182
|
+
return detail
|
|
5183
|
+
? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
|
|
5184
|
+
: `⏳ 處理中… (已進行 ${elapsed})`;
|
|
5185
|
+
}
|
|
5186
|
+
/** Render elapsed time consistently in live and retained progress bubbles. */
|
|
5187
|
+
static formatProgressElapsed(elapsedMs) {
|
|
4569
5188
|
const totalSeconds = Math.floor(elapsedMs / 1000);
|
|
4570
5189
|
const minutes = Math.floor(totalSeconds / 60);
|
|
4571
5190
|
const seconds = totalSeconds % 60;
|
|
4572
|
-
|
|
5191
|
+
return minutes >= 60
|
|
4573
5192
|
? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
4574
5193
|
: `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
5194
|
+
}
|
|
5195
|
+
/** Final read-only form of a bubble that contains semantic tool history.
|
|
5196
|
+
* Neutral wording is intentional: the same retirement primitive is used for
|
|
5197
|
+
* normal completion, a mid-turn bubble replacement, and user cancellation,
|
|
5198
|
+
* so claiming every retained checkpoint is "completed" would be false. */
|
|
5199
|
+
composeRetiredBubbleText(entry) {
|
|
5200
|
+
const elapsed = FleetManager.formatProgressElapsed(Date.now() - (entry.startedAt ?? Date.now()));
|
|
5201
|
+
return `🧾 工具歷程 (記錄至 ${elapsed})\n${entry.toolProgress}`;
|
|
4579
5202
|
}
|
|
4580
5203
|
/**
|
|
4581
5204
|
* Make a tool summary safe to paste into a channel message.
|
|
@@ -4608,6 +5231,88 @@ export class FleetManager {
|
|
|
4608
5231
|
else
|
|
4609
5232
|
this.instanceActivity.delete(name);
|
|
4610
5233
|
}
|
|
5234
|
+
/**
|
|
5235
|
+
* Cache the turn's tool-progress list and push it into the instance's live
|
|
5236
|
+
* bubble, coalesced so a burst of tool events cannot flood the Bot API
|
|
5237
|
+
* (Telegram's flood limit is per-chat across ALL forum topics, so edits are
|
|
5238
|
+
* rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
|
|
5239
|
+
*/
|
|
5240
|
+
cacheInstanceProgress(name, progress) {
|
|
5241
|
+
if (progress)
|
|
5242
|
+
this.instanceProgress.set(name, progress);
|
|
5243
|
+
else
|
|
5244
|
+
this.instanceProgress.delete(name);
|
|
5245
|
+
for (const entry of this.cancelButtons.values()) {
|
|
5246
|
+
if (entry.instanceName !== name)
|
|
5247
|
+
continue;
|
|
5248
|
+
// Empty means the daemon completed/reset the turn. Clear the live cache
|
|
5249
|
+
// for the next turn, but retain this bubble's last non-empty list so its
|
|
5250
|
+
// retirement can preserve the history instead of deleting the message.
|
|
5251
|
+
if (progress)
|
|
5252
|
+
entry.toolProgress = progress;
|
|
5253
|
+
this.scheduleProgressEdit(entry);
|
|
5254
|
+
}
|
|
5255
|
+
}
|
|
5256
|
+
/** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
|
|
5257
|
+
scheduleProgressEdit(entry) {
|
|
5258
|
+
if (entry.retiring)
|
|
5259
|
+
return;
|
|
5260
|
+
const since = Date.now() - (entry.lastProgressEditAt ?? 0);
|
|
5261
|
+
if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
|
|
5262
|
+
this.refreshBubble(entry);
|
|
5263
|
+
return;
|
|
5264
|
+
}
|
|
5265
|
+
if (entry.progressEditTimer)
|
|
5266
|
+
return; // trailing edit already scheduled
|
|
5267
|
+
entry.progressEditTimer = setTimeout(() => {
|
|
5268
|
+
entry.progressEditTimer = undefined;
|
|
5269
|
+
this.refreshBubble(entry);
|
|
5270
|
+
}, TOOL_PROGRESS_EDIT_MIN_MS - since);
|
|
5271
|
+
entry.progressEditTimer.unref?.();
|
|
5272
|
+
}
|
|
5273
|
+
/**
|
|
5274
|
+
* The ONE composer for the bubble text. Both writers — the elapsed-time
|
|
5275
|
+
* ticker and the tool-progress push — go through here; two independent
|
|
5276
|
+
* renderers editing the same message is how the progress list used to get
|
|
5277
|
+
* wiped by the next elapsed tick (#528 trap 2).
|
|
5278
|
+
*/
|
|
5279
|
+
composeBubbleText(entry) {
|
|
5280
|
+
return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
|
|
5281
|
+
}
|
|
5282
|
+
/** Pure composition of header + tool list, exposed for tests. */
|
|
5283
|
+
static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
|
|
5284
|
+
const header = FleetManager.progressText(elapsedMs,
|
|
5285
|
+
// The single-line activity detail is redundant once a tool list exists.
|
|
5286
|
+
progress ? undefined : activity, minElapsedMs);
|
|
5287
|
+
return progress ? `${header}\n${progress}` : header;
|
|
5288
|
+
}
|
|
5289
|
+
/** Recompose and edit the bubble in place; skips when nothing changed. */
|
|
5290
|
+
refreshBubble(entry) {
|
|
5291
|
+
if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
|
|
5292
|
+
clearInterval(entry.progressTimer);
|
|
5293
|
+
return;
|
|
5294
|
+
}
|
|
5295
|
+
const text = this.composeBubbleText(entry);
|
|
5296
|
+
if (text === entry.lastProgressText)
|
|
5297
|
+
return; // nothing changed — skip the API call
|
|
5298
|
+
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
5299
|
+
if (!adapter?.editAlert)
|
|
5300
|
+
return;
|
|
5301
|
+
entry.lastProgressText = text;
|
|
5302
|
+
entry.lastProgressEditAt = Date.now();
|
|
5303
|
+
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
5304
|
+
type: "cancel",
|
|
5305
|
+
instanceName: entry.instanceName,
|
|
5306
|
+
message: text,
|
|
5307
|
+
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
5308
|
+
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
5309
|
+
.catch(err => {
|
|
5310
|
+
// A failed progress edit must never escalate: the button still works and
|
|
5311
|
+
// the next tick retries. Common causes are a deleted message or a
|
|
5312
|
+
// rate limit.
|
|
5313
|
+
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
5314
|
+
});
|
|
5315
|
+
}
|
|
4611
5316
|
/**
|
|
4612
5317
|
* Refresh the button's text in place while the instance keeps working.
|
|
4613
5318
|
*
|
|
@@ -4625,31 +5330,7 @@ export class FleetManager {
|
|
|
4625
5330
|
return PROGRESS_MIN_ELAPSED_MS;
|
|
4626
5331
|
}
|
|
4627
5332
|
startProgressTicker(entry) {
|
|
4628
|
-
const tick = () =>
|
|
4629
|
-
if (!this.cancelButtons.has(entry.messageId)) {
|
|
4630
|
-
clearInterval(entry.progressTimer);
|
|
4631
|
-
return;
|
|
4632
|
-
}
|
|
4633
|
-
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
|
|
4634
|
-
if (text === entry.lastProgressText)
|
|
4635
|
-
return; // nothing changed — skip the API call
|
|
4636
|
-
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
4637
|
-
if (!adapter?.editAlert)
|
|
4638
|
-
return;
|
|
4639
|
-
entry.lastProgressText = text;
|
|
4640
|
-
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
4641
|
-
type: "cancel",
|
|
4642
|
-
instanceName: entry.instanceName,
|
|
4643
|
-
message: text,
|
|
4644
|
-
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
4645
|
-
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
4646
|
-
.catch(err => {
|
|
4647
|
-
// A failed progress edit must never escalate: the button still works and
|
|
4648
|
-
// the next tick retries. Common causes are a deleted message or a
|
|
4649
|
-
// rate limit.
|
|
4650
|
-
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
4651
|
-
});
|
|
4652
|
-
};
|
|
5333
|
+
const tick = () => this.refreshBubble(entry);
|
|
4653
5334
|
entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
|
|
4654
5335
|
entry.progressTimer.unref?.();
|
|
4655
5336
|
// One extra tick right when the threshold passes, so a 30s threshold shows
|
|
@@ -4705,7 +5386,7 @@ export class FleetManager {
|
|
|
4705
5386
|
this.deleteButtonMessage(entry)
|
|
4706
5387
|
.then(() => {
|
|
4707
5388
|
this.discardButton(entry);
|
|
4708
|
-
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button
|
|
5389
|
+
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
|
|
4709
5390
|
})
|
|
4710
5391
|
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
4711
5392
|
}
|
|
@@ -4717,6 +5398,8 @@ export class FleetManager {
|
|
|
4717
5398
|
clearInterval(entry.idleCheckTimer);
|
|
4718
5399
|
if (entry.progressTimer)
|
|
4719
5400
|
clearInterval(entry.progressTimer);
|
|
5401
|
+
if (entry.progressEditTimer)
|
|
5402
|
+
clearTimeout(entry.progressEditTimer);
|
|
4720
5403
|
if (entry.replyGraceTimer)
|
|
4721
5404
|
clearTimeout(entry.replyGraceTimer);
|
|
4722
5405
|
this.cancelButtons.delete(entry.messageId);
|
|
@@ -4794,12 +5477,21 @@ export class FleetManager {
|
|
|
4794
5477
|
// Continue the same retire cycle (bypass the retiring-guard in retireButton).
|
|
4795
5478
|
entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
|
|
4796
5479
|
}
|
|
4797
|
-
/**
|
|
5480
|
+
/** Retire one cancel bubble via its own adapter. A bubble that accumulated
|
|
5481
|
+
* tool progress is kept as a read-only history message; a plain bubble keeps
|
|
5482
|
+
* the legacy delete/edit-to-checkmark behavior. Resolves on success and
|
|
4798
5483
|
* rejects on failure so the caller can retry. */
|
|
4799
5484
|
deleteButtonMessage(e) {
|
|
4800
5485
|
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
4801
5486
|
if (!adapter)
|
|
4802
5487
|
return Promise.reject(new Error("no adapter for cancel button"));
|
|
5488
|
+
if (e.toolProgress && adapter.editMessageRemoveButtons) {
|
|
5489
|
+
return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeRetiredBubbleText(e), e.threadId);
|
|
5490
|
+
}
|
|
5491
|
+
// All production adapters currently implement editMessageRemoveButtons.
|
|
5492
|
+
// A third-party adapter without it degrades to the legacy delete behavior:
|
|
5493
|
+
// removing a live Cancel control is safer than retaining an actionable,
|
|
5494
|
+
// untracked button forever.
|
|
4803
5495
|
if (adapter.deleteMessage)
|
|
4804
5496
|
return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
|
|
4805
5497
|
if (adapter.editMessageRemoveButtons)
|
|
@@ -4890,13 +5582,29 @@ export class FleetManager {
|
|
|
4890
5582
|
}
|
|
4891
5583
|
async sendHangNotification(instanceName, unchangedForMs) {
|
|
4892
5584
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
5585
|
+
const adapterId = this.getInstanceAdapterId(instanceName);
|
|
5586
|
+
// Same three-way addressing as sendCancelButton: fleet topic → group+thread,
|
|
5587
|
+
// Classic → its own channel (Classic instances are absent from
|
|
5588
|
+
// fleetConfig.instances, so the topic path can never address them), else the
|
|
5589
|
+
// world group flat. getGroupIdForInstance (not getChannelConfig().group_id)
|
|
5590
|
+
// because on channels[]-configured fleets the legacy channel: block is empty.
|
|
5591
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
5592
|
+
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
5593
|
+
let chatId;
|
|
5594
|
+
let threadId;
|
|
5595
|
+
if (topicId != null && groupId) {
|
|
5596
|
+
chatId = String(groupId);
|
|
5597
|
+
threadId = String(topicId);
|
|
5598
|
+
}
|
|
5599
|
+
else {
|
|
5600
|
+
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
5601
|
+
if (!chatId && groupId)
|
|
5602
|
+
chatId = String(groupId);
|
|
5603
|
+
}
|
|
5604
|
+
if (!adapter || !adapterId || !chatId) {
|
|
5605
|
+
this.logger.warn({ instanceName, adapterId, chatId }, "Cannot address hang notification");
|
|
4898
5606
|
return;
|
|
4899
|
-
|
|
5607
|
+
}
|
|
4900
5608
|
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
4901
5609
|
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
4902
5610
|
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
@@ -4905,17 +5613,52 @@ export class FleetManager {
|
|
|
4905
5613
|
? configuredMinutes
|
|
4906
5614
|
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
4907
5615
|
this.setTopicIcon(instanceName, "red");
|
|
4908
|
-
await
|
|
4909
|
-
|
|
5616
|
+
await this.postNonceButtonPrompt({
|
|
5617
|
+
prefix: HANG_CALLBACK_PREFIX,
|
|
5618
|
+
alertType: "hang",
|
|
4910
5619
|
instanceName,
|
|
4911
|
-
|
|
5620
|
+
adapter,
|
|
5621
|
+
adapterId,
|
|
5622
|
+
chatId,
|
|
5623
|
+
threadId,
|
|
5624
|
+
message: t("hang.detected", instanceName, unchangedMinutes),
|
|
4912
5625
|
choices: [
|
|
4913
|
-
{
|
|
4914
|
-
{
|
|
5626
|
+
{ action: "restart", label: t("hang.restart") },
|
|
5627
|
+
{ action: "wait", label: t("hang.wait") },
|
|
4915
5628
|
],
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
5629
|
+
expiredText: t("hang.expired", instanceName),
|
|
5630
|
+
});
|
|
5631
|
+
}
|
|
5632
|
+
/**
|
|
5633
|
+
* Consume a hang Force-restart / Keep-waiting button exactly once. Restart
|
|
5634
|
+
* goes through restartSingleInstance — serialized against concurrent restart
|
|
5635
|
+
* sources, and with a Classic-instance fallback (the previous hand-rolled
|
|
5636
|
+
* stop+start silently left Classic instances stopped while reporting
|
|
5637
|
+
* "restarted").
|
|
5638
|
+
*/
|
|
5639
|
+
async handleHangPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
5640
|
+
const claimed = this.consumeNonceCallback(HANG_CALLBACK_PREFIX, /^hang:([0-9a-f]+):(restart|wait)$/, data, callbackAdapterId, receivingAdapter);
|
|
5641
|
+
if (claimed === null)
|
|
5642
|
+
return false;
|
|
5643
|
+
if (claimed === "consumed")
|
|
5644
|
+
return true;
|
|
5645
|
+
const { entry: pending, action } = claimed;
|
|
5646
|
+
this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
|
|
5647
|
+
if (action === "wait") {
|
|
5648
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
|
|
5649
|
+
return true;
|
|
5650
|
+
}
|
|
5651
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
5652
|
+
try {
|
|
5653
|
+
await this.restartSingleInstance(pending.instanceName);
|
|
5654
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
5655
|
+
}
|
|
5656
|
+
catch (err) {
|
|
5657
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5658
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
|
|
5659
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
|
|
5660
|
+
}
|
|
5661
|
+
return true;
|
|
4919
5662
|
}
|
|
4920
5663
|
// ── Topic icon + auto-archive ─────────────────────────────────────────────
|
|
4921
5664
|
static INSTRUCTIONS_FILENAME = {
|
|
@@ -4924,6 +5667,10 @@ export class FleetManager {
|
|
|
4924
5667
|
"gemini-cli": "GEMINI.md",
|
|
4925
5668
|
"opencode": "AGENTS.md",
|
|
4926
5669
|
"kiro-cli": ".kiro/steering/project.md",
|
|
5670
|
+
// Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
|
|
5671
|
+
// same files their writeConfig() appends fleet instructions to.
|
|
5672
|
+
"grok": "AGENTS.md",
|
|
5673
|
+
"antigravity": ".agents/agents.md",
|
|
4927
5674
|
"mock": "CLAUDE.md",
|
|
4928
5675
|
};
|
|
4929
5676
|
static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
|
|
@@ -4933,146 +5680,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
|
|
|
4933
5680
|
Do NOT modify project files directly — delegate file changes to the project's instance.
|
|
4934
5681
|
You CAN write code snippets, explain code, and answer technical questions directly.
|
|
4935
5682
|
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
## Task Classification
|
|
5683
|
+
## Task Routing
|
|
4939
5684
|
|
|
4940
|
-
|
|
5685
|
+
- **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
|
|
5686
|
+
- **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
|
|
5687
|
+
- **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
|
|
4941
5688
|
|
|
4942
|
-
|
|
5689
|
+
Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
|
|
4943
5690
|
|
|
4944
|
-
|
|
4945
|
-
- No external execution needed
|
|
4946
|
-
- Answerable from static knowledge
|
|
4947
|
-
- ≤ 2 reasoning steps
|
|
5691
|
+
## Reply Contract
|
|
4948
5692
|
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
### Delegate to 1 Instance
|
|
4952
|
-
|
|
4953
|
-
- Task scoped to a single project or repo
|
|
4954
|
-
- Requires file access, code changes, or execution
|
|
4955
|
-
|
|
4956
|
-
### Coordinate Multiple Instances
|
|
4957
|
-
|
|
4958
|
-
- Task spans multiple repos or domains
|
|
4959
|
-
- Requires outputs from one instance to feed into another
|
|
4960
|
-
- Benefits from parallel execution (max 3 instances per task)
|
|
4961
|
-
|
|
4962
|
-
-----
|
|
4963
|
-
|
|
4964
|
-
## Instance Discovery (in this order)
|
|
4965
|
-
1. list_teams() → reuse existing teams first
|
|
4966
|
-
2. list_instances() → find by working_directory, description, or tags
|
|
4967
|
-
3. describe_instance() → confirm capabilities before delegating
|
|
4968
|
-
4. create_instance() → only if no suitable instance exists
|
|
4969
|
-
|
|
4970
|
-
Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
|
|
4971
|
-
|
|
4972
|
-
-----
|
|
4973
|
-
|
|
4974
|
-
## Delegation Protocol
|
|
4975
|
-
|
|
4976
|
-
Every delegation via send_to_instance() MUST include:
|
|
4977
|
-
|
|
4978
|
-
1. Task scope — what exactly to do, bounded clearly
|
|
4979
|
-
2. Expected output — what to return and in what form
|
|
4980
|
-
3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
|
|
4981
|
-
|
|
4982
|
-
### Loop Prevention
|
|
4983
|
-
|
|
4984
|
-
- Never re-delegate a task back to the instance that sent it to you
|
|
4985
|
-
- If a task has bounced 3 times, stop and solve locally or reduce scope
|
|
4986
|
-
|
|
4987
|
-
### Execution Strategy
|
|
4988
|
-
|
|
4989
|
-
Parallel — use only when tasks are independent with no shared state
|
|
4990
|
-
Sequential — use when one task's output feeds into the next
|
|
4991
|
-
|
|
4992
|
-
-----
|
|
4993
|
-
|
|
4994
|
-
## Result Handling
|
|
4995
|
-
|
|
4996
|
-
When an instance reports back, classify the outcome:
|
|
4997
|
-
|
|
4998
|
-
- Success → Summarize key results for user. Omit internal coordination noise.
|
|
4999
|
-
- Partial → State what succeeded, what remains, proposed next steps.
|
|
5000
|
-
- Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
|
|
5001
|
-
- No response → Ping again after reasonable wait. If still silent: report to user with options.
|
|
5002
|
-
|
|
5003
|
-
### Output to User
|
|
5004
|
-
|
|
5005
|
-
Every final response to the user should contain:
|
|
5006
|
-
|
|
5007
|
-
- Result — the actual answer or deliverable
|
|
5008
|
-
- Gaps — anything incomplete or unresolved (omit if none)
|
|
5009
|
-
|
|
5010
|
-
-----
|
|
5011
|
-
|
|
5012
|
-
## Shared Decisions
|
|
5013
|
-
|
|
5014
|
-
Use post_decision() / list_decisions() for any choice that affects more than 1 instance, changes an API contract, introduces a new dependency, or alters deployment process.
|
|
5015
|
-
|
|
5016
|
-
When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
|
|
5017
|
-
|
|
5018
|
-
-----
|
|
5693
|
+
Every final response to the user contains: the result (the actual answer or deliverable) and gaps (anything incomplete — omit if none). Summarize instance reports; omit internal coordination noise.
|
|
5019
5694
|
|
|
5020
5695
|
## After Restart
|
|
5021
5696
|
|
|
5022
|
-
|
|
5023
|
-
1. list_instances() → rebuild fleet awareness
|
|
5024
|
-
2. list_teams() → restore team structure
|
|
5025
|
-
3. list_decisions() → reload policies and conventions
|
|
5697
|
+
BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
|
|
5026
5698
|
|
|
5027
|
-
|
|
5699
|
+
## Playbooks (on-demand skills)
|
|
5028
5700
|
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
All code changes across the fleet should follow this workflow.
|
|
5034
|
-
The coordinator enforces compliance but does not perform these steps directly.
|
|
5035
|
-
Remind instances of this policy when delegating code tasks.
|
|
5036
|
-
|
|
5037
|
-
### Workflow Stages
|
|
5038
|
-
Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
|
|
5039
|
-
|
|
5040
|
-
### Policy Rules
|
|
5041
|
-
|
|
5042
|
-
1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
|
|
5043
|
-
2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
|
|
5044
|
-
3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
|
|
5045
|
-
4. Independent review — every merge requires code review from someone other than the author.
|
|
5046
|
-
5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
|
|
5047
|
-
6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
|
|
5048
|
-
|
|
5049
|
-
### Specialist Instance Rules
|
|
5050
|
-
|
|
5051
|
-
- Execute within defined scope only
|
|
5052
|
-
- Return structured output: result, assumptions, uncertainties, verification status
|
|
5053
|
-
- Do NOT create new instances without coordinator approval
|
|
5054
|
-
|
|
5055
|
-
-----
|
|
5056
|
-
|
|
5057
|
-
## Team Management
|
|
5058
|
-
|
|
5059
|
-
- Always check existing teams before creating new ones
|
|
5060
|
-
- Default to ephemeral teams (created for a specific task, dissolved after completion)
|
|
5061
|
-
- Clean up ephemeral teams and instances after task completion
|
|
5062
|
-
|
|
5063
|
-
-----
|
|
5064
|
-
|
|
5065
|
-
## Instance Configuration Tips
|
|
5066
|
-
|
|
5067
|
-
When users create specialized instances, suggest these configurations:
|
|
5068
|
-
|
|
5069
|
-
- **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
|
|
5070
|
-
- **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
|
|
5071
|
-
- **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
|
|
5701
|
+
Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
|
|
5702
|
+
- **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
|
|
5703
|
+
- **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
|
|
5704
|
+
Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
|
|
5072
5705
|
`;
|
|
5073
5706
|
/** Ensure the general instance has its project instructions file + knowledge */
|
|
5074
|
-
ensureGeneralInstructions(workDir, backendName) {
|
|
5707
|
+
ensureGeneralInstructions(workDir, backendName, instanceName) {
|
|
5075
5708
|
const backend = backendName ?? "claude-code";
|
|
5709
|
+
workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
|
|
5076
5710
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
5077
5711
|
const filePath = join(workDir, filename);
|
|
5078
5712
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -5080,60 +5714,238 @@ When users create specialized instances, suggest these configurations:
|
|
|
5080
5714
|
writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
|
|
5081
5715
|
this.logger.info({ filePath }, "Created general instance instructions file");
|
|
5082
5716
|
}
|
|
5083
|
-
// Sync bundled knowledge files to general's steering
|
|
5717
|
+
// Sync bundled knowledge files to general's steering and skills directories.
|
|
5084
5718
|
this.syncGeneralKnowledge(workDir, backend);
|
|
5085
5719
|
}
|
|
5086
|
-
/**
|
|
5720
|
+
/** Resolve the workspace path a backend actually uses before publishing knowledge. */
|
|
5721
|
+
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
5722
|
+
// Some backends relocate their real cwd (agy moves hidden paths to
|
|
5723
|
+
// ~/agend-workspaces/<name>). Instructions and skills must land where the
|
|
5724
|
+
// CLI actually runs, not where fleet.yaml points.
|
|
5725
|
+
try {
|
|
5726
|
+
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
5727
|
+
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
5728
|
+
if (resolved)
|
|
5729
|
+
workDir = resolved;
|
|
5730
|
+
}
|
|
5731
|
+
catch { /* unknown backend name — keep the raw path */ }
|
|
5732
|
+
return workDir;
|
|
5733
|
+
}
|
|
5734
|
+
/**
|
|
5735
|
+
* Where each backend natively loads on-demand skills from, relative to the
|
|
5736
|
+
* workspace. Backends without a native skill mechanism (opencode, grok,
|
|
5737
|
+
* antigravity, gemini-cli) are deliberately absent: dropping files a CLI
|
|
5738
|
+
* never reads is clutter, not capability. Unknown directories are ignored
|
|
5739
|
+
* by older CLI versions, so publishing is fail-open across upgrades.
|
|
5740
|
+
*/
|
|
5741
|
+
static SKILLS_DIR_SEGMENTS = {
|
|
5742
|
+
"kiro-cli": [".kiro", "skills"],
|
|
5743
|
+
"claude-code": [".claude", "skills"],
|
|
5744
|
+
"codex": [".agents", "skills"],
|
|
5745
|
+
// Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
|
|
5746
|
+
// vendor-canonical location is .grok/skills.
|
|
5747
|
+
"opencode": [".agents", "skills"],
|
|
5748
|
+
"grok": [".grok", "skills"],
|
|
5749
|
+
"antigravity": [".agents", "skills"],
|
|
5750
|
+
};
|
|
5751
|
+
/** Copy general-knowledge steering + all role-eligible skills to General. */
|
|
5087
5752
|
syncGeneralKnowledge(workDir, backend) {
|
|
5088
5753
|
const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
5089
5754
|
if (!existsSync(knowledgeDir))
|
|
5090
5755
|
return;
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5756
|
+
this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
|
|
5757
|
+
this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
|
|
5758
|
+
this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
|
|
5759
|
+
}
|
|
5760
|
+
/** Publish only the bundled skills eligible for an instance role. */
|
|
5761
|
+
syncRoleSkills(workDir, backend, role, knowledgeDir) {
|
|
5762
|
+
const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
|
|
5763
|
+
if (!skillSegments)
|
|
5764
|
+
return;
|
|
5765
|
+
const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
5766
|
+
if (!existsSync(root))
|
|
5767
|
+
return;
|
|
5768
|
+
// Before managed-skill manifests existed, only Kiro General received
|
|
5769
|
+
// bundled skills. Allow that one legacy layout to be adopted so upgrades
|
|
5770
|
+
// can keep those copies current; other backends never had unmanaged
|
|
5771
|
+
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
5772
|
+
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
5773
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
|
|
5774
|
+
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
5775
|
+
}
|
|
5776
|
+
/**
|
|
5777
|
+
* Steering (always-on rules like core-rules.md). Kiro loads a native
|
|
5778
|
+
* steering directory; every other backend gets the content embedded into
|
|
5779
|
+
* its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
|
|
5780
|
+
* block — the previous behavior dropped bare .md files in the workspace
|
|
5781
|
+
* root, which no CLI ever read. The block is replaced in place on every
|
|
5782
|
+
* sync, so rule updates reach EXISTING workspaces; everything the user
|
|
5783
|
+
* wrote outside the markers is preserved byte-for-byte.
|
|
5784
|
+
*/
|
|
5785
|
+
syncGeneralSteering(workDir, backend, srcSteering) {
|
|
5786
|
+
if (!existsSync(srcSteering))
|
|
5787
|
+
return;
|
|
5788
|
+
const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
|
|
5789
|
+
if (files.length === 0)
|
|
5790
|
+
return;
|
|
5791
|
+
if (backend === "kiro-cli") {
|
|
5792
|
+
const steeringDir = join(workDir, ".kiro", "steering");
|
|
5793
|
+
mkdirSync(steeringDir, { recursive: true });
|
|
5794
|
+
for (const file of files) {
|
|
5102
5795
|
const dest = join(steeringDir, file);
|
|
5103
|
-
const newContent = readFileSync(
|
|
5796
|
+
const newContent = readFileSync(join(srcSteering, file), "utf-8");
|
|
5104
5797
|
try {
|
|
5105
5798
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
5106
5799
|
continue;
|
|
5107
5800
|
}
|
|
5108
|
-
catch { }
|
|
5801
|
+
catch { /* rewrite */ }
|
|
5109
5802
|
writeFileSync(dest, newContent);
|
|
5110
5803
|
}
|
|
5804
|
+
return;
|
|
5111
5805
|
}
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5806
|
+
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
5807
|
+
const instructionsPath = join(workDir, filename);
|
|
5808
|
+
const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
|
|
5809
|
+
const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
|
|
5810
|
+
let existing = "";
|
|
5811
|
+
try {
|
|
5812
|
+
existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
|
|
5813
|
+
}
|
|
5814
|
+
catch { /* treat as empty */ }
|
|
5815
|
+
const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
|
|
5816
|
+
const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
|
|
5817
|
+
let next;
|
|
5818
|
+
if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
|
|
5819
|
+
next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
|
|
5820
|
+
}
|
|
5821
|
+
else {
|
|
5822
|
+
next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
|
|
5823
|
+
}
|
|
5824
|
+
if (next !== existing) {
|
|
5825
|
+
mkdirSync(dirname(instructionsPath), { recursive: true });
|
|
5826
|
+
writeFileSync(instructionsPath, next);
|
|
5827
|
+
}
|
|
5828
|
+
}
|
|
5829
|
+
static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
|
|
5830
|
+
static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
|
|
5831
|
+
/**
|
|
5832
|
+
* Publish AgEnD's bundled skills into a CLI's native skills directory,
|
|
5833
|
+
* owning ONLY what we published. A manifest records which skill names AgEnD
|
|
5834
|
+
* wrote; a bundled rename/removal deletes the stale managed copy, while a
|
|
5835
|
+
* skill the user created by hand is never listed and therefore never
|
|
5836
|
+
* touched — even if a future bundle happens to reuse its name (the sync
|
|
5837
|
+
* then skips it and logs, rather than overwrite the user's work). The sole
|
|
5838
|
+
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
5839
|
+
* selected by adoptLegacyUnmanaged.
|
|
5840
|
+
*/
|
|
5841
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
|
|
5842
|
+
if (!existsSync(srcSkills))
|
|
5843
|
+
return;
|
|
5844
|
+
mkdirSync(destSkills, { recursive: true });
|
|
5845
|
+
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
5846
|
+
const hadManifest = existsSync(manifestPath);
|
|
5847
|
+
let previouslyManaged = [];
|
|
5848
|
+
try {
|
|
5849
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
5850
|
+
if (Array.isArray(parsed))
|
|
5851
|
+
previouslyManaged = parsed.filter(n => typeof n === "string");
|
|
5852
|
+
}
|
|
5853
|
+
catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
|
|
5854
|
+
const bundled = readdirSync(srcSkills)
|
|
5855
|
+
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
5856
|
+
.sort();
|
|
5857
|
+
// Pre-manifest Kiro General copied bundled skills directly into
|
|
5858
|
+
// .kiro/skills. If every existing skill is still a bundled name, this is
|
|
5859
|
+
// the unambiguous legacy layout: adopt it once, update it below, and write
|
|
5860
|
+
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
5861
|
+
// on the user-owned/collision path.
|
|
5862
|
+
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
5863
|
+
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
5864
|
+
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
5865
|
+
.map(entry => entry.name)
|
|
5866
|
+
.sort();
|
|
5867
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
5868
|
+
previouslyManaged = existing;
|
|
5869
|
+
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
5870
|
+
}
|
|
5871
|
+
}
|
|
5872
|
+
const eligible = bundled.filter(name => {
|
|
5873
|
+
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
5874
|
+
// General is the coordinator and receives both coordinator and worker
|
|
5875
|
+
// playbooks. Workers receive only skills explicitly marked for workers.
|
|
5876
|
+
return role === "general" || roles.includes("worker");
|
|
5877
|
+
});
|
|
5878
|
+
const managed = [];
|
|
5879
|
+
for (const name of eligible) {
|
|
5880
|
+
const destDir = join(destSkills, name);
|
|
5881
|
+
const dest = join(destDir, "SKILL.md");
|
|
5882
|
+
const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
|
|
5883
|
+
if (!isOurs) {
|
|
5884
|
+
// Name collision with a user-authored skill: theirs wins, loudly.
|
|
5885
|
+
this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
|
|
5886
|
+
continue;
|
|
5887
|
+
}
|
|
5888
|
+
managed.push(name);
|
|
5889
|
+
const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
|
|
5890
|
+
try {
|
|
5891
|
+
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
5892
|
+
continue;
|
|
5134
5893
|
}
|
|
5894
|
+
catch { /* rewrite */ }
|
|
5895
|
+
mkdirSync(destDir, { recursive: true });
|
|
5896
|
+
writeFileSync(dest, newContent);
|
|
5897
|
+
}
|
|
5898
|
+
// Remove managed skills that are no longer bundled OR no longer eligible
|
|
5899
|
+
// for this role. This makes a shared → general-only metadata change take
|
|
5900
|
+
// effect on the next worker startup instead of leaving stale capability.
|
|
5901
|
+
for (const stale of previouslyManaged) {
|
|
5902
|
+
if (eligible.includes(stale))
|
|
5903
|
+
continue;
|
|
5904
|
+
try {
|
|
5905
|
+
rmSync(join(destSkills, stale), { recursive: true, force: true });
|
|
5906
|
+
this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
|
|
5907
|
+
}
|
|
5908
|
+
catch (err) {
|
|
5909
|
+
this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
|
|
5910
|
+
}
|
|
5911
|
+
}
|
|
5912
|
+
try {
|
|
5913
|
+
writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
|
|
5914
|
+
}
|
|
5915
|
+
catch (err) {
|
|
5916
|
+
this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5919
|
+
/** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
|
|
5920
|
+
readManagedSkillRoles(skillPath) {
|
|
5921
|
+
const fallback = ["general"];
|
|
5922
|
+
try {
|
|
5923
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
5924
|
+
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
5925
|
+
if (!match)
|
|
5926
|
+
return fallback;
|
|
5927
|
+
const document = parseDocument(match[1]);
|
|
5928
|
+
if (document.errors.length > 0)
|
|
5929
|
+
throw document.errors[0];
|
|
5930
|
+
const frontmatter = document.toJS();
|
|
5931
|
+
if (!frontmatter || frontmatter.roles === undefined)
|
|
5932
|
+
return fallback;
|
|
5933
|
+
if (!Array.isArray(frontmatter.roles))
|
|
5934
|
+
throw new Error("roles must be an array");
|
|
5935
|
+
const roles = [...new Set(frontmatter.roles)]
|
|
5936
|
+
.filter((value) => value === "general" || value === "worker");
|
|
5937
|
+
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
5938
|
+
throw new Error("roles must contain only general and/or worker");
|
|
5939
|
+
}
|
|
5940
|
+
return roles;
|
|
5941
|
+
}
|
|
5942
|
+
catch (err) {
|
|
5943
|
+
// Fail closed for workers: malformed or unknown metadata keeps the
|
|
5944
|
+
// backwards-compatible General-only behavior instead of leaking an
|
|
5945
|
+
// administrative skill into worker workspaces.
|
|
5946
|
+
this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
|
|
5947
|
+
return fallback;
|
|
5135
5948
|
}
|
|
5136
|
-
this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
|
|
5137
5949
|
}
|
|
5138
5950
|
/** Fetch forum topic icon stickers and pick emoji IDs for each state */
|
|
5139
5951
|
async resolveTopicIcons() {
|
|
@@ -6046,13 +6858,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6046
6858
|
}
|
|
6047
6859
|
catch { /* default restart */ }
|
|
6048
6860
|
const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
|
|
6049
|
-
if (strategy === "runtime") {
|
|
6050
|
-
|
|
6051
|
-
return `${warn}❌ ${instanceName} is not running.`;
|
|
6052
|
-
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6053
|
-
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
6861
|
+
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
6862
|
+
return `${warn}❌ ${instanceName} is not running.`;
|
|
6054
6863
|
}
|
|
6055
|
-
//
|
|
6864
|
+
// Persist either way: a runtime switch must survive the next respawn too,
|
|
6865
|
+
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
6056
6866
|
let persisted = false;
|
|
6057
6867
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
6058
6868
|
this.fleetConfig.instances[instanceName].model = model;
|
|
@@ -6064,6 +6874,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6064
6874
|
}
|
|
6065
6875
|
if (!persisted)
|
|
6066
6876
|
return `${warn}❌ Could not set model for ${instanceName}.`;
|
|
6877
|
+
if (strategy === "runtime") {
|
|
6878
|
+
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6879
|
+
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
6880
|
+
}
|
|
6067
6881
|
await this.restartSingleInstance(instanceName);
|
|
6068
6882
|
return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
|
|
6069
6883
|
}
|
|
@@ -6401,6 +7215,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6401
7215
|
for (const pending of this.pendingClassicStarts.values())
|
|
6402
7216
|
clearTimeout(pending.timer);
|
|
6403
7217
|
this.pendingClassicStarts.clear();
|
|
7218
|
+
for (const pending of this.pendingNonceButtons.values()) {
|
|
7219
|
+
if (pending.timer)
|
|
7220
|
+
clearTimeout(pending.timer);
|
|
7221
|
+
}
|
|
7222
|
+
this.pendingNonceButtons.clear();
|
|
6404
7223
|
this.topicArchiver.stop();
|
|
6405
7224
|
this.scheduler?.shutdown();
|
|
6406
7225
|
// Stop instances in parallel batches to avoid long sequential waits.
|