@songsid/agend 2.1.8-beta.1 → 2.1.8-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/dist/agent-cli.js +14 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +10 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/claude-code.d.ts +35 -5
- package/dist/backend/claude-code.js +288 -17
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/cli-env-probe-worker.d.ts +1 -0
- package/dist/backend/cli-env-probe-worker.js +25 -0
- package/dist/backend/cli-env-probe-worker.js.map +1 -0
- package/dist/backend/codex-session-lookup.d.ts +16 -0
- package/dist/backend/codex-session-lookup.js +76 -2
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -4
- package/dist/backend/codex.js +44 -6
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +9 -0
- package/dist/backend/credential-profile.js +20 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/grok.js +19 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/muse.d.ts +6 -0
- package/dist/backend/muse.js +37 -3
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +13 -2
- package/dist/backend/types.js +14 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +16 -4
- package/dist/channel/adapters/discord.js +20 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +6 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +138 -48
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +11 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +7 -0
- package/dist/cross-instance-envelope.js +19 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +130 -6
- package/dist/daemon.js +486 -69
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +9 -1
- package/dist/delivery-outbox.js +25 -4
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +140 -18
- package/dist/fleet-manager.js +733 -106
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +160 -0
- package/dist/forged-envelope-scan.js +312 -0
- package/dist/forged-envelope-scan.js.map +1 -0
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +52 -0
- package/dist/instance-lifecycle.d.ts +81 -6
- package/dist/instance-lifecycle.js +201 -17
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +34 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +11 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +18 -1
- package/dist/login-flows.js +9 -2
- package/dist/login-flows.js.map +1 -1
- package/dist/ls-rows.d.ts +17 -0
- package/dist/ls-rows.js +73 -0
- package/dist/ls-rows.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +3 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +21 -4
- package/dist/outbound-schemas.js +12 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.js +4 -2
- package/dist/paths.js.map +1 -1
- package/dist/pause-marker.d.ts +10 -1
- package/dist/pause-marker.js +26 -2
- package/dist/pause-marker.js.map +1 -1
- package/dist/service-installer.d.ts +8 -0
- package/dist/service-installer.js +36 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +8 -8
- package/dist/settings-api.js +12 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/status-emojis.d.ts +19 -0
- package/dist/status-emojis.js.map +1 -1
- package/dist/tool-permissions.js +8 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +9 -0
- package/dist/topic-commands.js +64 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +3 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +63 -0
- package/dist/transcript-sources.js +171 -27
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/settings.html +23 -11
- package/dist/wake-coordinator.d.ts +143 -0
- package/dist/wake-coordinator.js +321 -0
- package/dist/wake-coordinator.js.map +1 -0
- package/dist/web-api.d.ts +3 -1
- package/dist/web-api.js +1 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-terminal.d.ts +2 -0
- package/dist/web-terminal.js +3 -6
- package/dist/web-terminal.js.map +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +7 -0
package/dist/fleet-manager.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { freemem, totalmem, cpus } from "node:os";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
|
-
import {
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
9
10
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
@@ -16,7 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
16
17
|
const __dirname = dirname(__filename);
|
|
17
18
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
18
19
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
19
|
-
import { STATUS_EMOJI_KEYS, builtinStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
20
|
+
import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
20
21
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
21
22
|
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
22
23
|
import { EventLog } from "./event-log.js";
|
|
@@ -28,7 +29,7 @@ import { IpcClient } from "./channel/ipc-bridge.js";
|
|
|
28
29
|
import { createAdapter } from "./channel/factory.js";
|
|
29
30
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
30
31
|
import { createBackend } from "./backend/factory.js";
|
|
31
|
-
import { isModelCompatible } from "./backend/types.js";
|
|
32
|
+
import { isModelCompatible, SYSINFO_BACKEND_IDS } from "./backend/types.js";
|
|
32
33
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
33
34
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
34
35
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -40,7 +41,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
|
|
|
40
41
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
41
42
|
import { safeHandler } from "./safe-async.js";
|
|
42
43
|
import { RoutingEngine } from "./routing-engine.js";
|
|
43
|
-
import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
44
|
+
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
44
45
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
45
46
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
46
47
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
@@ -65,7 +66,9 @@ import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBac
|
|
|
65
66
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
66
67
|
import { validateFleetConfig } from "./config-validator.js";
|
|
67
68
|
import { readLastInboundAt } from "./daemon.js";
|
|
68
|
-
import { clearPausedMarker } from "./pause-marker.js";
|
|
69
|
+
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
70
|
+
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
71
|
+
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
69
72
|
import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
|
|
70
73
|
import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
71
74
|
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
@@ -285,6 +288,8 @@ export class FleetManager {
|
|
|
285
288
|
managerBootId = randomUUID();
|
|
286
289
|
deliveryOutbox = null;
|
|
287
290
|
deliveryOutboxRecovered = false;
|
|
291
|
+
/** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
|
|
292
|
+
wakeCoordinator = null;
|
|
288
293
|
deliveryPumpScheduled = false;
|
|
289
294
|
deliveryPumpRunning = false;
|
|
290
295
|
deliveryPumpTimer = null;
|
|
@@ -569,9 +574,15 @@ export class FleetManager {
|
|
|
569
574
|
this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
|
|
570
575
|
this.deliveryOutboxRecovered = true;
|
|
571
576
|
}
|
|
572
|
-
outbox.on("admitted", () => this.scheduleDeliveryOutboxPump());
|
|
577
|
+
outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
|
|
573
578
|
outbox.on("state", (event) => {
|
|
574
579
|
this.scheduleDeliveryOutboxPump();
|
|
580
|
+
this.wakeCoordinator?.kick();
|
|
581
|
+
if (event.deliveryId && event.state === "delivered") {
|
|
582
|
+
const target = outbox.get(event.deliveryId)?.targetInstance;
|
|
583
|
+
if (target)
|
|
584
|
+
this.wakeCoordinator?.noteDelivered(target);
|
|
585
|
+
}
|
|
575
586
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
576
587
|
const row = outbox.get(event.deliveryId);
|
|
577
588
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
@@ -590,8 +601,11 @@ export class FleetManager {
|
|
|
590
601
|
outbox.on("content_digest_mismatch", (event) => {
|
|
591
602
|
this.logger.warn(event, "Delivered text differs from the admitted text (content digest mismatch)");
|
|
592
603
|
});
|
|
604
|
+
this.wakeCoordinator = this.createWakeCoordinator();
|
|
605
|
+
this.wakeCoordinator.start();
|
|
593
606
|
outbox.on("expired", (event) => {
|
|
594
607
|
this.scheduleDeliveryOutboxPump();
|
|
608
|
+
this.wakeCoordinator?.kick();
|
|
595
609
|
if (event.count)
|
|
596
610
|
this.notifyFleetError(`${event.count} durable delivery row(s) reached the 24-hour outbox bound; ${event.uncertain ?? 0} unresolved submissions are uncertain and sender notices were queued.`);
|
|
597
611
|
});
|
|
@@ -618,6 +632,9 @@ export class FleetManager {
|
|
|
618
632
|
kind: input.kind,
|
|
619
633
|
}, "Durable cross-instance delivery accepted");
|
|
620
634
|
this.scheduleDeliveryOutboxPump();
|
|
635
|
+
// Phase 2b: a paused wake_only target is woken by the coordinator, never by
|
|
636
|
+
// the pump; admission must tell it, or the work waits for the watchdog.
|
|
637
|
+
this.wakeCoordinator?.kick();
|
|
621
638
|
return {
|
|
622
639
|
deliveryId: admitted.delivery.deliveryId,
|
|
623
640
|
state: admitted.delivery.state,
|
|
@@ -680,7 +697,7 @@ export class FleetManager {
|
|
|
680
697
|
}
|
|
681
698
|
}
|
|
682
699
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
683
|
-
if (!this.deliveryOutbox || this.shuttingDown)
|
|
700
|
+
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
684
701
|
return;
|
|
685
702
|
if (this.deliveryPumpTimer) {
|
|
686
703
|
if (delayMs > 0)
|
|
@@ -700,14 +717,25 @@ export class FleetManager {
|
|
|
700
717
|
}
|
|
701
718
|
async runDeliveryOutboxPump() {
|
|
702
719
|
const outbox = this.deliveryOutbox;
|
|
703
|
-
|
|
720
|
+
// A closed database is never queried: this runs from timers, where a throw
|
|
721
|
+
// is an unhandled rejection. Checking here covers the finally block too:
|
|
722
|
+
// the body below never awaits, so the database cannot close in between.
|
|
723
|
+
if (!outbox || this.shuttingDown || this.deliveryPumpRunning || !outbox.isOpen)
|
|
704
724
|
return;
|
|
705
725
|
this.deliveryPumpRunning = true;
|
|
706
726
|
this.deliveryPumpScheduled = false;
|
|
707
727
|
try {
|
|
708
|
-
outbox.expireStale(
|
|
728
|
+
outbox.expireStale(undefined, undefined, target => {
|
|
729
|
+
const failure = this.wakeCoordinator?.wakeFailure(target);
|
|
730
|
+
return failure ? `target could not be woken (${failure})` : undefined;
|
|
731
|
+
});
|
|
709
732
|
while (this.activeDurableTargets.size < 8) {
|
|
710
|
-
const claimed = outbox.claimNext(this.managerBootId,
|
|
733
|
+
const claimed = outbox.claimNext(this.managerBootId,
|
|
734
|
+
// Phase 2b: a wake_only target is claimed only once it is awake; the
|
|
735
|
+
// wake coordinator, not this pump, wakes it (no claim, no attempt).
|
|
736
|
+
target => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
|
|
737
|
+
? null
|
|
738
|
+
: this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
|
|
711
739
|
if (!claimed)
|
|
712
740
|
break;
|
|
713
741
|
this.activeDurableTargets.add(claimed.targetInstance);
|
|
@@ -761,7 +789,7 @@ export class FleetManager {
|
|
|
761
789
|
content: payload.content,
|
|
762
790
|
delivery_id: claimed.deliveryId,
|
|
763
791
|
delivery_attempt: String(claimed.attemptNo),
|
|
764
|
-
}, { waitForIdle: false });
|
|
792
|
+
}, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
|
|
765
793
|
}
|
|
766
794
|
else {
|
|
767
795
|
payload.meta = {
|
|
@@ -776,6 +804,7 @@ export class FleetManager {
|
|
|
776
804
|
sent = await this.deliverToInstance(target, payload, {
|
|
777
805
|
isCrossInstance: true,
|
|
778
806
|
waitForIdle: claimed.kind !== "steer",
|
|
807
|
+
noInlineWake: this.deliveryWorkerMode(target) !== "off",
|
|
779
808
|
});
|
|
780
809
|
}
|
|
781
810
|
if (!sent) {
|
|
@@ -1687,13 +1716,15 @@ export class FleetManager {
|
|
|
1687
1716
|
exclude,
|
|
1688
1717
|
isEvicting: name => this.warmCapEvicting.has(name),
|
|
1689
1718
|
isGeneral: name => isGeneralInstance(this.fleetConfig, name),
|
|
1690
|
-
|
|
1719
|
+
// A work lease (Phase 2a) keeps a just-woken or mid-submission instance
|
|
1720
|
+
// out of the eviction candidates; it is not idle in the sense that matters.
|
|
1721
|
+
isIdle: name => this.getInstanceExecutionState(name) === "idle" && !this.lifecycle.hasWorkLease(name),
|
|
1691
1722
|
lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
|
|
1692
1723
|
});
|
|
1693
1724
|
for (const victim of victims) {
|
|
1694
1725
|
this.warmCapEvicting.add(victim);
|
|
1695
1726
|
this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
|
|
1696
|
-
this.lifecycle.pause(victim)
|
|
1727
|
+
this.lifecycle.pause(victim, "warm_cap")
|
|
1697
1728
|
.catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
|
|
1698
1729
|
.finally(() => this.warmCapEvicting.delete(victim));
|
|
1699
1730
|
}
|
|
@@ -1729,7 +1760,10 @@ export class FleetManager {
|
|
|
1729
1760
|
const query = () => {
|
|
1730
1761
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
1731
1762
|
if (ipc?.connected) {
|
|
1732
|
-
|
|
1763
|
+
// The daemon owns the backend/TTY proof and its 10s startup-input
|
|
1764
|
+
// timer. A cache-only query can never discover a Context-less idle
|
|
1765
|
+
// pane, and a second fleet timer would stack another 10s wait.
|
|
1766
|
+
ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}`, deliveryIdle: true });
|
|
1733
1767
|
}
|
|
1734
1768
|
check();
|
|
1735
1769
|
};
|
|
@@ -1744,8 +1778,8 @@ export class FleetManager {
|
|
|
1744
1778
|
/**
|
|
1745
1779
|
* Ask the daemon to capture the pane before answering, then wait for any state
|
|
1746
1780
|
* report produced after this request. The ordinary query is intentionally
|
|
1747
|
-
* cache-only
|
|
1748
|
-
*
|
|
1781
|
+
* cache-only; delivery idle gates opt into their own pane/input proof, and
|
|
1782
|
+
* this refresh serves lifecycle decisions where a stale state would strand UI.
|
|
1749
1783
|
*/
|
|
1750
1784
|
refreshInstanceExecutionState(instanceName, timeoutMs) {
|
|
1751
1785
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -1783,16 +1817,22 @@ export class FleetManager {
|
|
|
1783
1817
|
finish(false);
|
|
1784
1818
|
});
|
|
1785
1819
|
}
|
|
1786
|
-
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
|
|
1820
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
|
|
1787
1821
|
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1788
1822
|
return false;
|
|
1789
1823
|
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
1790
1824
|
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1791
1825
|
return false;
|
|
1792
1826
|
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
1827
|
+
if (this.lifecycle.isPaused(instanceName) && noInlineWake) {
|
|
1828
|
+
// Paused after its row was claimed (an operator or auth pause): hand the
|
|
1829
|
+
// row back; the coordinator decides whether and when to wake it.
|
|
1830
|
+
this.wakeCoordinator?.kick();
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1793
1833
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
1794
1834
|
const wakeStartedAt = Date.now();
|
|
1795
|
-
await this.
|
|
1835
|
+
await this.explicitWake(instanceName, 30_000);
|
|
1796
1836
|
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1797
1837
|
return false;
|
|
1798
1838
|
// Waking added one to the warm count — make room by evicting a different
|
|
@@ -1915,7 +1955,11 @@ export class FleetManager {
|
|
|
1915
1955
|
?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
|
|
1916
1956
|
if (!waitForIdle) {
|
|
1917
1957
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
1918
|
-
|
|
1958
|
+
if (options.noInlineWake) {
|
|
1959
|
+
this.wakeCoordinator?.kick();
|
|
1960
|
+
return false;
|
|
1961
|
+
}
|
|
1962
|
+
await this.explicitWake(instanceName, 30_000);
|
|
1919
1963
|
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1920
1964
|
return false;
|
|
1921
1965
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
@@ -1931,7 +1975,7 @@ export class FleetManager {
|
|
|
1931
1975
|
return true;
|
|
1932
1976
|
}
|
|
1933
1977
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
1934
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
|
|
1978
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true));
|
|
1935
1979
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
1936
1980
|
try {
|
|
1937
1981
|
return await delivery;
|
|
@@ -1947,9 +1991,117 @@ export class FleetManager {
|
|
|
1947
1991
|
const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
|
|
1948
1992
|
return allowed.some(entry => String(entry) === String(userId));
|
|
1949
1993
|
}
|
|
1994
|
+
/** Phase 2: delivery_worker for a target (instance override → fleet default → off). */
|
|
1995
|
+
deliveryWorkerMode(target) {
|
|
1996
|
+
return resolveDeliveryWorkerMode(this.fleetConfig, target);
|
|
1997
|
+
}
|
|
1998
|
+
/** Active instances mid-restart: they keep their warm slot through the stop → replacement gap. */
|
|
1999
|
+
restartsHoldingSlot = new Set();
|
|
2000
|
+
/**
|
|
2001
|
+
* Instances counted against the wake coordinator's hard cap: running
|
|
2002
|
+
* daemons, plus active instances whose restart is between stopping the old
|
|
2003
|
+
* daemon and publishing the replacement.
|
|
2004
|
+
*/
|
|
2005
|
+
warmInstanceNames() {
|
|
2006
|
+
const names = new Set([...this.daemons.keys()].filter(name => this.getInstanceStatus(name) === "running"));
|
|
2007
|
+
for (const name of this.restartsHoldingSlot)
|
|
2008
|
+
names.add(name);
|
|
2009
|
+
return [...names];
|
|
2010
|
+
}
|
|
2011
|
+
createWakeCoordinator() {
|
|
2012
|
+
return new WakeCoordinator({
|
|
2013
|
+
mode: target => this.deliveryWorkerMode(target),
|
|
2014
|
+
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
2015
|
+
listPending: () => this.deliveryOutbox?.listPending() ?? [],
|
|
2016
|
+
isPaused: target => this.lifecycle.isPaused(target),
|
|
2017
|
+
pauseReason: target => readPauseReason(this.getInstanceDir(target)),
|
|
2018
|
+
isRestarting: target => this.isInstanceRestarting(target),
|
|
2019
|
+
wake: async (target) => {
|
|
2020
|
+
await this.lifecycle.wake(target, 30_000, undefined, { source: "coordinator" });
|
|
2021
|
+
// The soft cap still applies: evict a different, unleased idle instance.
|
|
2022
|
+
this.enforceWarmCap(target);
|
|
2023
|
+
},
|
|
2024
|
+
residentNames: () => this.warmInstanceNames(),
|
|
2025
|
+
warmCap: () => this.fleetConfig?.defaults?.warm_cap ?? 0,
|
|
2026
|
+
warmOverflow: () => this.fleetConfig?.defaults?.warm_overflow ?? DEFAULT_WARM_OVERFLOW,
|
|
2027
|
+
wokeAt: target => this.lifecycle.wokeAtFor(target),
|
|
2028
|
+
notAcceptingReason: target => this.daemons.get(target)?.notAcceptingReason?.() ?? null,
|
|
2029
|
+
notifyTarget: (target, text) => { this.notifyInstanceTopic(target, text); },
|
|
2030
|
+
notifySender: (source, text) => { this.notifyInstanceTopic(source, text); },
|
|
2031
|
+
kickPump: () => this.scheduleDeliveryOutboxPump(),
|
|
2032
|
+
now: () => Date.now(),
|
|
2033
|
+
logger: this.logger,
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
/** LifecycleContext: an operator or user wake clears the coordinator's park/backoff for it. */
|
|
2037
|
+
onExternalWake(name) {
|
|
2038
|
+
this.wakeCoordinator?.noteExternalWake(name);
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Phase 2b shared warm-slot admission (design §1.3, §1.6) for every wake
|
|
2042
|
+
* that is not the coordinator's own: an operator's wake, a user's message,
|
|
2043
|
+
* an explicit start or a restart of a paused instance. With delivery_worker
|
|
2044
|
+
* off, or for a target that is not paused (an active restart is already
|
|
2045
|
+
* counted), nothing is reserved. A target already being woken is joined —
|
|
2046
|
+
* its flight holds the slot — rather than reserved twice. Otherwise a slot
|
|
2047
|
+
* is taken under the hard cap, an unleased idle instance is paused to make
|
|
2048
|
+
* one, and if none can be, the wake is refused instead of exceeding the cap.
|
|
2049
|
+
*/
|
|
2050
|
+
async acquireWakeSlot(name) {
|
|
2051
|
+
const coordinator = this.wakeCoordinator;
|
|
2052
|
+
if (!coordinator || this.deliveryWorkerMode(name) === "off" || !this.lifecycle.isPaused(name))
|
|
2053
|
+
return null;
|
|
2054
|
+
if (coordinator.isWaking(name))
|
|
2055
|
+
return null;
|
|
2056
|
+
let token = coordinator.tryReserve(name);
|
|
2057
|
+
if (!token) {
|
|
2058
|
+
const victim = this.lruUnleasedIdle(name);
|
|
2059
|
+
if (victim) {
|
|
2060
|
+
await this.lifecycle.pause(victim, "warm_cap");
|
|
2061
|
+
if (coordinator.isWaking(name))
|
|
2062
|
+
return null;
|
|
2063
|
+
token = coordinator.tryReserve(name);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
if (!token) {
|
|
2067
|
+
throw new Error(`No warm slot is free for '${name}' (warm_cap + overflow reached and no idle instance can be paused); try again when one goes idle`);
|
|
2068
|
+
}
|
|
2069
|
+
return token;
|
|
2070
|
+
}
|
|
2071
|
+
releaseWakeSlot(name, token) {
|
|
2072
|
+
if (!token || !this.wakeCoordinator)
|
|
2073
|
+
return;
|
|
2074
|
+
this.wakeCoordinator.release(name, token);
|
|
2075
|
+
this.wakeCoordinator.kick();
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* An operator's or user's wake (/wake, wake_instance, start_instance on a
|
|
2079
|
+
* paused instance, Settings, a channel message). With delivery_worker off it
|
|
2080
|
+
* is exactly the old lifecycle wake; otherwise it goes through the shared
|
|
2081
|
+
* slot admission and joins any wake already in flight for the target.
|
|
2082
|
+
*/
|
|
2083
|
+
async explicitWake(name, timeoutMs = 30_000) {
|
|
2084
|
+
const token = await this.acquireWakeSlot(name);
|
|
2085
|
+
try {
|
|
2086
|
+
await this.lifecycle.wake(name, timeoutMs);
|
|
2087
|
+
}
|
|
2088
|
+
finally {
|
|
2089
|
+
this.releaseWakeSlot(name, token);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
/** The least-recently-active idle, unleased, non-general running instance other than `exclude`. */
|
|
2093
|
+
lruUnleasedIdle(exclude) {
|
|
2094
|
+
return this.warmInstanceNames()
|
|
2095
|
+
.filter(name => name !== exclude
|
|
2096
|
+
&& !this.warmCapEvicting.has(name)
|
|
2097
|
+
&& !isGeneralInstance(this.fleetConfig, name)
|
|
2098
|
+
&& this.getInstanceExecutionState(name) === "idle"
|
|
2099
|
+
&& !this.lifecycle.hasWorkLease(name))
|
|
2100
|
+
.sort((a, b) => (readLastInboundAt(this.getInstanceDir(a)) ?? 0) - (readLastInboundAt(this.getInstanceDir(b)) ?? 0))[0];
|
|
2101
|
+
}
|
|
1950
2102
|
async changeInstancePauseState(name, action) {
|
|
1951
2103
|
if (action === "wake") {
|
|
1952
|
-
await this.
|
|
2104
|
+
await this.explicitWake(name, 30_000);
|
|
1953
2105
|
this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
|
|
1954
2106
|
return "awake";
|
|
1955
2107
|
}
|
|
@@ -2068,12 +2220,14 @@ export class FleetManager {
|
|
|
2068
2220
|
* and reconcile calls leave this false so a persisted pause remains paused
|
|
2069
2221
|
* across a fleet restart.
|
|
2070
2222
|
*/
|
|
2071
|
-
resumePaused = false
|
|
2223
|
+
resumePaused = false,
|
|
2224
|
+
/** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
|
|
2225
|
+
transition) {
|
|
2072
2226
|
// Any start supersedes a pending automatic retry (it would otherwise fire
|
|
2073
2227
|
// into a running instance — harmless, but noisy — or race this start).
|
|
2074
2228
|
this.cancelStartupRetry(name);
|
|
2075
2229
|
if (resumePaused && this.lifecycle.isPaused(name)) {
|
|
2076
|
-
await this.
|
|
2230
|
+
await this.explicitWake(name, 30_000);
|
|
2077
2231
|
// A successful wake clears the persisted pause marker and produces a
|
|
2078
2232
|
// fresh instance_state snapshot. Drop any stale process error left by a
|
|
2079
2233
|
// pre-pause crash so /api/fleet and `agend ls` converge on running.
|
|
@@ -2100,6 +2254,10 @@ export class FleetManager {
|
|
|
2100
2254
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
2101
2255
|
return;
|
|
2102
2256
|
}
|
|
2257
|
+
// An explicit start (CLI/API) begins a fresh idle window; boot and
|
|
2258
|
+
// reconcile starts keep the persisted last-inbound seed (Phase 2a).
|
|
2259
|
+
if (resumePaused)
|
|
2260
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
2103
2261
|
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
2104
2262
|
if (config.general_topic) {
|
|
2105
2263
|
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
@@ -2123,7 +2281,7 @@ export class FleetManager {
|
|
|
2123
2281
|
kind,
|
|
2124
2282
|
backend,
|
|
2125
2283
|
model: this.resolveInstanceModel(name).display,
|
|
2126
|
-
});
|
|
2284
|
+
}, transition);
|
|
2127
2285
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
2128
2286
|
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
2129
2287
|
// a falsely running instance when lifecycle.start() returns early.
|
|
@@ -2137,18 +2295,18 @@ export class FleetManager {
|
|
|
2137
2295
|
this.requestDiscordUsagePresenceRefresh();
|
|
2138
2296
|
}
|
|
2139
2297
|
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
2140
|
-
async startPersistedPausedInstance(name) {
|
|
2298
|
+
async startPersistedPausedInstance(name, transition) {
|
|
2141
2299
|
const topicMode = this.fleetConfig?.channel?.mode === "topic"
|
|
2142
2300
|
|| !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
|
|
2143
2301
|
const fleetConfig = this.fleetConfig?.instances[name];
|
|
2144
2302
|
if (fleetConfig) {
|
|
2145
|
-
await this.startInstance(name, fleetConfig, topicMode);
|
|
2303
|
+
await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
|
|
2146
2304
|
return;
|
|
2147
2305
|
}
|
|
2148
2306
|
const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
|
|
2149
2307
|
if (!channel || !this.classicChannels)
|
|
2150
2308
|
throw new Error(`Paused instance '${name}' is no longer configured`);
|
|
2151
|
-
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
2309
|
+
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition);
|
|
2152
2310
|
}
|
|
2153
2311
|
/**
|
|
2154
2312
|
* Start instances with configurable concurrency and stagger delay.
|
|
@@ -2197,6 +2355,10 @@ export class FleetManager {
|
|
|
2197
2355
|
return this.daemons.has(name);
|
|
2198
2356
|
}
|
|
2199
2357
|
catch (err) {
|
|
2358
|
+
// Superseded by a stop/restart (Phase 2a): that transition owns the
|
|
2359
|
+
// outcome. Retrying would start an instance the operator just stopped.
|
|
2360
|
+
if (err instanceof SupersededStartError)
|
|
2361
|
+
return false;
|
|
2200
2362
|
this.logger.error({ err, name }, `Failed to start ${what}`);
|
|
2201
2363
|
this.scheduleStartupRetry(name, 0);
|
|
2202
2364
|
return false;
|
|
@@ -2318,6 +2480,9 @@ export class FleetManager {
|
|
|
2318
2480
|
}
|
|
2319
2481
|
}
|
|
2320
2482
|
catch (err) {
|
|
2483
|
+
// Superseded by a stop/restart: that transition owns the outcome.
|
|
2484
|
+
if (err instanceof SupersededStartError)
|
|
2485
|
+
return;
|
|
2321
2486
|
this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
|
|
2322
2487
|
this.scheduleStartupRetry(name, attempt + 1);
|
|
2323
2488
|
}
|
|
@@ -2358,6 +2523,8 @@ export class FleetManager {
|
|
|
2358
2523
|
return this.daemons.has(ch.instanceName);
|
|
2359
2524
|
}
|
|
2360
2525
|
catch (err) {
|
|
2526
|
+
if (err instanceof SupersededStartError)
|
|
2527
|
+
return false;
|
|
2361
2528
|
this.logger.warn({ err, instanceName: ch.instanceName }, `Failed to start ${what}`);
|
|
2362
2529
|
this.scheduleStartupRetry(ch.instanceName, 0);
|
|
2363
2530
|
return false;
|
|
@@ -2514,7 +2681,7 @@ export class FleetManager {
|
|
|
2514
2681
|
}
|
|
2515
2682
|
return false;
|
|
2516
2683
|
}
|
|
2517
|
-
async stopInstance(name) {
|
|
2684
|
+
async stopInstance(name, transition) {
|
|
2518
2685
|
this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
|
|
2519
2686
|
this.cancelStartupRetry(name);
|
|
2520
2687
|
this.failoverActive.delete(name);
|
|
@@ -2533,7 +2700,7 @@ export class FleetManager {
|
|
|
2533
2700
|
// the hand-off snapshotted it).
|
|
2534
2701
|
const run = (async () => {
|
|
2535
2702
|
try {
|
|
2536
|
-
await this.lifecycle.stop(name);
|
|
2703
|
+
await this.lifecycle.stop(name, transition);
|
|
2537
2704
|
}
|
|
2538
2705
|
finally {
|
|
2539
2706
|
this.clearNoncePromptsForInstance(name);
|
|
@@ -2555,11 +2722,16 @@ export class FleetManager {
|
|
|
2555
2722
|
return inFlight;
|
|
2556
2723
|
}
|
|
2557
2724
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
2558
|
-
|
|
2725
|
+
// Phase 2a: invalidate synchronously so a start still spawning does not
|
|
2726
|
+
// publish, then queue behind it as this instance's next transition. The
|
|
2727
|
+
// spawn gate is taken inside the transition, never the other way round, so
|
|
2728
|
+
// the start being waited for can still get its own gate slot.
|
|
2729
|
+
this.lifecycle.invalidate(name);
|
|
2730
|
+
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
2559
2731
|
instanceName: name,
|
|
2560
2732
|
workingDirectory,
|
|
2561
2733
|
reason: "restart",
|
|
2562
|
-
}, () => this.doRestartSingleInstance(name, opts))
|
|
2734
|
+
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
2563
2735
|
.finally(() => {
|
|
2564
2736
|
this.restartsInFlight.delete(name);
|
|
2565
2737
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -2570,19 +2742,73 @@ export class FleetManager {
|
|
|
2570
2742
|
isInstanceRestarting(name) {
|
|
2571
2743
|
return this.restartsInFlight.has(name);
|
|
2572
2744
|
}
|
|
2573
|
-
|
|
2745
|
+
/** InstanceLifecycleContext: an explicit wake joins the restart in flight. */
|
|
2746
|
+
restartInFlight(name) {
|
|
2747
|
+
return this.restartsInFlight.get(name);
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* Phase 2a: restarting a paused instance is a wake. The stop leaves the
|
|
2751
|
+
* pause marker in place, and the start used to skip on it ("Persisted
|
|
2752
|
+
* paused instance"), turning a resident pause into a marker-only one that
|
|
2753
|
+
* nothing would wake. Clear it before the start; if the start fails, put it
|
|
2754
|
+
* back so the instance stays wakeable instead of becoming plain stopped.
|
|
2755
|
+
*/
|
|
2756
|
+
async startAfterRestart(name, wasPaused, pausedAt, reason, start) {
|
|
2757
|
+
const instanceDir = this.getInstanceDir(name);
|
|
2758
|
+
if (wasPaused)
|
|
2759
|
+
clearPausedMarker(instanceDir);
|
|
2760
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
2761
|
+
try {
|
|
2762
|
+
await start();
|
|
2763
|
+
}
|
|
2764
|
+
catch (err) {
|
|
2765
|
+
if (wasPaused && !this.daemons.has(name))
|
|
2766
|
+
writePausedMarker(instanceDir, pausedAt ?? Date.now(), reason);
|
|
2767
|
+
throw err;
|
|
2768
|
+
}
|
|
2769
|
+
finally {
|
|
2770
|
+
this.lifecycle.clearActivitySeedNow(name);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
async doRestartSingleInstance(name, opts, transition) {
|
|
2574
2774
|
if (this.configPath) {
|
|
2575
2775
|
this.loadConfig(this.configPath);
|
|
2576
2776
|
this.routing.rebuild(this.fleetConfig);
|
|
2577
2777
|
this.reregisterClassicChannels();
|
|
2578
2778
|
}
|
|
2579
2779
|
const config = this.fleetConfig?.instances[name];
|
|
2780
|
+
const wasPaused = this.lifecycle.isPaused(name);
|
|
2781
|
+
// A paused instance's restart is a wake: it takes a warm slot like any other
|
|
2782
|
+
// (refused before anything is stopped when none can be had). An active
|
|
2783
|
+
// restart is already counted and reserves nothing.
|
|
2784
|
+
const slot = await this.acquireWakeSlot(name);
|
|
2785
|
+
// An active instance keeps the slot it already occupies across its own
|
|
2786
|
+
// stop → replacement gap: counted as warm until the replacement is
|
|
2787
|
+
// published or the restart settles, so no other target can take it.
|
|
2788
|
+
if (!wasPaused)
|
|
2789
|
+
this.restartsHoldingSlot.add(name);
|
|
2790
|
+
try {
|
|
2791
|
+
await this.doRestartWithSlot(name, opts, transition, config, wasPaused);
|
|
2792
|
+
// An explicit restart of a paused instance is an operator wake (§1.6):
|
|
2793
|
+
// its success lifts the coordinator's park/backoff (the level stays).
|
|
2794
|
+
// Automatic restarts never do.
|
|
2795
|
+
if (wasPaused && opts?.explicit && this.daemons.has(name))
|
|
2796
|
+
this.wakeCoordinator?.noteExternalWake(name);
|
|
2797
|
+
}
|
|
2798
|
+
finally {
|
|
2799
|
+
this.restartsHoldingSlot.delete(name);
|
|
2800
|
+
this.releaseWakeSlot(name, slot);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
2804
|
+
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
2805
|
+
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
2580
2806
|
if (config) {
|
|
2581
|
-
await this.stopInstance(name);
|
|
2807
|
+
await this.stopInstance(name, transition);
|
|
2582
2808
|
if (opts?.freshStart)
|
|
2583
2809
|
this.writeFreshStartMarker(name);
|
|
2584
2810
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2585
|
-
await this.startInstance(name, config, topicMode ?? false);
|
|
2811
|
+
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
2586
2812
|
this.stormWindow.markRecovered(name);
|
|
2587
2813
|
return;
|
|
2588
2814
|
}
|
|
@@ -2591,11 +2817,11 @@ export class FleetManager {
|
|
|
2591
2817
|
if (channelId) {
|
|
2592
2818
|
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
2593
2819
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
2594
|
-
await this.stopInstance(name);
|
|
2820
|
+
await this.stopInstance(name, transition);
|
|
2595
2821
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
2596
2822
|
if (opts?.freshStart)
|
|
2597
2823
|
this.writeFreshStartMarker(name);
|
|
2598
|
-
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
2824
|
+
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
|
|
2599
2825
|
this.stormWindow.markRecovered(name);
|
|
2600
2826
|
return;
|
|
2601
2827
|
}
|
|
@@ -3915,7 +4141,7 @@ export class FleetManager {
|
|
|
3915
4141
|
}
|
|
3916
4142
|
else if (data.command === "sysinfo") {
|
|
3917
4143
|
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
3918
|
-
await
|
|
4144
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
3919
4145
|
}
|
|
3920
4146
|
else if (data.command === "dashboard") {
|
|
3921
4147
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
@@ -4275,7 +4501,7 @@ export class FleetManager {
|
|
|
4275
4501
|
}
|
|
4276
4502
|
else if (data.command === "sysinfo") {
|
|
4277
4503
|
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
4278
|
-
await
|
|
4504
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
4279
4505
|
}
|
|
4280
4506
|
else if (data.command === "dashboard") {
|
|
4281
4507
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
@@ -5850,7 +6076,8 @@ export class FleetManager {
|
|
|
5850
6076
|
: type.startsWith("fleet_decision_") ? "fleet_decision_response"
|
|
5851
6077
|
: type === "fleet_task" ? "fleet_task_response"
|
|
5852
6078
|
: type === "fleet_set_display_name" ? "fleet_display_name_response"
|
|
5853
|
-
: "
|
|
6079
|
+
: type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis" ? "fleet_persona_emoji_response"
|
|
6080
|
+
: "fleet_description_response";
|
|
5854
6081
|
ipc.send({ type: responseType, fleetRequestId, error: message });
|
|
5855
6082
|
}
|
|
5856
6083
|
dispatchTypedIpc(name, msg) {
|
|
@@ -5875,6 +6102,24 @@ export class FleetManager {
|
|
|
5875
6102
|
this.handleSetDescription(name, msg);
|
|
5876
6103
|
return;
|
|
5877
6104
|
}
|
|
6105
|
+
if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis") {
|
|
6106
|
+
this.handlePersonaEmoji(name, msg);
|
|
6107
|
+
return;
|
|
6108
|
+
}
|
|
6109
|
+
}
|
|
6110
|
+
/** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
|
|
6111
|
+
handlePersonaEmoji(instanceName, msg) {
|
|
6112
|
+
const fleetRequestId = msg.fleetRequestId;
|
|
6113
|
+
const payload = (msg.payload ?? {});
|
|
6114
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
6115
|
+
if (!ipc)
|
|
6116
|
+
return;
|
|
6117
|
+
const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true)
|
|
6118
|
+
: msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
|
|
6119
|
+
: this.setPersonaEmoji(instanceName, payload);
|
|
6120
|
+
op.then((r) => typeof r.error === "string"
|
|
6121
|
+
? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
|
|
6122
|
+
: ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
|
|
5878
6123
|
}
|
|
5879
6124
|
handleScheduleCrud(instanceName, msg) {
|
|
5880
6125
|
const fleetRequestId = msg.fleetRequestId;
|
|
@@ -5979,10 +6224,15 @@ export class FleetManager {
|
|
|
5979
6224
|
?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
|
|
5980
6225
|
?? instanceName;
|
|
5981
6226
|
}
|
|
6227
|
+
/** Inherited keys such as __proto__ must never resolve to a mutable instance. */
|
|
6228
|
+
ownInstanceConfig(instanceName) {
|
|
6229
|
+
const instances = this.fleetConfig?.instances;
|
|
6230
|
+
return instances && Object.hasOwn(instances, instanceName) ? instances[instanceName] : undefined;
|
|
6231
|
+
}
|
|
5982
6232
|
/** Persist identity to the instance's actual config store. Classic instances
|
|
5983
6233
|
* are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
|
|
5984
6234
|
setInstanceDisplayName(instanceName, displayName) {
|
|
5985
|
-
const fleetInstance = this.
|
|
6235
|
+
const fleetInstance = this.ownInstanceConfig(instanceName);
|
|
5986
6236
|
if (fleetInstance) {
|
|
5987
6237
|
fleetInstance.display_name = displayName;
|
|
5988
6238
|
this.saveFleetConfig();
|
|
@@ -5991,7 +6241,7 @@ export class FleetManager {
|
|
|
5991
6241
|
return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
|
|
5992
6242
|
}
|
|
5993
6243
|
setInstanceDescription(instanceName, description) {
|
|
5994
|
-
const fleetInstance = this.
|
|
6244
|
+
const fleetInstance = this.ownInstanceConfig(instanceName);
|
|
5995
6245
|
if (fleetInstance) {
|
|
5996
6246
|
fleetInstance.description = description;
|
|
5997
6247
|
this.saveFleetConfig();
|
|
@@ -6217,6 +6467,15 @@ export class FleetManager {
|
|
|
6217
6467
|
return { error: `Instance '${instance}' not found` };
|
|
6218
6468
|
return { display_name: name };
|
|
6219
6469
|
}
|
|
6470
|
+
async handleListEmojisHttp(instance, refresh) {
|
|
6471
|
+
return this.listEmojisFor(instance, refresh);
|
|
6472
|
+
}
|
|
6473
|
+
async handleSetPersonaEmojiHttp(instance, args) {
|
|
6474
|
+
return this.setPersonaEmoji(instance, args);
|
|
6475
|
+
}
|
|
6476
|
+
async handlePreviewEmojisHttp(instance, args) {
|
|
6477
|
+
return this.previewEmojis(instance, args);
|
|
6478
|
+
}
|
|
6220
6479
|
async handleSetDescriptionHttp(instance, description) {
|
|
6221
6480
|
if (!this.fleetConfig)
|
|
6222
6481
|
return { error: "Fleet config not available" };
|
|
@@ -6834,6 +7093,8 @@ export class FleetManager {
|
|
|
6834
7093
|
deliveryStatusChains = new Map();
|
|
6835
7094
|
/** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
|
|
6836
7095
|
pendingCliEnvProbes = new Map();
|
|
7096
|
+
/** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
|
|
7097
|
+
pendingVendorCliEnvProbes = new Map();
|
|
6837
7098
|
/**
|
|
6838
7099
|
* The status emojis for an instance (#1005): its own `status_emojis`, then
|
|
6839
7100
|
* its channel's `options.status_emojis`, then the platform built-in. An
|
|
@@ -6864,10 +7125,15 @@ export class FleetManager {
|
|
|
6864
7125
|
}
|
|
6865
7126
|
warnedStatusEmojis = new Set();
|
|
6866
7127
|
/**
|
|
6867
|
-
* A Discord connection's server emojis for the Settings picker (#1005)
|
|
6868
|
-
*
|
|
6869
|
-
*
|
|
6870
|
-
*
|
|
7128
|
+
* A Discord connection's server emojis for the Settings picker (#1005), from
|
|
7129
|
+
* every server its bot can draw on (#1021): the primary `group_id` server,
|
|
7130
|
+
* then each other server the bot is a member of that ClassicBot's
|
|
7131
|
+
* `allowed_guilds` admits (unset = all, as for classic routing). A bot can
|
|
7132
|
+
* react with a custom emoji from any server it is in. Cached per server — a
|
|
7133
|
+
* server's emoji set changes rarely — and one fetch per server at a time;
|
|
7134
|
+
* `refresh` forces new ones. One server refusing (e.g. Missing Access) is
|
|
7135
|
+
* reported on that server and does not hide the others. Uses the running
|
|
7136
|
+
* adapter, so the bot token never leaves the server.
|
|
6871
7137
|
*/
|
|
6872
7138
|
async listGuildEmojis(channelId, refresh = false) {
|
|
6873
7139
|
const world = this.worlds.get(channelId);
|
|
@@ -6878,28 +7144,268 @@ export class FleetManager {
|
|
|
6878
7144
|
const adapter = world.adapter;
|
|
6879
7145
|
if (!adapter.listGuildEmojis)
|
|
6880
7146
|
return { ok: false, error: "this adapter cannot list server emojis" };
|
|
6881
|
-
const
|
|
6882
|
-
|
|
6883
|
-
return { ok: true, ...cached };
|
|
6884
|
-
let pending = this.guildEmojiFetches.get(channelId);
|
|
6885
|
-
if (!pending) {
|
|
6886
|
-
pending = adapter.listGuildEmojis().then(emojis => {
|
|
6887
|
-
const entry = { fetched_at: Date.now(), emojis };
|
|
6888
|
-
this.guildEmojiCache.set(channelId, entry);
|
|
6889
|
-
return entry;
|
|
6890
|
-
}).finally(() => this.guildEmojiFetches.delete(channelId));
|
|
6891
|
-
this.guildEmojiFetches.set(channelId, pending);
|
|
6892
|
-
}
|
|
7147
|
+
const listEmojis = adapter.listGuildEmojis.bind(adapter);
|
|
7148
|
+
let servers;
|
|
6893
7149
|
try {
|
|
6894
|
-
|
|
7150
|
+
servers = adapter.listMemberGuilds
|
|
7151
|
+
? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
|
|
7152
|
+
: [{ id: "", name: "", primary: true }];
|
|
6895
7153
|
}
|
|
6896
7154
|
catch (e) {
|
|
6897
|
-
return { ok: false, error: `Discord refused the
|
|
6898
|
-
}
|
|
7155
|
+
return { ok: false, error: `Discord refused the server list: ${e.message}` };
|
|
7156
|
+
}
|
|
7157
|
+
const guilds = await Promise.all(servers.map(async (g) => {
|
|
7158
|
+
const key = `${channelId}\0${g.id}`;
|
|
7159
|
+
const cached = this.guildEmojiCache.get(key);
|
|
7160
|
+
if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
|
|
7161
|
+
return { ...g, ...cached };
|
|
7162
|
+
let pending = this.guildEmojiFetches.get(key);
|
|
7163
|
+
if (!pending) {
|
|
7164
|
+
pending = (g.id ? listEmojis(g.id) : listEmojis()).then(emojis => {
|
|
7165
|
+
const entry = { fetched_at: Date.now(), emojis };
|
|
7166
|
+
this.guildEmojiCache.set(key, entry);
|
|
7167
|
+
return entry;
|
|
7168
|
+
}).finally(() => this.guildEmojiFetches.delete(key));
|
|
7169
|
+
this.guildEmojiFetches.set(key, pending);
|
|
7170
|
+
}
|
|
7171
|
+
try {
|
|
7172
|
+
return { ...g, ...(await pending) };
|
|
7173
|
+
}
|
|
7174
|
+
catch (e) {
|
|
7175
|
+
return { ...g, error: `Discord refused the emoji list: ${e.message}` };
|
|
7176
|
+
}
|
|
7177
|
+
}));
|
|
7178
|
+
const listed = guilds.filter((g) => !!g.emojis);
|
|
7179
|
+
if (!listed.length)
|
|
7180
|
+
return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
|
|
7181
|
+
return {
|
|
7182
|
+
ok: true,
|
|
7183
|
+
fetched_at: Math.min(...listed.map(g => g.fetched_at)),
|
|
7184
|
+
emojis: listed.flatMap(g => g.emojis),
|
|
7185
|
+
guilds,
|
|
7186
|
+
};
|
|
6899
7187
|
}
|
|
6900
7188
|
static GUILD_EMOJI_TTL_MS = 10 * 60_000;
|
|
7189
|
+
/** Keyed `<connection>\0<server id>`. */
|
|
6901
7190
|
guildEmojiCache = new Map();
|
|
6902
7191
|
guildEmojiFetches = new Map();
|
|
7192
|
+
/**
|
|
7193
|
+
* What `instanceName` may pick as its persona emoji (the `list_emojis`
|
|
7194
|
+
* tool): its current status set with where each value comes from, the
|
|
7195
|
+
* standard emojis its platform takes, and on Discord the server emojis its
|
|
7196
|
+
* bot can draw on — the same lists, and the same resolution, as the
|
|
7197
|
+
* Settings picker (#1005/#1021), so an agent and an operator see one truth.
|
|
7198
|
+
*/
|
|
7199
|
+
async listEmojisFor(instanceName, refresh = false) {
|
|
7200
|
+
const self = this.fleetConfig?.instances[instanceName];
|
|
7201
|
+
if (!self)
|
|
7202
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7203
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7204
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7205
|
+
const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
|
|
7206
|
+
const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
|
|
7207
|
+
const out = {
|
|
7208
|
+
platform: platform ?? null,
|
|
7209
|
+
statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
|
|
7210
|
+
standard: platform === "telegram"
|
|
7211
|
+
? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
|
|
7212
|
+
: { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
|
|
7213
|
+
};
|
|
7214
|
+
if (platform === "discord" && worldId) {
|
|
7215
|
+
const listed = await this.listGuildEmojis(worldId, refresh);
|
|
7216
|
+
out.server_emojis = listed.ok
|
|
7217
|
+
? listed.guilds.map(g => g.emojis
|
|
7218
|
+
? {
|
|
7219
|
+
server: g.name || g.id, primary: g.primary,
|
|
7220
|
+
emojis: g.emojis.filter(e => e.available).map(e => {
|
|
7221
|
+
const value = customEmojiValue(e);
|
|
7222
|
+
return { value, image_url: emojiImageUrl(value) };
|
|
7223
|
+
}),
|
|
7224
|
+
}
|
|
7225
|
+
: { server: g.name || g.id, primary: g.primary, error: g.error })
|
|
7226
|
+
: { error: listed.error };
|
|
7227
|
+
}
|
|
7228
|
+
return out;
|
|
7229
|
+
}
|
|
7230
|
+
/**
|
|
7231
|
+
* `preview_emojis` (#1040): download a few Discord server emojis so an agent
|
|
7232
|
+
* can look at them before it picks one — a name and an id say nothing about
|
|
7233
|
+
* what an emoji looks like. The fleet fetches, not the agent (not every
|
|
7234
|
+
* backend can), and returns a local path per emoji to Read, as
|
|
7235
|
+
* download_attachment does for an attachment.
|
|
7236
|
+
*
|
|
7237
|
+
* SSRF-safe by construction: nothing from the caller reaches a URL. The
|
|
7238
|
+
* caller names emojis; each must be an available one in a server this bot
|
|
7239
|
+
* can draw on (the list set_persona_emoji checks), and the CDN URL is built
|
|
7240
|
+
* from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
|
|
7241
|
+
*/
|
|
7242
|
+
async previewEmojis(instanceName, args) {
|
|
7243
|
+
const self = this.fleetConfig?.instances[instanceName];
|
|
7244
|
+
if (!self)
|
|
7245
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7246
|
+
const wanted = args.emojis;
|
|
7247
|
+
if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
|
|
7248
|
+
return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
|
|
7249
|
+
}
|
|
7250
|
+
if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX) {
|
|
7251
|
+
return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
|
|
7252
|
+
}
|
|
7253
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7254
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7255
|
+
if (platform !== "discord" || !worldId) {
|
|
7256
|
+
return { error: "only Discord server emojis need a preview; standard emojis are what they look like" };
|
|
7257
|
+
}
|
|
7258
|
+
const listed = await this.listGuildEmojis(worldId);
|
|
7259
|
+
if (!listed.ok)
|
|
7260
|
+
return { error: `cannot list this bot's server emojis: ${listed.error}` };
|
|
7261
|
+
const previews = [];
|
|
7262
|
+
const errors = [];
|
|
7263
|
+
for (const raw of wanted) {
|
|
7264
|
+
const e = normalizeEmoji(raw);
|
|
7265
|
+
if (e?.kind !== "custom") {
|
|
7266
|
+
errors.push({ emoji: raw, error: "not a server emoji (a standard emoji needs no preview)" });
|
|
7267
|
+
continue;
|
|
7268
|
+
}
|
|
7269
|
+
const found = listed.emojis.find(g => g.id === e.id);
|
|
7270
|
+
if (!found?.available) {
|
|
7271
|
+
errors.push({ emoji: raw, error: "not a server emoji this bot can use" });
|
|
7272
|
+
continue;
|
|
7273
|
+
}
|
|
7274
|
+
try {
|
|
7275
|
+
previews.push({ emoji: customEmojiValue(found), path: await this.fetchEmojiPreview(found.id) });
|
|
7276
|
+
}
|
|
7277
|
+
catch (err) {
|
|
7278
|
+
errors.push({ emoji: raw, error: `download failed: ${err.message}` });
|
|
7279
|
+
}
|
|
7280
|
+
}
|
|
7281
|
+
return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
|
|
7282
|
+
}
|
|
7283
|
+
static EMOJI_PREVIEW_MAX = 8;
|
|
7284
|
+
static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
|
|
7285
|
+
static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
|
|
7286
|
+
/** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
|
|
7287
|
+
async fetchEmojiPreview(id) {
|
|
7288
|
+
if (!/^\d{15,25}$/.test(id))
|
|
7289
|
+
throw new Error("not a Discord emoji id");
|
|
7290
|
+
const dir = join(this.dataDir, "inbox", "emoji-previews");
|
|
7291
|
+
mkdirSync(dir, { recursive: true });
|
|
7292
|
+
const now = Date.now();
|
|
7293
|
+
for (const name of readdirSync(dir)) {
|
|
7294
|
+
try {
|
|
7295
|
+
if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
|
|
7296
|
+
unlinkSync(join(dir, name));
|
|
7297
|
+
}
|
|
7298
|
+
catch { /* raced */ }
|
|
7299
|
+
}
|
|
7300
|
+
const path = join(dir, `${id}.png`);
|
|
7301
|
+
if (existsSync(path))
|
|
7302
|
+
return path;
|
|
7303
|
+
const response = await fetch(`https://cdn.discordapp.com/emojis/${id}.png?size=96`, { signal: AbortSignal.timeout(10_000), redirect: "error" });
|
|
7304
|
+
if (!response.ok)
|
|
7305
|
+
throw new Error(`HTTP ${response.status}`);
|
|
7306
|
+
if (!/^image\/png\b/.test(response.headers.get("content-type") ?? ""))
|
|
7307
|
+
throw new Error("not a PNG");
|
|
7308
|
+
const reader = response.body?.getReader();
|
|
7309
|
+
if (!reader)
|
|
7310
|
+
throw new Error("empty response");
|
|
7311
|
+
const chunks = [];
|
|
7312
|
+
let size = 0;
|
|
7313
|
+
for (;;) {
|
|
7314
|
+
const { done, value } = await reader.read();
|
|
7315
|
+
if (done)
|
|
7316
|
+
break;
|
|
7317
|
+
size += value.byteLength;
|
|
7318
|
+
if (size > FleetManager.EMOJI_PREVIEW_MAX_BYTES) {
|
|
7319
|
+
await reader.cancel();
|
|
7320
|
+
throw new Error("image too large");
|
|
7321
|
+
}
|
|
7322
|
+
chunks.push(value);
|
|
7323
|
+
}
|
|
7324
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
7325
|
+
writeFileSync(tmp, Buffer.concat(chunks));
|
|
7326
|
+
renameSync(tmp, path);
|
|
7327
|
+
return path;
|
|
7328
|
+
}
|
|
7329
|
+
/**
|
|
7330
|
+
* `set_persona_emoji`: `instanceName` sets one of its own status emojis —
|
|
7331
|
+
* `delivered` unless it names another — in its per-instance `status_emojis`
|
|
7332
|
+
* override (#1005 addendum 2), so its stamp is told apart from other bots'
|
|
7333
|
+
* on a shared message. The value is judged exactly as Settings and the
|
|
7334
|
+
* react path judge it (statusEmojiProblem on the instance's platform), and
|
|
7335
|
+
* a Discord server emoji must be one its bot can use: an emoji from a
|
|
7336
|
+
* server it is not in would be stored, preview fine, and fail every react.
|
|
7337
|
+
* An empty emoji removes that status's override.
|
|
7338
|
+
*/
|
|
7339
|
+
async setPersonaEmoji(instanceName, args) {
|
|
7340
|
+
const self = this.ownInstanceConfig(instanceName);
|
|
7341
|
+
if (!self)
|
|
7342
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7343
|
+
const status = (args.status ?? "delivered");
|
|
7344
|
+
if (!STATUS_EMOJI_CONFIG_KEYS.includes(status)) {
|
|
7345
|
+
return { error: `status must be one of ${STATUS_EMOJI_CONFIG_KEYS.join(", ")}` };
|
|
7346
|
+
}
|
|
7347
|
+
// Only an explicit "" removes the override. The agent endpoint, typed IPC
|
|
7348
|
+
// and agent-cli do not run the MCP schema, so a call missing `emoji` must
|
|
7349
|
+
// be refused here, never read as "clear my delivered stamp".
|
|
7350
|
+
if (typeof args.emoji !== "string") {
|
|
7351
|
+
return { error: 'emoji is required: one emoji, a <:name:id> from list_emojis, or "" to remove your override' };
|
|
7352
|
+
}
|
|
7353
|
+
const raw = args.emoji.trim();
|
|
7354
|
+
// Whitespace is not an explicit "": a stray " " must not clear the stamp either.
|
|
7355
|
+
if (!raw && args.emoji !== "") {
|
|
7356
|
+
return { error: 'emoji is blank: pass one emoji, a <:name:id> from list_emojis, or exactly "" to remove your override' };
|
|
7357
|
+
}
|
|
7358
|
+
let value;
|
|
7359
|
+
if (raw) {
|
|
7360
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7361
|
+
const problem = statusEmojiProblem(platform, status, raw);
|
|
7362
|
+
if (problem)
|
|
7363
|
+
return { error: `${problem}. Call list_emojis for what ${platform ?? "this channel"} accepts.` };
|
|
7364
|
+
const e = normalizeEmoji(raw);
|
|
7365
|
+
if (e.kind === "custom") {
|
|
7366
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7367
|
+
if (!worldId)
|
|
7368
|
+
return { error: "no connection to check that server emoji against" };
|
|
7369
|
+
const listed = await this.listGuildEmojis(worldId);
|
|
7370
|
+
if (!listed.ok)
|
|
7371
|
+
return { error: `cannot check that server emoji: ${listed.error}` };
|
|
7372
|
+
const found = listed.emojis.find(g => g.id === e.id);
|
|
7373
|
+
if (!found?.available) {
|
|
7374
|
+
return { error: `${raw} is not a server emoji this bot can use${found ? " (Discord marks it unavailable)" : ""}. Call list_emojis for the ones it can.` };
|
|
7375
|
+
}
|
|
7376
|
+
value = customEmojiValue(found);
|
|
7377
|
+
}
|
|
7378
|
+
else {
|
|
7379
|
+
value = e.value;
|
|
7380
|
+
}
|
|
7381
|
+
}
|
|
7382
|
+
// Re-read after the validation above, which may have waited on Discord:
|
|
7383
|
+
// another set_persona_emoji or a Settings save can have changed another
|
|
7384
|
+
// status, or replaced this instance's config object, meanwhile. Change
|
|
7385
|
+
// only this one key on what is there now. Ownership must still hold:
|
|
7386
|
+
// removal during the await must not fall through to an inherited entry.
|
|
7387
|
+
const current = this.ownInstanceConfig(instanceName);
|
|
7388
|
+
if (!current)
|
|
7389
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7390
|
+
const map = { ...(current.status_emojis ?? {}) };
|
|
7391
|
+
if (value)
|
|
7392
|
+
map[status] = value;
|
|
7393
|
+
else
|
|
7394
|
+
delete map[status];
|
|
7395
|
+
if (Object.keys(map).length)
|
|
7396
|
+
current.status_emojis = map;
|
|
7397
|
+
else
|
|
7398
|
+
delete current.status_emojis;
|
|
7399
|
+
this.saveFleetConfig();
|
|
7400
|
+
this.logger.info({ instanceName, status, value: value ?? null }, value ? "Persona emoji set" : "Persona emoji cleared");
|
|
7401
|
+
const resolved = this.resolveStatusEmojisFor(instanceName);
|
|
7402
|
+
return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
|
|
7403
|
+
}
|
|
7404
|
+
personaEmojiMissing(instanceName) {
|
|
7405
|
+
return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
|
|
7406
|
+
? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
|
|
7407
|
+
: `Instance '${instanceName}' not found`;
|
|
7408
|
+
}
|
|
6903
7409
|
/** What the instructions tell `instanceName` not to react with (its own status set). */
|
|
6904
7410
|
statusEmojiAvoidList(instanceName) {
|
|
6905
7411
|
return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
|
|
@@ -9405,10 +9911,16 @@ export class FleetManager {
|
|
|
9405
9911
|
}
|
|
9406
9912
|
// The installer may only have added the binary to a profile PATH; a
|
|
9407
9913
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
9408
|
-
|
|
9914
|
+
const installedAt = this.locateBinaryOnLoginShell(info.binary);
|
|
9915
|
+
if (!installedAt) {
|
|
9409
9916
|
await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
|
|
9410
9917
|
return;
|
|
9411
9918
|
}
|
|
9919
|
+
// #1059: make it reachable from this process too. `/login` lists what
|
|
9920
|
+
// `which` finds on the fleet's own PATH, and instances resolve their
|
|
9921
|
+
// binary the same way, so a binary only a login shell could see was
|
|
9922
|
+
// installed, reported as verified, and then offered nowhere.
|
|
9923
|
+
this.adoptBinaryDirectory(installedAt, backend);
|
|
9412
9924
|
if (!LOGIN_FLOWS[backend]) {
|
|
9413
9925
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
9414
9926
|
return;
|
|
@@ -9468,15 +9980,35 @@ export class FleetManager {
|
|
|
9468
9980
|
return t("install.cancelled", backend);
|
|
9469
9981
|
}
|
|
9470
9982
|
/** `command -v` on a login shell, so PATH additions from rc files count. */
|
|
9471
|
-
|
|
9983
|
+
/**
|
|
9984
|
+
* The absolute path a fresh login shell resolves `binary` to, or null. Only
|
|
9985
|
+
* a real executable file counts: `command -v` also answers with an alias
|
|
9986
|
+
* definition or a function name, neither of which a spawn could run.
|
|
9987
|
+
*/
|
|
9988
|
+
locateBinaryOnLoginShell(binary) {
|
|
9472
9989
|
try {
|
|
9473
|
-
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
|
|
9474
|
-
|
|
9990
|
+
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" });
|
|
9991
|
+
if (result.status !== 0)
|
|
9992
|
+
return null;
|
|
9993
|
+
const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
|
|
9994
|
+
if (!isAbsolute(path))
|
|
9995
|
+
return null;
|
|
9996
|
+
accessSync(path, fsConstants.X_OK);
|
|
9997
|
+
return statSync(path).isFile() ? path : null;
|
|
9475
9998
|
}
|
|
9476
9999
|
catch {
|
|
9477
|
-
return
|
|
10000
|
+
return null;
|
|
9478
10001
|
}
|
|
9479
10002
|
}
|
|
10003
|
+
/** Put an installed binary's directory on the fleet's PATH (#1059), once. */
|
|
10004
|
+
adoptBinaryDirectory(binaryPath, backend) {
|
|
10005
|
+
const dir = dirname(binaryPath);
|
|
10006
|
+
const entries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
10007
|
+
if (entries.includes(dir))
|
|
10008
|
+
return;
|
|
10009
|
+
process.env.PATH = [dir, ...entries].join(delimiter);
|
|
10010
|
+
this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
|
|
10011
|
+
}
|
|
9480
10012
|
/** "Sign in now?" button after a successful install. */
|
|
9481
10013
|
async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
|
|
9482
10014
|
const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
|
|
@@ -10325,6 +10857,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10325
10857
|
catch { /* missing / stale / corrupt */ }
|
|
10326
10858
|
return null;
|
|
10327
10859
|
}
|
|
10860
|
+
/** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
|
|
10861
|
+
getBackendCliVersionSnapshot() {
|
|
10862
|
+
const snapshot = {};
|
|
10863
|
+
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
10864
|
+
const cached = this.readCliEnv(backend);
|
|
10865
|
+
const needsRefresh = this.cliEnvNeedsRefresh(cached);
|
|
10866
|
+
const version = typeof cached?.version === "string" && cached.version.trim()
|
|
10867
|
+
? cached.version.trim()
|
|
10868
|
+
: null;
|
|
10869
|
+
snapshot[backend] = {
|
|
10870
|
+
version,
|
|
10871
|
+
probing: needsRefresh && version === null,
|
|
10872
|
+
};
|
|
10873
|
+
}
|
|
10874
|
+
return snapshot;
|
|
10875
|
+
}
|
|
10876
|
+
/** Called only after the user-visible sysinfo send/respond has settled. */
|
|
10877
|
+
refreshBackendCliVersions() {
|
|
10878
|
+
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
10879
|
+
if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
|
|
10880
|
+
continue;
|
|
10881
|
+
void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
|
|
10882
|
+
this.logger.debug({ err, backend }, "Background CLI version refresh failed");
|
|
10883
|
+
});
|
|
10884
|
+
}
|
|
10885
|
+
}
|
|
10328
10886
|
/** True when a cached CLI env is old enough that `/model` should re-probe. */
|
|
10329
10887
|
cliEnvNeedsRefresh(env) {
|
|
10330
10888
|
return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
|
|
@@ -10335,33 +10893,97 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10335
10893
|
* previous list, never stall the command that asked for it.
|
|
10336
10894
|
*/
|
|
10337
10895
|
async probeBackendBounded(backend, opts = {}) {
|
|
10338
|
-
// #725: single-flight per backend
|
|
10339
|
-
//
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
if (
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
const work = this.probeBackend(backend, opts);
|
|
10896
|
+
// #725: single-flight per backend. Explicit vendor refreshes remain a
|
|
10897
|
+
// separate flight from an ordinary probe, while sysinfo can join either.
|
|
10898
|
+
const regularFlight = this.pendingCliEnvProbes.get(backend);
|
|
10899
|
+
const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
|
|
10900
|
+
if (opts.refreshVendorCatalog && vendorFlight)
|
|
10901
|
+
return vendorFlight;
|
|
10902
|
+
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
10903
|
+
return vendorFlight ?? regularFlight;
|
|
10904
|
+
const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
|
|
10905
|
+
const work = worker?.promise ?? this.probeBackend(backend, opts);
|
|
10348
10906
|
work.catch(() => { });
|
|
10349
10907
|
let timer;
|
|
10350
10908
|
const deadline = new Promise(resolve => {
|
|
10351
10909
|
timer = setTimeout(() => {
|
|
10910
|
+
worker?.terminate();
|
|
10352
10911
|
this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
|
|
10353
10912
|
resolve(null);
|
|
10354
10913
|
}, CLI_ENV_PROBE_DEADLINE_MS);
|
|
10355
10914
|
});
|
|
10356
10915
|
const bounded = Promise.race([work, deadline]).finally(() => {
|
|
10357
10916
|
clearTimeout(timer);
|
|
10358
|
-
|
|
10359
|
-
|
|
10917
|
+
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
10918
|
+
if (flights.get(backend) === bounded)
|
|
10919
|
+
flights.delete(backend);
|
|
10360
10920
|
});
|
|
10361
|
-
|
|
10362
|
-
this.pendingCliEnvProbes.set(backend, bounded);
|
|
10921
|
+
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
|
|
10363
10922
|
return bounded;
|
|
10364
10923
|
}
|
|
10924
|
+
startCliEnvProbeWorker(backend, opts) {
|
|
10925
|
+
const workerData = {
|
|
10926
|
+
backend,
|
|
10927
|
+
instanceDir: join(getAgendHome(), "cli-env"),
|
|
10928
|
+
config: {
|
|
10929
|
+
workingDirectory: "",
|
|
10930
|
+
instanceDir: join(getAgendHome(), "cli-env"),
|
|
10931
|
+
instanceName: `probe-${backend}`,
|
|
10932
|
+
mcpServers: {},
|
|
10933
|
+
},
|
|
10934
|
+
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
10935
|
+
};
|
|
10936
|
+
// `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
|
|
10937
|
+
// Published builds load the compiled worker and require no tsx dependency.
|
|
10938
|
+
const worker = import.meta.url.endsWith(".ts")
|
|
10939
|
+
? new Worker(`const { workerData } = require("node:worker_threads"); import("tsx/esm/api").then(({ tsImport }) => tsImport(workerData.entry, workerData.entry));`, { eval: true, execArgv: [], workerData: { ...workerData, entry: new URL("./backend/cli-env-probe-worker.ts", import.meta.url).href } })
|
|
10940
|
+
: new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
|
|
10941
|
+
let settled = false;
|
|
10942
|
+
let resolveProbe;
|
|
10943
|
+
const promise = new Promise(resolve => { resolveProbe = resolve; });
|
|
10944
|
+
const finish = (value) => {
|
|
10945
|
+
if (settled)
|
|
10946
|
+
return;
|
|
10947
|
+
settled = true;
|
|
10948
|
+
resolveProbe(value);
|
|
10949
|
+
void worker.terminate().catch(err => {
|
|
10950
|
+
this.logger.debug({ backend, err }, "CLI env worker termination failed");
|
|
10951
|
+
});
|
|
10952
|
+
};
|
|
10953
|
+
worker.once("message", (message) => {
|
|
10954
|
+
if (settled)
|
|
10955
|
+
return;
|
|
10956
|
+
if (!message.ok) {
|
|
10957
|
+
this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
|
|
10958
|
+
finish(null);
|
|
10959
|
+
}
|
|
10960
|
+
else if (!message.result) {
|
|
10961
|
+
finish(null);
|
|
10962
|
+
}
|
|
10963
|
+
else {
|
|
10964
|
+
try {
|
|
10965
|
+
finish(this.persistCliEnvProbeResult(backend, message.result));
|
|
10966
|
+
}
|
|
10967
|
+
catch (err) {
|
|
10968
|
+
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
10969
|
+
finish(null);
|
|
10970
|
+
}
|
|
10971
|
+
}
|
|
10972
|
+
});
|
|
10973
|
+
worker.once("error", err => {
|
|
10974
|
+
if (settled)
|
|
10975
|
+
return;
|
|
10976
|
+
this.logger.warn({ backend, err }, "CLI env worker crashed");
|
|
10977
|
+
finish(null);
|
|
10978
|
+
});
|
|
10979
|
+
worker.once("exit", code => {
|
|
10980
|
+
if (settled)
|
|
10981
|
+
return;
|
|
10982
|
+
this.logger.warn({ backend, code }, "CLI env worker exited without a result");
|
|
10983
|
+
finish(null);
|
|
10984
|
+
});
|
|
10985
|
+
return { promise, terminate: () => { finish(null); } };
|
|
10986
|
+
}
|
|
10365
10987
|
/**
|
|
10366
10988
|
* Resolve the effective model for a fleet or ClassicBot instance, plus where it
|
|
10367
10989
|
* came from. Single source of truth for `/model` and `/ctx` — precedence:
|
|
@@ -10428,35 +11050,39 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10428
11050
|
if (opts.refreshVendorCatalog && be.refreshModelCatalog)
|
|
10429
11051
|
await be.refreshModelCatalog();
|
|
10430
11052
|
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
10431
|
-
|
|
10432
|
-
// An empty result must never overwrite a catalog we already have. Some
|
|
10433
|
-
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
10434
|
-
// returns [] — and writing that would blank the list for the whole 24h
|
|
10435
|
-
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
10436
|
-
// antigravity cache replaced by an empty one. Keep the known models and
|
|
10437
|
-
// let the fresher currentModel/version through.
|
|
10438
|
-
if (!env.models?.length) {
|
|
10439
|
-
const previous = this.readCliEnv(backend);
|
|
10440
|
-
if (previous?.models?.length)
|
|
10441
|
-
env.models = previous.models;
|
|
10442
|
-
}
|
|
10443
|
-
// Same protection for the extended catalog: one offline moment must not
|
|
10444
|
-
// blank a good account list for the whole cache TTL.
|
|
10445
|
-
if (!env.apiModels?.length) {
|
|
10446
|
-
const previous = this.readCliEnv(backend);
|
|
10447
|
-
if (previous?.apiModels?.length)
|
|
10448
|
-
env.apiModels = previous.apiModels;
|
|
10449
|
-
}
|
|
10450
|
-
const path = this.cliEnvPath(backend);
|
|
10451
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
10452
|
-
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
10453
|
-
return env;
|
|
11053
|
+
return this.persistCliEnvProbeResult(backend, probed);
|
|
10454
11054
|
}
|
|
10455
11055
|
catch (err) {
|
|
10456
11056
|
this.logger.warn({ err, backend }, "CLI env probe failed");
|
|
10457
11057
|
return null;
|
|
10458
11058
|
}
|
|
10459
11059
|
}
|
|
11060
|
+
/** Preserve the existing cache merge rules for both in-process and worker probes. */
|
|
11061
|
+
persistCliEnvProbeResult(backend, probed) {
|
|
11062
|
+
const env = { backend, probedAt: Date.now(), ...probed };
|
|
11063
|
+
// An empty result must never overwrite a catalog we already have. Some
|
|
11064
|
+
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
11065
|
+
// returns [] — and writing that would blank the list for the whole 24h
|
|
11066
|
+
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
11067
|
+
// antigravity cache replaced by an empty one. Keep the known models and
|
|
11068
|
+
// let the fresher currentModel/version through.
|
|
11069
|
+
if (!env.models?.length) {
|
|
11070
|
+
const previous = this.readCliEnv(backend);
|
|
11071
|
+
if (previous?.models?.length)
|
|
11072
|
+
env.models = previous.models;
|
|
11073
|
+
}
|
|
11074
|
+
// Same protection for the extended catalog: one offline moment must not
|
|
11075
|
+
// blank a good account list for the whole cache TTL.
|
|
11076
|
+
if (!env.apiModels?.length) {
|
|
11077
|
+
const previous = this.readCliEnv(backend);
|
|
11078
|
+
if (previous?.apiModels?.length)
|
|
11079
|
+
env.apiModels = previous.apiModels;
|
|
11080
|
+
}
|
|
11081
|
+
const path = this.cliEnvPath(backend);
|
|
11082
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
11083
|
+
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
11084
|
+
return env;
|
|
11085
|
+
}
|
|
10460
11086
|
/** Background-probe every distinct backend in use at startup (non-blocking). */
|
|
10461
11087
|
probeCliEnvs() {
|
|
10462
11088
|
const backends = new Set();
|
|
@@ -11429,7 +12055,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11429
12055
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
11430
12056
|
}
|
|
11431
12057
|
/** Start a classic channel instance with lightweight config */
|
|
11432
|
-
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
|
|
12058
|
+
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
|
|
11433
12059
|
if (this.daemons.has(instanceName))
|
|
11434
12060
|
return;
|
|
11435
12061
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
@@ -11456,7 +12082,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11456
12082
|
...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
|
|
11457
12083
|
};
|
|
11458
12084
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
11459
|
-
await this.startInstance(instanceName, config, topicMode, "classic");
|
|
12085
|
+
await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
|
|
11460
12086
|
}
|
|
11461
12087
|
/** Handle /start slash command — register classic channel */
|
|
11462
12088
|
async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
|
|
@@ -11568,6 +12194,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11568
12194
|
clearInterval(this.replyObligationTimer);
|
|
11569
12195
|
this.replyObligationTimer = null;
|
|
11570
12196
|
}
|
|
12197
|
+
this.wakeCoordinator?.stop();
|
|
11571
12198
|
if (this.logRotateTimer) {
|
|
11572
12199
|
clearInterval(this.logRotateTimer);
|
|
11573
12200
|
this.logRotateTimer = null;
|
|
@@ -13629,7 +14256,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13629
14256
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
13630
14257
|
(async () => {
|
|
13631
14258
|
try {
|
|
13632
|
-
await this.restartSingleInstance(name);
|
|
14259
|
+
await this.restartSingleInstance(name, { explicit: true });
|
|
13633
14260
|
this.logger.info({ name }, "Instance restarted");
|
|
13634
14261
|
this.emitSseEvent("status", this.getUiStatus());
|
|
13635
14262
|
res.writeHead(200);
|