@hydra-acp/cli 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -47
- package/dist/cli.js +2622 -433
- package/dist/index.d.ts +171 -26
- package/dist/index.js +1605 -206
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// src/daemon/server.ts
|
|
2
|
-
import * as
|
|
3
|
-
import * as
|
|
2
|
+
import * as fs14 from "fs";
|
|
3
|
+
import * as fsp5 from "fs/promises";
|
|
4
4
|
import Fastify from "fastify";
|
|
5
5
|
import websocketPlugin from "@fastify/websocket";
|
|
6
6
|
import pino from "pino";
|
|
7
7
|
import createPinoRoll from "pino-roll";
|
|
8
8
|
|
|
9
9
|
// src/core/config.ts
|
|
10
|
-
import * as
|
|
10
|
+
import * as fs2 from "fs/promises";
|
|
11
11
|
import { homedir as homedir2 } from "os";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
|
|
@@ -62,6 +62,12 @@ var paths = {
|
|
|
62
62
|
sessionDir: (id) => path.join(hydraHome(), "sessions", id),
|
|
63
63
|
sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
|
|
64
64
|
historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
|
|
65
|
+
// Persisted prompt queue for a session. ndjson, one record per
|
|
66
|
+
// entry. Survives daemon restarts so queued prompts get a chance to
|
|
67
|
+
// run rather than being silently lost. Entries are removed BEFORE
|
|
68
|
+
// the agent invocation (see Session.drainQueue) so a crash mid-
|
|
69
|
+
// generation doesn't double-run on restart.
|
|
70
|
+
queueFile: (id) => path.join(hydraHome(), "sessions", id, "queue.ndjson"),
|
|
65
71
|
extensionsDir: () => path.join(hydraHome(), "extensions"),
|
|
66
72
|
extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
|
|
67
73
|
extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
|
|
@@ -69,16 +75,70 @@ var paths = {
|
|
|
69
75
|
tuiLogFile: () => path.join(hydraHome(), "tui.log")
|
|
70
76
|
};
|
|
71
77
|
|
|
78
|
+
// src/core/service-token.ts
|
|
79
|
+
import * as fs from "fs/promises";
|
|
80
|
+
function generateServiceToken() {
|
|
81
|
+
const bytes = new Uint8Array(32);
|
|
82
|
+
crypto.getRandomValues(bytes);
|
|
83
|
+
let hex = "";
|
|
84
|
+
for (const b of bytes) {
|
|
85
|
+
hex += b.toString(16).padStart(2, "0");
|
|
86
|
+
}
|
|
87
|
+
return `hydra_token_${hex}`;
|
|
88
|
+
}
|
|
89
|
+
async function readServiceToken() {
|
|
90
|
+
try {
|
|
91
|
+
const text = await fs.readFile(paths.authToken(), "utf8");
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const e = err;
|
|
96
|
+
if (e.code === "ENOENT") {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function loadServiceToken() {
|
|
103
|
+
const token = await readServiceToken();
|
|
104
|
+
if (!token) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`No service token found at ${paths.authToken()}. Run \`hydra-acp init\` to create one.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return token;
|
|
110
|
+
}
|
|
111
|
+
async function writeServiceToken(token) {
|
|
112
|
+
await fs.mkdir(paths.home(), { recursive: true });
|
|
113
|
+
await fs.writeFile(paths.authToken(), token + "\n", {
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
mode: 384
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async function ensureServiceToken() {
|
|
119
|
+
const existing = await readServiceToken();
|
|
120
|
+
if (existing) {
|
|
121
|
+
return existing;
|
|
122
|
+
}
|
|
123
|
+
const token = generateServiceToken();
|
|
124
|
+
await writeServiceToken(token);
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
`hydra-acp: initialized ${paths.authToken()} with a fresh service token.
|
|
127
|
+
`
|
|
128
|
+
);
|
|
129
|
+
return token;
|
|
130
|
+
}
|
|
131
|
+
|
|
72
132
|
// src/core/config.ts
|
|
73
133
|
var REGISTRY_URL_DEFAULT = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
|
74
134
|
var TlsConfig = z.object({
|
|
75
135
|
cert: z.string(),
|
|
76
136
|
key: z.string()
|
|
77
137
|
});
|
|
138
|
+
var DEFAULT_DAEMON_PORT = 55514;
|
|
78
139
|
var DaemonConfig = z.object({
|
|
79
140
|
host: z.string().default("127.0.0.1"),
|
|
80
|
-
port: z.number().int().positive().default(
|
|
81
|
-
authToken: z.string().min(16),
|
|
141
|
+
port: z.number().int().positive().default(DEFAULT_DAEMON_PORT),
|
|
82
142
|
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
83
143
|
tls: TlsConfig.optional(),
|
|
84
144
|
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600),
|
|
@@ -139,7 +199,7 @@ var ExtensionBody = z.object({
|
|
|
139
199
|
enabled: z.boolean().default(true)
|
|
140
200
|
});
|
|
141
201
|
var HydraConfig = z.object({
|
|
142
|
-
daemon: DaemonConfig,
|
|
202
|
+
daemon: DaemonConfig.default({}),
|
|
143
203
|
registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
|
|
144
204
|
defaultAgent: z.string().default("claude-acp"),
|
|
145
205
|
// Optional per-agent default model id. When a brand-new agent process
|
|
@@ -159,6 +219,11 @@ var HydraConfig = z.object({
|
|
|
159
219
|
// recency and truncated to this count. `--all` overrides in the CLI.
|
|
160
220
|
sessionListColdLimit: z.number().int().nonnegative().default(20),
|
|
161
221
|
extensions: z.record(ExtensionName, ExtensionBody).default({}),
|
|
222
|
+
// npm registry URL used when installing npm-distributed agents into
|
|
223
|
+
// ~/.hydra-acp/agents. Overrides the global ~/.npmrc registry so a
|
|
224
|
+
// corporate .npmrc pointing at an internal registry doesn't break
|
|
225
|
+
// public-package installs. Omit to let npm use its own defaults.
|
|
226
|
+
npmRegistry: z.string().url().optional(),
|
|
162
227
|
tui: TuiConfig.default({
|
|
163
228
|
repaintThrottleMs: 1e3,
|
|
164
229
|
maxScrollbackLines: 1e4,
|
|
@@ -168,9 +233,6 @@ var HydraConfig = z.object({
|
|
|
168
233
|
progressIndicator: true
|
|
169
234
|
})
|
|
170
235
|
});
|
|
171
|
-
var HydraConfigReadOnly = HydraConfig.extend({
|
|
172
|
-
daemon: DaemonConfig.omit({ authToken: true }).default({})
|
|
173
|
-
});
|
|
174
236
|
function extensionList(config) {
|
|
175
237
|
return Object.entries(config.extensions).map(([name, body]) => ({
|
|
176
238
|
name,
|
|
@@ -180,7 +242,7 @@ function extensionList(config) {
|
|
|
180
242
|
async function readConfigFile() {
|
|
181
243
|
let raw;
|
|
182
244
|
try {
|
|
183
|
-
raw = await
|
|
245
|
+
raw = await fs2.readFile(paths.config(), "utf8");
|
|
184
246
|
} catch (err) {
|
|
185
247
|
const e = err;
|
|
186
248
|
if (e.code === "ENOENT") {
|
|
@@ -190,44 +252,34 @@ async function readConfigFile() {
|
|
|
190
252
|
}
|
|
191
253
|
return JSON.parse(raw);
|
|
192
254
|
}
|
|
193
|
-
async function
|
|
194
|
-
|
|
255
|
+
async function migrateLegacyAuthToken() {
|
|
256
|
+
const raw = await readConfigFile();
|
|
257
|
+
const daemon = raw.daemon;
|
|
258
|
+
const legacy = daemon && typeof daemon.authToken === "string" ? daemon.authToken : void 0;
|
|
259
|
+
if (!legacy) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
let tokenFileExists = false;
|
|
195
263
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (trimmed.length > 0) {
|
|
199
|
-
tokenFile = trimmed;
|
|
200
|
-
}
|
|
264
|
+
await fs2.access(paths.authToken());
|
|
265
|
+
tokenFileExists = true;
|
|
201
266
|
} catch (err) {
|
|
202
267
|
const e = err;
|
|
203
268
|
if (e.code !== "ENOENT") {
|
|
204
269
|
throw err;
|
|
205
270
|
}
|
|
206
271
|
}
|
|
207
|
-
|
|
208
|
-
const daemon = raw.daemon;
|
|
209
|
-
const legacy = daemon && typeof daemon.authToken === "string" ? daemon.authToken : void 0;
|
|
210
|
-
if (tokenFile && legacy) {
|
|
272
|
+
if (tokenFileExists) {
|
|
211
273
|
throw new Error(
|
|
212
274
|
`Auth token present in both ${paths.authToken()} and ${paths.config()} (daemon.authToken). Remove daemon.authToken from config.json to resolve.`
|
|
213
275
|
);
|
|
214
276
|
}
|
|
215
|
-
|
|
216
|
-
return tokenFile;
|
|
217
|
-
}
|
|
218
|
-
if (legacy) {
|
|
219
|
-
await migrateLegacyAuthToken(raw, daemon, legacy);
|
|
220
|
-
return legacy;
|
|
221
|
-
}
|
|
222
|
-
return void 0;
|
|
223
|
-
}
|
|
224
|
-
async function migrateLegacyAuthToken(raw, daemon, token) {
|
|
225
|
-
await writeAuthToken(token);
|
|
277
|
+
await writeServiceToken(legacy);
|
|
226
278
|
delete daemon.authToken;
|
|
227
279
|
if (Object.keys(daemon).length === 0) {
|
|
228
280
|
delete raw.daemon;
|
|
229
281
|
}
|
|
230
|
-
await
|
|
282
|
+
await fs2.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
|
|
231
283
|
encoding: "utf8",
|
|
232
284
|
mode: 384
|
|
233
285
|
});
|
|
@@ -236,61 +288,19 @@ async function migrateLegacyAuthToken(raw, daemon, token) {
|
|
|
236
288
|
`
|
|
237
289
|
);
|
|
238
290
|
}
|
|
239
|
-
async function writeAuthToken(token) {
|
|
240
|
-
await fs.mkdir(paths.home(), { recursive: true });
|
|
241
|
-
await fs.writeFile(paths.authToken(), token + "\n", {
|
|
242
|
-
encoding: "utf8",
|
|
243
|
-
mode: 384
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
291
|
async function loadConfig() {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
throw new Error(
|
|
250
|
-
`No auth token found at ${paths.authToken()}. Run \`hydra-acp init\` to create one.`
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
const raw = await readConfigFile();
|
|
254
|
-
const daemon = raw.daemon ??= {};
|
|
255
|
-
daemon.authToken = token;
|
|
256
|
-
return HydraConfig.parse(raw);
|
|
257
|
-
}
|
|
258
|
-
async function ensureConfig() {
|
|
259
|
-
if (!await loadAuthToken()) {
|
|
260
|
-
const token = generateAuthToken();
|
|
261
|
-
await writeAuthToken(token);
|
|
262
|
-
process.stderr.write(
|
|
263
|
-
`hydra-acp: initialized ${paths.authToken()} with a fresh auth token.
|
|
264
|
-
`
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
return loadConfig();
|
|
292
|
+
await migrateLegacyAuthToken();
|
|
293
|
+
return HydraConfig.parse(await readConfigFile());
|
|
268
294
|
}
|
|
269
295
|
async function writeConfig(config) {
|
|
270
|
-
await
|
|
271
|
-
|
|
272
|
-
const { authToken: _authToken, ...daemonRest } = daemon;
|
|
273
|
-
const onDisk = { ...rest, daemon: daemonRest };
|
|
274
|
-
await fs.writeFile(paths.config(), JSON.stringify(onDisk, null, 2) + "\n", {
|
|
296
|
+
await fs2.mkdir(paths.home(), { recursive: true });
|
|
297
|
+
await fs2.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
|
|
275
298
|
encoding: "utf8",
|
|
276
299
|
mode: 384
|
|
277
300
|
});
|
|
278
301
|
}
|
|
279
|
-
function generateAuthToken() {
|
|
280
|
-
const bytes = new Uint8Array(32);
|
|
281
|
-
crypto.getRandomValues(bytes);
|
|
282
|
-
let hex = "";
|
|
283
|
-
for (const b of bytes) {
|
|
284
|
-
hex += b.toString(16).padStart(2, "0");
|
|
285
|
-
}
|
|
286
|
-
return `hydra_token_${hex}`;
|
|
287
|
-
}
|
|
288
302
|
function defaultConfig() {
|
|
289
|
-
return HydraConfig.parse({
|
|
290
|
-
daemon: {
|
|
291
|
-
authToken: generateAuthToken()
|
|
292
|
-
}
|
|
293
|
-
});
|
|
303
|
+
return HydraConfig.parse({});
|
|
294
304
|
}
|
|
295
305
|
function expandHome(p) {
|
|
296
306
|
if (p === "~" || p === "$HOME") {
|
|
@@ -306,11 +316,11 @@ function expandHome(p) {
|
|
|
306
316
|
}
|
|
307
317
|
|
|
308
318
|
// src/core/registry.ts
|
|
309
|
-
import * as
|
|
319
|
+
import * as fs4 from "fs/promises";
|
|
310
320
|
import { z as z2 } from "zod";
|
|
311
321
|
|
|
312
322
|
// src/core/binary-install.ts
|
|
313
|
-
import * as
|
|
323
|
+
import * as fs3 from "fs";
|
|
314
324
|
import * as fsp from "fs/promises";
|
|
315
325
|
import * as path2 from "path";
|
|
316
326
|
import { spawn } from "child_process";
|
|
@@ -415,7 +425,7 @@ async function downloadTo(args) {
|
|
|
415
425
|
);
|
|
416
426
|
}
|
|
417
427
|
const total = Number(response.headers.get("content-length") ?? "0");
|
|
418
|
-
const out =
|
|
428
|
+
const out = fs3.createWriteStream(dest);
|
|
419
429
|
const nodeStream = Readable.fromWeb(response.body);
|
|
420
430
|
let received = 0;
|
|
421
431
|
let lastEmit = Date.now();
|
|
@@ -541,7 +551,8 @@ async function ensureNpmPackage(args) {
|
|
|
541
551
|
await installInto({
|
|
542
552
|
agentId: args.agentId,
|
|
543
553
|
packageSpec: args.packageSpec,
|
|
544
|
-
installDir
|
|
554
|
+
installDir,
|
|
555
|
+
registry: args.registry
|
|
545
556
|
});
|
|
546
557
|
if (!await fileExists2(binPath)) {
|
|
547
558
|
throw new Error(
|
|
@@ -559,7 +570,8 @@ async function installInto(args) {
|
|
|
559
570
|
);
|
|
560
571
|
await runNpmInstall({
|
|
561
572
|
packageSpec: args.packageSpec,
|
|
562
|
-
cwd: tempDir
|
|
573
|
+
cwd: tempDir,
|
|
574
|
+
registry: args.registry
|
|
563
575
|
});
|
|
564
576
|
try {
|
|
565
577
|
await fsp2.rename(tempDir, args.installDir);
|
|
@@ -583,9 +595,10 @@ async function installInto(args) {
|
|
|
583
595
|
}
|
|
584
596
|
function runNpmInstall(args) {
|
|
585
597
|
return new Promise((resolve3, reject) => {
|
|
598
|
+
const registryArgs = args.registry ? ["--registry", args.registry] : [];
|
|
586
599
|
const child = spawn2(
|
|
587
600
|
"npm",
|
|
588
|
-
["install", "--no-audit", "--no-fund", "--silent", args.packageSpec],
|
|
601
|
+
["install", "--no-audit", "--no-fund", "--silent", ...registryArgs, args.packageSpec],
|
|
589
602
|
{
|
|
590
603
|
cwd: args.cwd,
|
|
591
604
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -680,10 +693,12 @@ var RegistryDocument = z2.object({
|
|
|
680
693
|
extensions: z2.array(z2.unknown()).optional()
|
|
681
694
|
});
|
|
682
695
|
var Registry = class {
|
|
683
|
-
constructor(config) {
|
|
696
|
+
constructor(config, options = {}) {
|
|
684
697
|
this.config = config;
|
|
698
|
+
this.options = options;
|
|
685
699
|
}
|
|
686
700
|
config;
|
|
701
|
+
options;
|
|
687
702
|
cache;
|
|
688
703
|
async load() {
|
|
689
704
|
if (this.cache && this.isFresh(this.cache.fetchedAt)) {
|
|
@@ -733,12 +748,17 @@ var Registry = class {
|
|
|
733
748
|
}
|
|
734
749
|
const raw = await response.json();
|
|
735
750
|
const data = RegistryDocument.parse(raw);
|
|
736
|
-
|
|
751
|
+
const cached = { fetchedAt: Date.now(), raw, data };
|
|
752
|
+
const hook = this.options.onFetched;
|
|
753
|
+
if (hook) {
|
|
754
|
+
void Promise.resolve().then(() => hook(data)).catch(() => void 0);
|
|
755
|
+
}
|
|
756
|
+
return cached;
|
|
737
757
|
}
|
|
738
758
|
async readDiskCache() {
|
|
739
759
|
let text;
|
|
740
760
|
try {
|
|
741
|
-
text = await
|
|
761
|
+
text = await fs4.readFile(paths.registryCache(), "utf8");
|
|
742
762
|
} catch (err) {
|
|
743
763
|
const e = err;
|
|
744
764
|
if (e.code === "ENOENT") {
|
|
@@ -764,7 +784,7 @@ var Registry = class {
|
|
|
764
784
|
// without a lock file: the loser of the rename race just gets its
|
|
765
785
|
// version replaced by the winner's.
|
|
766
786
|
async writeDiskCache(cache) {
|
|
767
|
-
await
|
|
787
|
+
await fs4.mkdir(paths.home(), { recursive: true });
|
|
768
788
|
const final = paths.registryCache();
|
|
769
789
|
const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
|
|
770
790
|
const body = JSON.stringify(
|
|
@@ -773,10 +793,10 @@ var Registry = class {
|
|
|
773
793
|
2
|
|
774
794
|
) + "\n";
|
|
775
795
|
try {
|
|
776
|
-
await
|
|
777
|
-
await
|
|
796
|
+
await fs4.writeFile(tmp, body, "utf8");
|
|
797
|
+
await fs4.rename(tmp, final);
|
|
778
798
|
} catch (err) {
|
|
779
|
-
await
|
|
799
|
+
await fs4.unlink(tmp).catch(() => void 0);
|
|
780
800
|
throw err;
|
|
781
801
|
}
|
|
782
802
|
}
|
|
@@ -794,7 +814,8 @@ function npxPackageBasename(agent) {
|
|
|
794
814
|
const atIdx = afterSlash.lastIndexOf("@");
|
|
795
815
|
return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
|
|
796
816
|
}
|
|
797
|
-
async function planSpawn(agent, callerArgs = []) {
|
|
817
|
+
async function planSpawn(agent, callerArgs = [], options = {}) {
|
|
818
|
+
const version = agent.version ?? "current";
|
|
798
819
|
if (agent.distribution.npx) {
|
|
799
820
|
const npx = agent.distribution.npx;
|
|
800
821
|
const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
|
|
@@ -802,20 +823,23 @@ async function planSpawn(agent, callerArgs = []) {
|
|
|
802
823
|
return {
|
|
803
824
|
command: "npx",
|
|
804
825
|
args: ["-y", npx.package, ...tail],
|
|
805
|
-
env: npx.env ?? {}
|
|
826
|
+
env: npx.env ?? {},
|
|
827
|
+
version
|
|
806
828
|
};
|
|
807
829
|
}
|
|
808
830
|
const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
|
|
809
831
|
const binPath = await ensureNpmPackage({
|
|
810
832
|
agentId: agent.id,
|
|
811
|
-
version
|
|
833
|
+
version,
|
|
812
834
|
packageSpec: npx.package,
|
|
813
|
-
bin
|
|
835
|
+
bin,
|
|
836
|
+
registry: options.npmRegistry
|
|
814
837
|
});
|
|
815
838
|
return {
|
|
816
839
|
command: binPath,
|
|
817
840
|
args: tail,
|
|
818
|
-
env: npx.env ?? {}
|
|
841
|
+
env: npx.env ?? {},
|
|
842
|
+
version
|
|
819
843
|
};
|
|
820
844
|
}
|
|
821
845
|
if (agent.distribution.binary) {
|
|
@@ -827,14 +851,15 @@ async function planSpawn(agent, callerArgs = []) {
|
|
|
827
851
|
}
|
|
828
852
|
const cmdPath = await ensureBinary({
|
|
829
853
|
agentId: agent.id,
|
|
830
|
-
version
|
|
854
|
+
version,
|
|
831
855
|
target
|
|
832
856
|
});
|
|
833
857
|
const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
|
|
834
858
|
return {
|
|
835
859
|
command: cmdPath,
|
|
836
860
|
args: tail,
|
|
837
|
-
env: target.env ?? {}
|
|
861
|
+
env: target.env ?? {},
|
|
862
|
+
version
|
|
838
863
|
};
|
|
839
864
|
}
|
|
840
865
|
if (agent.distribution.uvx) {
|
|
@@ -843,7 +868,8 @@ async function planSpawn(agent, callerArgs = []) {
|
|
|
843
868
|
return {
|
|
844
869
|
command: "uvx",
|
|
845
870
|
args: [uvx.package, ...tail],
|
|
846
|
-
env: uvx.env ?? {}
|
|
871
|
+
env: uvx.env ?? {},
|
|
872
|
+
version
|
|
847
873
|
};
|
|
848
874
|
}
|
|
849
875
|
throw new Error(`Agent ${agent.id} has no usable distribution method.`);
|
|
@@ -995,6 +1021,58 @@ function extractHydraMeta(meta) {
|
|
|
995
1021
|
out.availableCommands = cmds;
|
|
996
1022
|
}
|
|
997
1023
|
}
|
|
1024
|
+
if (typeof obj.promptQueueing === "boolean") {
|
|
1025
|
+
out.promptQueueing = obj.promptQueueing;
|
|
1026
|
+
}
|
|
1027
|
+
if (Array.isArray(obj.queue)) {
|
|
1028
|
+
const entries = [];
|
|
1029
|
+
for (const raw of obj.queue) {
|
|
1030
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const r = raw;
|
|
1034
|
+
const orig = r.originator;
|
|
1035
|
+
if (typeof r.messageId !== "string" || !orig || typeof orig.clientId !== "string" || !Array.isArray(r.prompt) || typeof r.position !== "number" || typeof r.enqueuedAt !== "number") {
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
const originator = { clientId: orig.clientId };
|
|
1039
|
+
if (typeof orig.name === "string") originator.name = orig.name;
|
|
1040
|
+
if (typeof orig.version === "string") originator.version = orig.version;
|
|
1041
|
+
entries.push({
|
|
1042
|
+
messageId: r.messageId,
|
|
1043
|
+
originator,
|
|
1044
|
+
prompt: r.prompt,
|
|
1045
|
+
position: r.position,
|
|
1046
|
+
enqueuedAt: r.enqueuedAt
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
if (entries.length > 0) {
|
|
1050
|
+
out.queue = entries;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (Array.isArray(obj.availableModes)) {
|
|
1054
|
+
const modes = [];
|
|
1055
|
+
for (const raw of obj.availableModes) {
|
|
1056
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
const m = raw;
|
|
1060
|
+
if (typeof m.id !== "string") {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
const mode = { id: m.id };
|
|
1064
|
+
if (typeof m.name === "string") {
|
|
1065
|
+
mode.name = m.name;
|
|
1066
|
+
}
|
|
1067
|
+
if (typeof m.description === "string") {
|
|
1068
|
+
mode.description = m.description;
|
|
1069
|
+
}
|
|
1070
|
+
modes.push(mode);
|
|
1071
|
+
}
|
|
1072
|
+
if (modes.length > 0) {
|
|
1073
|
+
out.availableModes = modes;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
998
1076
|
return out;
|
|
999
1077
|
}
|
|
1000
1078
|
function mergeMeta(passthrough, ours) {
|
|
@@ -1047,6 +1125,49 @@ var SessionPromptParams = z3.object({
|
|
|
1047
1125
|
var SessionCancelParams = z3.object({
|
|
1048
1126
|
sessionId: z3.string()
|
|
1049
1127
|
});
|
|
1128
|
+
var PromptOriginatorSchema = z3.object({
|
|
1129
|
+
clientId: z3.string(),
|
|
1130
|
+
name: z3.string().optional(),
|
|
1131
|
+
version: z3.string().optional()
|
|
1132
|
+
});
|
|
1133
|
+
var PromptQueueAddedParams = z3.object({
|
|
1134
|
+
sessionId: z3.string(),
|
|
1135
|
+
messageId: z3.string(),
|
|
1136
|
+
originator: PromptOriginatorSchema,
|
|
1137
|
+
prompt: z3.array(z3.unknown()),
|
|
1138
|
+
// 0 = head (currently in-flight). At enqueue time the new entry's
|
|
1139
|
+
// position equals the count of entries already ahead of it.
|
|
1140
|
+
position: z3.number().int().nonnegative(),
|
|
1141
|
+
queueDepth: z3.number().int().positive(),
|
|
1142
|
+
enqueuedAt: z3.number()
|
|
1143
|
+
});
|
|
1144
|
+
var PromptQueueUpdatedParams = z3.object({
|
|
1145
|
+
sessionId: z3.string(),
|
|
1146
|
+
messageId: z3.string(),
|
|
1147
|
+
prompt: z3.array(z3.unknown())
|
|
1148
|
+
});
|
|
1149
|
+
var PromptQueueRemovedParams = z3.object({
|
|
1150
|
+
sessionId: z3.string(),
|
|
1151
|
+
messageId: z3.string(),
|
|
1152
|
+
reason: z3.enum(["started", "cancelled", "abandoned"])
|
|
1153
|
+
});
|
|
1154
|
+
var CancelPromptParams = z3.object({
|
|
1155
|
+
sessionId: z3.string(),
|
|
1156
|
+
messageId: z3.string()
|
|
1157
|
+
});
|
|
1158
|
+
var CancelPromptResult = z3.object({
|
|
1159
|
+
cancelled: z3.boolean(),
|
|
1160
|
+
reason: z3.enum(["ok", "not_found", "already_running"])
|
|
1161
|
+
});
|
|
1162
|
+
var UpdatePromptParams = z3.object({
|
|
1163
|
+
sessionId: z3.string(),
|
|
1164
|
+
messageId: z3.string(),
|
|
1165
|
+
prompt: z3.array(z3.unknown())
|
|
1166
|
+
});
|
|
1167
|
+
var UpdatePromptResult = z3.object({
|
|
1168
|
+
updated: z3.boolean(),
|
|
1169
|
+
reason: z3.enum(["ok", "not_found", "already_running"])
|
|
1170
|
+
});
|
|
1050
1171
|
var ProxyInitializeParams = z3.object({
|
|
1051
1172
|
protocolVersion: z3.number().optional(),
|
|
1052
1173
|
proxyInfo: z3.object({
|
|
@@ -1334,6 +1455,9 @@ var JsonRpcConnection = class _JsonRpcConnection {
|
|
|
1334
1455
|
var DEFAULT_STDERR_TAIL_BYTES = 4096;
|
|
1335
1456
|
var AgentInstance = class _AgentInstance {
|
|
1336
1457
|
agentId;
|
|
1458
|
+
// Version this process was spawned from — used by the registry-fetch
|
|
1459
|
+
// prune sweep to skip install dirs belonging to a live agent.
|
|
1460
|
+
version;
|
|
1337
1461
|
cwd;
|
|
1338
1462
|
connection;
|
|
1339
1463
|
child;
|
|
@@ -1345,6 +1469,7 @@ var AgentInstance = class _AgentInstance {
|
|
|
1345
1469
|
exitHandlers = [];
|
|
1346
1470
|
constructor(opts, child) {
|
|
1347
1471
|
this.agentId = opts.agentId;
|
|
1472
|
+
this.version = opts.plan.version;
|
|
1348
1473
|
this.cwd = opts.cwd;
|
|
1349
1474
|
this.child = child;
|
|
1350
1475
|
this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
|
|
@@ -1435,7 +1560,7 @@ stderr: ${tail}` : reason;
|
|
|
1435
1560
|
};
|
|
1436
1561
|
|
|
1437
1562
|
// src/core/session-manager.ts
|
|
1438
|
-
import * as
|
|
1563
|
+
import * as fs10 from "fs/promises";
|
|
1439
1564
|
import * as os2 from "os";
|
|
1440
1565
|
import { customAlphabet as customAlphabet3 } from "nanoid";
|
|
1441
1566
|
|
|
@@ -1469,6 +1594,47 @@ function hydraCommandsAsAdvertised() {
|
|
|
1469
1594
|
}));
|
|
1470
1595
|
}
|
|
1471
1596
|
|
|
1597
|
+
// src/core/queue-store.ts
|
|
1598
|
+
import * as fs5 from "fs/promises";
|
|
1599
|
+
async function rewriteQueue(sessionId, entries) {
|
|
1600
|
+
const file = paths.queueFile(sessionId);
|
|
1601
|
+
if (entries.length === 0) {
|
|
1602
|
+
await fs5.unlink(file).catch(() => void 0);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1606
|
+
const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1607
|
+
await fs5.writeFile(file, body, "utf8");
|
|
1608
|
+
}
|
|
1609
|
+
async function loadQueue(sessionId) {
|
|
1610
|
+
const file = paths.queueFile(sessionId);
|
|
1611
|
+
let text;
|
|
1612
|
+
try {
|
|
1613
|
+
text = await fs5.readFile(file, "utf8");
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
if (err.code === "ENOENT") {
|
|
1616
|
+
return [];
|
|
1617
|
+
}
|
|
1618
|
+
throw err;
|
|
1619
|
+
}
|
|
1620
|
+
const out = [];
|
|
1621
|
+
for (const line of text.split("\n")) {
|
|
1622
|
+
if (!line.trim()) continue;
|
|
1623
|
+
try {
|
|
1624
|
+
const parsed = JSON.parse(line);
|
|
1625
|
+
if (parsed && typeof parsed.messageId === "string" && Array.isArray(parsed.prompt) && typeof parsed.enqueuedAt === "number") {
|
|
1626
|
+
out.push(parsed);
|
|
1627
|
+
}
|
|
1628
|
+
} catch {
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return out;
|
|
1632
|
+
}
|
|
1633
|
+
async function deleteQueue(sessionId) {
|
|
1634
|
+
const file = paths.queueFile(sessionId);
|
|
1635
|
+
await fs5.unlink(file).catch(() => void 0);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1472
1638
|
// src/core/session.ts
|
|
1473
1639
|
var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
1474
1640
|
var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
|
|
@@ -1504,7 +1670,18 @@ var Session = class {
|
|
|
1504
1670
|
clients = /* @__PURE__ */ new Map();
|
|
1505
1671
|
historyStore;
|
|
1506
1672
|
promptQueue = [];
|
|
1673
|
+
// The entry that drainQueue is currently awaiting. Distinct from
|
|
1674
|
+
// promptQueue[0] (which is the *next* one to dequeue): once shifted
|
|
1675
|
+
// off, the entry lives here for the duration of its task() so
|
|
1676
|
+
// cancelQueuedPrompt can distinguish "still in line" from "running"
|
|
1677
|
+
// and return already_running for the latter.
|
|
1678
|
+
currentEntry;
|
|
1507
1679
|
promptInFlight = false;
|
|
1680
|
+
// Serialize disk writes to the persisted queue file. Without this
|
|
1681
|
+
// chain, fire-and-forget appends/rewrites can interleave (e.g.
|
|
1682
|
+
// drainQueue's rewrite-to-empty races a sibling's append-on-
|
|
1683
|
+
// enqueue) and leave the file out of sync with in-memory state.
|
|
1684
|
+
queueWriteChain = Promise.resolve();
|
|
1508
1685
|
closed = false;
|
|
1509
1686
|
closeHandlers = [];
|
|
1510
1687
|
titleHandlers = [];
|
|
@@ -1557,10 +1734,14 @@ var Session = class {
|
|
|
1557
1734
|
// can deliver the merged list via _meta without depending on history
|
|
1558
1735
|
// replay.
|
|
1559
1736
|
agentAdvertisedCommands = [];
|
|
1737
|
+
// Last available_modes_update we observed from the agent. Same
|
|
1738
|
+
// pattern as commands: cache, persist, broadcast on change.
|
|
1739
|
+
agentAdvertisedModes = [];
|
|
1560
1740
|
// Persist hooks for snapshot-shaped state. SessionManager hooks these
|
|
1561
1741
|
// to mirror changes into meta.json so cold-resurrect attaches can
|
|
1562
1742
|
// surface the latest snapshot via the attach response _meta.
|
|
1563
1743
|
agentCommandsHandlers = [];
|
|
1744
|
+
agentModesHandlers = [];
|
|
1564
1745
|
modelHandlers = [];
|
|
1565
1746
|
modeHandlers = [];
|
|
1566
1747
|
usageHandlers = [];
|
|
@@ -1579,6 +1760,9 @@ var Session = class {
|
|
|
1579
1760
|
if (init.agentCommands && init.agentCommands.length > 0) {
|
|
1580
1761
|
this.agentAdvertisedCommands = [...init.agentCommands];
|
|
1581
1762
|
}
|
|
1763
|
+
if (init.agentModes && init.agentModes.length > 0) {
|
|
1764
|
+
this.agentAdvertisedModes = [...init.agentModes];
|
|
1765
|
+
}
|
|
1582
1766
|
this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
|
|
1583
1767
|
this.spawnReplacementAgent = init.spawnReplacementAgent;
|
|
1584
1768
|
this.logger = init.logger;
|
|
@@ -1607,6 +1791,15 @@ var Session = class {
|
|
|
1607
1791
|
}
|
|
1608
1792
|
});
|
|
1609
1793
|
}
|
|
1794
|
+
broadcastAvailableModes() {
|
|
1795
|
+
this.recordAndBroadcast("session/update", {
|
|
1796
|
+
sessionId: this.upstreamSessionId,
|
|
1797
|
+
update: {
|
|
1798
|
+
sessionUpdate: "available_modes_update",
|
|
1799
|
+
availableModes: this.agentAdvertisedModes
|
|
1800
|
+
}
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1610
1803
|
// Register session/update, session/request_permission, and onExit
|
|
1611
1804
|
// handlers on an agent connection. Re-run on every /hydra agent so
|
|
1612
1805
|
// the new agent is plumbed identically. The exit handler's identity
|
|
@@ -1624,6 +1817,11 @@ var Session = class {
|
|
|
1624
1817
|
this.setAgentAdvertisedCommands(agentCmds);
|
|
1625
1818
|
return;
|
|
1626
1819
|
}
|
|
1820
|
+
const agentModes = extractAdvertisedModes(params);
|
|
1821
|
+
if (agentModes !== null) {
|
|
1822
|
+
this.setAgentAdvertisedModes(agentModes);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1627
1825
|
if (this.maybeApplyAgentModel(params)) {
|
|
1628
1826
|
this.recordAndBroadcast("session/update", params);
|
|
1629
1827
|
return;
|
|
@@ -1800,7 +1998,7 @@ var Session = class {
|
|
|
1800
1998
|
sessionId,
|
|
1801
1999
|
update: {
|
|
1802
2000
|
sessionUpdate: "current_mode_update",
|
|
1803
|
-
|
|
2001
|
+
currentModeId: this.currentMode
|
|
1804
2002
|
}
|
|
1805
2003
|
},
|
|
1806
2004
|
recordedAt
|
|
@@ -1820,6 +2018,19 @@ var Session = class {
|
|
|
1820
2018
|
recordedAt
|
|
1821
2019
|
});
|
|
1822
2020
|
}
|
|
2021
|
+
if (this.agentAdvertisedModes.length > 0) {
|
|
2022
|
+
out.push({
|
|
2023
|
+
method: "session/update",
|
|
2024
|
+
params: {
|
|
2025
|
+
sessionId,
|
|
2026
|
+
update: {
|
|
2027
|
+
sessionUpdate: "available_modes_update",
|
|
2028
|
+
availableModes: [...this.agentAdvertisedModes]
|
|
2029
|
+
}
|
|
2030
|
+
},
|
|
2031
|
+
recordedAt
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
1823
2034
|
if (this.currentUsage !== void 0) {
|
|
1824
2035
|
const u = this.currentUsage;
|
|
1825
2036
|
const update = {
|
|
@@ -1910,34 +2121,28 @@ var Session = class {
|
|
|
1910
2121
|
if (promptText.startsWith("/hydra")) {
|
|
1911
2122
|
return this.handleSlashCommand(promptText);
|
|
1912
2123
|
}
|
|
1913
|
-
|
|
2124
|
+
const messageId = generateMessageId();
|
|
1914
2125
|
this.maybeSeedTitleFromPrompt(params);
|
|
1915
|
-
return this.
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
const sentBy = { clientId: client.clientId };
|
|
1936
|
-
if (client.clientInfo?.name) {
|
|
1937
|
-
sentBy.name = client.clientInfo.name;
|
|
1938
|
-
}
|
|
1939
|
-
if (client.clientInfo?.version) {
|
|
1940
|
-
sentBy.version = client.clientInfo.version;
|
|
2126
|
+
return this.enqueueUserPrompt(client, params, messageId);
|
|
2127
|
+
}
|
|
2128
|
+
// DEVIATION FROM RFD #533: this broadcast is deliberately deferred
|
|
2129
|
+
// until the prompt actually becomes the active turn (i.e. drainQueue
|
|
2130
|
+
// is about to forward it to the agent), NOT when hydra first accepts
|
|
2131
|
+
// the request. The literal RFD doesn't pin the timing — it just says
|
|
2132
|
+
// peers should learn about the turn — but it was authored before
|
|
2133
|
+
// prompt queueing existed, so accept-time and start-time were the
|
|
2134
|
+
// same moment. With hydra's per-session FIFO, deferring gives
|
|
2135
|
+
// prompt_received a single, useful meaning ("the agent is now taking
|
|
2136
|
+
// a turn on this prompt"), which is how attached clients (notably
|
|
2137
|
+
// agent-shell) consume it. The accept-time signal that peers can use
|
|
2138
|
+
// for queue chip rendering is hydra-acp/prompt_queue_added instead.
|
|
2139
|
+
broadcastPromptReceived(entry) {
|
|
2140
|
+
const sentBy = { clientId: entry.originator.clientId };
|
|
2141
|
+
if (entry.originator.name) {
|
|
2142
|
+
sentBy.name = entry.originator.name;
|
|
2143
|
+
}
|
|
2144
|
+
if (entry.originator.version) {
|
|
2145
|
+
sentBy.version = entry.originator.version;
|
|
1941
2146
|
}
|
|
1942
2147
|
this.promptStartedAt = Date.now();
|
|
1943
2148
|
this.recordAndBroadcast(
|
|
@@ -1946,14 +2151,14 @@ var Session = class {
|
|
|
1946
2151
|
sessionId: this.sessionId,
|
|
1947
2152
|
update: {
|
|
1948
2153
|
sessionUpdate: "prompt_received",
|
|
1949
|
-
messageId:
|
|
1950
|
-
prompt:
|
|
2154
|
+
messageId: entry.messageId,
|
|
2155
|
+
prompt: entry.prompt,
|
|
1951
2156
|
sentBy
|
|
1952
2157
|
}
|
|
1953
2158
|
},
|
|
1954
|
-
|
|
2159
|
+
entry.clientId
|
|
1955
2160
|
);
|
|
1956
|
-
const text = extractPromptText(
|
|
2161
|
+
const text = extractPromptText(entry.prompt);
|
|
1957
2162
|
if (text.length > 0) {
|
|
1958
2163
|
this.recordAndBroadcast(
|
|
1959
2164
|
"session/update",
|
|
@@ -1965,7 +2170,7 @@ var Session = class {
|
|
|
1965
2170
|
_meta: { "hydra-acp": { compatFor: "prompt_received" } }
|
|
1966
2171
|
}
|
|
1967
2172
|
},
|
|
1968
|
-
|
|
2173
|
+
entry.clientId
|
|
1969
2174
|
);
|
|
1970
2175
|
}
|
|
1971
2176
|
}
|
|
@@ -1988,6 +2193,172 @@ var Session = class {
|
|
|
1988
2193
|
originatorClientId
|
|
1989
2194
|
);
|
|
1990
2195
|
}
|
|
2196
|
+
// Total visible-or-running entries: the in-flight head (if any) plus
|
|
2197
|
+
// the queue's user-visible waiting entries. Internal entries don't
|
|
2198
|
+
// count — they're an implementation detail and the wire never
|
|
2199
|
+
// surfaces them.
|
|
2200
|
+
visibleQueueDepth() {
|
|
2201
|
+
let count = this.currentEntry?.kind === "user" && !this.currentEntry.cancelled ? 1 : 0;
|
|
2202
|
+
for (const entry of this.promptQueue) {
|
|
2203
|
+
if (entry.kind === "user" && !entry.cancelled) count += 1;
|
|
2204
|
+
}
|
|
2205
|
+
return count;
|
|
2206
|
+
}
|
|
2207
|
+
broadcastQueueAdded(entry) {
|
|
2208
|
+
const depth = this.visibleQueueDepth();
|
|
2209
|
+
const position = Math.max(0, depth - 1);
|
|
2210
|
+
const params = {
|
|
2211
|
+
sessionId: this.sessionId,
|
|
2212
|
+
messageId: entry.messageId,
|
|
2213
|
+
originator: entry.originator,
|
|
2214
|
+
prompt: entry.prompt,
|
|
2215
|
+
position,
|
|
2216
|
+
queueDepth: depth,
|
|
2217
|
+
enqueuedAt: entry.enqueuedAt
|
|
2218
|
+
};
|
|
2219
|
+
this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
|
|
2220
|
+
}
|
|
2221
|
+
broadcastQueueUpdated(messageId, prompt) {
|
|
2222
|
+
this.broadcastQueueNotification("hydra-acp/prompt_queue_updated", {
|
|
2223
|
+
sessionId: this.sessionId,
|
|
2224
|
+
messageId,
|
|
2225
|
+
prompt
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
broadcastQueueRemoved(messageId, reason) {
|
|
2229
|
+
this.broadcastQueueNotification("hydra-acp/prompt_queue_removed", {
|
|
2230
|
+
sessionId: this.sessionId,
|
|
2231
|
+
messageId,
|
|
2232
|
+
reason
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
// Fan-out for queue lifecycle notifications. Ephemeral by design —
|
|
2236
|
+
// these signals describe transient daemon state, not conversation
|
|
2237
|
+
// content, so we deliberately bypass recordAndBroadcast (no history,
|
|
2238
|
+
// no idle-timer arm, no rewrite-for-client since we already emit the
|
|
2239
|
+
// hydra sessionId).
|
|
2240
|
+
broadcastQueueNotification(method, params) {
|
|
2241
|
+
for (const client of this.clients.values()) {
|
|
2242
|
+
void client.connection.notify(method, params).catch(() => void 0);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
// Snapshot of user-visible queue state at this moment. Surfaced to
|
|
2246
|
+
// late-attaching clients via the session/attach response _meta so
|
|
2247
|
+
// they boot with the same chip list as their peers without waiting
|
|
2248
|
+
// for new prompt_queue_added notifications. Internal entries are
|
|
2249
|
+
// omitted (they're not surfaced on the wire at all).
|
|
2250
|
+
queueSnapshot() {
|
|
2251
|
+
const out = [];
|
|
2252
|
+
let position = 0;
|
|
2253
|
+
if (this.currentEntry?.kind === "user" && !this.currentEntry.cancelled) {
|
|
2254
|
+
out.push({
|
|
2255
|
+
messageId: this.currentEntry.messageId,
|
|
2256
|
+
originator: this.currentEntry.originator,
|
|
2257
|
+
prompt: this.currentEntry.prompt,
|
|
2258
|
+
position: position++,
|
|
2259
|
+
enqueuedAt: this.currentEntry.enqueuedAt
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
for (const entry of this.promptQueue) {
|
|
2263
|
+
if (entry.kind !== "user" || entry.cancelled) continue;
|
|
2264
|
+
out.push({
|
|
2265
|
+
messageId: entry.messageId,
|
|
2266
|
+
originator: entry.originator,
|
|
2267
|
+
prompt: entry.prompt,
|
|
2268
|
+
position: position++,
|
|
2269
|
+
enqueuedAt: entry.enqueuedAt
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
return out;
|
|
2273
|
+
}
|
|
2274
|
+
// Wait for any pending queue-file writes to settle. Test hook so
|
|
2275
|
+
// assertions about on-disk state don't race with fire-and-forget
|
|
2276
|
+
// rewrites. Production code doesn't need this — the chain
|
|
2277
|
+
// self-serializes.
|
|
2278
|
+
async flushPersistWrites() {
|
|
2279
|
+
await this.queueWriteChain.catch(() => void 0);
|
|
2280
|
+
}
|
|
2281
|
+
// Push pre-existing queue entries back through the daemon-side
|
|
2282
|
+
// pipeline on startup. Called by SessionManager after resurrecting
|
|
2283
|
+
// a session that had a non-empty queue.ndjson on disk. Each entry
|
|
2284
|
+
// gets a synthetic UserPromptQueueEntry with no real caller
|
|
2285
|
+
// (resolve/reject are no-ops since the original WS is long gone),
|
|
2286
|
+
// then drainQueue picks it up like any other entry. Late-attaching
|
|
2287
|
+
// clients see the entries via prompt_queue_added broadcasts and the
|
|
2288
|
+
// attach-response snapshot.
|
|
2289
|
+
replayPersistedQueue(entries) {
|
|
2290
|
+
for (const persisted of entries) {
|
|
2291
|
+
const originator = {
|
|
2292
|
+
clientId: `hydra-resurrected_${persisted.messageId}`
|
|
2293
|
+
};
|
|
2294
|
+
if (persisted.originator.clientInfo.name !== void 0) {
|
|
2295
|
+
originator.name = persisted.originator.clientInfo.name;
|
|
2296
|
+
}
|
|
2297
|
+
if (persisted.originator.clientInfo.version !== void 0) {
|
|
2298
|
+
originator.version = persisted.originator.clientInfo.version;
|
|
2299
|
+
}
|
|
2300
|
+
const entry = {
|
|
2301
|
+
kind: "user",
|
|
2302
|
+
messageId: persisted.messageId,
|
|
2303
|
+
originator,
|
|
2304
|
+
// Synthetic clientId. broadcastTurnComplete uses this as
|
|
2305
|
+
// excludeClientId for the peer-only broadcast; with a synthetic
|
|
2306
|
+
// id no real attached client matches the exclude, so everyone
|
|
2307
|
+
// sees turn_complete — which is what we want, since none of
|
|
2308
|
+
// them originated this restart-replayed prompt.
|
|
2309
|
+
clientId: originator.clientId,
|
|
2310
|
+
prompt: persisted.prompt,
|
|
2311
|
+
enqueuedAt: persisted.enqueuedAt,
|
|
2312
|
+
cancelled: false,
|
|
2313
|
+
resolve: () => void 0,
|
|
2314
|
+
reject: () => void 0
|
|
2315
|
+
};
|
|
2316
|
+
this.promptQueue.push(entry);
|
|
2317
|
+
this.broadcastQueueAdded(entry);
|
|
2318
|
+
}
|
|
2319
|
+
void this.drainQueue();
|
|
2320
|
+
}
|
|
2321
|
+
// Drop a queued prompt by messageId. Returns already_running when
|
|
2322
|
+
// the messageId names the in-flight entry — callers should fall back
|
|
2323
|
+
// to session/cancel for that case. Originator-agnostic: any attached
|
|
2324
|
+
// client may cancel any queued prompt (matches the existing slack
|
|
2325
|
+
// :stop_sign: reaction UX and the TUI's queue-edit dispatcher).
|
|
2326
|
+
cancelQueuedPrompt(messageId) {
|
|
2327
|
+
if (this.currentEntry?.messageId === messageId) {
|
|
2328
|
+
return { cancelled: false, reason: "already_running" };
|
|
2329
|
+
}
|
|
2330
|
+
const idx = this.promptQueue.findIndex((e) => e.messageId === messageId);
|
|
2331
|
+
if (idx < 0) {
|
|
2332
|
+
return { cancelled: false, reason: "not_found" };
|
|
2333
|
+
}
|
|
2334
|
+
const entry = this.promptQueue[idx];
|
|
2335
|
+
entry.cancelled = true;
|
|
2336
|
+
this.promptQueue.splice(idx, 1);
|
|
2337
|
+
if (entry.kind === "user") {
|
|
2338
|
+
this.broadcastQueueRemoved(messageId, "cancelled");
|
|
2339
|
+
this.persistRewrite();
|
|
2340
|
+
}
|
|
2341
|
+
entry.resolve({ stopReason: "cancelled" });
|
|
2342
|
+
return { cancelled: true, reason: "ok" };
|
|
2343
|
+
}
|
|
2344
|
+
// Replace the prompt payload of a queued (not-yet-running) entry.
|
|
2345
|
+
// Returns already_running for the in-flight head; not_found for
|
|
2346
|
+
// unknown messageIds or for internal queue entries (internal tasks
|
|
2347
|
+
// don't expose a mutable prompt). Broadcasts prompt_queue_updated on
|
|
2348
|
+
// success so every attached client refreshes its chip.
|
|
2349
|
+
updateQueuedPrompt(messageId, prompt) {
|
|
2350
|
+
if (this.currentEntry?.messageId === messageId) {
|
|
2351
|
+
return { updated: false, reason: "already_running" };
|
|
2352
|
+
}
|
|
2353
|
+
const entry = this.promptQueue.find((e) => e.messageId === messageId);
|
|
2354
|
+
if (!entry || entry.kind !== "user") {
|
|
2355
|
+
return { updated: false, reason: "not_found" };
|
|
2356
|
+
}
|
|
2357
|
+
entry.prompt = prompt;
|
|
2358
|
+
this.broadcastQueueUpdated(messageId, prompt);
|
|
2359
|
+
this.persistRewrite();
|
|
2360
|
+
return { updated: true, reason: "ok" };
|
|
2361
|
+
}
|
|
1991
2362
|
async cancel(clientId) {
|
|
1992
2363
|
const client = this.clients.get(clientId);
|
|
1993
2364
|
if (!client) {
|
|
@@ -2114,7 +2485,7 @@ var Session = class {
|
|
|
2114
2485
|
if (update.sessionUpdate !== "current_mode_update") {
|
|
2115
2486
|
return false;
|
|
2116
2487
|
}
|
|
2117
|
-
const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
|
|
2488
|
+
const raw = typeof update.currentModeId === "string" ? update.currentModeId : typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
|
|
2118
2489
|
if (raw === void 0) {
|
|
2119
2490
|
return true;
|
|
2120
2491
|
}
|
|
@@ -2192,12 +2563,29 @@ var Session = class {
|
|
|
2192
2563
|
}
|
|
2193
2564
|
this.broadcastMergedCommands();
|
|
2194
2565
|
}
|
|
2566
|
+
setAgentAdvertisedModes(modes) {
|
|
2567
|
+
if (sameAdvertisedModes(this.agentAdvertisedModes, modes)) {
|
|
2568
|
+
this.broadcastAvailableModes();
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
this.agentAdvertisedModes = modes;
|
|
2572
|
+
for (const handler of this.agentModesHandlers) {
|
|
2573
|
+
try {
|
|
2574
|
+
handler(modes);
|
|
2575
|
+
} catch {
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
this.broadcastAvailableModes();
|
|
2579
|
+
}
|
|
2195
2580
|
// Subscribe to snapshot-state updates. SessionManager wires these to
|
|
2196
2581
|
// persist the new value into meta.json so cold resurrect can restore
|
|
2197
2582
|
// them via the attach response _meta.
|
|
2198
2583
|
onAgentCommandsChange(handler) {
|
|
2199
2584
|
this.agentCommandsHandlers.push(handler);
|
|
2200
2585
|
}
|
|
2586
|
+
onAgentModesChange(handler) {
|
|
2587
|
+
this.agentModesHandlers.push(handler);
|
|
2588
|
+
}
|
|
2201
2589
|
onModelChange(handler) {
|
|
2202
2590
|
this.modelHandlers.push(handler);
|
|
2203
2591
|
}
|
|
@@ -2219,6 +2607,10 @@ var Session = class {
|
|
|
2219
2607
|
agentOnlyAdvertisedCommands() {
|
|
2220
2608
|
return [...this.agentAdvertisedCommands];
|
|
2221
2609
|
}
|
|
2610
|
+
// The agent's advertised modes list, for callers that need a snapshot.
|
|
2611
|
+
availableModes() {
|
|
2612
|
+
return [...this.agentAdvertisedModes];
|
|
2613
|
+
}
|
|
2222
2614
|
// Pick up an agent-emitted session_info_update and store its title
|
|
2223
2615
|
// as our canonical record. The notification is also forwarded to
|
|
2224
2616
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -2531,6 +2923,20 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2531
2923
|
}
|
|
2532
2924
|
this.closed = true;
|
|
2533
2925
|
this.cancelIdleTimer();
|
|
2926
|
+
const stranded = this.promptQueue;
|
|
2927
|
+
this.promptQueue = [];
|
|
2928
|
+
for (const entry of stranded) {
|
|
2929
|
+
entry.cancelled = true;
|
|
2930
|
+
if (entry.kind === "user") {
|
|
2931
|
+
this.broadcastQueueRemoved(entry.messageId, "abandoned");
|
|
2932
|
+
}
|
|
2933
|
+
try {
|
|
2934
|
+
entry.resolve({ stopReason: "cancelled" });
|
|
2935
|
+
} catch {
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
const sessionId = this.sessionId;
|
|
2939
|
+
this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
|
|
2534
2940
|
for (const client of this.clients.values()) {
|
|
2535
2941
|
void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
|
|
2536
2942
|
}
|
|
@@ -2576,7 +2982,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2576
2982
|
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
2577
2983
|
return;
|
|
2578
2984
|
}
|
|
2579
|
-
if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
|
|
2985
|
+
if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0 || this.promptQueue.length > 0) {
|
|
2580
2986
|
this.armIdleTimer(this.idleTimeoutMs);
|
|
2581
2987
|
return;
|
|
2582
2988
|
}
|
|
@@ -2705,20 +3111,88 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2705
3111
|
}
|
|
2706
3112
|
});
|
|
2707
3113
|
}
|
|
3114
|
+
// Schedule an internal task (title regen, agent swap transcript
|
|
3115
|
+
// injection, import seed). Serializes behind any user prompts already
|
|
3116
|
+
// in flight, but doesn't emit prompt_queue_* broadcasts — clients
|
|
3117
|
+
// shouldn't see hydra's housekeeping in their chip list.
|
|
2708
3118
|
async enqueuePrompt(task) {
|
|
2709
3119
|
return new Promise((resolve3, reject) => {
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
3120
|
+
const entry = {
|
|
3121
|
+
kind: "internal",
|
|
3122
|
+
messageId: generateMessageId(),
|
|
3123
|
+
enqueuedAt: Date.now(),
|
|
3124
|
+
cancelled: false,
|
|
3125
|
+
task,
|
|
3126
|
+
resolve: resolve3,
|
|
3127
|
+
reject
|
|
3128
|
+
};
|
|
3129
|
+
this.promptQueue.push(entry);
|
|
3130
|
+
void this.drainQueue();
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
// Schedule a user-originated session/prompt. Emits prompt_queue_added
|
|
3134
|
+
// immediately on enqueue (so peer clients can render the queued chip)
|
|
3135
|
+
// and prompt_queue_removed when the entry leaves the queue. The
|
|
3136
|
+
// returned promise resolves with the upstream agent's session/prompt
|
|
3137
|
+
// result, or { stopReason: "cancelled" } if the entry is dropped via
|
|
3138
|
+
// cancelQueuedPrompt before reaching the head.
|
|
3139
|
+
async enqueueUserPrompt(client, params, messageId) {
|
|
3140
|
+
const promptArray = (params ?? {}).prompt ?? [];
|
|
3141
|
+
const originator = { clientId: client.clientId };
|
|
3142
|
+
if (client.clientInfo?.name) originator.name = client.clientInfo.name;
|
|
3143
|
+
if (client.clientInfo?.version)
|
|
3144
|
+
originator.version = client.clientInfo.version;
|
|
3145
|
+
return new Promise((resolve3, reject) => {
|
|
3146
|
+
const entry = {
|
|
3147
|
+
kind: "user",
|
|
3148
|
+
messageId,
|
|
3149
|
+
originator,
|
|
3150
|
+
clientId: client.clientId,
|
|
3151
|
+
prompt: promptArray,
|
|
3152
|
+
enqueuedAt: Date.now(),
|
|
3153
|
+
cancelled: false,
|
|
3154
|
+
resolve: resolve3,
|
|
3155
|
+
reject
|
|
2717
3156
|
};
|
|
2718
|
-
this.promptQueue.push(
|
|
3157
|
+
this.promptQueue.push(entry);
|
|
3158
|
+
this.persistRewrite();
|
|
3159
|
+
this.broadcastQueueAdded(entry);
|
|
2719
3160
|
void this.drainQueue();
|
|
2720
3161
|
});
|
|
2721
3162
|
}
|
|
3163
|
+
// Rewrite the on-disk queue to reflect the current set of WAITING
|
|
3164
|
+
// entries (excluding currentEntry, the in-flight head). Excluding
|
|
3165
|
+
// the head is the key idempotency choice: once drainQueue shifts an
|
|
3166
|
+
// entry off and calls persistRewrite, a daemon crash mid-generation
|
|
3167
|
+
// will NOT re-run it on restart. Partial output (if any streamed
|
|
3168
|
+
// before the crash) stays in history; the prompt itself is lost
|
|
3169
|
+
// and the user can re-submit if they care.
|
|
3170
|
+
//
|
|
3171
|
+
// Snapshots in-memory state synchronously (so subsequent mutations
|
|
3172
|
+
// can't perturb what we're about to write) and chains the write
|
|
3173
|
+
// onto queueWriteChain so all persists are serialized.
|
|
3174
|
+
persistRewrite() {
|
|
3175
|
+
const entries = [];
|
|
3176
|
+
for (const entry of this.promptQueue) {
|
|
3177
|
+
if (entry.kind !== "user" || entry.cancelled) continue;
|
|
3178
|
+
entries.push(this.persistedFromEntry(entry));
|
|
3179
|
+
}
|
|
3180
|
+
const sessionId = this.sessionId;
|
|
3181
|
+
this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => rewriteQueue(sessionId, entries).catch(() => void 0));
|
|
3182
|
+
}
|
|
3183
|
+
persistedFromEntry(entry) {
|
|
3184
|
+
return {
|
|
3185
|
+
messageId: entry.messageId,
|
|
3186
|
+
originator: {
|
|
3187
|
+
clientInfo: {
|
|
3188
|
+
...entry.originator.name !== void 0 ? { name: entry.originator.name } : {},
|
|
3189
|
+
...entry.originator.version !== void 0 ? { version: entry.originator.version } : {}
|
|
3190
|
+
}
|
|
3191
|
+
},
|
|
3192
|
+
prompt: entry.prompt,
|
|
3193
|
+
enqueuedAt: entry.enqueuedAt
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
2722
3196
|
async drainQueue() {
|
|
2723
3197
|
if (this.promptInFlight) {
|
|
2724
3198
|
return;
|
|
@@ -2727,14 +3201,64 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2727
3201
|
try {
|
|
2728
3202
|
while (this.promptQueue.length > 0) {
|
|
2729
3203
|
const next = this.promptQueue.shift();
|
|
2730
|
-
if (next) {
|
|
2731
|
-
|
|
3204
|
+
if (!next) {
|
|
3205
|
+
break;
|
|
3206
|
+
}
|
|
3207
|
+
if (next.cancelled) {
|
|
3208
|
+
continue;
|
|
3209
|
+
}
|
|
3210
|
+
this.currentEntry = next;
|
|
3211
|
+
if (next.kind === "user") {
|
|
3212
|
+
this.persistRewrite();
|
|
3213
|
+
}
|
|
3214
|
+
if (next.kind === "user") {
|
|
3215
|
+
this.broadcastQueueRemoved(next.messageId, "started");
|
|
3216
|
+
}
|
|
3217
|
+
try {
|
|
3218
|
+
const result = await this.runQueueEntry(next);
|
|
3219
|
+
next.resolve(result);
|
|
3220
|
+
} catch (err) {
|
|
3221
|
+
next.reject(err);
|
|
3222
|
+
} finally {
|
|
3223
|
+
this.currentEntry = void 0;
|
|
2732
3224
|
}
|
|
2733
3225
|
}
|
|
2734
3226
|
} finally {
|
|
2735
3227
|
this.promptInFlight = false;
|
|
2736
3228
|
}
|
|
2737
3229
|
}
|
|
3230
|
+
// Execute a queue entry. User-prompt entries forward to the upstream
|
|
3231
|
+
// agent and pair with broadcastTurnComplete; internal entries run
|
|
3232
|
+
// their captured task closure. Reads entry.prompt at dispatch time
|
|
3233
|
+
// so updateQueuedPrompt's mutations are honoured.
|
|
3234
|
+
//
|
|
3235
|
+
// For user entries, broadcastPromptReceived fires HERE — not in
|
|
3236
|
+
// Session.prompt — so peer clients see prompt_received only when the
|
|
3237
|
+
// turn actually starts (a deliberate deviation from a naive reading
|
|
3238
|
+
// of RFD #533; see the comment on broadcastPromptReceived). Order on
|
|
3239
|
+
// the wire: prompt_queue_removed{started} (already emitted by
|
|
3240
|
+
// drainQueue) → prompt_received → upstream session/prompt.
|
|
3241
|
+
async runQueueEntry(entry) {
|
|
3242
|
+
if (entry.kind === "internal") {
|
|
3243
|
+
return entry.task();
|
|
3244
|
+
}
|
|
3245
|
+
this.broadcastPromptReceived(entry);
|
|
3246
|
+
let response;
|
|
3247
|
+
try {
|
|
3248
|
+
response = await this.agent.connection.request(
|
|
3249
|
+
"session/prompt",
|
|
3250
|
+
{
|
|
3251
|
+
sessionId: this.upstreamSessionId,
|
|
3252
|
+
prompt: entry.prompt
|
|
3253
|
+
}
|
|
3254
|
+
);
|
|
3255
|
+
} catch (err) {
|
|
3256
|
+
this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
|
|
3257
|
+
throw err;
|
|
3258
|
+
}
|
|
3259
|
+
this.broadcastTurnComplete(entry.clientId, response);
|
|
3260
|
+
return response;
|
|
3261
|
+
}
|
|
2738
3262
|
};
|
|
2739
3263
|
function withCode(err, code) {
|
|
2740
3264
|
err.code = code;
|
|
@@ -2745,6 +3269,7 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
|
|
|
2745
3269
|
"current_model_update",
|
|
2746
3270
|
"current_mode_update",
|
|
2747
3271
|
"available_commands_update",
|
|
3272
|
+
"available_modes_update",
|
|
2748
3273
|
"usage_update"
|
|
2749
3274
|
]);
|
|
2750
3275
|
function isStateUpdate(method, params) {
|
|
@@ -2766,8 +3291,49 @@ function sameAdvertisedCommands(a, b) {
|
|
|
2766
3291
|
}
|
|
2767
3292
|
return true;
|
|
2768
3293
|
}
|
|
2769
|
-
function
|
|
2770
|
-
|
|
3294
|
+
function sameAdvertisedModes(a, b) {
|
|
3295
|
+
if (a.length !== b.length) {
|
|
3296
|
+
return false;
|
|
3297
|
+
}
|
|
3298
|
+
for (let i = 0; i < a.length; i++) {
|
|
3299
|
+
if (a[i]?.id !== b[i]?.id || a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
|
|
3300
|
+
return false;
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return true;
|
|
3304
|
+
}
|
|
3305
|
+
function extractAdvertisedModes(params) {
|
|
3306
|
+
const obj = params ?? {};
|
|
3307
|
+
const update = obj.update ?? {};
|
|
3308
|
+
if (update.sessionUpdate !== "available_modes_update") {
|
|
3309
|
+
return null;
|
|
3310
|
+
}
|
|
3311
|
+
const list = update.availableModes;
|
|
3312
|
+
if (!Array.isArray(list)) {
|
|
3313
|
+
return [];
|
|
3314
|
+
}
|
|
3315
|
+
const out = [];
|
|
3316
|
+
for (const raw of list) {
|
|
3317
|
+
if (!raw || typeof raw !== "object") {
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
3320
|
+
const m = raw;
|
|
3321
|
+
if (typeof m.id !== "string" || m.id.length === 0) {
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
const mode = { id: m.id };
|
|
3325
|
+
if (typeof m.name === "string") {
|
|
3326
|
+
mode.name = m.name;
|
|
3327
|
+
}
|
|
3328
|
+
if (typeof m.description === "string") {
|
|
3329
|
+
mode.description = m.description;
|
|
3330
|
+
}
|
|
3331
|
+
out.push(mode);
|
|
3332
|
+
}
|
|
3333
|
+
return out;
|
|
3334
|
+
}
|
|
3335
|
+
function captureInternalChunk(capture, params) {
|
|
3336
|
+
const obj = params ?? {};
|
|
2771
3337
|
const update = obj.update ?? {};
|
|
2772
3338
|
if (update.sessionUpdate !== "agent_message_chunk") {
|
|
2773
3339
|
return;
|
|
@@ -2921,7 +3487,7 @@ function firstLine(text, max) {
|
|
|
2921
3487
|
}
|
|
2922
3488
|
|
|
2923
3489
|
// src/core/session-store.ts
|
|
2924
|
-
import * as
|
|
3490
|
+
import * as fs6 from "fs/promises";
|
|
2925
3491
|
import * as path4 from "path";
|
|
2926
3492
|
import { customAlphabet as customAlphabet2 } from "nanoid";
|
|
2927
3493
|
import { z as z4 } from "zod";
|
|
@@ -2935,6 +3501,11 @@ var PersistedAgentCommand = z4.object({
|
|
|
2935
3501
|
name: z4.string(),
|
|
2936
3502
|
description: z4.string().optional()
|
|
2937
3503
|
});
|
|
3504
|
+
var PersistedAgentMode = z4.object({
|
|
3505
|
+
id: z4.string(),
|
|
3506
|
+
name: z4.string().optional(),
|
|
3507
|
+
description: z4.string().optional()
|
|
3508
|
+
});
|
|
2938
3509
|
var PersistedUsage = z4.object({
|
|
2939
3510
|
used: z4.number().optional(),
|
|
2940
3511
|
size: z4.number().optional(),
|
|
@@ -2980,6 +3551,7 @@ var SessionRecord = z4.object({
|
|
|
2980
3551
|
currentMode: z4.string().optional(),
|
|
2981
3552
|
currentUsage: PersistedUsage.optional(),
|
|
2982
3553
|
agentCommands: z4.array(PersistedAgentCommand).optional(),
|
|
3554
|
+
agentModes: z4.array(PersistedAgentMode).optional(),
|
|
2983
3555
|
createdAt: z4.string(),
|
|
2984
3556
|
updatedAt: z4.string()
|
|
2985
3557
|
});
|
|
@@ -2992,9 +3564,9 @@ function assertSafeId(id) {
|
|
|
2992
3564
|
var SessionStore = class {
|
|
2993
3565
|
async write(record) {
|
|
2994
3566
|
assertSafeId(record.sessionId);
|
|
2995
|
-
await
|
|
3567
|
+
await fs6.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
|
|
2996
3568
|
const full = { version: 1, ...record };
|
|
2997
|
-
await
|
|
3569
|
+
await fs6.writeFile(
|
|
2998
3570
|
paths.sessionFile(record.sessionId),
|
|
2999
3571
|
JSON.stringify(full, null, 2) + "\n",
|
|
3000
3572
|
{ encoding: "utf8", mode: 384 }
|
|
@@ -3006,7 +3578,7 @@ var SessionStore = class {
|
|
|
3006
3578
|
}
|
|
3007
3579
|
let raw;
|
|
3008
3580
|
try {
|
|
3009
|
-
raw = await
|
|
3581
|
+
raw = await fs6.readFile(paths.sessionFile(sessionId), "utf8");
|
|
3010
3582
|
} catch (err) {
|
|
3011
3583
|
const e = err;
|
|
3012
3584
|
if (e.code === "ENOENT") {
|
|
@@ -3025,7 +3597,7 @@ var SessionStore = class {
|
|
|
3025
3597
|
return;
|
|
3026
3598
|
}
|
|
3027
3599
|
try {
|
|
3028
|
-
await
|
|
3600
|
+
await fs6.unlink(paths.sessionFile(sessionId));
|
|
3029
3601
|
} catch (err) {
|
|
3030
3602
|
const e = err;
|
|
3031
3603
|
if (e.code !== "ENOENT") {
|
|
@@ -3033,7 +3605,7 @@ var SessionStore = class {
|
|
|
3033
3605
|
}
|
|
3034
3606
|
}
|
|
3035
3607
|
try {
|
|
3036
|
-
await
|
|
3608
|
+
await fs6.rmdir(paths.sessionDir(sessionId));
|
|
3037
3609
|
} catch (err) {
|
|
3038
3610
|
const e = err;
|
|
3039
3611
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -3063,7 +3635,7 @@ var SessionStore = class {
|
|
|
3063
3635
|
async list() {
|
|
3064
3636
|
let entries;
|
|
3065
3637
|
try {
|
|
3066
|
-
entries = await
|
|
3638
|
+
entries = await fs6.readdir(paths.sessionsDir());
|
|
3067
3639
|
} catch (err) {
|
|
3068
3640
|
const e = err;
|
|
3069
3641
|
if (e.code === "ENOENT") {
|
|
@@ -3098,13 +3670,14 @@ function recordFromMemorySession(args) {
|
|
|
3098
3670
|
currentMode: args.currentMode,
|
|
3099
3671
|
currentUsage: args.currentUsage,
|
|
3100
3672
|
agentCommands: args.agentCommands,
|
|
3673
|
+
agentModes: args.agentModes,
|
|
3101
3674
|
createdAt: args.createdAt ?? now,
|
|
3102
3675
|
updatedAt: args.updatedAt ?? now
|
|
3103
3676
|
};
|
|
3104
3677
|
}
|
|
3105
3678
|
|
|
3106
3679
|
// src/core/history-store.ts
|
|
3107
|
-
import * as
|
|
3680
|
+
import * as fs7 from "fs/promises";
|
|
3108
3681
|
var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
|
|
3109
3682
|
var DEFAULT_MAX_ENTRIES = 1e3;
|
|
3110
3683
|
var HistoryStore = class {
|
|
@@ -3121,9 +3694,9 @@ var HistoryStore = class {
|
|
|
3121
3694
|
return;
|
|
3122
3695
|
}
|
|
3123
3696
|
return this.enqueue(sessionId, async () => {
|
|
3124
|
-
await
|
|
3697
|
+
await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
3125
3698
|
const line = JSON.stringify(entry) + "\n";
|
|
3126
|
-
await
|
|
3699
|
+
await fs7.appendFile(paths.historyFile(sessionId), line, {
|
|
3127
3700
|
encoding: "utf8",
|
|
3128
3701
|
mode: 384
|
|
3129
3702
|
});
|
|
@@ -3134,9 +3707,9 @@ var HistoryStore = class {
|
|
|
3134
3707
|
return;
|
|
3135
3708
|
}
|
|
3136
3709
|
return this.enqueue(sessionId, async () => {
|
|
3137
|
-
await
|
|
3710
|
+
await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
3138
3711
|
const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
3139
|
-
await
|
|
3712
|
+
await fs7.writeFile(paths.historyFile(sessionId), body, {
|
|
3140
3713
|
encoding: "utf8",
|
|
3141
3714
|
mode: 384
|
|
3142
3715
|
});
|
|
@@ -3153,7 +3726,7 @@ var HistoryStore = class {
|
|
|
3153
3726
|
return this.enqueue(sessionId, async () => {
|
|
3154
3727
|
let raw;
|
|
3155
3728
|
try {
|
|
3156
|
-
raw = await
|
|
3729
|
+
raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
|
|
3157
3730
|
} catch (err) {
|
|
3158
3731
|
const e = err;
|
|
3159
3732
|
if (e.code === "ENOENT") {
|
|
@@ -3166,7 +3739,7 @@ var HistoryStore = class {
|
|
|
3166
3739
|
return;
|
|
3167
3740
|
}
|
|
3168
3741
|
const trimmed = lines.slice(-maxEntries);
|
|
3169
|
-
await
|
|
3742
|
+
await fs7.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
|
|
3170
3743
|
encoding: "utf8",
|
|
3171
3744
|
mode: 384
|
|
3172
3745
|
});
|
|
@@ -3182,7 +3755,7 @@ var HistoryStore = class {
|
|
|
3182
3755
|
}
|
|
3183
3756
|
let raw;
|
|
3184
3757
|
try {
|
|
3185
|
-
raw = await
|
|
3758
|
+
raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
|
|
3186
3759
|
} catch (err) {
|
|
3187
3760
|
const e = err;
|
|
3188
3761
|
if (e.code === "ENOENT") {
|
|
@@ -3228,7 +3801,7 @@ var HistoryStore = class {
|
|
|
3228
3801
|
}
|
|
3229
3802
|
return this.enqueue(sessionId, async () => {
|
|
3230
3803
|
try {
|
|
3231
|
-
await
|
|
3804
|
+
await fs7.unlink(paths.historyFile(sessionId));
|
|
3232
3805
|
} catch (err) {
|
|
3233
3806
|
const e = err;
|
|
3234
3807
|
if (e.code !== "ENOENT") {
|
|
@@ -3236,7 +3809,7 @@ var HistoryStore = class {
|
|
|
3236
3809
|
}
|
|
3237
3810
|
}
|
|
3238
3811
|
try {
|
|
3239
|
-
await
|
|
3812
|
+
await fs7.rmdir(paths.sessionDir(sessionId));
|
|
3240
3813
|
} catch (err) {
|
|
3241
3814
|
const e = err;
|
|
3242
3815
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -3260,25 +3833,25 @@ var HistoryStore = class {
|
|
|
3260
3833
|
};
|
|
3261
3834
|
|
|
3262
3835
|
// src/tui/history.ts
|
|
3263
|
-
import { promises as
|
|
3836
|
+
import { promises as fs8 } from "fs";
|
|
3264
3837
|
import * as path5 from "path";
|
|
3265
3838
|
async function saveHistory(file, history) {
|
|
3266
|
-
await
|
|
3839
|
+
await fs8.mkdir(path5.dirname(file), { recursive: true });
|
|
3267
3840
|
const lines = history.map((entry) => JSON.stringify(entry));
|
|
3268
|
-
await
|
|
3841
|
+
await fs8.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
|
|
3269
3842
|
}
|
|
3270
3843
|
|
|
3271
3844
|
// src/core/hydra-version.ts
|
|
3272
3845
|
import { fileURLToPath } from "url";
|
|
3273
3846
|
import * as path6 from "path";
|
|
3274
|
-
import * as
|
|
3847
|
+
import * as fs9 from "fs";
|
|
3275
3848
|
function resolveVersion() {
|
|
3276
3849
|
try {
|
|
3277
3850
|
let dir = path6.dirname(fileURLToPath(import.meta.url));
|
|
3278
3851
|
for (let i = 0; i < 8; i += 1) {
|
|
3279
3852
|
const candidate = path6.join(dir, "package.json");
|
|
3280
|
-
if (
|
|
3281
|
-
const pkg = JSON.parse(
|
|
3853
|
+
if (fs9.existsSync(candidate)) {
|
|
3854
|
+
const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
|
|
3282
3855
|
if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
|
|
3283
3856
|
return pkg.version;
|
|
3284
3857
|
}
|
|
@@ -3296,6 +3869,7 @@ function resolveVersion() {
|
|
|
3296
3869
|
var HYDRA_VERSION = resolveVersion();
|
|
3297
3870
|
|
|
3298
3871
|
// src/core/session-manager.ts
|
|
3872
|
+
var QUEUE_REPLAY_TTL_MS = 15 * 60 * 1e3;
|
|
3299
3873
|
var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
3300
3874
|
var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
|
|
3301
3875
|
var SessionManager = class {
|
|
@@ -3308,6 +3882,7 @@ var SessionManager = class {
|
|
|
3308
3882
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
3309
3883
|
this.defaultModels = options.defaultModels ?? {};
|
|
3310
3884
|
this.logger = options.logger;
|
|
3885
|
+
this.npmRegistry = options.npmRegistry;
|
|
3311
3886
|
}
|
|
3312
3887
|
registry;
|
|
3313
3888
|
sessions = /* @__PURE__ */ new Map();
|
|
@@ -3323,6 +3898,7 @@ var SessionManager = class {
|
|
|
3323
3898
|
// back-to-back) don't lose writes via interleaved reads.
|
|
3324
3899
|
metaWriteQueues = /* @__PURE__ */ new Map();
|
|
3325
3900
|
logger;
|
|
3901
|
+
npmRegistry;
|
|
3326
3902
|
async create(params) {
|
|
3327
3903
|
const fresh = await this.bootstrapAgent({
|
|
3328
3904
|
agentId: params.agentId,
|
|
@@ -3344,7 +3920,9 @@ var SessionManager = class {
|
|
|
3344
3920
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
3345
3921
|
historyStore: this.histories,
|
|
3346
3922
|
historyMaxEntries: this.sessionHistoryMaxEntries,
|
|
3347
|
-
currentModel: fresh.initialModel
|
|
3923
|
+
currentModel: fresh.initialModel,
|
|
3924
|
+
currentMode: fresh.initialMode,
|
|
3925
|
+
agentModes: fresh.initialModes
|
|
3348
3926
|
});
|
|
3349
3927
|
await this.attachManagerHooks(session);
|
|
3350
3928
|
return session;
|
|
@@ -3389,7 +3967,7 @@ var SessionManager = class {
|
|
|
3389
3967
|
if (params.upstreamSessionId === "") {
|
|
3390
3968
|
return this.doResurrectFromImport(params);
|
|
3391
3969
|
}
|
|
3392
|
-
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
3970
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
|
|
3393
3971
|
const agent = this.spawner({
|
|
3394
3972
|
agentId: params.agentId,
|
|
3395
3973
|
cwd: params.cwd,
|
|
@@ -3443,9 +4021,10 @@ var SessionManager = class {
|
|
|
3443
4021
|
// this fix), fall back to the model the agent ships in its
|
|
3444
4022
|
// session/load response body.
|
|
3445
4023
|
currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
|
|
3446
|
-
currentMode: params.currentMode,
|
|
4024
|
+
currentMode: params.currentMode ?? extractInitialCurrentMode(loadResult ?? {}),
|
|
3447
4025
|
currentUsage: params.currentUsage,
|
|
3448
4026
|
agentCommands: params.agentCommands,
|
|
4027
|
+
agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
|
|
3449
4028
|
// Only gate the first-prompt title heuristic when we actually have
|
|
3450
4029
|
// a title to preserve. A title-less session (lost to a write race
|
|
3451
4030
|
// or never seeded) should re-derive from the next prompt rather
|
|
@@ -3488,9 +4067,10 @@ var SessionManager = class {
|
|
|
3488
4067
|
// Prefer the stored value (set by a previous current_model_update);
|
|
3489
4068
|
// fall back to whatever the agent ships in its session/new response.
|
|
3490
4069
|
currentModel: params.currentModel ?? fresh.initialModel,
|
|
3491
|
-
currentMode: params.currentMode,
|
|
4070
|
+
currentMode: params.currentMode ?? fresh.initialMode,
|
|
3492
4071
|
currentUsage: params.currentUsage,
|
|
3493
4072
|
agentCommands: params.agentCommands,
|
|
4073
|
+
agentModes: params.agentModes ?? fresh.initialModes,
|
|
3494
4074
|
firstPromptSeeded: !!params.title,
|
|
3495
4075
|
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
3496
4076
|
});
|
|
@@ -3500,7 +4080,7 @@ var SessionManager = class {
|
|
|
3500
4080
|
}
|
|
3501
4081
|
async resolveImportCwd(cwd) {
|
|
3502
4082
|
try {
|
|
3503
|
-
const stat2 = await
|
|
4083
|
+
const stat2 = await fs10.stat(cwd);
|
|
3504
4084
|
if (stat2.isDirectory()) {
|
|
3505
4085
|
return cwd;
|
|
3506
4086
|
}
|
|
@@ -3520,7 +4100,7 @@ var SessionManager = class {
|
|
|
3520
4100
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
3521
4101
|
throw err;
|
|
3522
4102
|
}
|
|
3523
|
-
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
4103
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
|
|
3524
4104
|
const agent = this.spawner({
|
|
3525
4105
|
agentId: params.agentId,
|
|
3526
4106
|
cwd: params.cwd,
|
|
@@ -3557,11 +4137,15 @@ var SessionManager = class {
|
|
|
3557
4137
|
} catch {
|
|
3558
4138
|
}
|
|
3559
4139
|
}
|
|
4140
|
+
const initialModes = extractInitialModes(newResult);
|
|
4141
|
+
const initialMode = extractInitialCurrentMode(newResult);
|
|
3560
4142
|
return {
|
|
3561
4143
|
agent,
|
|
3562
4144
|
upstreamSessionId: sessionIdRaw,
|
|
3563
4145
|
agentMeta: newResult._meta,
|
|
3564
|
-
initialModel
|
|
4146
|
+
initialModel,
|
|
4147
|
+
initialModes: initialModes.length > 0 ? initialModes : void 0,
|
|
4148
|
+
initialMode
|
|
3565
4149
|
};
|
|
3566
4150
|
} catch (err) {
|
|
3567
4151
|
await agent.kill().catch(() => void 0);
|
|
@@ -3613,6 +4197,15 @@ var SessionManager = class {
|
|
|
3613
4197
|
}))
|
|
3614
4198
|
}).catch(() => void 0);
|
|
3615
4199
|
});
|
|
4200
|
+
session.onAgentModesChange((modes) => {
|
|
4201
|
+
void this.persistSnapshot(session.sessionId, {
|
|
4202
|
+
agentModes: modes.map((m) => ({
|
|
4203
|
+
id: m.id,
|
|
4204
|
+
...m.name !== void 0 ? { name: m.name } : {},
|
|
4205
|
+
...m.description !== void 0 ? { description: m.description } : {}
|
|
4206
|
+
}))
|
|
4207
|
+
}).catch(() => void 0);
|
|
4208
|
+
});
|
|
3616
4209
|
this.sessions.set(session.sessionId, session);
|
|
3617
4210
|
await this.enqueueMetaWrite(session.sessionId, async () => {
|
|
3618
4211
|
const existing = await this.store.read(session.sessionId);
|
|
@@ -3655,6 +4248,7 @@ var SessionManager = class {
|
|
|
3655
4248
|
currentMode: record.currentMode,
|
|
3656
4249
|
currentUsage: persistedUsageToSnapshot(record.currentUsage),
|
|
3657
4250
|
agentCommands: record.agentCommands,
|
|
4251
|
+
agentModes: record.agentModes,
|
|
3658
4252
|
createdAt: record.createdAt
|
|
3659
4253
|
};
|
|
3660
4254
|
}
|
|
@@ -3679,6 +4273,23 @@ var SessionManager = class {
|
|
|
3679
4273
|
get(sessionId) {
|
|
3680
4274
|
return this.sessions.get(sessionId);
|
|
3681
4275
|
}
|
|
4276
|
+
// Snapshot of which agent versions are currently in use by live
|
|
4277
|
+
// sessions, keyed by agentId. Read by the registry-fetch prune sweep
|
|
4278
|
+
// so it can skip install dirs that still back a running process.
|
|
4279
|
+
activeAgentVersions() {
|
|
4280
|
+
const out = /* @__PURE__ */ new Map();
|
|
4281
|
+
for (const session of this.sessions.values()) {
|
|
4282
|
+
const id = session.agent.agentId;
|
|
4283
|
+
const version = session.agent.version;
|
|
4284
|
+
let set = out.get(id);
|
|
4285
|
+
if (!set) {
|
|
4286
|
+
set = /* @__PURE__ */ new Set();
|
|
4287
|
+
out.set(id, set);
|
|
4288
|
+
}
|
|
4289
|
+
set.add(version);
|
|
4290
|
+
}
|
|
4291
|
+
return out;
|
|
4292
|
+
}
|
|
3682
4293
|
// Resolve a user-typed session id (which may have the hydra_session_
|
|
3683
4294
|
// prefix stripped — that's what `sessions list` and the picker show) to
|
|
3684
4295
|
// the canonical form that actually exists. Tries the input as-given
|
|
@@ -3932,6 +4543,7 @@ var SessionManager = class {
|
|
|
3932
4543
|
...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
|
|
3933
4544
|
...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
|
|
3934
4545
|
...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
|
|
4546
|
+
...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
|
|
3935
4547
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3936
4548
|
});
|
|
3937
4549
|
});
|
|
@@ -3965,6 +4577,53 @@ var SessionManager = class {
|
|
|
3965
4577
|
}
|
|
3966
4578
|
await Promise.allSettled(pending);
|
|
3967
4579
|
}
|
|
4580
|
+
// Startup hook: scan persisted sessions for non-empty queue files,
|
|
4581
|
+
// apply the TTL, resurrect anything with surviving entries, and
|
|
4582
|
+
// replay them through the normal queue path. Called from the daemon
|
|
4583
|
+
// boot sequence; failures per session are logged and don't block
|
|
4584
|
+
// the boot.
|
|
4585
|
+
//
|
|
4586
|
+
// Concurrency is deliberately sequential — resurrect each session
|
|
4587
|
+
// one at a time so a runaway daemon with 100 queued sessions
|
|
4588
|
+
// doesn't burst-spawn 100 agents on startup. Inside a single
|
|
4589
|
+
// session, the queue still drains in parallel-friendly fashion via
|
|
4590
|
+
// drainQueue once resurrect() completes.
|
|
4591
|
+
async resurrectPendingQueues() {
|
|
4592
|
+
const records = await this.store.list().catch(() => []);
|
|
4593
|
+
for (const rec of records) {
|
|
4594
|
+
const queue = await loadQueue(rec.sessionId).catch(() => []);
|
|
4595
|
+
if (queue.length === 0) continue;
|
|
4596
|
+
const now = Date.now();
|
|
4597
|
+
const fresh = queue.filter((e) => now - e.enqueuedAt < QUEUE_REPLAY_TTL_MS);
|
|
4598
|
+
const dropped = queue.length - fresh.length;
|
|
4599
|
+
if (dropped > 0) {
|
|
4600
|
+
this.logger?.info(
|
|
4601
|
+
`queue replay: dropping ${dropped} stale prompt(s) for ${rec.sessionId} (TTL ${QUEUE_REPLAY_TTL_MS / 1e3}s)`
|
|
4602
|
+
);
|
|
4603
|
+
await rewriteQueue(rec.sessionId, fresh).catch(() => void 0);
|
|
4604
|
+
}
|
|
4605
|
+
if (fresh.length === 0) continue;
|
|
4606
|
+
const fromDisk = await this.loadFromDisk(rec.sessionId).catch(() => void 0);
|
|
4607
|
+
if (!fromDisk) {
|
|
4608
|
+
this.logger?.warn(
|
|
4609
|
+
`queue replay: no meta for ${rec.sessionId}; discarding ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"}`
|
|
4610
|
+
);
|
|
4611
|
+
await rewriteQueue(rec.sessionId, []).catch(() => void 0);
|
|
4612
|
+
continue;
|
|
4613
|
+
}
|
|
4614
|
+
try {
|
|
4615
|
+
const session = await this.resurrect(fromDisk);
|
|
4616
|
+
this.logger?.info(
|
|
4617
|
+
`queue replay: resurrected ${rec.sessionId} and replaying ${fresh.length} prompt(s)`
|
|
4618
|
+
);
|
|
4619
|
+
session.replayPersistedQueue(fresh);
|
|
4620
|
+
} catch (err) {
|
|
4621
|
+
this.logger?.warn(
|
|
4622
|
+
`queue replay: failed to resurrect ${rec.sessionId}: ${err.message}`
|
|
4623
|
+
);
|
|
4624
|
+
}
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
3968
4627
|
};
|
|
3969
4628
|
function mergeForPersistence(session, existing) {
|
|
3970
4629
|
const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
|
|
@@ -3974,6 +4633,18 @@ function mergeForPersistence(session, existing) {
|
|
|
3974
4633
|
return { name: c.name };
|
|
3975
4634
|
}) : void 0;
|
|
3976
4635
|
const agentCommands = persistedCommands ?? existing?.agentCommands;
|
|
4636
|
+
const sessionModes = session.availableModes();
|
|
4637
|
+
const persistedModes = sessionModes.length > 0 ? sessionModes.map((m) => {
|
|
4638
|
+
const out = { id: m.id };
|
|
4639
|
+
if (m.name !== void 0) {
|
|
4640
|
+
out.name = m.name;
|
|
4641
|
+
}
|
|
4642
|
+
if (m.description !== void 0) {
|
|
4643
|
+
out.description = m.description;
|
|
4644
|
+
}
|
|
4645
|
+
return out;
|
|
4646
|
+
}) : void 0;
|
|
4647
|
+
const agentModes = persistedModes ?? existing?.agentModes;
|
|
3977
4648
|
return recordFromMemorySession({
|
|
3978
4649
|
sessionId: session.sessionId,
|
|
3979
4650
|
lineageId: existing?.lineageId ?? generateLineageId(),
|
|
@@ -3989,6 +4660,7 @@ function mergeForPersistence(session, existing) {
|
|
|
3989
4660
|
currentMode: session.currentMode ?? existing?.currentMode,
|
|
3990
4661
|
currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
|
|
3991
4662
|
agentCommands,
|
|
4663
|
+
agentModes,
|
|
3992
4664
|
createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
|
|
3993
4665
|
});
|
|
3994
4666
|
}
|
|
@@ -4051,9 +4723,103 @@ function asString(value) {
|
|
|
4051
4723
|
const trimmed = value.trim();
|
|
4052
4724
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
4053
4725
|
}
|
|
4726
|
+
function nonEmptyOrUndefined(arr) {
|
|
4727
|
+
return arr.length > 0 ? arr : void 0;
|
|
4728
|
+
}
|
|
4729
|
+
function extractInitialModes(result) {
|
|
4730
|
+
const direct = parseModesList(result.availableModes);
|
|
4731
|
+
if (direct.length > 0) {
|
|
4732
|
+
return direct;
|
|
4733
|
+
}
|
|
4734
|
+
const modes = result.modes;
|
|
4735
|
+
if (modes && typeof modes === "object" && !Array.isArray(modes)) {
|
|
4736
|
+
const fromModesObj = parseModesList(
|
|
4737
|
+
modes.availableModes
|
|
4738
|
+
);
|
|
4739
|
+
if (fromModesObj.length > 0) {
|
|
4740
|
+
return fromModesObj;
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
const meta = result._meta;
|
|
4744
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
4745
|
+
for (const [key, value] of Object.entries(
|
|
4746
|
+
meta
|
|
4747
|
+
)) {
|
|
4748
|
+
if (key === "hydra-acp") {
|
|
4749
|
+
continue;
|
|
4750
|
+
}
|
|
4751
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
4752
|
+
const fromMeta = parseModesList(
|
|
4753
|
+
value.availableModes
|
|
4754
|
+
);
|
|
4755
|
+
if (fromMeta.length > 0) {
|
|
4756
|
+
return fromMeta;
|
|
4757
|
+
}
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
return [];
|
|
4762
|
+
}
|
|
4763
|
+
function extractInitialCurrentMode(result) {
|
|
4764
|
+
const direct = asString(result.currentModeId) ?? asString(result.currentMode) ?? asString(result.modeId) ?? asString(result.mode);
|
|
4765
|
+
if (direct) {
|
|
4766
|
+
return direct;
|
|
4767
|
+
}
|
|
4768
|
+
const modes = result.modes;
|
|
4769
|
+
if (modes && typeof modes === "object" && !Array.isArray(modes)) {
|
|
4770
|
+
const m = asString(modes.currentModeId) ?? asString(modes.currentMode);
|
|
4771
|
+
if (m) {
|
|
4772
|
+
return m;
|
|
4773
|
+
}
|
|
4774
|
+
}
|
|
4775
|
+
const meta = result._meta;
|
|
4776
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
4777
|
+
for (const [key, value] of Object.entries(
|
|
4778
|
+
meta
|
|
4779
|
+
)) {
|
|
4780
|
+
if (key === "hydra-acp") {
|
|
4781
|
+
continue;
|
|
4782
|
+
}
|
|
4783
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
4784
|
+
const m = asString(value.currentModeId) ?? asString(value.currentMode) ?? asString(value.modeId);
|
|
4785
|
+
if (m) {
|
|
4786
|
+
return m;
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
}
|
|
4790
|
+
}
|
|
4791
|
+
return void 0;
|
|
4792
|
+
}
|
|
4793
|
+
function parseModesList(list) {
|
|
4794
|
+
if (!Array.isArray(list)) {
|
|
4795
|
+
return [];
|
|
4796
|
+
}
|
|
4797
|
+
const out = [];
|
|
4798
|
+
for (const raw of list) {
|
|
4799
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
4800
|
+
continue;
|
|
4801
|
+
}
|
|
4802
|
+
const r = raw;
|
|
4803
|
+
const id = asString(r.id) ?? asString(r.modeId);
|
|
4804
|
+
if (!id) {
|
|
4805
|
+
continue;
|
|
4806
|
+
}
|
|
4807
|
+
const mode = { id };
|
|
4808
|
+
const name = asString(r.name);
|
|
4809
|
+
if (name) {
|
|
4810
|
+
mode.name = name;
|
|
4811
|
+
}
|
|
4812
|
+
const description = asString(r.description);
|
|
4813
|
+
if (description) {
|
|
4814
|
+
mode.description = description;
|
|
4815
|
+
}
|
|
4816
|
+
out.push(mode);
|
|
4817
|
+
}
|
|
4818
|
+
return out;
|
|
4819
|
+
}
|
|
4054
4820
|
async function loadPromptHistorySafely(sessionId) {
|
|
4055
4821
|
try {
|
|
4056
|
-
const raw = await
|
|
4822
|
+
const raw = await fs10.readFile(paths.tuiHistoryFile(sessionId), "utf8");
|
|
4057
4823
|
const out = [];
|
|
4058
4824
|
for (const line of raw.split("\n")) {
|
|
4059
4825
|
if (line.length === 0) {
|
|
@@ -4074,7 +4840,7 @@ async function loadPromptHistorySafely(sessionId) {
|
|
|
4074
4840
|
}
|
|
4075
4841
|
async function historyMtimeIso(sessionId) {
|
|
4076
4842
|
try {
|
|
4077
|
-
const st = await
|
|
4843
|
+
const st = await fs10.stat(paths.historyFile(sessionId));
|
|
4078
4844
|
return new Date(st.mtimeMs).toISOString();
|
|
4079
4845
|
} catch {
|
|
4080
4846
|
return void 0;
|
|
@@ -4083,7 +4849,7 @@ async function historyMtimeIso(sessionId) {
|
|
|
4083
4849
|
|
|
4084
4850
|
// src/core/extensions.ts
|
|
4085
4851
|
import { spawn as spawn4 } from "child_process";
|
|
4086
|
-
import * as
|
|
4852
|
+
import * as fs11 from "fs";
|
|
4087
4853
|
import * as fsp3 from "fs/promises";
|
|
4088
4854
|
import * as path7 from "path";
|
|
4089
4855
|
var RESTART_BASE_MS = 1e3;
|
|
@@ -4366,7 +5132,7 @@ var ExtensionManager = class {
|
|
|
4366
5132
|
}
|
|
4367
5133
|
const ext = entry.config;
|
|
4368
5134
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
4369
|
-
const logStream =
|
|
5135
|
+
const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
4370
5136
|
flags: "a"
|
|
4371
5137
|
});
|
|
4372
5138
|
logStream.write(
|
|
@@ -4378,7 +5144,7 @@ var ExtensionManager = class {
|
|
|
4378
5144
|
HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
|
|
4379
5145
|
HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
|
|
4380
5146
|
HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
|
|
4381
|
-
HYDRA_ACP_TOKEN: ctx.
|
|
5147
|
+
HYDRA_ACP_TOKEN: ctx.serviceToken,
|
|
4382
5148
|
HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
|
|
4383
5149
|
HYDRA_ACP_HOME: ctx.hydraHome,
|
|
4384
5150
|
HYDRA_ACP_EXTENSION_NAME: ext.name,
|
|
@@ -4416,7 +5182,7 @@ var ExtensionManager = class {
|
|
|
4416
5182
|
}
|
|
4417
5183
|
if (typeof child.pid === "number") {
|
|
4418
5184
|
try {
|
|
4419
|
-
|
|
5185
|
+
fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
4420
5186
|
`, {
|
|
4421
5187
|
encoding: "utf8",
|
|
4422
5188
|
mode: 384
|
|
@@ -4441,7 +5207,7 @@ var ExtensionManager = class {
|
|
|
4441
5207
|
});
|
|
4442
5208
|
child.on("exit", (code, signal) => {
|
|
4443
5209
|
try {
|
|
4444
|
-
|
|
5210
|
+
fs11.unlinkSync(paths.extensionPidFile(ext.name));
|
|
4445
5211
|
} catch {
|
|
4446
5212
|
}
|
|
4447
5213
|
logStream.write(
|
|
@@ -4497,8 +5263,303 @@ function withCode2(err, code) {
|
|
|
4497
5263
|
return err;
|
|
4498
5264
|
}
|
|
4499
5265
|
|
|
5266
|
+
// src/core/agent-prune.ts
|
|
5267
|
+
import * as fsp4 from "fs/promises";
|
|
5268
|
+
import * as path8 from "path";
|
|
5269
|
+
var logSink3 = (msg) => {
|
|
5270
|
+
process.stderr.write(msg + "\n");
|
|
5271
|
+
};
|
|
5272
|
+
function setAgentPruneLogger(log) {
|
|
5273
|
+
logSink3 = log ?? ((msg) => process.stderr.write(msg + "\n"));
|
|
5274
|
+
}
|
|
5275
|
+
async function pruneStaleAgentVersions(registry, sessionManager) {
|
|
5276
|
+
const platformKey = currentPlatformKey();
|
|
5277
|
+
if (!platformKey) {
|
|
5278
|
+
return;
|
|
5279
|
+
}
|
|
5280
|
+
const doc = await registry.load();
|
|
5281
|
+
const desiredByAgent = /* @__PURE__ */ new Map();
|
|
5282
|
+
for (const a of doc.agents) {
|
|
5283
|
+
desiredByAgent.set(a.id, a.version ?? "current");
|
|
5284
|
+
}
|
|
5285
|
+
const activeByAgent = sessionManager.activeAgentVersions();
|
|
5286
|
+
const platformDir = path8.join(paths.agentsDir(), platformKey);
|
|
5287
|
+
let agentEntries;
|
|
5288
|
+
try {
|
|
5289
|
+
agentEntries = await fsp4.readdir(platformDir, { withFileTypes: true });
|
|
5290
|
+
} catch (err) {
|
|
5291
|
+
const e = err;
|
|
5292
|
+
if (e.code === "ENOENT") {
|
|
5293
|
+
return;
|
|
5294
|
+
}
|
|
5295
|
+
logSink3(`hydra-acp: prune: failed to read ${platformDir}: ${e.message}`);
|
|
5296
|
+
return;
|
|
5297
|
+
}
|
|
5298
|
+
for (const agentEntry of agentEntries) {
|
|
5299
|
+
if (!agentEntry.isDirectory()) {
|
|
5300
|
+
continue;
|
|
5301
|
+
}
|
|
5302
|
+
const agentId = agentEntry.name;
|
|
5303
|
+
const desired = desiredByAgent.get(agentId);
|
|
5304
|
+
if (desired === void 0) {
|
|
5305
|
+
continue;
|
|
5306
|
+
}
|
|
5307
|
+
const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
|
|
5308
|
+
const agentDir = path8.join(platformDir, agentId);
|
|
5309
|
+
let versionEntries;
|
|
5310
|
+
try {
|
|
5311
|
+
versionEntries = await fsp4.readdir(agentDir, { withFileTypes: true });
|
|
5312
|
+
} catch (err) {
|
|
5313
|
+
logSink3(
|
|
5314
|
+
`hydra-acp: prune: failed to read ${agentDir}: ${err.message}`
|
|
5315
|
+
);
|
|
5316
|
+
continue;
|
|
5317
|
+
}
|
|
5318
|
+
for (const versionEntry of versionEntries) {
|
|
5319
|
+
if (!versionEntry.isDirectory()) {
|
|
5320
|
+
continue;
|
|
5321
|
+
}
|
|
5322
|
+
const version = versionEntry.name;
|
|
5323
|
+
if (version === desired) {
|
|
5324
|
+
continue;
|
|
5325
|
+
}
|
|
5326
|
+
if (activeVersions.has(version)) {
|
|
5327
|
+
continue;
|
|
5328
|
+
}
|
|
5329
|
+
const versionDir = path8.join(agentDir, version);
|
|
5330
|
+
try {
|
|
5331
|
+
await fsp4.rm(versionDir, { recursive: true, force: true });
|
|
5332
|
+
logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
|
|
5333
|
+
} catch (err) {
|
|
5334
|
+
logSink3(
|
|
5335
|
+
`hydra-acp: prune: failed to remove ${versionDir}: ${err.message}`
|
|
5336
|
+
);
|
|
5337
|
+
}
|
|
5338
|
+
}
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
// src/core/session-tokens.ts
|
|
5343
|
+
import * as fs12 from "fs/promises";
|
|
5344
|
+
import * as path9 from "path";
|
|
5345
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
5346
|
+
var TOKEN_PREFIX = "hydra_session_";
|
|
5347
|
+
var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
|
|
5348
|
+
var ID_LENGTH = 12;
|
|
5349
|
+
var TOKEN_BYTES = 32;
|
|
5350
|
+
var WRITE_DEBOUNCE_MS = 50;
|
|
5351
|
+
function tokensFilePath() {
|
|
5352
|
+
return path9.join(paths.home(), "session-tokens.json");
|
|
5353
|
+
}
|
|
5354
|
+
function sha256Hex(input) {
|
|
5355
|
+
return createHash("sha256").update(input).digest("hex");
|
|
5356
|
+
}
|
|
5357
|
+
function randomHex(bytes) {
|
|
5358
|
+
return randomBytes(bytes).toString("hex");
|
|
5359
|
+
}
|
|
5360
|
+
function generateId() {
|
|
5361
|
+
return randomHex(ID_LENGTH).slice(0, ID_LENGTH * 2);
|
|
5362
|
+
}
|
|
5363
|
+
function generateToken() {
|
|
5364
|
+
return `${TOKEN_PREFIX}${randomHex(TOKEN_BYTES)}`;
|
|
5365
|
+
}
|
|
5366
|
+
var SessionTokenStore = class _SessionTokenStore {
|
|
5367
|
+
records = /* @__PURE__ */ new Map();
|
|
5368
|
+
// keyed by hash
|
|
5369
|
+
writeTimer = null;
|
|
5370
|
+
writeInflight = null;
|
|
5371
|
+
constructor(records) {
|
|
5372
|
+
for (const r of records) {
|
|
5373
|
+
this.records.set(r.hash, r);
|
|
5374
|
+
}
|
|
5375
|
+
}
|
|
5376
|
+
static async load() {
|
|
5377
|
+
let records = [];
|
|
5378
|
+
try {
|
|
5379
|
+
const raw = await fs12.readFile(tokensFilePath(), "utf8");
|
|
5380
|
+
const parsed = JSON.parse(raw);
|
|
5381
|
+
if (parsed && Array.isArray(parsed.records)) {
|
|
5382
|
+
records = parsed.records.filter(isRecord);
|
|
5383
|
+
}
|
|
5384
|
+
} catch (err) {
|
|
5385
|
+
const e = err;
|
|
5386
|
+
if (e.code !== "ENOENT") {
|
|
5387
|
+
throw err;
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
5390
|
+
const store = new _SessionTokenStore(records);
|
|
5391
|
+
const removed = store.sweepExpired(/* @__PURE__ */ new Date());
|
|
5392
|
+
if (removed > 0) {
|
|
5393
|
+
await store.flush();
|
|
5394
|
+
}
|
|
5395
|
+
return store;
|
|
5396
|
+
}
|
|
5397
|
+
async issue(opts = {}) {
|
|
5398
|
+
const token = generateToken();
|
|
5399
|
+
const hash = sha256Hex(token);
|
|
5400
|
+
const id = generateId();
|
|
5401
|
+
const now = /* @__PURE__ */ new Date();
|
|
5402
|
+
const ttlSec = opts.ttlSec && opts.ttlSec > 0 ? opts.ttlSec : DEFAULT_TTL_SEC;
|
|
5403
|
+
const expiresAt = new Date(now.getTime() + ttlSec * 1e3);
|
|
5404
|
+
const record = {
|
|
5405
|
+
id,
|
|
5406
|
+
hash,
|
|
5407
|
+
label: opts.label,
|
|
5408
|
+
createdAt: now.toISOString(),
|
|
5409
|
+
expiresAt: expiresAt.toISOString(),
|
|
5410
|
+
lastUsedAt: now.toISOString()
|
|
5411
|
+
};
|
|
5412
|
+
this.records.set(hash, record);
|
|
5413
|
+
this.scheduleWrite();
|
|
5414
|
+
return { id, token, expiresAt: record.expiresAt };
|
|
5415
|
+
}
|
|
5416
|
+
// Verifies a presented token. Returns the matching record id (so the
|
|
5417
|
+
// caller can revoke it on logout) and bumps lastUsedAt; returns
|
|
5418
|
+
// undefined when no record matches or when the matched record has
|
|
5419
|
+
// expired.
|
|
5420
|
+
async verify(token) {
|
|
5421
|
+
if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX)) {
|
|
5422
|
+
return void 0;
|
|
5423
|
+
}
|
|
5424
|
+
const hash = sha256Hex(token);
|
|
5425
|
+
const record = this.records.get(hash);
|
|
5426
|
+
if (!record) {
|
|
5427
|
+
return void 0;
|
|
5428
|
+
}
|
|
5429
|
+
const expected = Buffer.from(record.hash, "hex");
|
|
5430
|
+
const actual = Buffer.from(hash, "hex");
|
|
5431
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
5432
|
+
return void 0;
|
|
5433
|
+
}
|
|
5434
|
+
const now = /* @__PURE__ */ new Date();
|
|
5435
|
+
if (new Date(record.expiresAt).getTime() <= now.getTime()) {
|
|
5436
|
+
this.records.delete(hash);
|
|
5437
|
+
this.scheduleWrite();
|
|
5438
|
+
return void 0;
|
|
5439
|
+
}
|
|
5440
|
+
record.lastUsedAt = now.toISOString();
|
|
5441
|
+
this.scheduleWrite();
|
|
5442
|
+
return record.id;
|
|
5443
|
+
}
|
|
5444
|
+
async revoke(id) {
|
|
5445
|
+
for (const [hash, r] of this.records) {
|
|
5446
|
+
if (r.id === id) {
|
|
5447
|
+
this.records.delete(hash);
|
|
5448
|
+
this.scheduleWrite();
|
|
5449
|
+
return true;
|
|
5450
|
+
}
|
|
5451
|
+
}
|
|
5452
|
+
return false;
|
|
5453
|
+
}
|
|
5454
|
+
async revokeAll() {
|
|
5455
|
+
const n = this.records.size;
|
|
5456
|
+
this.records.clear();
|
|
5457
|
+
this.scheduleWrite();
|
|
5458
|
+
return n;
|
|
5459
|
+
}
|
|
5460
|
+
list() {
|
|
5461
|
+
return Array.from(this.records.values()).map(({ id, label, createdAt, expiresAt, lastUsedAt }) => ({
|
|
5462
|
+
id,
|
|
5463
|
+
label,
|
|
5464
|
+
createdAt,
|
|
5465
|
+
expiresAt,
|
|
5466
|
+
lastUsedAt
|
|
5467
|
+
})).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
5468
|
+
}
|
|
5469
|
+
sweepExpired(now = /* @__PURE__ */ new Date()) {
|
|
5470
|
+
let removed = 0;
|
|
5471
|
+
for (const [hash, r] of this.records) {
|
|
5472
|
+
if (new Date(r.expiresAt).getTime() <= now.getTime()) {
|
|
5473
|
+
this.records.delete(hash);
|
|
5474
|
+
removed += 1;
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
if (removed > 0) {
|
|
5478
|
+
this.scheduleWrite();
|
|
5479
|
+
}
|
|
5480
|
+
return removed;
|
|
5481
|
+
}
|
|
5482
|
+
// Force any pending write to complete. Useful in tests and at shutdown.
|
|
5483
|
+
async flush() {
|
|
5484
|
+
if (this.writeTimer) {
|
|
5485
|
+
clearTimeout(this.writeTimer);
|
|
5486
|
+
this.writeTimer = null;
|
|
5487
|
+
}
|
|
5488
|
+
await this.persist();
|
|
5489
|
+
}
|
|
5490
|
+
scheduleWrite() {
|
|
5491
|
+
if (this.writeTimer) {
|
|
5492
|
+
return;
|
|
5493
|
+
}
|
|
5494
|
+
this.writeTimer = setTimeout(() => {
|
|
5495
|
+
this.writeTimer = null;
|
|
5496
|
+
this.persist().catch(() => {
|
|
5497
|
+
});
|
|
5498
|
+
}, WRITE_DEBOUNCE_MS);
|
|
5499
|
+
}
|
|
5500
|
+
async persist() {
|
|
5501
|
+
if (this.writeInflight) {
|
|
5502
|
+
await this.writeInflight;
|
|
5503
|
+
}
|
|
5504
|
+
const records = Array.from(this.records.values());
|
|
5505
|
+
const payload = JSON.stringify({ records }, null, 2) + "\n";
|
|
5506
|
+
this.writeInflight = (async () => {
|
|
5507
|
+
await fs12.mkdir(paths.home(), { recursive: true });
|
|
5508
|
+
await fs12.writeFile(tokensFilePath(), payload, {
|
|
5509
|
+
encoding: "utf8",
|
|
5510
|
+
mode: 384
|
|
5511
|
+
});
|
|
5512
|
+
})();
|
|
5513
|
+
try {
|
|
5514
|
+
await this.writeInflight;
|
|
5515
|
+
} finally {
|
|
5516
|
+
this.writeInflight = null;
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
};
|
|
5520
|
+
function isRecord(value) {
|
|
5521
|
+
if (!value || typeof value !== "object") {
|
|
5522
|
+
return false;
|
|
5523
|
+
}
|
|
5524
|
+
const v = value;
|
|
5525
|
+
return typeof v.id === "string" && typeof v.hash === "string" && typeof v.createdAt === "string" && typeof v.expiresAt === "string" && typeof v.lastUsedAt === "string" && (v.label === void 0 || typeof v.label === "string");
|
|
5526
|
+
}
|
|
5527
|
+
|
|
4500
5528
|
// src/daemon/auth.ts
|
|
4501
5529
|
var BEARER_PREFIX = "Bearer ";
|
|
5530
|
+
var StaticTokenValidator = class {
|
|
5531
|
+
constructor(token) {
|
|
5532
|
+
this.token = token;
|
|
5533
|
+
}
|
|
5534
|
+
token;
|
|
5535
|
+
async validate(token) {
|
|
5536
|
+
return constantTimeEqual(token, this.token) ? "service" : void 0;
|
|
5537
|
+
}
|
|
5538
|
+
};
|
|
5539
|
+
var SessionTokenValidator = class {
|
|
5540
|
+
constructor(store) {
|
|
5541
|
+
this.store = store;
|
|
5542
|
+
}
|
|
5543
|
+
store;
|
|
5544
|
+
async validate(token) {
|
|
5545
|
+
return this.store.verify(token);
|
|
5546
|
+
}
|
|
5547
|
+
};
|
|
5548
|
+
var CompositeTokenValidator = class {
|
|
5549
|
+
constructor(validators) {
|
|
5550
|
+
this.validators = validators;
|
|
5551
|
+
}
|
|
5552
|
+
validators;
|
|
5553
|
+
async validate(token) {
|
|
5554
|
+
for (const v of this.validators) {
|
|
5555
|
+
const id = await v.validate(token);
|
|
5556
|
+
if (id !== void 0) {
|
|
5557
|
+
return id;
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
5560
|
+
return void 0;
|
|
5561
|
+
}
|
|
5562
|
+
};
|
|
4502
5563
|
function bearerAuth(opts) {
|
|
4503
5564
|
return async function authMiddleware(request, reply) {
|
|
4504
5565
|
const header = request.headers.authorization;
|
|
@@ -4507,10 +5568,12 @@ function bearerAuth(opts) {
|
|
|
4507
5568
|
return;
|
|
4508
5569
|
}
|
|
4509
5570
|
const token = header.slice(BEARER_PREFIX.length).trim();
|
|
4510
|
-
|
|
5571
|
+
const identity = await opts.validator.validate(token);
|
|
5572
|
+
if (!identity) {
|
|
4511
5573
|
reply.code(403).send({ error: "Invalid token" });
|
|
4512
5574
|
return;
|
|
4513
5575
|
}
|
|
5576
|
+
request.authIdentity = identity;
|
|
4514
5577
|
};
|
|
4515
5578
|
}
|
|
4516
5579
|
function tokenFromUpgradeRequest(req) {
|
|
@@ -4549,6 +5612,40 @@ function constantTimeEqual(a, b) {
|
|
|
4549
5612
|
return mismatch === 0;
|
|
4550
5613
|
}
|
|
4551
5614
|
|
|
5615
|
+
// src/daemon/rate-limit.ts
|
|
5616
|
+
var AuthRateLimiter = class {
|
|
5617
|
+
entries = /* @__PURE__ */ new Map();
|
|
5618
|
+
maxFails;
|
|
5619
|
+
windowMs;
|
|
5620
|
+
constructor(maxFails = 10, windowMs = 15 * 60 * 1e3) {
|
|
5621
|
+
this.maxFails = maxFails;
|
|
5622
|
+
this.windowMs = windowMs;
|
|
5623
|
+
}
|
|
5624
|
+
isBlocked(ip) {
|
|
5625
|
+
const e = this.entries.get(ip);
|
|
5626
|
+
if (!e) {
|
|
5627
|
+
return false;
|
|
5628
|
+
}
|
|
5629
|
+
if (Date.now() - e.windowStart > this.windowMs) {
|
|
5630
|
+
this.entries.delete(ip);
|
|
5631
|
+
return false;
|
|
5632
|
+
}
|
|
5633
|
+
return e.fails >= this.maxFails;
|
|
5634
|
+
}
|
|
5635
|
+
recordFailure(ip) {
|
|
5636
|
+
const now = Date.now();
|
|
5637
|
+
const e = this.entries.get(ip);
|
|
5638
|
+
if (!e || now - e.windowStart > this.windowMs) {
|
|
5639
|
+
this.entries.set(ip, { fails: 1, windowStart: now });
|
|
5640
|
+
return;
|
|
5641
|
+
}
|
|
5642
|
+
e.fails += 1;
|
|
5643
|
+
}
|
|
5644
|
+
recordSuccess(ip) {
|
|
5645
|
+
this.entries.delete(ip);
|
|
5646
|
+
}
|
|
5647
|
+
};
|
|
5648
|
+
|
|
4552
5649
|
// src/daemon/routes/sessions.ts
|
|
4553
5650
|
import * as os3 from "os";
|
|
4554
5651
|
|
|
@@ -4579,6 +5676,7 @@ var BundleSession = z5.object({
|
|
|
4579
5676
|
currentMode: z5.string().optional(),
|
|
4580
5677
|
currentUsage: PersistedUsage.optional(),
|
|
4581
5678
|
agentCommands: z5.array(PersistedAgentCommand).optional(),
|
|
5679
|
+
agentModes: z5.array(PersistedAgentMode).optional(),
|
|
4582
5680
|
createdAt: z5.string(),
|
|
4583
5681
|
updatedAt: z5.string()
|
|
4584
5682
|
});
|
|
@@ -4612,6 +5710,7 @@ function encodeBundle(params) {
|
|
|
4612
5710
|
...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
|
|
4613
5711
|
...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
|
|
4614
5712
|
...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
|
|
5713
|
+
...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
|
|
4615
5714
|
createdAt: params.record.createdAt,
|
|
4616
5715
|
updatedAt: params.record.updatedAt
|
|
4617
5716
|
},
|
|
@@ -4670,6 +5769,8 @@ function mapUpdate(update) {
|
|
|
4670
5769
|
return mapUsage(u);
|
|
4671
5770
|
case "available_commands_update":
|
|
4672
5771
|
return mapAvailableCommands(u);
|
|
5772
|
+
case "available_modes_update":
|
|
5773
|
+
return mapAvailableModes(u);
|
|
4673
5774
|
case "session_info_update":
|
|
4674
5775
|
return mapSessionInfo(u);
|
|
4675
5776
|
default:
|
|
@@ -4731,6 +5832,31 @@ function mapAvailableCommands(u) {
|
|
|
4731
5832
|
}
|
|
4732
5833
|
return { kind: "available-commands", commands: normalizeAdvertisedCommands(list) };
|
|
4733
5834
|
}
|
|
5835
|
+
function mapAvailableModes(u) {
|
|
5836
|
+
const list = u.availableModes;
|
|
5837
|
+
if (!Array.isArray(list)) {
|
|
5838
|
+
return null;
|
|
5839
|
+
}
|
|
5840
|
+
const modes = [];
|
|
5841
|
+
for (const raw of list) {
|
|
5842
|
+
if (!raw || typeof raw !== "object") {
|
|
5843
|
+
continue;
|
|
5844
|
+
}
|
|
5845
|
+
const m = raw;
|
|
5846
|
+
if (typeof m.id !== "string" || m.id.length === 0) {
|
|
5847
|
+
continue;
|
|
5848
|
+
}
|
|
5849
|
+
const mode = { id: sanitizeSingleLine(m.id) };
|
|
5850
|
+
if (typeof m.name === "string") {
|
|
5851
|
+
mode.name = sanitizeSingleLine(m.name);
|
|
5852
|
+
}
|
|
5853
|
+
if (typeof m.description === "string") {
|
|
5854
|
+
mode.description = sanitizeSingleLine(m.description);
|
|
5855
|
+
}
|
|
5856
|
+
modes.push(mode);
|
|
5857
|
+
}
|
|
5858
|
+
return { kind: "available-modes", modes };
|
|
5859
|
+
}
|
|
4734
5860
|
function mapUsage(u) {
|
|
4735
5861
|
const event = { kind: "usage-update" };
|
|
4736
5862
|
if (typeof u.used === "number") {
|
|
@@ -4851,7 +5977,7 @@ function mapPlan(u) {
|
|
|
4851
5977
|
return { kind: "plan", entries: normalized };
|
|
4852
5978
|
}
|
|
4853
5979
|
function mapMode(u) {
|
|
4854
|
-
const mode = readString(u, "currentMode") ?? readString(u, "mode");
|
|
5980
|
+
const mode = readString(u, "currentModeId") ?? readString(u, "currentMode") ?? readString(u, "mode");
|
|
4855
5981
|
if (!mode) {
|
|
4856
5982
|
return null;
|
|
4857
5983
|
}
|
|
@@ -5514,6 +6640,157 @@ function registerConfigRoutes(app, defaults) {
|
|
|
5514
6640
|
});
|
|
5515
6641
|
}
|
|
5516
6642
|
|
|
6643
|
+
// src/daemon/routes/auth.ts
|
|
6644
|
+
import { z as z6 } from "zod";
|
|
6645
|
+
|
|
6646
|
+
// src/core/password.ts
|
|
6647
|
+
import * as fs13 from "fs/promises";
|
|
6648
|
+
import * as path10 from "path";
|
|
6649
|
+
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
6650
|
+
import { promisify } from "util";
|
|
6651
|
+
var scryptAsync = promisify(scrypt);
|
|
6652
|
+
function passwordHashPath() {
|
|
6653
|
+
return path10.join(paths.home(), "password-hash");
|
|
6654
|
+
}
|
|
6655
|
+
var DEFAULT_N = 1 << 15;
|
|
6656
|
+
var MAX_MEM = 128 * 1024 * 1024;
|
|
6657
|
+
async function hasPassword() {
|
|
6658
|
+
try {
|
|
6659
|
+
const text = await fs13.readFile(passwordHashPath(), "utf8");
|
|
6660
|
+
return text.trim().length > 0;
|
|
6661
|
+
} catch (err) {
|
|
6662
|
+
const e = err;
|
|
6663
|
+
if (e.code === "ENOENT") {
|
|
6664
|
+
return false;
|
|
6665
|
+
}
|
|
6666
|
+
throw err;
|
|
6667
|
+
}
|
|
6668
|
+
}
|
|
6669
|
+
async function verifyPassword(plaintext) {
|
|
6670
|
+
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
|
6671
|
+
return false;
|
|
6672
|
+
}
|
|
6673
|
+
let line;
|
|
6674
|
+
try {
|
|
6675
|
+
line = (await fs13.readFile(passwordHashPath(), "utf8")).trim();
|
|
6676
|
+
} catch (err) {
|
|
6677
|
+
const e = err;
|
|
6678
|
+
if (e.code === "ENOENT") {
|
|
6679
|
+
return false;
|
|
6680
|
+
}
|
|
6681
|
+
throw err;
|
|
6682
|
+
}
|
|
6683
|
+
const parts = line.split("$");
|
|
6684
|
+
if (parts.length !== 6 || parts[0] !== "scrypt") {
|
|
6685
|
+
return false;
|
|
6686
|
+
}
|
|
6687
|
+
const N = parseInt(parts[1], 10);
|
|
6688
|
+
const r = parseInt(parts[2], 10);
|
|
6689
|
+
const p = parseInt(parts[3], 10);
|
|
6690
|
+
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) {
|
|
6691
|
+
return false;
|
|
6692
|
+
}
|
|
6693
|
+
const salt = Buffer.from(parts[4], "hex");
|
|
6694
|
+
const expected = Buffer.from(parts[5], "hex");
|
|
6695
|
+
if (salt.length === 0 || expected.length === 0) {
|
|
6696
|
+
return false;
|
|
6697
|
+
}
|
|
6698
|
+
const actual = await scryptAsync(plaintext, salt, expected.length, {
|
|
6699
|
+
N,
|
|
6700
|
+
r,
|
|
6701
|
+
p,
|
|
6702
|
+
maxmem: MAX_MEM
|
|
6703
|
+
});
|
|
6704
|
+
if (actual.length !== expected.length) {
|
|
6705
|
+
return false;
|
|
6706
|
+
}
|
|
6707
|
+
return timingSafeEqual2(actual, expected);
|
|
6708
|
+
}
|
|
6709
|
+
|
|
6710
|
+
// src/daemon/routes/auth.ts
|
|
6711
|
+
var LoginBody = z6.object({
|
|
6712
|
+
password: z6.string().min(1),
|
|
6713
|
+
label: z6.string().min(1).max(256).optional(),
|
|
6714
|
+
ttlSec: z6.number().int().positive().optional()
|
|
6715
|
+
});
|
|
6716
|
+
var LogoutBody = z6.object({
|
|
6717
|
+
id: z6.string().optional()
|
|
6718
|
+
}).optional();
|
|
6719
|
+
function registerAuthRoutes(app, deps) {
|
|
6720
|
+
app.post(
|
|
6721
|
+
"/v1/auth/login",
|
|
6722
|
+
{ config: { skipAuth: true } },
|
|
6723
|
+
async (request, reply) => {
|
|
6724
|
+
const ip = remoteIp(request);
|
|
6725
|
+
if (deps.rateLimiter.isBlocked(ip)) {
|
|
6726
|
+
return reply.code(429).send({
|
|
6727
|
+
error: "Too many failed attempts; try again later."
|
|
6728
|
+
});
|
|
6729
|
+
}
|
|
6730
|
+
let body;
|
|
6731
|
+
try {
|
|
6732
|
+
body = LoginBody.parse(request.body);
|
|
6733
|
+
} catch {
|
|
6734
|
+
return reply.code(400).send({ error: "Invalid request body" });
|
|
6735
|
+
}
|
|
6736
|
+
if (!await hasPassword()) {
|
|
6737
|
+
return reply.code(403).send({
|
|
6738
|
+
error: "No password configured. Run `hydra-acp auth password` on the daemon host."
|
|
6739
|
+
});
|
|
6740
|
+
}
|
|
6741
|
+
const ok = await verifyPassword(body.password);
|
|
6742
|
+
if (!ok) {
|
|
6743
|
+
deps.rateLimiter.recordFailure(ip);
|
|
6744
|
+
return reply.code(401).send({ error: "Invalid password" });
|
|
6745
|
+
}
|
|
6746
|
+
deps.rateLimiter.recordSuccess(ip);
|
|
6747
|
+
const issued = await deps.store.issue({
|
|
6748
|
+
label: body.label,
|
|
6749
|
+
ttlSec: body.ttlSec
|
|
6750
|
+
});
|
|
6751
|
+
return reply.code(200).send({
|
|
6752
|
+
session_token: issued.token,
|
|
6753
|
+
id: issued.id,
|
|
6754
|
+
expires_at: issued.expiresAt
|
|
6755
|
+
});
|
|
6756
|
+
}
|
|
6757
|
+
);
|
|
6758
|
+
app.post("/v1/auth/logout", async (request, reply) => {
|
|
6759
|
+
let body = void 0;
|
|
6760
|
+
try {
|
|
6761
|
+
body = LogoutBody.parse(request.body ?? void 0);
|
|
6762
|
+
} catch {
|
|
6763
|
+
return reply.code(400).send({ error: "Invalid request body" });
|
|
6764
|
+
}
|
|
6765
|
+
const id = body?.id ?? request.authIdentity;
|
|
6766
|
+
if (!id || id === "service") {
|
|
6767
|
+
return reply.code(200).send({ revoked: false });
|
|
6768
|
+
}
|
|
6769
|
+
const revoked = await deps.store.revoke(id);
|
|
6770
|
+
return reply.code(200).send({ revoked });
|
|
6771
|
+
});
|
|
6772
|
+
app.get("/v1/auth/verify", async (_request, reply) => {
|
|
6773
|
+
return reply.code(200).send({ ok: true });
|
|
6774
|
+
});
|
|
6775
|
+
app.get("/v1/auth/sessions", async (_request, reply) => {
|
|
6776
|
+
return reply.code(200).send({ sessions: deps.store.list() });
|
|
6777
|
+
});
|
|
6778
|
+
app.delete(
|
|
6779
|
+
"/v1/auth/sessions/:id",
|
|
6780
|
+
async (request, reply) => {
|
|
6781
|
+
const id = request.params.id;
|
|
6782
|
+
const revoked = await deps.store.revoke(id);
|
|
6783
|
+
if (!revoked) {
|
|
6784
|
+
return reply.code(404).send({ error: "Not found" });
|
|
6785
|
+
}
|
|
6786
|
+
return reply.code(204).send();
|
|
6787
|
+
}
|
|
6788
|
+
);
|
|
6789
|
+
}
|
|
6790
|
+
function remoteIp(request) {
|
|
6791
|
+
return request.ip || "unknown";
|
|
6792
|
+
}
|
|
6793
|
+
|
|
5517
6794
|
// src/daemon/acp-ws.ts
|
|
5518
6795
|
import { nanoid as nanoid2 } from "nanoid";
|
|
5519
6796
|
|
|
@@ -5590,12 +6867,12 @@ function wsToMessageStream(ws) {
|
|
|
5590
6867
|
|
|
5591
6868
|
// src/daemon/acp-ws.ts
|
|
5592
6869
|
function registerAcpWsEndpoint(app, deps) {
|
|
5593
|
-
app.get("/acp", { websocket: true }, (socket, request) => {
|
|
6870
|
+
app.get("/acp", { websocket: true }, async (socket, request) => {
|
|
5594
6871
|
const token = tokenFromUpgradeRequest({
|
|
5595
6872
|
headers: request.headers,
|
|
5596
6873
|
url: request.url
|
|
5597
6874
|
});
|
|
5598
|
-
if (!token || !
|
|
6875
|
+
if (!token || !await deps.validator.validate(token)) {
|
|
5599
6876
|
socket.close(4401, "Unauthorized");
|
|
5600
6877
|
return;
|
|
5601
6878
|
}
|
|
@@ -5646,8 +6923,15 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5646
6923
|
}
|
|
5647
6924
|
})();
|
|
5648
6925
|
});
|
|
6926
|
+
const modesPayload = buildModesPayload(session);
|
|
5649
6927
|
return {
|
|
5650
6928
|
sessionId: session.sessionId,
|
|
6929
|
+
// session/new is implicitly an attach; mirror session/attach's
|
|
6930
|
+
// shape by including the clientId so deferred-echo clients
|
|
6931
|
+
// (TUI's queue work) can recognize their own prompt_queue_added
|
|
6932
|
+
// events without an extra round-trip.
|
|
6933
|
+
clientId: client.clientId,
|
|
6934
|
+
...modesPayload ? { modes: modesPayload } : {},
|
|
5651
6935
|
_meta: buildResponseMeta(session)
|
|
5652
6936
|
};
|
|
5653
6937
|
});
|
|
@@ -5708,6 +6992,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5708
6992
|
await connection.notify(note.method, note.params);
|
|
5709
6993
|
}
|
|
5710
6994
|
session.replayPendingPermissions(client);
|
|
6995
|
+
const modesPayload = buildModesPayload(session);
|
|
5711
6996
|
return {
|
|
5712
6997
|
sessionId: session.sessionId,
|
|
5713
6998
|
clientId: client.clientId,
|
|
@@ -5718,6 +7003,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5718
7003
|
// ran, not what was asked for.
|
|
5719
7004
|
historyPolicy: appliedPolicy,
|
|
5720
7005
|
replayed: replay.length,
|
|
7006
|
+
...modesPayload ? { modes: modesPayload } : {},
|
|
5721
7007
|
_meta: buildResponseMeta(session)
|
|
5722
7008
|
};
|
|
5723
7009
|
});
|
|
@@ -5751,7 +7037,29 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5751
7037
|
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
5752
7038
|
throw err;
|
|
5753
7039
|
}
|
|
5754
|
-
|
|
7040
|
+
let session = deps.manager.get(params.sessionId);
|
|
7041
|
+
if (!session) {
|
|
7042
|
+
const fromDisk = await deps.manager.loadFromDisk(params.sessionId);
|
|
7043
|
+
if (!fromDisk) {
|
|
7044
|
+
const err = new Error(
|
|
7045
|
+
`session ${params.sessionId} not found`
|
|
7046
|
+
);
|
|
7047
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
7048
|
+
throw err;
|
|
7049
|
+
}
|
|
7050
|
+
app.log.info(
|
|
7051
|
+
`session/prompt auto-resurrecting cold sessionId=${params.sessionId}`
|
|
7052
|
+
);
|
|
7053
|
+
session = await deps.manager.resurrect(fromDisk);
|
|
7054
|
+
const client = bindClientToSession(
|
|
7055
|
+
connection,
|
|
7056
|
+
session,
|
|
7057
|
+
state,
|
|
7058
|
+
void 0,
|
|
7059
|
+
att.clientId
|
|
7060
|
+
);
|
|
7061
|
+
await session.attach(client, "none");
|
|
7062
|
+
}
|
|
5755
7063
|
return session.prompt(att.clientId, params);
|
|
5756
7064
|
});
|
|
5757
7065
|
const handleCancelParams = (raw) => {
|
|
@@ -5783,6 +7091,26 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5783
7091
|
handleCancelParams(raw);
|
|
5784
7092
|
return null;
|
|
5785
7093
|
});
|
|
7094
|
+
connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
|
|
7095
|
+
const params = CancelPromptParams.parse(raw);
|
|
7096
|
+
const session = deps.manager.get(params.sessionId);
|
|
7097
|
+
if (!session) {
|
|
7098
|
+
const err = new Error(`session ${params.sessionId} not found`);
|
|
7099
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
7100
|
+
throw err;
|
|
7101
|
+
}
|
|
7102
|
+
return session.cancelQueuedPrompt(params.messageId);
|
|
7103
|
+
});
|
|
7104
|
+
connection.onRequest("hydra-acp/update_prompt", async (raw) => {
|
|
7105
|
+
const params = UpdatePromptParams.parse(raw);
|
|
7106
|
+
const session = deps.manager.get(params.sessionId);
|
|
7107
|
+
if (!session) {
|
|
7108
|
+
const err = new Error(`session ${params.sessionId} not found`);
|
|
7109
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
7110
|
+
throw err;
|
|
7111
|
+
}
|
|
7112
|
+
return session.updateQueuedPrompt(params.messageId, params.prompt);
|
|
7113
|
+
});
|
|
5786
7114
|
connection.onRequest("session/load", async (raw) => {
|
|
5787
7115
|
const rawObj = raw ?? {};
|
|
5788
7116
|
const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
|
|
@@ -5814,8 +7142,13 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5814
7142
|
await connection.notify(note.method, note.params);
|
|
5815
7143
|
}
|
|
5816
7144
|
session.replayPendingPermissions(client);
|
|
7145
|
+
const modesPayload = buildModesPayload(session);
|
|
5817
7146
|
return {
|
|
5818
7147
|
sessionId: session.sessionId,
|
|
7148
|
+
// Same as session/new: include clientId so the deferred-echo
|
|
7149
|
+
// path in queue-aware clients can recognize own broadcasts.
|
|
7150
|
+
clientId: client.clientId,
|
|
7151
|
+
...modesPayload ? { modes: modesPayload } : {},
|
|
5819
7152
|
_meta: buildResponseMeta(session)
|
|
5820
7153
|
};
|
|
5821
7154
|
});
|
|
@@ -5841,6 +7174,26 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5841
7174
|
});
|
|
5842
7175
|
});
|
|
5843
7176
|
}
|
|
7177
|
+
function buildModesPayload(session) {
|
|
7178
|
+
const modes = session.availableModes();
|
|
7179
|
+
if (modes.length === 0) {
|
|
7180
|
+
return void 0;
|
|
7181
|
+
}
|
|
7182
|
+
const availableModes = modes.map((m) => {
|
|
7183
|
+
const out = {
|
|
7184
|
+
id: m.id,
|
|
7185
|
+
// ACP spec requires `name` — fall back to id when the agent didn't
|
|
7186
|
+
// supply one so we never emit an invalid SessionMode.
|
|
7187
|
+
name: m.name ?? m.id
|
|
7188
|
+
};
|
|
7189
|
+
if (m.description !== void 0) {
|
|
7190
|
+
out.description = m.description;
|
|
7191
|
+
}
|
|
7192
|
+
return out;
|
|
7193
|
+
});
|
|
7194
|
+
const currentModeId = session.currentMode ?? modes[0].id;
|
|
7195
|
+
return { currentModeId, availableModes };
|
|
7196
|
+
}
|
|
5844
7197
|
function buildResponseMeta(session) {
|
|
5845
7198
|
const ours = {
|
|
5846
7199
|
upstreamSessionId: session.upstreamSessionId,
|
|
@@ -5866,9 +7219,17 @@ function buildResponseMeta(session) {
|
|
|
5866
7219
|
if (commands.length > 0) {
|
|
5867
7220
|
ours.availableCommands = commands;
|
|
5868
7221
|
}
|
|
7222
|
+
const modes = session.availableModes();
|
|
7223
|
+
if (modes.length > 0) {
|
|
7224
|
+
ours.availableModes = modes;
|
|
7225
|
+
}
|
|
5869
7226
|
if (session.turnStartedAt !== void 0) {
|
|
5870
7227
|
ours.turnStartedAt = session.turnStartedAt;
|
|
5871
7228
|
}
|
|
7229
|
+
const queue = session.queueSnapshot();
|
|
7230
|
+
if (queue.length > 0) {
|
|
7231
|
+
ours.queue = queue;
|
|
7232
|
+
}
|
|
5872
7233
|
return mergeMeta(session.agentMeta, ours);
|
|
5873
7234
|
}
|
|
5874
7235
|
function buildInitializeResult() {
|
|
@@ -5899,7 +7260,13 @@ function buildInitializeResult() {
|
|
|
5899
7260
|
id: "bearer-token",
|
|
5900
7261
|
description: "Bearer token presented at WS upgrade"
|
|
5901
7262
|
}
|
|
5902
|
-
]
|
|
7263
|
+
],
|
|
7264
|
+
// Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
|
|
7265
|
+
// ACP clients ignore the field; capability-aware clients learn here
|
|
7266
|
+
// that hydra accepts concurrent session/prompt requests and emits
|
|
7267
|
+
// prompt_queue_* notifications so they can stop running their own
|
|
7268
|
+
// local queue.
|
|
7269
|
+
_meta: mergeMeta(void 0, { promptQueueing: true })
|
|
5903
7270
|
};
|
|
5904
7271
|
}
|
|
5905
7272
|
function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
|
|
@@ -5913,13 +7280,13 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
|
|
|
5913
7280
|
}
|
|
5914
7281
|
|
|
5915
7282
|
// src/daemon/server.ts
|
|
5916
|
-
async function startDaemon(config) {
|
|
7283
|
+
async function startDaemon(config, serviceToken) {
|
|
5917
7284
|
ensureLoopbackOrTls(config);
|
|
5918
7285
|
const httpsOptions = config.daemon.tls ? {
|
|
5919
|
-
key: await
|
|
5920
|
-
cert: await
|
|
7286
|
+
key: await fsp5.readFile(config.daemon.tls.key),
|
|
7287
|
+
cert: await fsp5.readFile(config.daemon.tls.cert)
|
|
5921
7288
|
} : void 0;
|
|
5922
|
-
await
|
|
7289
|
+
await fsp5.mkdir(paths.home(), { recursive: true });
|
|
5923
7290
|
const { stream: logStream, fileStream } = await buildLogStream(
|
|
5924
7291
|
config.daemon.logLevel
|
|
5925
7292
|
);
|
|
@@ -5940,7 +7307,13 @@ async function startDaemon(config) {
|
|
|
5940
7307
|
setNpmInstallLogger((msg) => {
|
|
5941
7308
|
app.log.info(msg);
|
|
5942
7309
|
});
|
|
5943
|
-
const
|
|
7310
|
+
const sessionTokenStore = await SessionTokenStore.load();
|
|
7311
|
+
const authRateLimiter = new AuthRateLimiter();
|
|
7312
|
+
const validator = new CompositeTokenValidator([
|
|
7313
|
+
new StaticTokenValidator(serviceToken),
|
|
7314
|
+
new SessionTokenValidator(sessionTokenStore)
|
|
7315
|
+
]);
|
|
7316
|
+
const auth = bearerAuth({ validator });
|
|
5944
7317
|
app.addHook("onRequest", async (request, reply) => {
|
|
5945
7318
|
if (request.routeOptions.config?.skipAuth) {
|
|
5946
7319
|
return;
|
|
@@ -5950,7 +7323,19 @@ async function startDaemon(config) {
|
|
|
5950
7323
|
}
|
|
5951
7324
|
await auth(request, reply);
|
|
5952
7325
|
});
|
|
5953
|
-
const
|
|
7326
|
+
const sweepInterval = setInterval(
|
|
7327
|
+
() => {
|
|
7328
|
+
sessionTokenStore.sweepExpired();
|
|
7329
|
+
},
|
|
7330
|
+
5 * 60 * 1e3
|
|
7331
|
+
);
|
|
7332
|
+
sweepInterval.unref();
|
|
7333
|
+
const registry = new Registry(config, {
|
|
7334
|
+
onFetched: () => {
|
|
7335
|
+
void pruneStaleAgentVersions(registry, manager);
|
|
7336
|
+
}
|
|
7337
|
+
});
|
|
7338
|
+
setAgentPruneLogger((msg) => app.log.info(msg));
|
|
5954
7339
|
const agentLogger = {
|
|
5955
7340
|
info: (msg) => app.log.info(msg),
|
|
5956
7341
|
warn: (msg) => app.log.warn(msg)
|
|
@@ -5964,7 +7349,8 @@ async function startDaemon(config) {
|
|
|
5964
7349
|
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
|
|
5965
7350
|
defaultModels: config.defaultModels,
|
|
5966
7351
|
sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
|
|
5967
|
-
logger: agentLogger
|
|
7352
|
+
logger: agentLogger,
|
|
7353
|
+
npmRegistry: config.npmRegistry
|
|
5968
7354
|
});
|
|
5969
7355
|
const extensions = new ExtensionManager(extensionList(config));
|
|
5970
7356
|
registerHealthRoutes(app, HYDRA_VERSION);
|
|
@@ -5978,16 +7364,20 @@ async function startDaemon(config) {
|
|
|
5978
7364
|
defaultAgent: config.defaultAgent,
|
|
5979
7365
|
defaultCwd: config.defaultCwd
|
|
5980
7366
|
});
|
|
7367
|
+
registerAuthRoutes(app, {
|
|
7368
|
+
store: sessionTokenStore,
|
|
7369
|
+
rateLimiter: authRateLimiter
|
|
7370
|
+
});
|
|
5981
7371
|
registerAcpWsEndpoint(app, {
|
|
5982
|
-
|
|
7372
|
+
validator,
|
|
5983
7373
|
manager,
|
|
5984
7374
|
defaultAgent: config.defaultAgent
|
|
5985
7375
|
});
|
|
5986
7376
|
await app.listen({ host: config.daemon.host, port: config.daemon.port });
|
|
5987
7377
|
const address = app.server.address();
|
|
5988
7378
|
const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
|
|
5989
|
-
await
|
|
5990
|
-
await
|
|
7379
|
+
await fsp5.mkdir(paths.home(), { recursive: true });
|
|
7380
|
+
await fsp5.writeFile(
|
|
5991
7381
|
paths.pidFile(),
|
|
5992
7382
|
JSON.stringify({
|
|
5993
7383
|
pid: process.pid,
|
|
@@ -6003,20 +7393,28 @@ async function startDaemon(config) {
|
|
|
6003
7393
|
daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
|
|
6004
7394
|
daemonHost: config.daemon.host,
|
|
6005
7395
|
daemonPort: boundPort,
|
|
6006
|
-
|
|
7396
|
+
serviceToken,
|
|
6007
7397
|
daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
|
|
6008
7398
|
hydraHome: paths.home()
|
|
6009
7399
|
});
|
|
6010
7400
|
await extensions.start();
|
|
7401
|
+
void manager.resurrectPendingQueues().catch((err) => {
|
|
7402
|
+
app.log.warn(
|
|
7403
|
+
`queue replay scan failed: ${err.message}`
|
|
7404
|
+
);
|
|
7405
|
+
});
|
|
6011
7406
|
const shutdown = async () => {
|
|
7407
|
+
clearInterval(sweepInterval);
|
|
7408
|
+
await sessionTokenStore.flush();
|
|
6012
7409
|
await extensions.stop();
|
|
6013
7410
|
await manager.closeAll();
|
|
6014
7411
|
await manager.flushMetaWrites();
|
|
6015
7412
|
setBinaryInstallLogger(null);
|
|
6016
7413
|
setNpmInstallLogger(null);
|
|
7414
|
+
setAgentPruneLogger(null);
|
|
6017
7415
|
await app.close();
|
|
6018
7416
|
try {
|
|
6019
|
-
|
|
7417
|
+
fs14.unlinkSync(paths.pidFile());
|
|
6020
7418
|
} catch {
|
|
6021
7419
|
}
|
|
6022
7420
|
try {
|
|
@@ -6057,9 +7455,10 @@ export {
|
|
|
6057
7455
|
Session,
|
|
6058
7456
|
SessionManager,
|
|
6059
7457
|
defaultConfig,
|
|
6060
|
-
|
|
6061
|
-
|
|
7458
|
+
ensureServiceToken,
|
|
7459
|
+
generateServiceToken,
|
|
6062
7460
|
loadConfig,
|
|
7461
|
+
loadServiceToken,
|
|
6063
7462
|
ndjsonStreamFromStdio,
|
|
6064
7463
|
paths,
|
|
6065
7464
|
planSpawn,
|