@songsid/agend 2.1.3 → 2.1.4-beta.10
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/LICENSE +21 -0
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- 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 +83 -5
- 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 +74 -23
- 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 -9
- package/dist/daemon.js +222 -86
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +8 -0
- package/dist/fleet-manager.d.ts +136 -5
- package/dist/fleet-manager.js +1088 -316
- 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 +29 -2
- 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/setup-wizard.d.ts +4 -2
- package/dist/setup-wizard.js +2 -2
- package/dist/setup-wizard.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/ui/dashboard.html +5 -4
- 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/web-api.js +2 -2
- package/dist/web-api.js.map +1 -1
- 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) {
|
|
@@ -2607,9 +2799,6 @@ export class FleetManager {
|
|
|
2607
2799
|
}
|
|
2608
2800
|
await this.handleOutboundFromInstance(name, msg);
|
|
2609
2801
|
}
|
|
2610
|
-
else if (msg.type === "fleet_tool_status") {
|
|
2611
|
-
this.handleToolStatusFromInstance(name, msg);
|
|
2612
|
-
}
|
|
2613
2802
|
else if (msg.type === "fleet_schedule_create" || msg.type === "fleet_schedule_list" ||
|
|
2614
2803
|
msg.type === "fleet_schedule_update" || msg.type === "fleet_schedule_delete") {
|
|
2615
2804
|
this.handleScheduleCrud(name, msg);
|
|
@@ -2633,6 +2822,9 @@ export class FleetManager {
|
|
|
2633
2822
|
else if (msg.type === "instance_activity") {
|
|
2634
2823
|
this.cacheInstanceActivity(name, msg.activity);
|
|
2635
2824
|
}
|
|
2825
|
+
else if (msg.type === "instance_progress") {
|
|
2826
|
+
this.cacheInstanceProgress(name, msg.progress || null);
|
|
2827
|
+
}
|
|
2636
2828
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
2637
2829
|
this.cacheInstanceExecutionState(name, msg);
|
|
2638
2830
|
if (msg.type === "instance_state_response") {
|
|
@@ -2821,6 +3013,10 @@ export class FleetManager {
|
|
|
2821
3013
|
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
|
|
2822
3014
|
return;
|
|
2823
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
|
+
}
|
|
2824
3020
|
this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
|
|
2825
3021
|
if (r.action === "add") {
|
|
2826
3022
|
this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
|
|
@@ -3076,6 +3272,43 @@ export class FleetManager {
|
|
|
3076
3272
|
await msgAdapter?.sendText(chatId, result);
|
|
3077
3273
|
return;
|
|
3078
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
|
+
}
|
|
3079
3312
|
// Handle /cancel command
|
|
3080
3313
|
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
3081
3314
|
const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
@@ -3466,7 +3699,7 @@ export class FleetManager {
|
|
|
3466
3699
|
this.clearCancelButton(instanceName);
|
|
3467
3700
|
}
|
|
3468
3701
|
else {
|
|
3469
|
-
void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
|
|
3702
|
+
void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
|
|
3470
3703
|
}
|
|
3471
3704
|
this.reactDone(instanceName);
|
|
3472
3705
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
@@ -3482,34 +3715,6 @@ export class FleetManager {
|
|
|
3482
3715
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
3483
3716
|
}
|
|
3484
3717
|
}
|
|
3485
|
-
/** Handle tool status update from a daemon instance */
|
|
3486
|
-
handleToolStatusFromInstance(instanceName, msg) {
|
|
3487
|
-
const statusAdapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
3488
|
-
if (!statusAdapter)
|
|
3489
|
-
return;
|
|
3490
|
-
const text = msg.text;
|
|
3491
|
-
const editMessageId = msg.editMessageId;
|
|
3492
|
-
const senderSessionName = msg.senderSessionName;
|
|
3493
|
-
const senderInstanceName = senderSessionName && this.fleetConfig?.instances[senderSessionName]
|
|
3494
|
-
? senderSessionName
|
|
3495
|
-
: null;
|
|
3496
|
-
const routingConfig = senderInstanceName
|
|
3497
|
-
? this.fleetConfig?.instances[senderInstanceName]
|
|
3498
|
-
: (senderSessionName ? undefined : this.fleetConfig?.instances[instanceName]);
|
|
3499
|
-
const threadId = routingConfig?.topic_id ? String(routingConfig.topic_id) : undefined;
|
|
3500
|
-
const chatId = statusAdapter.getChatId();
|
|
3501
|
-
if (!chatId)
|
|
3502
|
-
return;
|
|
3503
|
-
if (editMessageId) {
|
|
3504
|
-
statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
3505
|
-
}
|
|
3506
|
-
else {
|
|
3507
|
-
statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
|
|
3508
|
-
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3509
|
-
ipc?.send({ type: "fleet_tool_status_ack", messageId: sent.messageId });
|
|
3510
|
-
}).catch(e => this.logger.warn({ err: e }, "Failed to send tool status message"));
|
|
3511
|
-
}
|
|
3512
|
-
}
|
|
3513
3718
|
// ===================== Scheduler =====================
|
|
3514
3719
|
async handleScheduleTrigger(schedule) {
|
|
3515
3720
|
const { target, reply_chat_id, reply_thread_id, message, label, id, source, silent } = schedule;
|
|
@@ -4243,7 +4448,13 @@ export class FleetManager {
|
|
|
4243
4448
|
}
|
|
4244
4449
|
catch (err) {
|
|
4245
4450
|
const e = err;
|
|
4246
|
-
|
|
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";
|
|
4247
4458
|
}
|
|
4248
4459
|
}
|
|
4249
4460
|
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
@@ -4395,6 +4606,371 @@ export class FleetManager {
|
|
|
4395
4606
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
4396
4607
|
}
|
|
4397
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
|
+
}
|
|
4398
4974
|
// ── Cancel button ────────────────────────────────────────────────────
|
|
4399
4975
|
// Sent after delivering a user message to an instance; clicking it (or
|
|
4400
4976
|
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
@@ -4483,7 +5059,7 @@ export class FleetManager {
|
|
|
4483
5059
|
}
|
|
4484
5060
|
return false;
|
|
4485
5061
|
}
|
|
4486
|
-
async sendCancelButton(instanceName, correlationId) {
|
|
5062
|
+
async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
|
|
4487
5063
|
// Post first, retire after (see the tail of this method). Retiring up front
|
|
4488
5064
|
// meant that from the delete until the new message came back — a chat API
|
|
4489
5065
|
// round trip, and every reply goes through here — the instance had NO live
|
|
@@ -4544,6 +5120,10 @@ export class FleetManager {
|
|
|
4544
5120
|
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
4545
5121
|
// with nothing visibly changed.
|
|
4546
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,
|
|
4547
5127
|
};
|
|
4548
5128
|
this.startProgressTicker(entry);
|
|
4549
5129
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
@@ -4639,6 +5219,88 @@ export class FleetManager {
|
|
|
4639
5219
|
else
|
|
4640
5220
|
this.instanceActivity.delete(name);
|
|
4641
5221
|
}
|
|
5222
|
+
/**
|
|
5223
|
+
* Cache the turn's tool-progress list and push it into the instance's live
|
|
5224
|
+
* bubble, coalesced so a burst of tool events cannot flood the Bot API
|
|
5225
|
+
* (Telegram's flood limit is per-chat across ALL forum topics, so edits are
|
|
5226
|
+
* rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
|
|
5227
|
+
*/
|
|
5228
|
+
cacheInstanceProgress(name, progress) {
|
|
5229
|
+
if (progress)
|
|
5230
|
+
this.instanceProgress.set(name, progress);
|
|
5231
|
+
else
|
|
5232
|
+
this.instanceProgress.delete(name);
|
|
5233
|
+
for (const entry of this.cancelButtons.values()) {
|
|
5234
|
+
if (entry.instanceName !== name)
|
|
5235
|
+
continue;
|
|
5236
|
+
// Empty means the daemon completed/reset the turn. Clear the live cache
|
|
5237
|
+
// for the next turn, but retain this bubble's last non-empty list so its
|
|
5238
|
+
// retirement can preserve the history instead of deleting the message.
|
|
5239
|
+
if (progress)
|
|
5240
|
+
entry.toolProgress = progress;
|
|
5241
|
+
this.scheduleProgressEdit(entry);
|
|
5242
|
+
}
|
|
5243
|
+
}
|
|
5244
|
+
/** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
|
|
5245
|
+
scheduleProgressEdit(entry) {
|
|
5246
|
+
if (entry.retiring)
|
|
5247
|
+
return;
|
|
5248
|
+
const since = Date.now() - (entry.lastProgressEditAt ?? 0);
|
|
5249
|
+
if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
|
|
5250
|
+
this.refreshBubble(entry);
|
|
5251
|
+
return;
|
|
5252
|
+
}
|
|
5253
|
+
if (entry.progressEditTimer)
|
|
5254
|
+
return; // trailing edit already scheduled
|
|
5255
|
+
entry.progressEditTimer = setTimeout(() => {
|
|
5256
|
+
entry.progressEditTimer = undefined;
|
|
5257
|
+
this.refreshBubble(entry);
|
|
5258
|
+
}, TOOL_PROGRESS_EDIT_MIN_MS - since);
|
|
5259
|
+
entry.progressEditTimer.unref?.();
|
|
5260
|
+
}
|
|
5261
|
+
/**
|
|
5262
|
+
* The ONE composer for the bubble text. Both writers — the elapsed-time
|
|
5263
|
+
* ticker and the tool-progress push — go through here; two independent
|
|
5264
|
+
* renderers editing the same message is how the progress list used to get
|
|
5265
|
+
* wiped by the next elapsed tick (#528 trap 2).
|
|
5266
|
+
*/
|
|
5267
|
+
composeBubbleText(entry) {
|
|
5268
|
+
return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
|
|
5269
|
+
}
|
|
5270
|
+
/** Pure composition of header + tool list, exposed for tests. */
|
|
5271
|
+
static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
|
|
5272
|
+
const header = FleetManager.progressText(elapsedMs,
|
|
5273
|
+
// The single-line activity detail is redundant once a tool list exists.
|
|
5274
|
+
progress ? undefined : activity, minElapsedMs);
|
|
5275
|
+
return progress ? `${header}\n${progress}` : header;
|
|
5276
|
+
}
|
|
5277
|
+
/** Recompose and edit the bubble in place; skips when nothing changed. */
|
|
5278
|
+
refreshBubble(entry) {
|
|
5279
|
+
if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
|
|
5280
|
+
clearInterval(entry.progressTimer);
|
|
5281
|
+
return;
|
|
5282
|
+
}
|
|
5283
|
+
const text = this.composeBubbleText(entry);
|
|
5284
|
+
if (text === entry.lastProgressText)
|
|
5285
|
+
return; // nothing changed — skip the API call
|
|
5286
|
+
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
5287
|
+
if (!adapter?.editAlert)
|
|
5288
|
+
return;
|
|
5289
|
+
entry.lastProgressText = text;
|
|
5290
|
+
entry.lastProgressEditAt = Date.now();
|
|
5291
|
+
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
5292
|
+
type: "cancel",
|
|
5293
|
+
instanceName: entry.instanceName,
|
|
5294
|
+
message: text,
|
|
5295
|
+
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
5296
|
+
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
5297
|
+
.catch(err => {
|
|
5298
|
+
// A failed progress edit must never escalate: the button still works and
|
|
5299
|
+
// the next tick retries. Common causes are a deleted message or a
|
|
5300
|
+
// rate limit.
|
|
5301
|
+
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
5302
|
+
});
|
|
5303
|
+
}
|
|
4642
5304
|
/**
|
|
4643
5305
|
* Refresh the button's text in place while the instance keeps working.
|
|
4644
5306
|
*
|
|
@@ -4656,31 +5318,7 @@ export class FleetManager {
|
|
|
4656
5318
|
return PROGRESS_MIN_ELAPSED_MS;
|
|
4657
5319
|
}
|
|
4658
5320
|
startProgressTicker(entry) {
|
|
4659
|
-
const tick = () =>
|
|
4660
|
-
if (!this.cancelButtons.has(entry.messageId)) {
|
|
4661
|
-
clearInterval(entry.progressTimer);
|
|
4662
|
-
return;
|
|
4663
|
-
}
|
|
4664
|
-
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
|
|
4665
|
-
if (text === entry.lastProgressText)
|
|
4666
|
-
return; // nothing changed — skip the API call
|
|
4667
|
-
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
4668
|
-
if (!adapter?.editAlert)
|
|
4669
|
-
return;
|
|
4670
|
-
entry.lastProgressText = text;
|
|
4671
|
-
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
4672
|
-
type: "cancel",
|
|
4673
|
-
instanceName: entry.instanceName,
|
|
4674
|
-
message: text,
|
|
4675
|
-
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
4676
|
-
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
4677
|
-
.catch(err => {
|
|
4678
|
-
// A failed progress edit must never escalate: the button still works and
|
|
4679
|
-
// the next tick retries. Common causes are a deleted message or a
|
|
4680
|
-
// rate limit.
|
|
4681
|
-
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
4682
|
-
});
|
|
4683
|
-
};
|
|
5321
|
+
const tick = () => this.refreshBubble(entry);
|
|
4684
5322
|
entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
|
|
4685
5323
|
entry.progressTimer.unref?.();
|
|
4686
5324
|
// One extra tick right when the threshold passes, so a 30s threshold shows
|
|
@@ -4736,7 +5374,7 @@ export class FleetManager {
|
|
|
4736
5374
|
this.deleteButtonMessage(entry)
|
|
4737
5375
|
.then(() => {
|
|
4738
5376
|
this.discardButton(entry);
|
|
4739
|
-
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button
|
|
5377
|
+
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
|
|
4740
5378
|
})
|
|
4741
5379
|
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
4742
5380
|
}
|
|
@@ -4748,6 +5386,8 @@ export class FleetManager {
|
|
|
4748
5386
|
clearInterval(entry.idleCheckTimer);
|
|
4749
5387
|
if (entry.progressTimer)
|
|
4750
5388
|
clearInterval(entry.progressTimer);
|
|
5389
|
+
if (entry.progressEditTimer)
|
|
5390
|
+
clearTimeout(entry.progressEditTimer);
|
|
4751
5391
|
if (entry.replyGraceTimer)
|
|
4752
5392
|
clearTimeout(entry.replyGraceTimer);
|
|
4753
5393
|
this.cancelButtons.delete(entry.messageId);
|
|
@@ -4825,12 +5465,17 @@ export class FleetManager {
|
|
|
4825
5465
|
// Continue the same retire cycle (bypass the retiring-guard in retireButton).
|
|
4826
5466
|
entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
|
|
4827
5467
|
}
|
|
4828
|
-
/**
|
|
5468
|
+
/** Retire one cancel bubble via its own adapter. A bubble that accumulated
|
|
5469
|
+
* tool progress is kept as a read-only history message; a plain bubble keeps
|
|
5470
|
+
* the legacy delete/edit-to-checkmark behavior. Resolves on success and
|
|
4829
5471
|
* rejects on failure so the caller can retry. */
|
|
4830
5472
|
deleteButtonMessage(e) {
|
|
4831
5473
|
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
4832
5474
|
if (!adapter)
|
|
4833
5475
|
return Promise.reject(new Error("no adapter for cancel button"));
|
|
5476
|
+
if (e.toolProgress && adapter.editMessageRemoveButtons) {
|
|
5477
|
+
return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeBubbleText(e), e.threadId);
|
|
5478
|
+
}
|
|
4834
5479
|
if (adapter.deleteMessage)
|
|
4835
5480
|
return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
|
|
4836
5481
|
if (adapter.editMessageRemoveButtons)
|
|
@@ -4921,13 +5566,29 @@ export class FleetManager {
|
|
|
4921
5566
|
}
|
|
4922
5567
|
async sendHangNotification(instanceName, unchangedForMs) {
|
|
4923
5568
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
5569
|
+
const adapterId = this.getInstanceAdapterId(instanceName);
|
|
5570
|
+
// Same three-way addressing as sendCancelButton: fleet topic → group+thread,
|
|
5571
|
+
// Classic → its own channel (Classic instances are absent from
|
|
5572
|
+
// fleetConfig.instances, so the topic path can never address them), else the
|
|
5573
|
+
// world group flat. getGroupIdForInstance (not getChannelConfig().group_id)
|
|
5574
|
+
// because on channels[]-configured fleets the legacy channel: block is empty.
|
|
5575
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
5576
|
+
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
5577
|
+
let chatId;
|
|
5578
|
+
let threadId;
|
|
5579
|
+
if (topicId != null && groupId) {
|
|
5580
|
+
chatId = String(groupId);
|
|
5581
|
+
threadId = String(topicId);
|
|
5582
|
+
}
|
|
5583
|
+
else {
|
|
5584
|
+
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
5585
|
+
if (!chatId && groupId)
|
|
5586
|
+
chatId = String(groupId);
|
|
5587
|
+
}
|
|
5588
|
+
if (!adapter || !adapterId || !chatId) {
|
|
5589
|
+
this.logger.warn({ instanceName, adapterId, chatId }, "Cannot address hang notification");
|
|
4929
5590
|
return;
|
|
4930
|
-
|
|
5591
|
+
}
|
|
4931
5592
|
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
4932
5593
|
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
4933
5594
|
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
@@ -4936,17 +5597,52 @@ export class FleetManager {
|
|
|
4936
5597
|
? configuredMinutes
|
|
4937
5598
|
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
4938
5599
|
this.setTopicIcon(instanceName, "red");
|
|
4939
|
-
await
|
|
4940
|
-
|
|
5600
|
+
await this.postNonceButtonPrompt({
|
|
5601
|
+
prefix: HANG_CALLBACK_PREFIX,
|
|
5602
|
+
alertType: "hang",
|
|
4941
5603
|
instanceName,
|
|
4942
|
-
|
|
5604
|
+
adapter,
|
|
5605
|
+
adapterId,
|
|
5606
|
+
chatId,
|
|
5607
|
+
threadId,
|
|
5608
|
+
message: t("hang.detected", instanceName, unchangedMinutes),
|
|
4943
5609
|
choices: [
|
|
4944
|
-
{
|
|
4945
|
-
{
|
|
5610
|
+
{ action: "restart", label: t("hang.restart") },
|
|
5611
|
+
{ action: "wait", label: t("hang.wait") },
|
|
4946
5612
|
],
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
5613
|
+
expiredText: t("hang.expired", instanceName),
|
|
5614
|
+
});
|
|
5615
|
+
}
|
|
5616
|
+
/**
|
|
5617
|
+
* Consume a hang Force-restart / Keep-waiting button exactly once. Restart
|
|
5618
|
+
* goes through restartSingleInstance — serialized against concurrent restart
|
|
5619
|
+
* sources, and with a Classic-instance fallback (the previous hand-rolled
|
|
5620
|
+
* stop+start silently left Classic instances stopped while reporting
|
|
5621
|
+
* "restarted").
|
|
5622
|
+
*/
|
|
5623
|
+
async handleHangPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
5624
|
+
const claimed = this.consumeNonceCallback(HANG_CALLBACK_PREFIX, /^hang:([0-9a-f]+):(restart|wait)$/, data, callbackAdapterId, receivingAdapter);
|
|
5625
|
+
if (claimed === null)
|
|
5626
|
+
return false;
|
|
5627
|
+
if (claimed === "consumed")
|
|
5628
|
+
return true;
|
|
5629
|
+
const { entry: pending, action } = claimed;
|
|
5630
|
+
this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
|
|
5631
|
+
if (action === "wait") {
|
|
5632
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
|
|
5633
|
+
return true;
|
|
5634
|
+
}
|
|
5635
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
5636
|
+
try {
|
|
5637
|
+
await this.restartSingleInstance(pending.instanceName);
|
|
5638
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
5639
|
+
}
|
|
5640
|
+
catch (err) {
|
|
5641
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5642
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
|
|
5643
|
+
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"));
|
|
5644
|
+
}
|
|
5645
|
+
return true;
|
|
4950
5646
|
}
|
|
4951
5647
|
// ── Topic icon + auto-archive ─────────────────────────────────────────────
|
|
4952
5648
|
static INSTRUCTIONS_FILENAME = {
|
|
@@ -4955,6 +5651,10 @@ export class FleetManager {
|
|
|
4955
5651
|
"gemini-cli": "GEMINI.md",
|
|
4956
5652
|
"opencode": "AGENTS.md",
|
|
4957
5653
|
"kiro-cli": ".kiro/steering/project.md",
|
|
5654
|
+
// Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
|
|
5655
|
+
// same files their writeConfig() appends fleet instructions to.
|
|
5656
|
+
"grok": "AGENTS.md",
|
|
5657
|
+
"antigravity": ".agents/agents.md",
|
|
4958
5658
|
"mock": "CLAUDE.md",
|
|
4959
5659
|
};
|
|
4960
5660
|
static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
|
|
@@ -4964,146 +5664,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
|
|
|
4964
5664
|
Do NOT modify project files directly — delegate file changes to the project's instance.
|
|
4965
5665
|
You CAN write code snippets, explain code, and answer technical questions directly.
|
|
4966
5666
|
|
|
4967
|
-
|
|
5667
|
+
## Task Routing
|
|
4968
5668
|
|
|
4969
|
-
|
|
5669
|
+
- **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
|
|
5670
|
+
- **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
|
|
5671
|
+
- **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
|
|
4970
5672
|
|
|
4971
|
-
|
|
5673
|
+
Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
|
|
4972
5674
|
|
|
4973
|
-
|
|
5675
|
+
## Reply Contract
|
|
4974
5676
|
|
|
4975
|
-
|
|
4976
|
-
- No external execution needed
|
|
4977
|
-
- Answerable from static knowledge
|
|
4978
|
-
- ≤ 2 reasoning steps
|
|
4979
|
-
|
|
4980
|
-
Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
|
|
4981
|
-
|
|
4982
|
-
### Delegate to 1 Instance
|
|
4983
|
-
|
|
4984
|
-
- Task scoped to a single project or repo
|
|
4985
|
-
- Requires file access, code changes, or execution
|
|
4986
|
-
|
|
4987
|
-
### Coordinate Multiple Instances
|
|
4988
|
-
|
|
4989
|
-
- Task spans multiple repos or domains
|
|
4990
|
-
- Requires outputs from one instance to feed into another
|
|
4991
|
-
- Benefits from parallel execution (max 3 instances per task)
|
|
4992
|
-
|
|
4993
|
-
-----
|
|
4994
|
-
|
|
4995
|
-
## Instance Discovery (in this order)
|
|
4996
|
-
1. list_teams() → reuse existing teams first
|
|
4997
|
-
2. list_instances() → find by working_directory, description, or tags
|
|
4998
|
-
3. describe_instance() → confirm capabilities before delegating
|
|
4999
|
-
4. create_instance() → only if no suitable instance exists
|
|
5000
|
-
|
|
5001
|
-
Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
|
|
5002
|
-
|
|
5003
|
-
-----
|
|
5004
|
-
|
|
5005
|
-
## Delegation Protocol
|
|
5006
|
-
|
|
5007
|
-
Every delegation via send_to_instance() MUST include:
|
|
5008
|
-
|
|
5009
|
-
1. Task scope — what exactly to do, bounded clearly
|
|
5010
|
-
2. Expected output — what to return and in what form
|
|
5011
|
-
3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
|
|
5012
|
-
|
|
5013
|
-
### Loop Prevention
|
|
5014
|
-
|
|
5015
|
-
- Never re-delegate a task back to the instance that sent it to you
|
|
5016
|
-
- If a task has bounced 3 times, stop and solve locally or reduce scope
|
|
5017
|
-
|
|
5018
|
-
### Execution Strategy
|
|
5019
|
-
|
|
5020
|
-
Parallel — use only when tasks are independent with no shared state
|
|
5021
|
-
Sequential — use when one task's output feeds into the next
|
|
5022
|
-
|
|
5023
|
-
-----
|
|
5024
|
-
|
|
5025
|
-
## Result Handling
|
|
5026
|
-
|
|
5027
|
-
When an instance reports back, classify the outcome:
|
|
5028
|
-
|
|
5029
|
-
- Success → Summarize key results for user. Omit internal coordination noise.
|
|
5030
|
-
- Partial → State what succeeded, what remains, proposed next steps.
|
|
5031
|
-
- Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
|
|
5032
|
-
- No response → Ping again after reasonable wait. If still silent: report to user with options.
|
|
5033
|
-
|
|
5034
|
-
### Output to User
|
|
5035
|
-
|
|
5036
|
-
Every final response to the user should contain:
|
|
5037
|
-
|
|
5038
|
-
- Result — the actual answer or deliverable
|
|
5039
|
-
- Gaps — anything incomplete or unresolved (omit if none)
|
|
5040
|
-
|
|
5041
|
-
-----
|
|
5042
|
-
|
|
5043
|
-
## Shared Decisions
|
|
5044
|
-
|
|
5045
|
-
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.
|
|
5046
|
-
|
|
5047
|
-
When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
|
|
5048
|
-
|
|
5049
|
-
-----
|
|
5677
|
+
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.
|
|
5050
5678
|
|
|
5051
5679
|
## After Restart
|
|
5052
5680
|
|
|
5053
|
-
|
|
5054
|
-
1. list_instances() → rebuild fleet awareness
|
|
5055
|
-
2. list_teams() → restore team structure
|
|
5056
|
-
3. list_decisions() → reload policies and conventions
|
|
5057
|
-
|
|
5058
|
-
Only then handle incoming requests.
|
|
5059
|
-
|
|
5060
|
-
-----
|
|
5061
|
-
|
|
5062
|
-
## Development Workflow Policy
|
|
5063
|
-
|
|
5064
|
-
All code changes across the fleet should follow this workflow.
|
|
5065
|
-
The coordinator enforces compliance but does not perform these steps directly.
|
|
5066
|
-
Remind instances of this policy when delegating code tasks.
|
|
5067
|
-
|
|
5068
|
-
### Workflow Stages
|
|
5069
|
-
Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
|
|
5070
|
-
|
|
5071
|
-
### Policy Rules
|
|
5072
|
-
|
|
5073
|
-
1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
|
|
5074
|
-
2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
|
|
5075
|
-
3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
|
|
5076
|
-
4. Independent review — every merge requires code review from someone other than the author.
|
|
5077
|
-
5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
|
|
5078
|
-
6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
|
|
5079
|
-
|
|
5080
|
-
### Specialist Instance Rules
|
|
5081
|
-
|
|
5082
|
-
- Execute within defined scope only
|
|
5083
|
-
- Return structured output: result, assumptions, uncertainties, verification status
|
|
5084
|
-
- Do NOT create new instances without coordinator approval
|
|
5085
|
-
|
|
5086
|
-
-----
|
|
5087
|
-
|
|
5088
|
-
## Team Management
|
|
5089
|
-
|
|
5090
|
-
- Always check existing teams before creating new ones
|
|
5091
|
-
- Default to ephemeral teams (created for a specific task, dissolved after completion)
|
|
5092
|
-
- Clean up ephemeral teams and instances after task completion
|
|
5681
|
+
BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
|
|
5093
5682
|
|
|
5094
|
-
|
|
5683
|
+
## Playbooks (on-demand skills)
|
|
5095
5684
|
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
- **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
|
|
5101
|
-
- **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
|
|
5102
|
-
- **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
|
|
5685
|
+
Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
|
|
5686
|
+
- **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
|
|
5687
|
+
- **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
|
|
5688
|
+
Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
|
|
5103
5689
|
`;
|
|
5104
5690
|
/** Ensure the general instance has its project instructions file + knowledge */
|
|
5105
|
-
ensureGeneralInstructions(workDir, backendName) {
|
|
5691
|
+
ensureGeneralInstructions(workDir, backendName, instanceName) {
|
|
5106
5692
|
const backend = backendName ?? "claude-code";
|
|
5693
|
+
workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
|
|
5107
5694
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
5108
5695
|
const filePath = join(workDir, filename);
|
|
5109
5696
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -5111,60 +5698,238 @@ When users create specialized instances, suggest these configurations:
|
|
|
5111
5698
|
writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
|
|
5112
5699
|
this.logger.info({ filePath }, "Created general instance instructions file");
|
|
5113
5700
|
}
|
|
5114
|
-
// Sync bundled knowledge files to general's steering
|
|
5701
|
+
// Sync bundled knowledge files to general's steering and skills directories.
|
|
5115
5702
|
this.syncGeneralKnowledge(workDir, backend);
|
|
5116
5703
|
}
|
|
5117
|
-
/**
|
|
5704
|
+
/** Resolve the workspace path a backend actually uses before publishing knowledge. */
|
|
5705
|
+
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
5706
|
+
// Some backends relocate their real cwd (agy moves hidden paths to
|
|
5707
|
+
// ~/agend-workspaces/<name>). Instructions and skills must land where the
|
|
5708
|
+
// CLI actually runs, not where fleet.yaml points.
|
|
5709
|
+
try {
|
|
5710
|
+
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
5711
|
+
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
5712
|
+
if (resolved)
|
|
5713
|
+
workDir = resolved;
|
|
5714
|
+
}
|
|
5715
|
+
catch { /* unknown backend name — keep the raw path */ }
|
|
5716
|
+
return workDir;
|
|
5717
|
+
}
|
|
5718
|
+
/**
|
|
5719
|
+
* Where each backend natively loads on-demand skills from, relative to the
|
|
5720
|
+
* workspace. Backends without a native skill mechanism (opencode, grok,
|
|
5721
|
+
* antigravity, gemini-cli) are deliberately absent: dropping files a CLI
|
|
5722
|
+
* never reads is clutter, not capability. Unknown directories are ignored
|
|
5723
|
+
* by older CLI versions, so publishing is fail-open across upgrades.
|
|
5724
|
+
*/
|
|
5725
|
+
static SKILLS_DIR_SEGMENTS = {
|
|
5726
|
+
"kiro-cli": [".kiro", "skills"],
|
|
5727
|
+
"claude-code": [".claude", "skills"],
|
|
5728
|
+
"codex": [".agents", "skills"],
|
|
5729
|
+
// Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
|
|
5730
|
+
// vendor-canonical location is .grok/skills.
|
|
5731
|
+
"opencode": [".agents", "skills"],
|
|
5732
|
+
"grok": [".grok", "skills"],
|
|
5733
|
+
"antigravity": [".agents", "skills"],
|
|
5734
|
+
};
|
|
5735
|
+
/** Copy general-knowledge steering + all role-eligible skills to General. */
|
|
5118
5736
|
syncGeneralKnowledge(workDir, backend) {
|
|
5119
5737
|
const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
5120
5738
|
if (!existsSync(knowledgeDir))
|
|
5121
5739
|
return;
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5740
|
+
this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
|
|
5741
|
+
this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
|
|
5742
|
+
this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
|
|
5743
|
+
}
|
|
5744
|
+
/** Publish only the bundled skills eligible for an instance role. */
|
|
5745
|
+
syncRoleSkills(workDir, backend, role, knowledgeDir) {
|
|
5746
|
+
const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
|
|
5747
|
+
if (!skillSegments)
|
|
5748
|
+
return;
|
|
5749
|
+
const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
5750
|
+
if (!existsSync(root))
|
|
5751
|
+
return;
|
|
5752
|
+
// Before managed-skill manifests existed, only Kiro General received
|
|
5753
|
+
// bundled skills. Allow that one legacy layout to be adopted so upgrades
|
|
5754
|
+
// can keep those copies current; other backends never had unmanaged
|
|
5755
|
+
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
5756
|
+
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
5757
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
|
|
5758
|
+
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
5759
|
+
}
|
|
5760
|
+
/**
|
|
5761
|
+
* Steering (always-on rules like core-rules.md). Kiro loads a native
|
|
5762
|
+
* steering directory; every other backend gets the content embedded into
|
|
5763
|
+
* its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
|
|
5764
|
+
* block — the previous behavior dropped bare .md files in the workspace
|
|
5765
|
+
* root, which no CLI ever read. The block is replaced in place on every
|
|
5766
|
+
* sync, so rule updates reach EXISTING workspaces; everything the user
|
|
5767
|
+
* wrote outside the markers is preserved byte-for-byte.
|
|
5768
|
+
*/
|
|
5769
|
+
syncGeneralSteering(workDir, backend, srcSteering) {
|
|
5770
|
+
if (!existsSync(srcSteering))
|
|
5771
|
+
return;
|
|
5772
|
+
const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
|
|
5773
|
+
if (files.length === 0)
|
|
5774
|
+
return;
|
|
5775
|
+
if (backend === "kiro-cli") {
|
|
5776
|
+
const steeringDir = join(workDir, ".kiro", "steering");
|
|
5777
|
+
mkdirSync(steeringDir, { recursive: true });
|
|
5778
|
+
for (const file of files) {
|
|
5133
5779
|
const dest = join(steeringDir, file);
|
|
5134
|
-
const newContent = readFileSync(
|
|
5780
|
+
const newContent = readFileSync(join(srcSteering, file), "utf-8");
|
|
5135
5781
|
try {
|
|
5136
5782
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
5137
5783
|
continue;
|
|
5138
5784
|
}
|
|
5139
|
-
catch { }
|
|
5785
|
+
catch { /* rewrite */ }
|
|
5140
5786
|
writeFileSync(dest, newContent);
|
|
5141
5787
|
}
|
|
5788
|
+
return;
|
|
5142
5789
|
}
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5790
|
+
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
5791
|
+
const instructionsPath = join(workDir, filename);
|
|
5792
|
+
const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
|
|
5793
|
+
const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
|
|
5794
|
+
let existing = "";
|
|
5795
|
+
try {
|
|
5796
|
+
existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
|
|
5797
|
+
}
|
|
5798
|
+
catch { /* treat as empty */ }
|
|
5799
|
+
const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
|
|
5800
|
+
const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
|
|
5801
|
+
let next;
|
|
5802
|
+
if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
|
|
5803
|
+
next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
|
|
5804
|
+
}
|
|
5805
|
+
else {
|
|
5806
|
+
next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
|
|
5807
|
+
}
|
|
5808
|
+
if (next !== existing) {
|
|
5809
|
+
mkdirSync(dirname(instructionsPath), { recursive: true });
|
|
5810
|
+
writeFileSync(instructionsPath, next);
|
|
5811
|
+
}
|
|
5812
|
+
}
|
|
5813
|
+
static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
|
|
5814
|
+
static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
|
|
5815
|
+
/**
|
|
5816
|
+
* Publish AgEnD's bundled skills into a CLI's native skills directory,
|
|
5817
|
+
* owning ONLY what we published. A manifest records which skill names AgEnD
|
|
5818
|
+
* wrote; a bundled rename/removal deletes the stale managed copy, while a
|
|
5819
|
+
* skill the user created by hand is never listed and therefore never
|
|
5820
|
+
* touched — even if a future bundle happens to reuse its name (the sync
|
|
5821
|
+
* then skips it and logs, rather than overwrite the user's work). The sole
|
|
5822
|
+
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
5823
|
+
* selected by adoptLegacyUnmanaged.
|
|
5824
|
+
*/
|
|
5825
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
|
|
5826
|
+
if (!existsSync(srcSkills))
|
|
5827
|
+
return;
|
|
5828
|
+
mkdirSync(destSkills, { recursive: true });
|
|
5829
|
+
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
5830
|
+
const hadManifest = existsSync(manifestPath);
|
|
5831
|
+
let previouslyManaged = [];
|
|
5832
|
+
try {
|
|
5833
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
5834
|
+
if (Array.isArray(parsed))
|
|
5835
|
+
previouslyManaged = parsed.filter(n => typeof n === "string");
|
|
5836
|
+
}
|
|
5837
|
+
catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
|
|
5838
|
+
const bundled = readdirSync(srcSkills)
|
|
5839
|
+
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
5840
|
+
.sort();
|
|
5841
|
+
// Pre-manifest Kiro General copied bundled skills directly into
|
|
5842
|
+
// .kiro/skills. If every existing skill is still a bundled name, this is
|
|
5843
|
+
// the unambiguous legacy layout: adopt it once, update it below, and write
|
|
5844
|
+
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
5845
|
+
// on the user-owned/collision path.
|
|
5846
|
+
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
5847
|
+
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
5848
|
+
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
5849
|
+
.map(entry => entry.name)
|
|
5850
|
+
.sort();
|
|
5851
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
5852
|
+
previouslyManaged = existing;
|
|
5853
|
+
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5856
|
+
const eligible = bundled.filter(name => {
|
|
5857
|
+
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
5858
|
+
// General is the coordinator and receives both coordinator and worker
|
|
5859
|
+
// playbooks. Workers receive only skills explicitly marked for workers.
|
|
5860
|
+
return role === "general" || roles.includes("worker");
|
|
5861
|
+
});
|
|
5862
|
+
const managed = [];
|
|
5863
|
+
for (const name of eligible) {
|
|
5864
|
+
const destDir = join(destSkills, name);
|
|
5865
|
+
const dest = join(destDir, "SKILL.md");
|
|
5866
|
+
const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
|
|
5867
|
+
if (!isOurs) {
|
|
5868
|
+
// Name collision with a user-authored skill: theirs wins, loudly.
|
|
5869
|
+
this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
|
|
5870
|
+
continue;
|
|
5871
|
+
}
|
|
5872
|
+
managed.push(name);
|
|
5873
|
+
const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
|
|
5874
|
+
try {
|
|
5875
|
+
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
5876
|
+
continue;
|
|
5165
5877
|
}
|
|
5878
|
+
catch { /* rewrite */ }
|
|
5879
|
+
mkdirSync(destDir, { recursive: true });
|
|
5880
|
+
writeFileSync(dest, newContent);
|
|
5881
|
+
}
|
|
5882
|
+
// Remove managed skills that are no longer bundled OR no longer eligible
|
|
5883
|
+
// for this role. This makes a shared → general-only metadata change take
|
|
5884
|
+
// effect on the next worker startup instead of leaving stale capability.
|
|
5885
|
+
for (const stale of previouslyManaged) {
|
|
5886
|
+
if (eligible.includes(stale))
|
|
5887
|
+
continue;
|
|
5888
|
+
try {
|
|
5889
|
+
rmSync(join(destSkills, stale), { recursive: true, force: true });
|
|
5890
|
+
this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
|
|
5891
|
+
}
|
|
5892
|
+
catch (err) {
|
|
5893
|
+
this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5896
|
+
try {
|
|
5897
|
+
writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
|
|
5898
|
+
}
|
|
5899
|
+
catch (err) {
|
|
5900
|
+
this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
|
|
5901
|
+
}
|
|
5902
|
+
}
|
|
5903
|
+
/** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
|
|
5904
|
+
readManagedSkillRoles(skillPath) {
|
|
5905
|
+
const fallback = ["general"];
|
|
5906
|
+
try {
|
|
5907
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
5908
|
+
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
5909
|
+
if (!match)
|
|
5910
|
+
return fallback;
|
|
5911
|
+
const document = parseDocument(match[1]);
|
|
5912
|
+
if (document.errors.length > 0)
|
|
5913
|
+
throw document.errors[0];
|
|
5914
|
+
const frontmatter = document.toJS();
|
|
5915
|
+
if (!frontmatter || frontmatter.roles === undefined)
|
|
5916
|
+
return fallback;
|
|
5917
|
+
if (!Array.isArray(frontmatter.roles))
|
|
5918
|
+
throw new Error("roles must be an array");
|
|
5919
|
+
const roles = [...new Set(frontmatter.roles)]
|
|
5920
|
+
.filter((value) => value === "general" || value === "worker");
|
|
5921
|
+
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
5922
|
+
throw new Error("roles must contain only general and/or worker");
|
|
5923
|
+
}
|
|
5924
|
+
return roles;
|
|
5925
|
+
}
|
|
5926
|
+
catch (err) {
|
|
5927
|
+
// Fail closed for workers: malformed or unknown metadata keeps the
|
|
5928
|
+
// backwards-compatible General-only behavior instead of leaking an
|
|
5929
|
+
// administrative skill into worker workspaces.
|
|
5930
|
+
this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
|
|
5931
|
+
return fallback;
|
|
5166
5932
|
}
|
|
5167
|
-
this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
|
|
5168
5933
|
}
|
|
5169
5934
|
/** Fetch forum topic icon stickers and pick emoji IDs for each state */
|
|
5170
5935
|
async resolveTopicIcons() {
|
|
@@ -6077,13 +6842,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6077
6842
|
}
|
|
6078
6843
|
catch { /* default restart */ }
|
|
6079
6844
|
const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
|
|
6080
|
-
if (strategy === "runtime") {
|
|
6081
|
-
|
|
6082
|
-
return `${warn}❌ ${instanceName} is not running.`;
|
|
6083
|
-
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6084
|
-
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
6845
|
+
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
6846
|
+
return `${warn}❌ ${instanceName} is not running.`;
|
|
6085
6847
|
}
|
|
6086
|
-
//
|
|
6848
|
+
// Persist either way: a runtime switch must survive the next respawn too,
|
|
6849
|
+
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
6087
6850
|
let persisted = false;
|
|
6088
6851
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
6089
6852
|
this.fleetConfig.instances[instanceName].model = model;
|
|
@@ -6095,6 +6858,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6095
6858
|
}
|
|
6096
6859
|
if (!persisted)
|
|
6097
6860
|
return `${warn}❌ Could not set model for ${instanceName}.`;
|
|
6861
|
+
if (strategy === "runtime") {
|
|
6862
|
+
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6863
|
+
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
6864
|
+
}
|
|
6098
6865
|
await this.restartSingleInstance(instanceName);
|
|
6099
6866
|
return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
|
|
6100
6867
|
}
|
|
@@ -6432,6 +7199,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6432
7199
|
for (const pending of this.pendingClassicStarts.values())
|
|
6433
7200
|
clearTimeout(pending.timer);
|
|
6434
7201
|
this.pendingClassicStarts.clear();
|
|
7202
|
+
for (const pending of this.pendingNonceButtons.values()) {
|
|
7203
|
+
if (pending.timer)
|
|
7204
|
+
clearTimeout(pending.timer);
|
|
7205
|
+
}
|
|
7206
|
+
this.pendingNonceButtons.clear();
|
|
6435
7207
|
this.topicArchiver.stop();
|
|
6436
7208
|
this.scheduler?.shutdown();
|
|
6437
7209
|
// Stop instances in parallel batches to avoid long sequential waits.
|