@hydra-acp/cli 0.1.21 → 0.1.23
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 +2177 -401
- package/dist/index.d.ts +163 -25
- package/dist/index.js +1477 -193
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/daemon/server.ts
|
|
2
|
-
import * as
|
|
2
|
+
import * as fs14 from "fs";
|
|
3
3
|
import * as fsp4 from "fs/promises";
|
|
4
4
|
import Fastify from "fastify";
|
|
5
5
|
import websocketPlugin from "@fastify/websocket";
|
|
@@ -7,7 +7,7 @@ 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"]
|
|
@@ -738,7 +751,7 @@ var Registry = class {
|
|
|
738
751
|
async readDiskCache() {
|
|
739
752
|
let text;
|
|
740
753
|
try {
|
|
741
|
-
text = await
|
|
754
|
+
text = await fs4.readFile(paths.registryCache(), "utf8");
|
|
742
755
|
} catch (err) {
|
|
743
756
|
const e = err;
|
|
744
757
|
if (e.code === "ENOENT") {
|
|
@@ -764,7 +777,7 @@ var Registry = class {
|
|
|
764
777
|
// without a lock file: the loser of the rename race just gets its
|
|
765
778
|
// version replaced by the winner's.
|
|
766
779
|
async writeDiskCache(cache) {
|
|
767
|
-
await
|
|
780
|
+
await fs4.mkdir(paths.home(), { recursive: true });
|
|
768
781
|
const final = paths.registryCache();
|
|
769
782
|
const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
|
|
770
783
|
const body = JSON.stringify(
|
|
@@ -773,10 +786,10 @@ var Registry = class {
|
|
|
773
786
|
2
|
|
774
787
|
) + "\n";
|
|
775
788
|
try {
|
|
776
|
-
await
|
|
777
|
-
await
|
|
789
|
+
await fs4.writeFile(tmp, body, "utf8");
|
|
790
|
+
await fs4.rename(tmp, final);
|
|
778
791
|
} catch (err) {
|
|
779
|
-
await
|
|
792
|
+
await fs4.unlink(tmp).catch(() => void 0);
|
|
780
793
|
throw err;
|
|
781
794
|
}
|
|
782
795
|
}
|
|
@@ -794,7 +807,7 @@ function npxPackageBasename(agent) {
|
|
|
794
807
|
const atIdx = afterSlash.lastIndexOf("@");
|
|
795
808
|
return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
|
|
796
809
|
}
|
|
797
|
-
async function planSpawn(agent, callerArgs = []) {
|
|
810
|
+
async function planSpawn(agent, callerArgs = [], options = {}) {
|
|
798
811
|
if (agent.distribution.npx) {
|
|
799
812
|
const npx = agent.distribution.npx;
|
|
800
813
|
const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
|
|
@@ -810,7 +823,8 @@ async function planSpawn(agent, callerArgs = []) {
|
|
|
810
823
|
agentId: agent.id,
|
|
811
824
|
version: agent.version ?? "current",
|
|
812
825
|
packageSpec: npx.package,
|
|
813
|
-
bin
|
|
826
|
+
bin,
|
|
827
|
+
registry: options.npmRegistry
|
|
814
828
|
});
|
|
815
829
|
return {
|
|
816
830
|
command: binPath,
|
|
@@ -995,6 +1009,58 @@ function extractHydraMeta(meta) {
|
|
|
995
1009
|
out.availableCommands = cmds;
|
|
996
1010
|
}
|
|
997
1011
|
}
|
|
1012
|
+
if (typeof obj.promptQueueing === "boolean") {
|
|
1013
|
+
out.promptQueueing = obj.promptQueueing;
|
|
1014
|
+
}
|
|
1015
|
+
if (Array.isArray(obj.queue)) {
|
|
1016
|
+
const entries = [];
|
|
1017
|
+
for (const raw of obj.queue) {
|
|
1018
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
const r = raw;
|
|
1022
|
+
const orig = r.originator;
|
|
1023
|
+
if (typeof r.messageId !== "string" || !orig || typeof orig.clientId !== "string" || !Array.isArray(r.prompt) || typeof r.position !== "number" || typeof r.enqueuedAt !== "number") {
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
const originator = { clientId: orig.clientId };
|
|
1027
|
+
if (typeof orig.name === "string") originator.name = orig.name;
|
|
1028
|
+
if (typeof orig.version === "string") originator.version = orig.version;
|
|
1029
|
+
entries.push({
|
|
1030
|
+
messageId: r.messageId,
|
|
1031
|
+
originator,
|
|
1032
|
+
prompt: r.prompt,
|
|
1033
|
+
position: r.position,
|
|
1034
|
+
enqueuedAt: r.enqueuedAt
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
if (entries.length > 0) {
|
|
1038
|
+
out.queue = entries;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (Array.isArray(obj.availableModes)) {
|
|
1042
|
+
const modes = [];
|
|
1043
|
+
for (const raw of obj.availableModes) {
|
|
1044
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const m = raw;
|
|
1048
|
+
if (typeof m.id !== "string") {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
const mode = { id: m.id };
|
|
1052
|
+
if (typeof m.name === "string") {
|
|
1053
|
+
mode.name = m.name;
|
|
1054
|
+
}
|
|
1055
|
+
if (typeof m.description === "string") {
|
|
1056
|
+
mode.description = m.description;
|
|
1057
|
+
}
|
|
1058
|
+
modes.push(mode);
|
|
1059
|
+
}
|
|
1060
|
+
if (modes.length > 0) {
|
|
1061
|
+
out.availableModes = modes;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
998
1064
|
return out;
|
|
999
1065
|
}
|
|
1000
1066
|
function mergeMeta(passthrough, ours) {
|
|
@@ -1047,6 +1113,49 @@ var SessionPromptParams = z3.object({
|
|
|
1047
1113
|
var SessionCancelParams = z3.object({
|
|
1048
1114
|
sessionId: z3.string()
|
|
1049
1115
|
});
|
|
1116
|
+
var PromptOriginatorSchema = z3.object({
|
|
1117
|
+
clientId: z3.string(),
|
|
1118
|
+
name: z3.string().optional(),
|
|
1119
|
+
version: z3.string().optional()
|
|
1120
|
+
});
|
|
1121
|
+
var PromptQueueAddedParams = z3.object({
|
|
1122
|
+
sessionId: z3.string(),
|
|
1123
|
+
messageId: z3.string(),
|
|
1124
|
+
originator: PromptOriginatorSchema,
|
|
1125
|
+
prompt: z3.array(z3.unknown()),
|
|
1126
|
+
// 0 = head (currently in-flight). At enqueue time the new entry's
|
|
1127
|
+
// position equals the count of entries already ahead of it.
|
|
1128
|
+
position: z3.number().int().nonnegative(),
|
|
1129
|
+
queueDepth: z3.number().int().positive(),
|
|
1130
|
+
enqueuedAt: z3.number()
|
|
1131
|
+
});
|
|
1132
|
+
var PromptQueueUpdatedParams = z3.object({
|
|
1133
|
+
sessionId: z3.string(),
|
|
1134
|
+
messageId: z3.string(),
|
|
1135
|
+
prompt: z3.array(z3.unknown())
|
|
1136
|
+
});
|
|
1137
|
+
var PromptQueueRemovedParams = z3.object({
|
|
1138
|
+
sessionId: z3.string(),
|
|
1139
|
+
messageId: z3.string(),
|
|
1140
|
+
reason: z3.enum(["started", "cancelled", "abandoned"])
|
|
1141
|
+
});
|
|
1142
|
+
var CancelPromptParams = z3.object({
|
|
1143
|
+
sessionId: z3.string(),
|
|
1144
|
+
messageId: z3.string()
|
|
1145
|
+
});
|
|
1146
|
+
var CancelPromptResult = z3.object({
|
|
1147
|
+
cancelled: z3.boolean(),
|
|
1148
|
+
reason: z3.enum(["ok", "not_found", "already_running"])
|
|
1149
|
+
});
|
|
1150
|
+
var UpdatePromptParams = z3.object({
|
|
1151
|
+
sessionId: z3.string(),
|
|
1152
|
+
messageId: z3.string(),
|
|
1153
|
+
prompt: z3.array(z3.unknown())
|
|
1154
|
+
});
|
|
1155
|
+
var UpdatePromptResult = z3.object({
|
|
1156
|
+
updated: z3.boolean(),
|
|
1157
|
+
reason: z3.enum(["ok", "not_found", "already_running"])
|
|
1158
|
+
});
|
|
1050
1159
|
var ProxyInitializeParams = z3.object({
|
|
1051
1160
|
protocolVersion: z3.number().optional(),
|
|
1052
1161
|
proxyInfo: z3.object({
|
|
@@ -1435,7 +1544,7 @@ stderr: ${tail}` : reason;
|
|
|
1435
1544
|
};
|
|
1436
1545
|
|
|
1437
1546
|
// src/core/session-manager.ts
|
|
1438
|
-
import * as
|
|
1547
|
+
import * as fs10 from "fs/promises";
|
|
1439
1548
|
import * as os2 from "os";
|
|
1440
1549
|
import { customAlphabet as customAlphabet3 } from "nanoid";
|
|
1441
1550
|
|
|
@@ -1469,6 +1578,47 @@ function hydraCommandsAsAdvertised() {
|
|
|
1469
1578
|
}));
|
|
1470
1579
|
}
|
|
1471
1580
|
|
|
1581
|
+
// src/core/queue-store.ts
|
|
1582
|
+
import * as fs5 from "fs/promises";
|
|
1583
|
+
async function rewriteQueue(sessionId, entries) {
|
|
1584
|
+
const file = paths.queueFile(sessionId);
|
|
1585
|
+
if (entries.length === 0) {
|
|
1586
|
+
await fs5.unlink(file).catch(() => void 0);
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1590
|
+
const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1591
|
+
await fs5.writeFile(file, body, "utf8");
|
|
1592
|
+
}
|
|
1593
|
+
async function loadQueue(sessionId) {
|
|
1594
|
+
const file = paths.queueFile(sessionId);
|
|
1595
|
+
let text;
|
|
1596
|
+
try {
|
|
1597
|
+
text = await fs5.readFile(file, "utf8");
|
|
1598
|
+
} catch (err) {
|
|
1599
|
+
if (err.code === "ENOENT") {
|
|
1600
|
+
return [];
|
|
1601
|
+
}
|
|
1602
|
+
throw err;
|
|
1603
|
+
}
|
|
1604
|
+
const out = [];
|
|
1605
|
+
for (const line of text.split("\n")) {
|
|
1606
|
+
if (!line.trim()) continue;
|
|
1607
|
+
try {
|
|
1608
|
+
const parsed = JSON.parse(line);
|
|
1609
|
+
if (parsed && typeof parsed.messageId === "string" && Array.isArray(parsed.prompt) && typeof parsed.enqueuedAt === "number") {
|
|
1610
|
+
out.push(parsed);
|
|
1611
|
+
}
|
|
1612
|
+
} catch {
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return out;
|
|
1616
|
+
}
|
|
1617
|
+
async function deleteQueue(sessionId) {
|
|
1618
|
+
const file = paths.queueFile(sessionId);
|
|
1619
|
+
await fs5.unlink(file).catch(() => void 0);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1472
1622
|
// src/core/session.ts
|
|
1473
1623
|
var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
1474
1624
|
var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
|
|
@@ -1504,7 +1654,18 @@ var Session = class {
|
|
|
1504
1654
|
clients = /* @__PURE__ */ new Map();
|
|
1505
1655
|
historyStore;
|
|
1506
1656
|
promptQueue = [];
|
|
1657
|
+
// The entry that drainQueue is currently awaiting. Distinct from
|
|
1658
|
+
// promptQueue[0] (which is the *next* one to dequeue): once shifted
|
|
1659
|
+
// off, the entry lives here for the duration of its task() so
|
|
1660
|
+
// cancelQueuedPrompt can distinguish "still in line" from "running"
|
|
1661
|
+
// and return already_running for the latter.
|
|
1662
|
+
currentEntry;
|
|
1507
1663
|
promptInFlight = false;
|
|
1664
|
+
// Serialize disk writes to the persisted queue file. Without this
|
|
1665
|
+
// chain, fire-and-forget appends/rewrites can interleave (e.g.
|
|
1666
|
+
// drainQueue's rewrite-to-empty races a sibling's append-on-
|
|
1667
|
+
// enqueue) and leave the file out of sync with in-memory state.
|
|
1668
|
+
queueWriteChain = Promise.resolve();
|
|
1508
1669
|
closed = false;
|
|
1509
1670
|
closeHandlers = [];
|
|
1510
1671
|
titleHandlers = [];
|
|
@@ -1557,10 +1718,14 @@ var Session = class {
|
|
|
1557
1718
|
// can deliver the merged list via _meta without depending on history
|
|
1558
1719
|
// replay.
|
|
1559
1720
|
agentAdvertisedCommands = [];
|
|
1721
|
+
// Last available_modes_update we observed from the agent. Same
|
|
1722
|
+
// pattern as commands: cache, persist, broadcast on change.
|
|
1723
|
+
agentAdvertisedModes = [];
|
|
1560
1724
|
// Persist hooks for snapshot-shaped state. SessionManager hooks these
|
|
1561
1725
|
// to mirror changes into meta.json so cold-resurrect attaches can
|
|
1562
1726
|
// surface the latest snapshot via the attach response _meta.
|
|
1563
1727
|
agentCommandsHandlers = [];
|
|
1728
|
+
agentModesHandlers = [];
|
|
1564
1729
|
modelHandlers = [];
|
|
1565
1730
|
modeHandlers = [];
|
|
1566
1731
|
usageHandlers = [];
|
|
@@ -1579,6 +1744,9 @@ var Session = class {
|
|
|
1579
1744
|
if (init.agentCommands && init.agentCommands.length > 0) {
|
|
1580
1745
|
this.agentAdvertisedCommands = [...init.agentCommands];
|
|
1581
1746
|
}
|
|
1747
|
+
if (init.agentModes && init.agentModes.length > 0) {
|
|
1748
|
+
this.agentAdvertisedModes = [...init.agentModes];
|
|
1749
|
+
}
|
|
1582
1750
|
this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
|
|
1583
1751
|
this.spawnReplacementAgent = init.spawnReplacementAgent;
|
|
1584
1752
|
this.logger = init.logger;
|
|
@@ -1607,6 +1775,15 @@ var Session = class {
|
|
|
1607
1775
|
}
|
|
1608
1776
|
});
|
|
1609
1777
|
}
|
|
1778
|
+
broadcastAvailableModes() {
|
|
1779
|
+
this.recordAndBroadcast("session/update", {
|
|
1780
|
+
sessionId: this.upstreamSessionId,
|
|
1781
|
+
update: {
|
|
1782
|
+
sessionUpdate: "available_modes_update",
|
|
1783
|
+
availableModes: this.agentAdvertisedModes
|
|
1784
|
+
}
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1610
1787
|
// Register session/update, session/request_permission, and onExit
|
|
1611
1788
|
// handlers on an agent connection. Re-run on every /hydra agent so
|
|
1612
1789
|
// the new agent is plumbed identically. The exit handler's identity
|
|
@@ -1624,6 +1801,11 @@ var Session = class {
|
|
|
1624
1801
|
this.setAgentAdvertisedCommands(agentCmds);
|
|
1625
1802
|
return;
|
|
1626
1803
|
}
|
|
1804
|
+
const agentModes = extractAdvertisedModes(params);
|
|
1805
|
+
if (agentModes !== null) {
|
|
1806
|
+
this.setAgentAdvertisedModes(agentModes);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1627
1809
|
if (this.maybeApplyAgentModel(params)) {
|
|
1628
1810
|
this.recordAndBroadcast("session/update", params);
|
|
1629
1811
|
return;
|
|
@@ -1800,7 +1982,7 @@ var Session = class {
|
|
|
1800
1982
|
sessionId,
|
|
1801
1983
|
update: {
|
|
1802
1984
|
sessionUpdate: "current_mode_update",
|
|
1803
|
-
|
|
1985
|
+
currentModeId: this.currentMode
|
|
1804
1986
|
}
|
|
1805
1987
|
},
|
|
1806
1988
|
recordedAt
|
|
@@ -1820,6 +2002,19 @@ var Session = class {
|
|
|
1820
2002
|
recordedAt
|
|
1821
2003
|
});
|
|
1822
2004
|
}
|
|
2005
|
+
if (this.agentAdvertisedModes.length > 0) {
|
|
2006
|
+
out.push({
|
|
2007
|
+
method: "session/update",
|
|
2008
|
+
params: {
|
|
2009
|
+
sessionId,
|
|
2010
|
+
update: {
|
|
2011
|
+
sessionUpdate: "available_modes_update",
|
|
2012
|
+
availableModes: [...this.agentAdvertisedModes]
|
|
2013
|
+
}
|
|
2014
|
+
},
|
|
2015
|
+
recordedAt
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
1823
2018
|
if (this.currentUsage !== void 0) {
|
|
1824
2019
|
const u = this.currentUsage;
|
|
1825
2020
|
const update = {
|
|
@@ -1910,34 +2105,28 @@ var Session = class {
|
|
|
1910
2105
|
if (promptText.startsWith("/hydra")) {
|
|
1911
2106
|
return this.handleSlashCommand(promptText);
|
|
1912
2107
|
}
|
|
1913
|
-
|
|
2108
|
+
const messageId = generateMessageId();
|
|
1914
2109
|
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;
|
|
2110
|
+
return this.enqueueUserPrompt(client, params, messageId);
|
|
2111
|
+
}
|
|
2112
|
+
// DEVIATION FROM RFD #533: this broadcast is deliberately deferred
|
|
2113
|
+
// until the prompt actually becomes the active turn (i.e. drainQueue
|
|
2114
|
+
// is about to forward it to the agent), NOT when hydra first accepts
|
|
2115
|
+
// the request. The literal RFD doesn't pin the timing — it just says
|
|
2116
|
+
// peers should learn about the turn — but it was authored before
|
|
2117
|
+
// prompt queueing existed, so accept-time and start-time were the
|
|
2118
|
+
// same moment. With hydra's per-session FIFO, deferring gives
|
|
2119
|
+
// prompt_received a single, useful meaning ("the agent is now taking
|
|
2120
|
+
// a turn on this prompt"), which is how attached clients (notably
|
|
2121
|
+
// agent-shell) consume it. The accept-time signal that peers can use
|
|
2122
|
+
// for queue chip rendering is hydra-acp/prompt_queue_added instead.
|
|
2123
|
+
broadcastPromptReceived(entry) {
|
|
2124
|
+
const sentBy = { clientId: entry.originator.clientId };
|
|
2125
|
+
if (entry.originator.name) {
|
|
2126
|
+
sentBy.name = entry.originator.name;
|
|
2127
|
+
}
|
|
2128
|
+
if (entry.originator.version) {
|
|
2129
|
+
sentBy.version = entry.originator.version;
|
|
1941
2130
|
}
|
|
1942
2131
|
this.promptStartedAt = Date.now();
|
|
1943
2132
|
this.recordAndBroadcast(
|
|
@@ -1946,14 +2135,14 @@ var Session = class {
|
|
|
1946
2135
|
sessionId: this.sessionId,
|
|
1947
2136
|
update: {
|
|
1948
2137
|
sessionUpdate: "prompt_received",
|
|
1949
|
-
messageId:
|
|
1950
|
-
prompt:
|
|
2138
|
+
messageId: entry.messageId,
|
|
2139
|
+
prompt: entry.prompt,
|
|
1951
2140
|
sentBy
|
|
1952
2141
|
}
|
|
1953
2142
|
},
|
|
1954
|
-
|
|
2143
|
+
entry.clientId
|
|
1955
2144
|
);
|
|
1956
|
-
const text = extractPromptText(
|
|
2145
|
+
const text = extractPromptText(entry.prompt);
|
|
1957
2146
|
if (text.length > 0) {
|
|
1958
2147
|
this.recordAndBroadcast(
|
|
1959
2148
|
"session/update",
|
|
@@ -1965,7 +2154,7 @@ var Session = class {
|
|
|
1965
2154
|
_meta: { "hydra-acp": { compatFor: "prompt_received" } }
|
|
1966
2155
|
}
|
|
1967
2156
|
},
|
|
1968
|
-
|
|
2157
|
+
entry.clientId
|
|
1969
2158
|
);
|
|
1970
2159
|
}
|
|
1971
2160
|
}
|
|
@@ -1988,6 +2177,172 @@ var Session = class {
|
|
|
1988
2177
|
originatorClientId
|
|
1989
2178
|
);
|
|
1990
2179
|
}
|
|
2180
|
+
// Total visible-or-running entries: the in-flight head (if any) plus
|
|
2181
|
+
// the queue's user-visible waiting entries. Internal entries don't
|
|
2182
|
+
// count — they're an implementation detail and the wire never
|
|
2183
|
+
// surfaces them.
|
|
2184
|
+
visibleQueueDepth() {
|
|
2185
|
+
let count = this.currentEntry?.kind === "user" && !this.currentEntry.cancelled ? 1 : 0;
|
|
2186
|
+
for (const entry of this.promptQueue) {
|
|
2187
|
+
if (entry.kind === "user" && !entry.cancelled) count += 1;
|
|
2188
|
+
}
|
|
2189
|
+
return count;
|
|
2190
|
+
}
|
|
2191
|
+
broadcastQueueAdded(entry) {
|
|
2192
|
+
const depth = this.visibleQueueDepth();
|
|
2193
|
+
const position = Math.max(0, depth - 1);
|
|
2194
|
+
const params = {
|
|
2195
|
+
sessionId: this.sessionId,
|
|
2196
|
+
messageId: entry.messageId,
|
|
2197
|
+
originator: entry.originator,
|
|
2198
|
+
prompt: entry.prompt,
|
|
2199
|
+
position,
|
|
2200
|
+
queueDepth: depth,
|
|
2201
|
+
enqueuedAt: entry.enqueuedAt
|
|
2202
|
+
};
|
|
2203
|
+
this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
|
|
2204
|
+
}
|
|
2205
|
+
broadcastQueueUpdated(messageId, prompt) {
|
|
2206
|
+
this.broadcastQueueNotification("hydra-acp/prompt_queue_updated", {
|
|
2207
|
+
sessionId: this.sessionId,
|
|
2208
|
+
messageId,
|
|
2209
|
+
prompt
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
broadcastQueueRemoved(messageId, reason) {
|
|
2213
|
+
this.broadcastQueueNotification("hydra-acp/prompt_queue_removed", {
|
|
2214
|
+
sessionId: this.sessionId,
|
|
2215
|
+
messageId,
|
|
2216
|
+
reason
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
// Fan-out for queue lifecycle notifications. Ephemeral by design —
|
|
2220
|
+
// these signals describe transient daemon state, not conversation
|
|
2221
|
+
// content, so we deliberately bypass recordAndBroadcast (no history,
|
|
2222
|
+
// no idle-timer arm, no rewrite-for-client since we already emit the
|
|
2223
|
+
// hydra sessionId).
|
|
2224
|
+
broadcastQueueNotification(method, params) {
|
|
2225
|
+
for (const client of this.clients.values()) {
|
|
2226
|
+
void client.connection.notify(method, params).catch(() => void 0);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
// Snapshot of user-visible queue state at this moment. Surfaced to
|
|
2230
|
+
// late-attaching clients via the session/attach response _meta so
|
|
2231
|
+
// they boot with the same chip list as their peers without waiting
|
|
2232
|
+
// for new prompt_queue_added notifications. Internal entries are
|
|
2233
|
+
// omitted (they're not surfaced on the wire at all).
|
|
2234
|
+
queueSnapshot() {
|
|
2235
|
+
const out = [];
|
|
2236
|
+
let position = 0;
|
|
2237
|
+
if (this.currentEntry?.kind === "user" && !this.currentEntry.cancelled) {
|
|
2238
|
+
out.push({
|
|
2239
|
+
messageId: this.currentEntry.messageId,
|
|
2240
|
+
originator: this.currentEntry.originator,
|
|
2241
|
+
prompt: this.currentEntry.prompt,
|
|
2242
|
+
position: position++,
|
|
2243
|
+
enqueuedAt: this.currentEntry.enqueuedAt
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
for (const entry of this.promptQueue) {
|
|
2247
|
+
if (entry.kind !== "user" || entry.cancelled) continue;
|
|
2248
|
+
out.push({
|
|
2249
|
+
messageId: entry.messageId,
|
|
2250
|
+
originator: entry.originator,
|
|
2251
|
+
prompt: entry.prompt,
|
|
2252
|
+
position: position++,
|
|
2253
|
+
enqueuedAt: entry.enqueuedAt
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
return out;
|
|
2257
|
+
}
|
|
2258
|
+
// Wait for any pending queue-file writes to settle. Test hook so
|
|
2259
|
+
// assertions about on-disk state don't race with fire-and-forget
|
|
2260
|
+
// rewrites. Production code doesn't need this — the chain
|
|
2261
|
+
// self-serializes.
|
|
2262
|
+
async flushPersistWrites() {
|
|
2263
|
+
await this.queueWriteChain.catch(() => void 0);
|
|
2264
|
+
}
|
|
2265
|
+
// Push pre-existing queue entries back through the daemon-side
|
|
2266
|
+
// pipeline on startup. Called by SessionManager after resurrecting
|
|
2267
|
+
// a session that had a non-empty queue.ndjson on disk. Each entry
|
|
2268
|
+
// gets a synthetic UserPromptQueueEntry with no real caller
|
|
2269
|
+
// (resolve/reject are no-ops since the original WS is long gone),
|
|
2270
|
+
// then drainQueue picks it up like any other entry. Late-attaching
|
|
2271
|
+
// clients see the entries via prompt_queue_added broadcasts and the
|
|
2272
|
+
// attach-response snapshot.
|
|
2273
|
+
replayPersistedQueue(entries) {
|
|
2274
|
+
for (const persisted of entries) {
|
|
2275
|
+
const originator = {
|
|
2276
|
+
clientId: `hydra-resurrected_${persisted.messageId}`
|
|
2277
|
+
};
|
|
2278
|
+
if (persisted.originator.clientInfo.name !== void 0) {
|
|
2279
|
+
originator.name = persisted.originator.clientInfo.name;
|
|
2280
|
+
}
|
|
2281
|
+
if (persisted.originator.clientInfo.version !== void 0) {
|
|
2282
|
+
originator.version = persisted.originator.clientInfo.version;
|
|
2283
|
+
}
|
|
2284
|
+
const entry = {
|
|
2285
|
+
kind: "user",
|
|
2286
|
+
messageId: persisted.messageId,
|
|
2287
|
+
originator,
|
|
2288
|
+
// Synthetic clientId. broadcastTurnComplete uses this as
|
|
2289
|
+
// excludeClientId for the peer-only broadcast; with a synthetic
|
|
2290
|
+
// id no real attached client matches the exclude, so everyone
|
|
2291
|
+
// sees turn_complete — which is what we want, since none of
|
|
2292
|
+
// them originated this restart-replayed prompt.
|
|
2293
|
+
clientId: originator.clientId,
|
|
2294
|
+
prompt: persisted.prompt,
|
|
2295
|
+
enqueuedAt: persisted.enqueuedAt,
|
|
2296
|
+
cancelled: false,
|
|
2297
|
+
resolve: () => void 0,
|
|
2298
|
+
reject: () => void 0
|
|
2299
|
+
};
|
|
2300
|
+
this.promptQueue.push(entry);
|
|
2301
|
+
this.broadcastQueueAdded(entry);
|
|
2302
|
+
}
|
|
2303
|
+
void this.drainQueue();
|
|
2304
|
+
}
|
|
2305
|
+
// Drop a queued prompt by messageId. Returns already_running when
|
|
2306
|
+
// the messageId names the in-flight entry — callers should fall back
|
|
2307
|
+
// to session/cancel for that case. Originator-agnostic: any attached
|
|
2308
|
+
// client may cancel any queued prompt (matches the existing slack
|
|
2309
|
+
// :stop_sign: reaction UX and the TUI's queue-edit dispatcher).
|
|
2310
|
+
cancelQueuedPrompt(messageId) {
|
|
2311
|
+
if (this.currentEntry?.messageId === messageId) {
|
|
2312
|
+
return { cancelled: false, reason: "already_running" };
|
|
2313
|
+
}
|
|
2314
|
+
const idx = this.promptQueue.findIndex((e) => e.messageId === messageId);
|
|
2315
|
+
if (idx < 0) {
|
|
2316
|
+
return { cancelled: false, reason: "not_found" };
|
|
2317
|
+
}
|
|
2318
|
+
const entry = this.promptQueue[idx];
|
|
2319
|
+
entry.cancelled = true;
|
|
2320
|
+
this.promptQueue.splice(idx, 1);
|
|
2321
|
+
if (entry.kind === "user") {
|
|
2322
|
+
this.broadcastQueueRemoved(messageId, "cancelled");
|
|
2323
|
+
this.persistRewrite();
|
|
2324
|
+
}
|
|
2325
|
+
entry.resolve({ stopReason: "cancelled" });
|
|
2326
|
+
return { cancelled: true, reason: "ok" };
|
|
2327
|
+
}
|
|
2328
|
+
// Replace the prompt payload of a queued (not-yet-running) entry.
|
|
2329
|
+
// Returns already_running for the in-flight head; not_found for
|
|
2330
|
+
// unknown messageIds or for internal queue entries (internal tasks
|
|
2331
|
+
// don't expose a mutable prompt). Broadcasts prompt_queue_updated on
|
|
2332
|
+
// success so every attached client refreshes its chip.
|
|
2333
|
+
updateQueuedPrompt(messageId, prompt) {
|
|
2334
|
+
if (this.currentEntry?.messageId === messageId) {
|
|
2335
|
+
return { updated: false, reason: "already_running" };
|
|
2336
|
+
}
|
|
2337
|
+
const entry = this.promptQueue.find((e) => e.messageId === messageId);
|
|
2338
|
+
if (!entry || entry.kind !== "user") {
|
|
2339
|
+
return { updated: false, reason: "not_found" };
|
|
2340
|
+
}
|
|
2341
|
+
entry.prompt = prompt;
|
|
2342
|
+
this.broadcastQueueUpdated(messageId, prompt);
|
|
2343
|
+
this.persistRewrite();
|
|
2344
|
+
return { updated: true, reason: "ok" };
|
|
2345
|
+
}
|
|
1991
2346
|
async cancel(clientId) {
|
|
1992
2347
|
const client = this.clients.get(clientId);
|
|
1993
2348
|
if (!client) {
|
|
@@ -2114,7 +2469,7 @@ var Session = class {
|
|
|
2114
2469
|
if (update.sessionUpdate !== "current_mode_update") {
|
|
2115
2470
|
return false;
|
|
2116
2471
|
}
|
|
2117
|
-
const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
|
|
2472
|
+
const raw = typeof update.currentModeId === "string" ? update.currentModeId : typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
|
|
2118
2473
|
if (raw === void 0) {
|
|
2119
2474
|
return true;
|
|
2120
2475
|
}
|
|
@@ -2192,12 +2547,29 @@ var Session = class {
|
|
|
2192
2547
|
}
|
|
2193
2548
|
this.broadcastMergedCommands();
|
|
2194
2549
|
}
|
|
2550
|
+
setAgentAdvertisedModes(modes) {
|
|
2551
|
+
if (sameAdvertisedModes(this.agentAdvertisedModes, modes)) {
|
|
2552
|
+
this.broadcastAvailableModes();
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
this.agentAdvertisedModes = modes;
|
|
2556
|
+
for (const handler of this.agentModesHandlers) {
|
|
2557
|
+
try {
|
|
2558
|
+
handler(modes);
|
|
2559
|
+
} catch {
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
this.broadcastAvailableModes();
|
|
2563
|
+
}
|
|
2195
2564
|
// Subscribe to snapshot-state updates. SessionManager wires these to
|
|
2196
2565
|
// persist the new value into meta.json so cold resurrect can restore
|
|
2197
2566
|
// them via the attach response _meta.
|
|
2198
2567
|
onAgentCommandsChange(handler) {
|
|
2199
2568
|
this.agentCommandsHandlers.push(handler);
|
|
2200
2569
|
}
|
|
2570
|
+
onAgentModesChange(handler) {
|
|
2571
|
+
this.agentModesHandlers.push(handler);
|
|
2572
|
+
}
|
|
2201
2573
|
onModelChange(handler) {
|
|
2202
2574
|
this.modelHandlers.push(handler);
|
|
2203
2575
|
}
|
|
@@ -2219,6 +2591,10 @@ var Session = class {
|
|
|
2219
2591
|
agentOnlyAdvertisedCommands() {
|
|
2220
2592
|
return [...this.agentAdvertisedCommands];
|
|
2221
2593
|
}
|
|
2594
|
+
// The agent's advertised modes list, for callers that need a snapshot.
|
|
2595
|
+
availableModes() {
|
|
2596
|
+
return [...this.agentAdvertisedModes];
|
|
2597
|
+
}
|
|
2222
2598
|
// Pick up an agent-emitted session_info_update and store its title
|
|
2223
2599
|
// as our canonical record. The notification is also forwarded to
|
|
2224
2600
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -2531,6 +2907,20 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2531
2907
|
}
|
|
2532
2908
|
this.closed = true;
|
|
2533
2909
|
this.cancelIdleTimer();
|
|
2910
|
+
const stranded = this.promptQueue;
|
|
2911
|
+
this.promptQueue = [];
|
|
2912
|
+
for (const entry of stranded) {
|
|
2913
|
+
entry.cancelled = true;
|
|
2914
|
+
if (entry.kind === "user") {
|
|
2915
|
+
this.broadcastQueueRemoved(entry.messageId, "abandoned");
|
|
2916
|
+
}
|
|
2917
|
+
try {
|
|
2918
|
+
entry.resolve({ stopReason: "cancelled" });
|
|
2919
|
+
} catch {
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
const sessionId = this.sessionId;
|
|
2923
|
+
this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
|
|
2534
2924
|
for (const client of this.clients.values()) {
|
|
2535
2925
|
void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
|
|
2536
2926
|
}
|
|
@@ -2576,7 +2966,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2576
2966
|
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
2577
2967
|
return;
|
|
2578
2968
|
}
|
|
2579
|
-
if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
|
|
2969
|
+
if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0 || this.promptQueue.length > 0) {
|
|
2580
2970
|
this.armIdleTimer(this.idleTimeoutMs);
|
|
2581
2971
|
return;
|
|
2582
2972
|
}
|
|
@@ -2705,20 +3095,88 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2705
3095
|
}
|
|
2706
3096
|
});
|
|
2707
3097
|
}
|
|
3098
|
+
// Schedule an internal task (title regen, agent swap transcript
|
|
3099
|
+
// injection, import seed). Serializes behind any user prompts already
|
|
3100
|
+
// in flight, but doesn't emit prompt_queue_* broadcasts — clients
|
|
3101
|
+
// shouldn't see hydra's housekeeping in their chip list.
|
|
2708
3102
|
async enqueuePrompt(task) {
|
|
2709
3103
|
return new Promise((resolve3, reject) => {
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
3104
|
+
const entry = {
|
|
3105
|
+
kind: "internal",
|
|
3106
|
+
messageId: generateMessageId(),
|
|
3107
|
+
enqueuedAt: Date.now(),
|
|
3108
|
+
cancelled: false,
|
|
3109
|
+
task,
|
|
3110
|
+
resolve: resolve3,
|
|
3111
|
+
reject
|
|
3112
|
+
};
|
|
3113
|
+
this.promptQueue.push(entry);
|
|
3114
|
+
void this.drainQueue();
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
// Schedule a user-originated session/prompt. Emits prompt_queue_added
|
|
3118
|
+
// immediately on enqueue (so peer clients can render the queued chip)
|
|
3119
|
+
// and prompt_queue_removed when the entry leaves the queue. The
|
|
3120
|
+
// returned promise resolves with the upstream agent's session/prompt
|
|
3121
|
+
// result, or { stopReason: "cancelled" } if the entry is dropped via
|
|
3122
|
+
// cancelQueuedPrompt before reaching the head.
|
|
3123
|
+
async enqueueUserPrompt(client, params, messageId) {
|
|
3124
|
+
const promptArray = (params ?? {}).prompt ?? [];
|
|
3125
|
+
const originator = { clientId: client.clientId };
|
|
3126
|
+
if (client.clientInfo?.name) originator.name = client.clientInfo.name;
|
|
3127
|
+
if (client.clientInfo?.version)
|
|
3128
|
+
originator.version = client.clientInfo.version;
|
|
3129
|
+
return new Promise((resolve3, reject) => {
|
|
3130
|
+
const entry = {
|
|
3131
|
+
kind: "user",
|
|
3132
|
+
messageId,
|
|
3133
|
+
originator,
|
|
3134
|
+
clientId: client.clientId,
|
|
3135
|
+
prompt: promptArray,
|
|
3136
|
+
enqueuedAt: Date.now(),
|
|
3137
|
+
cancelled: false,
|
|
3138
|
+
resolve: resolve3,
|
|
3139
|
+
reject
|
|
2717
3140
|
};
|
|
2718
|
-
this.promptQueue.push(
|
|
3141
|
+
this.promptQueue.push(entry);
|
|
3142
|
+
this.persistRewrite();
|
|
3143
|
+
this.broadcastQueueAdded(entry);
|
|
2719
3144
|
void this.drainQueue();
|
|
2720
3145
|
});
|
|
2721
3146
|
}
|
|
3147
|
+
// Rewrite the on-disk queue to reflect the current set of WAITING
|
|
3148
|
+
// entries (excluding currentEntry, the in-flight head). Excluding
|
|
3149
|
+
// the head is the key idempotency choice: once drainQueue shifts an
|
|
3150
|
+
// entry off and calls persistRewrite, a daemon crash mid-generation
|
|
3151
|
+
// will NOT re-run it on restart. Partial output (if any streamed
|
|
3152
|
+
// before the crash) stays in history; the prompt itself is lost
|
|
3153
|
+
// and the user can re-submit if they care.
|
|
3154
|
+
//
|
|
3155
|
+
// Snapshots in-memory state synchronously (so subsequent mutations
|
|
3156
|
+
// can't perturb what we're about to write) and chains the write
|
|
3157
|
+
// onto queueWriteChain so all persists are serialized.
|
|
3158
|
+
persistRewrite() {
|
|
3159
|
+
const entries = [];
|
|
3160
|
+
for (const entry of this.promptQueue) {
|
|
3161
|
+
if (entry.kind !== "user" || entry.cancelled) continue;
|
|
3162
|
+
entries.push(this.persistedFromEntry(entry));
|
|
3163
|
+
}
|
|
3164
|
+
const sessionId = this.sessionId;
|
|
3165
|
+
this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => rewriteQueue(sessionId, entries).catch(() => void 0));
|
|
3166
|
+
}
|
|
3167
|
+
persistedFromEntry(entry) {
|
|
3168
|
+
return {
|
|
3169
|
+
messageId: entry.messageId,
|
|
3170
|
+
originator: {
|
|
3171
|
+
clientInfo: {
|
|
3172
|
+
...entry.originator.name !== void 0 ? { name: entry.originator.name } : {},
|
|
3173
|
+
...entry.originator.version !== void 0 ? { version: entry.originator.version } : {}
|
|
3174
|
+
}
|
|
3175
|
+
},
|
|
3176
|
+
prompt: entry.prompt,
|
|
3177
|
+
enqueuedAt: entry.enqueuedAt
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
2722
3180
|
async drainQueue() {
|
|
2723
3181
|
if (this.promptInFlight) {
|
|
2724
3182
|
return;
|
|
@@ -2727,14 +3185,64 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
2727
3185
|
try {
|
|
2728
3186
|
while (this.promptQueue.length > 0) {
|
|
2729
3187
|
const next = this.promptQueue.shift();
|
|
2730
|
-
if (next) {
|
|
2731
|
-
|
|
3188
|
+
if (!next) {
|
|
3189
|
+
break;
|
|
3190
|
+
}
|
|
3191
|
+
if (next.cancelled) {
|
|
3192
|
+
continue;
|
|
3193
|
+
}
|
|
3194
|
+
this.currentEntry = next;
|
|
3195
|
+
if (next.kind === "user") {
|
|
3196
|
+
this.persistRewrite();
|
|
3197
|
+
}
|
|
3198
|
+
if (next.kind === "user") {
|
|
3199
|
+
this.broadcastQueueRemoved(next.messageId, "started");
|
|
3200
|
+
}
|
|
3201
|
+
try {
|
|
3202
|
+
const result = await this.runQueueEntry(next);
|
|
3203
|
+
next.resolve(result);
|
|
3204
|
+
} catch (err) {
|
|
3205
|
+
next.reject(err);
|
|
3206
|
+
} finally {
|
|
3207
|
+
this.currentEntry = void 0;
|
|
2732
3208
|
}
|
|
2733
3209
|
}
|
|
2734
3210
|
} finally {
|
|
2735
3211
|
this.promptInFlight = false;
|
|
2736
3212
|
}
|
|
2737
3213
|
}
|
|
3214
|
+
// Execute a queue entry. User-prompt entries forward to the upstream
|
|
3215
|
+
// agent and pair with broadcastTurnComplete; internal entries run
|
|
3216
|
+
// their captured task closure. Reads entry.prompt at dispatch time
|
|
3217
|
+
// so updateQueuedPrompt's mutations are honoured.
|
|
3218
|
+
//
|
|
3219
|
+
// For user entries, broadcastPromptReceived fires HERE — not in
|
|
3220
|
+
// Session.prompt — so peer clients see prompt_received only when the
|
|
3221
|
+
// turn actually starts (a deliberate deviation from a naive reading
|
|
3222
|
+
// of RFD #533; see the comment on broadcastPromptReceived). Order on
|
|
3223
|
+
// the wire: prompt_queue_removed{started} (already emitted by
|
|
3224
|
+
// drainQueue) → prompt_received → upstream session/prompt.
|
|
3225
|
+
async runQueueEntry(entry) {
|
|
3226
|
+
if (entry.kind === "internal") {
|
|
3227
|
+
return entry.task();
|
|
3228
|
+
}
|
|
3229
|
+
this.broadcastPromptReceived(entry);
|
|
3230
|
+
let response;
|
|
3231
|
+
try {
|
|
3232
|
+
response = await this.agent.connection.request(
|
|
3233
|
+
"session/prompt",
|
|
3234
|
+
{
|
|
3235
|
+
sessionId: this.upstreamSessionId,
|
|
3236
|
+
prompt: entry.prompt
|
|
3237
|
+
}
|
|
3238
|
+
);
|
|
3239
|
+
} catch (err) {
|
|
3240
|
+
this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
|
|
3241
|
+
throw err;
|
|
3242
|
+
}
|
|
3243
|
+
this.broadcastTurnComplete(entry.clientId, response);
|
|
3244
|
+
return response;
|
|
3245
|
+
}
|
|
2738
3246
|
};
|
|
2739
3247
|
function withCode(err, code) {
|
|
2740
3248
|
err.code = code;
|
|
@@ -2745,6 +3253,7 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
|
|
|
2745
3253
|
"current_model_update",
|
|
2746
3254
|
"current_mode_update",
|
|
2747
3255
|
"available_commands_update",
|
|
3256
|
+
"available_modes_update",
|
|
2748
3257
|
"usage_update"
|
|
2749
3258
|
]);
|
|
2750
3259
|
function isStateUpdate(method, params) {
|
|
@@ -2766,10 +3275,51 @@ function sameAdvertisedCommands(a, b) {
|
|
|
2766
3275
|
}
|
|
2767
3276
|
return true;
|
|
2768
3277
|
}
|
|
2769
|
-
function
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
3278
|
+
function sameAdvertisedModes(a, b) {
|
|
3279
|
+
if (a.length !== b.length) {
|
|
3280
|
+
return false;
|
|
3281
|
+
}
|
|
3282
|
+
for (let i = 0; i < a.length; i++) {
|
|
3283
|
+
if (a[i]?.id !== b[i]?.id || a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
|
|
3284
|
+
return false;
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
return true;
|
|
3288
|
+
}
|
|
3289
|
+
function extractAdvertisedModes(params) {
|
|
3290
|
+
const obj = params ?? {};
|
|
3291
|
+
const update = obj.update ?? {};
|
|
3292
|
+
if (update.sessionUpdate !== "available_modes_update") {
|
|
3293
|
+
return null;
|
|
3294
|
+
}
|
|
3295
|
+
const list = update.availableModes;
|
|
3296
|
+
if (!Array.isArray(list)) {
|
|
3297
|
+
return [];
|
|
3298
|
+
}
|
|
3299
|
+
const out = [];
|
|
3300
|
+
for (const raw of list) {
|
|
3301
|
+
if (!raw || typeof raw !== "object") {
|
|
3302
|
+
continue;
|
|
3303
|
+
}
|
|
3304
|
+
const m = raw;
|
|
3305
|
+
if (typeof m.id !== "string" || m.id.length === 0) {
|
|
3306
|
+
continue;
|
|
3307
|
+
}
|
|
3308
|
+
const mode = { id: m.id };
|
|
3309
|
+
if (typeof m.name === "string") {
|
|
3310
|
+
mode.name = m.name;
|
|
3311
|
+
}
|
|
3312
|
+
if (typeof m.description === "string") {
|
|
3313
|
+
mode.description = m.description;
|
|
3314
|
+
}
|
|
3315
|
+
out.push(mode);
|
|
3316
|
+
}
|
|
3317
|
+
return out;
|
|
3318
|
+
}
|
|
3319
|
+
function captureInternalChunk(capture, params) {
|
|
3320
|
+
const obj = params ?? {};
|
|
3321
|
+
const update = obj.update ?? {};
|
|
3322
|
+
if (update.sessionUpdate !== "agent_message_chunk") {
|
|
2773
3323
|
return;
|
|
2774
3324
|
}
|
|
2775
3325
|
const content = update.content ?? {};
|
|
@@ -2921,7 +3471,7 @@ function firstLine(text, max) {
|
|
|
2921
3471
|
}
|
|
2922
3472
|
|
|
2923
3473
|
// src/core/session-store.ts
|
|
2924
|
-
import * as
|
|
3474
|
+
import * as fs6 from "fs/promises";
|
|
2925
3475
|
import * as path4 from "path";
|
|
2926
3476
|
import { customAlphabet as customAlphabet2 } from "nanoid";
|
|
2927
3477
|
import { z as z4 } from "zod";
|
|
@@ -2935,6 +3485,11 @@ var PersistedAgentCommand = z4.object({
|
|
|
2935
3485
|
name: z4.string(),
|
|
2936
3486
|
description: z4.string().optional()
|
|
2937
3487
|
});
|
|
3488
|
+
var PersistedAgentMode = z4.object({
|
|
3489
|
+
id: z4.string(),
|
|
3490
|
+
name: z4.string().optional(),
|
|
3491
|
+
description: z4.string().optional()
|
|
3492
|
+
});
|
|
2938
3493
|
var PersistedUsage = z4.object({
|
|
2939
3494
|
used: z4.number().optional(),
|
|
2940
3495
|
size: z4.number().optional(),
|
|
@@ -2980,6 +3535,7 @@ var SessionRecord = z4.object({
|
|
|
2980
3535
|
currentMode: z4.string().optional(),
|
|
2981
3536
|
currentUsage: PersistedUsage.optional(),
|
|
2982
3537
|
agentCommands: z4.array(PersistedAgentCommand).optional(),
|
|
3538
|
+
agentModes: z4.array(PersistedAgentMode).optional(),
|
|
2983
3539
|
createdAt: z4.string(),
|
|
2984
3540
|
updatedAt: z4.string()
|
|
2985
3541
|
});
|
|
@@ -2992,9 +3548,9 @@ function assertSafeId(id) {
|
|
|
2992
3548
|
var SessionStore = class {
|
|
2993
3549
|
async write(record) {
|
|
2994
3550
|
assertSafeId(record.sessionId);
|
|
2995
|
-
await
|
|
3551
|
+
await fs6.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
|
|
2996
3552
|
const full = { version: 1, ...record };
|
|
2997
|
-
await
|
|
3553
|
+
await fs6.writeFile(
|
|
2998
3554
|
paths.sessionFile(record.sessionId),
|
|
2999
3555
|
JSON.stringify(full, null, 2) + "\n",
|
|
3000
3556
|
{ encoding: "utf8", mode: 384 }
|
|
@@ -3006,7 +3562,7 @@ var SessionStore = class {
|
|
|
3006
3562
|
}
|
|
3007
3563
|
let raw;
|
|
3008
3564
|
try {
|
|
3009
|
-
raw = await
|
|
3565
|
+
raw = await fs6.readFile(paths.sessionFile(sessionId), "utf8");
|
|
3010
3566
|
} catch (err) {
|
|
3011
3567
|
const e = err;
|
|
3012
3568
|
if (e.code === "ENOENT") {
|
|
@@ -3025,7 +3581,7 @@ var SessionStore = class {
|
|
|
3025
3581
|
return;
|
|
3026
3582
|
}
|
|
3027
3583
|
try {
|
|
3028
|
-
await
|
|
3584
|
+
await fs6.unlink(paths.sessionFile(sessionId));
|
|
3029
3585
|
} catch (err) {
|
|
3030
3586
|
const e = err;
|
|
3031
3587
|
if (e.code !== "ENOENT") {
|
|
@@ -3033,7 +3589,7 @@ var SessionStore = class {
|
|
|
3033
3589
|
}
|
|
3034
3590
|
}
|
|
3035
3591
|
try {
|
|
3036
|
-
await
|
|
3592
|
+
await fs6.rmdir(paths.sessionDir(sessionId));
|
|
3037
3593
|
} catch (err) {
|
|
3038
3594
|
const e = err;
|
|
3039
3595
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -3063,7 +3619,7 @@ var SessionStore = class {
|
|
|
3063
3619
|
async list() {
|
|
3064
3620
|
let entries;
|
|
3065
3621
|
try {
|
|
3066
|
-
entries = await
|
|
3622
|
+
entries = await fs6.readdir(paths.sessionsDir());
|
|
3067
3623
|
} catch (err) {
|
|
3068
3624
|
const e = err;
|
|
3069
3625
|
if (e.code === "ENOENT") {
|
|
@@ -3098,13 +3654,14 @@ function recordFromMemorySession(args) {
|
|
|
3098
3654
|
currentMode: args.currentMode,
|
|
3099
3655
|
currentUsage: args.currentUsage,
|
|
3100
3656
|
agentCommands: args.agentCommands,
|
|
3657
|
+
agentModes: args.agentModes,
|
|
3101
3658
|
createdAt: args.createdAt ?? now,
|
|
3102
3659
|
updatedAt: args.updatedAt ?? now
|
|
3103
3660
|
};
|
|
3104
3661
|
}
|
|
3105
3662
|
|
|
3106
3663
|
// src/core/history-store.ts
|
|
3107
|
-
import * as
|
|
3664
|
+
import * as fs7 from "fs/promises";
|
|
3108
3665
|
var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
|
|
3109
3666
|
var DEFAULT_MAX_ENTRIES = 1e3;
|
|
3110
3667
|
var HistoryStore = class {
|
|
@@ -3121,9 +3678,9 @@ var HistoryStore = class {
|
|
|
3121
3678
|
return;
|
|
3122
3679
|
}
|
|
3123
3680
|
return this.enqueue(sessionId, async () => {
|
|
3124
|
-
await
|
|
3681
|
+
await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
3125
3682
|
const line = JSON.stringify(entry) + "\n";
|
|
3126
|
-
await
|
|
3683
|
+
await fs7.appendFile(paths.historyFile(sessionId), line, {
|
|
3127
3684
|
encoding: "utf8",
|
|
3128
3685
|
mode: 384
|
|
3129
3686
|
});
|
|
@@ -3134,9 +3691,9 @@ var HistoryStore = class {
|
|
|
3134
3691
|
return;
|
|
3135
3692
|
}
|
|
3136
3693
|
return this.enqueue(sessionId, async () => {
|
|
3137
|
-
await
|
|
3694
|
+
await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
3138
3695
|
const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
3139
|
-
await
|
|
3696
|
+
await fs7.writeFile(paths.historyFile(sessionId), body, {
|
|
3140
3697
|
encoding: "utf8",
|
|
3141
3698
|
mode: 384
|
|
3142
3699
|
});
|
|
@@ -3153,7 +3710,7 @@ var HistoryStore = class {
|
|
|
3153
3710
|
return this.enqueue(sessionId, async () => {
|
|
3154
3711
|
let raw;
|
|
3155
3712
|
try {
|
|
3156
|
-
raw = await
|
|
3713
|
+
raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
|
|
3157
3714
|
} catch (err) {
|
|
3158
3715
|
const e = err;
|
|
3159
3716
|
if (e.code === "ENOENT") {
|
|
@@ -3166,7 +3723,7 @@ var HistoryStore = class {
|
|
|
3166
3723
|
return;
|
|
3167
3724
|
}
|
|
3168
3725
|
const trimmed = lines.slice(-maxEntries);
|
|
3169
|
-
await
|
|
3726
|
+
await fs7.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
|
|
3170
3727
|
encoding: "utf8",
|
|
3171
3728
|
mode: 384
|
|
3172
3729
|
});
|
|
@@ -3182,7 +3739,7 @@ var HistoryStore = class {
|
|
|
3182
3739
|
}
|
|
3183
3740
|
let raw;
|
|
3184
3741
|
try {
|
|
3185
|
-
raw = await
|
|
3742
|
+
raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
|
|
3186
3743
|
} catch (err) {
|
|
3187
3744
|
const e = err;
|
|
3188
3745
|
if (e.code === "ENOENT") {
|
|
@@ -3228,7 +3785,7 @@ var HistoryStore = class {
|
|
|
3228
3785
|
}
|
|
3229
3786
|
return this.enqueue(sessionId, async () => {
|
|
3230
3787
|
try {
|
|
3231
|
-
await
|
|
3788
|
+
await fs7.unlink(paths.historyFile(sessionId));
|
|
3232
3789
|
} catch (err) {
|
|
3233
3790
|
const e = err;
|
|
3234
3791
|
if (e.code !== "ENOENT") {
|
|
@@ -3236,7 +3793,7 @@ var HistoryStore = class {
|
|
|
3236
3793
|
}
|
|
3237
3794
|
}
|
|
3238
3795
|
try {
|
|
3239
|
-
await
|
|
3796
|
+
await fs7.rmdir(paths.sessionDir(sessionId));
|
|
3240
3797
|
} catch (err) {
|
|
3241
3798
|
const e = err;
|
|
3242
3799
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -3260,25 +3817,25 @@ var HistoryStore = class {
|
|
|
3260
3817
|
};
|
|
3261
3818
|
|
|
3262
3819
|
// src/tui/history.ts
|
|
3263
|
-
import { promises as
|
|
3820
|
+
import { promises as fs8 } from "fs";
|
|
3264
3821
|
import * as path5 from "path";
|
|
3265
3822
|
async function saveHistory(file, history) {
|
|
3266
|
-
await
|
|
3823
|
+
await fs8.mkdir(path5.dirname(file), { recursive: true });
|
|
3267
3824
|
const lines = history.map((entry) => JSON.stringify(entry));
|
|
3268
|
-
await
|
|
3825
|
+
await fs8.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
|
|
3269
3826
|
}
|
|
3270
3827
|
|
|
3271
3828
|
// src/core/hydra-version.ts
|
|
3272
3829
|
import { fileURLToPath } from "url";
|
|
3273
3830
|
import * as path6 from "path";
|
|
3274
|
-
import * as
|
|
3831
|
+
import * as fs9 from "fs";
|
|
3275
3832
|
function resolveVersion() {
|
|
3276
3833
|
try {
|
|
3277
3834
|
let dir = path6.dirname(fileURLToPath(import.meta.url));
|
|
3278
3835
|
for (let i = 0; i < 8; i += 1) {
|
|
3279
3836
|
const candidate = path6.join(dir, "package.json");
|
|
3280
|
-
if (
|
|
3281
|
-
const pkg = JSON.parse(
|
|
3837
|
+
if (fs9.existsSync(candidate)) {
|
|
3838
|
+
const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
|
|
3282
3839
|
if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
|
|
3283
3840
|
return pkg.version;
|
|
3284
3841
|
}
|
|
@@ -3296,6 +3853,7 @@ function resolveVersion() {
|
|
|
3296
3853
|
var HYDRA_VERSION = resolveVersion();
|
|
3297
3854
|
|
|
3298
3855
|
// src/core/session-manager.ts
|
|
3856
|
+
var QUEUE_REPLAY_TTL_MS = 15 * 60 * 1e3;
|
|
3299
3857
|
var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
3300
3858
|
var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
|
|
3301
3859
|
var SessionManager = class {
|
|
@@ -3308,6 +3866,7 @@ var SessionManager = class {
|
|
|
3308
3866
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
3309
3867
|
this.defaultModels = options.defaultModels ?? {};
|
|
3310
3868
|
this.logger = options.logger;
|
|
3869
|
+
this.npmRegistry = options.npmRegistry;
|
|
3311
3870
|
}
|
|
3312
3871
|
registry;
|
|
3313
3872
|
sessions = /* @__PURE__ */ new Map();
|
|
@@ -3323,6 +3882,7 @@ var SessionManager = class {
|
|
|
3323
3882
|
// back-to-back) don't lose writes via interleaved reads.
|
|
3324
3883
|
metaWriteQueues = /* @__PURE__ */ new Map();
|
|
3325
3884
|
logger;
|
|
3885
|
+
npmRegistry;
|
|
3326
3886
|
async create(params) {
|
|
3327
3887
|
const fresh = await this.bootstrapAgent({
|
|
3328
3888
|
agentId: params.agentId,
|
|
@@ -3344,7 +3904,9 @@ var SessionManager = class {
|
|
|
3344
3904
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
3345
3905
|
historyStore: this.histories,
|
|
3346
3906
|
historyMaxEntries: this.sessionHistoryMaxEntries,
|
|
3347
|
-
currentModel: fresh.initialModel
|
|
3907
|
+
currentModel: fresh.initialModel,
|
|
3908
|
+
currentMode: fresh.initialMode,
|
|
3909
|
+
agentModes: fresh.initialModes
|
|
3348
3910
|
});
|
|
3349
3911
|
await this.attachManagerHooks(session);
|
|
3350
3912
|
return session;
|
|
@@ -3389,7 +3951,7 @@ var SessionManager = class {
|
|
|
3389
3951
|
if (params.upstreamSessionId === "") {
|
|
3390
3952
|
return this.doResurrectFromImport(params);
|
|
3391
3953
|
}
|
|
3392
|
-
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
3954
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
|
|
3393
3955
|
const agent = this.spawner({
|
|
3394
3956
|
agentId: params.agentId,
|
|
3395
3957
|
cwd: params.cwd,
|
|
@@ -3443,9 +4005,10 @@ var SessionManager = class {
|
|
|
3443
4005
|
// this fix), fall back to the model the agent ships in its
|
|
3444
4006
|
// session/load response body.
|
|
3445
4007
|
currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
|
|
3446
|
-
currentMode: params.currentMode,
|
|
4008
|
+
currentMode: params.currentMode ?? extractInitialCurrentMode(loadResult ?? {}),
|
|
3447
4009
|
currentUsage: params.currentUsage,
|
|
3448
4010
|
agentCommands: params.agentCommands,
|
|
4011
|
+
agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
|
|
3449
4012
|
// Only gate the first-prompt title heuristic when we actually have
|
|
3450
4013
|
// a title to preserve. A title-less session (lost to a write race
|
|
3451
4014
|
// or never seeded) should re-derive from the next prompt rather
|
|
@@ -3488,9 +4051,10 @@ var SessionManager = class {
|
|
|
3488
4051
|
// Prefer the stored value (set by a previous current_model_update);
|
|
3489
4052
|
// fall back to whatever the agent ships in its session/new response.
|
|
3490
4053
|
currentModel: params.currentModel ?? fresh.initialModel,
|
|
3491
|
-
currentMode: params.currentMode,
|
|
4054
|
+
currentMode: params.currentMode ?? fresh.initialMode,
|
|
3492
4055
|
currentUsage: params.currentUsage,
|
|
3493
4056
|
agentCommands: params.agentCommands,
|
|
4057
|
+
agentModes: params.agentModes ?? fresh.initialModes,
|
|
3494
4058
|
firstPromptSeeded: !!params.title,
|
|
3495
4059
|
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
3496
4060
|
});
|
|
@@ -3500,7 +4064,7 @@ var SessionManager = class {
|
|
|
3500
4064
|
}
|
|
3501
4065
|
async resolveImportCwd(cwd) {
|
|
3502
4066
|
try {
|
|
3503
|
-
const stat2 = await
|
|
4067
|
+
const stat2 = await fs10.stat(cwd);
|
|
3504
4068
|
if (stat2.isDirectory()) {
|
|
3505
4069
|
return cwd;
|
|
3506
4070
|
}
|
|
@@ -3520,7 +4084,7 @@ var SessionManager = class {
|
|
|
3520
4084
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
3521
4085
|
throw err;
|
|
3522
4086
|
}
|
|
3523
|
-
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
4087
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
|
|
3524
4088
|
const agent = this.spawner({
|
|
3525
4089
|
agentId: params.agentId,
|
|
3526
4090
|
cwd: params.cwd,
|
|
@@ -3557,11 +4121,15 @@ var SessionManager = class {
|
|
|
3557
4121
|
} catch {
|
|
3558
4122
|
}
|
|
3559
4123
|
}
|
|
4124
|
+
const initialModes = extractInitialModes(newResult);
|
|
4125
|
+
const initialMode = extractInitialCurrentMode(newResult);
|
|
3560
4126
|
return {
|
|
3561
4127
|
agent,
|
|
3562
4128
|
upstreamSessionId: sessionIdRaw,
|
|
3563
4129
|
agentMeta: newResult._meta,
|
|
3564
|
-
initialModel
|
|
4130
|
+
initialModel,
|
|
4131
|
+
initialModes: initialModes.length > 0 ? initialModes : void 0,
|
|
4132
|
+
initialMode
|
|
3565
4133
|
};
|
|
3566
4134
|
} catch (err) {
|
|
3567
4135
|
await agent.kill().catch(() => void 0);
|
|
@@ -3613,6 +4181,15 @@ var SessionManager = class {
|
|
|
3613
4181
|
}))
|
|
3614
4182
|
}).catch(() => void 0);
|
|
3615
4183
|
});
|
|
4184
|
+
session.onAgentModesChange((modes) => {
|
|
4185
|
+
void this.persistSnapshot(session.sessionId, {
|
|
4186
|
+
agentModes: modes.map((m) => ({
|
|
4187
|
+
id: m.id,
|
|
4188
|
+
...m.name !== void 0 ? { name: m.name } : {},
|
|
4189
|
+
...m.description !== void 0 ? { description: m.description } : {}
|
|
4190
|
+
}))
|
|
4191
|
+
}).catch(() => void 0);
|
|
4192
|
+
});
|
|
3616
4193
|
this.sessions.set(session.sessionId, session);
|
|
3617
4194
|
await this.enqueueMetaWrite(session.sessionId, async () => {
|
|
3618
4195
|
const existing = await this.store.read(session.sessionId);
|
|
@@ -3655,6 +4232,7 @@ var SessionManager = class {
|
|
|
3655
4232
|
currentMode: record.currentMode,
|
|
3656
4233
|
currentUsage: persistedUsageToSnapshot(record.currentUsage),
|
|
3657
4234
|
agentCommands: record.agentCommands,
|
|
4235
|
+
agentModes: record.agentModes,
|
|
3658
4236
|
createdAt: record.createdAt
|
|
3659
4237
|
};
|
|
3660
4238
|
}
|
|
@@ -3932,6 +4510,7 @@ var SessionManager = class {
|
|
|
3932
4510
|
...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
|
|
3933
4511
|
...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
|
|
3934
4512
|
...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
|
|
4513
|
+
...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
|
|
3935
4514
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3936
4515
|
});
|
|
3937
4516
|
});
|
|
@@ -3965,6 +4544,53 @@ var SessionManager = class {
|
|
|
3965
4544
|
}
|
|
3966
4545
|
await Promise.allSettled(pending);
|
|
3967
4546
|
}
|
|
4547
|
+
// Startup hook: scan persisted sessions for non-empty queue files,
|
|
4548
|
+
// apply the TTL, resurrect anything with surviving entries, and
|
|
4549
|
+
// replay them through the normal queue path. Called from the daemon
|
|
4550
|
+
// boot sequence; failures per session are logged and don't block
|
|
4551
|
+
// the boot.
|
|
4552
|
+
//
|
|
4553
|
+
// Concurrency is deliberately sequential — resurrect each session
|
|
4554
|
+
// one at a time so a runaway daemon with 100 queued sessions
|
|
4555
|
+
// doesn't burst-spawn 100 agents on startup. Inside a single
|
|
4556
|
+
// session, the queue still drains in parallel-friendly fashion via
|
|
4557
|
+
// drainQueue once resurrect() completes.
|
|
4558
|
+
async resurrectPendingQueues() {
|
|
4559
|
+
const records = await this.store.list().catch(() => []);
|
|
4560
|
+
for (const rec of records) {
|
|
4561
|
+
const queue = await loadQueue(rec.sessionId).catch(() => []);
|
|
4562
|
+
if (queue.length === 0) continue;
|
|
4563
|
+
const now = Date.now();
|
|
4564
|
+
const fresh = queue.filter((e) => now - e.enqueuedAt < QUEUE_REPLAY_TTL_MS);
|
|
4565
|
+
const dropped = queue.length - fresh.length;
|
|
4566
|
+
if (dropped > 0) {
|
|
4567
|
+
this.logger?.info(
|
|
4568
|
+
`queue replay: dropping ${dropped} stale prompt(s) for ${rec.sessionId} (TTL ${QUEUE_REPLAY_TTL_MS / 1e3}s)`
|
|
4569
|
+
);
|
|
4570
|
+
await rewriteQueue(rec.sessionId, fresh).catch(() => void 0);
|
|
4571
|
+
}
|
|
4572
|
+
if (fresh.length === 0) continue;
|
|
4573
|
+
const fromDisk = await this.loadFromDisk(rec.sessionId).catch(() => void 0);
|
|
4574
|
+
if (!fromDisk) {
|
|
4575
|
+
this.logger?.warn(
|
|
4576
|
+
`queue replay: no meta for ${rec.sessionId}; discarding ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"}`
|
|
4577
|
+
);
|
|
4578
|
+
await rewriteQueue(rec.sessionId, []).catch(() => void 0);
|
|
4579
|
+
continue;
|
|
4580
|
+
}
|
|
4581
|
+
try {
|
|
4582
|
+
const session = await this.resurrect(fromDisk);
|
|
4583
|
+
this.logger?.info(
|
|
4584
|
+
`queue replay: resurrected ${rec.sessionId} and replaying ${fresh.length} prompt(s)`
|
|
4585
|
+
);
|
|
4586
|
+
session.replayPersistedQueue(fresh);
|
|
4587
|
+
} catch (err) {
|
|
4588
|
+
this.logger?.warn(
|
|
4589
|
+
`queue replay: failed to resurrect ${rec.sessionId}: ${err.message}`
|
|
4590
|
+
);
|
|
4591
|
+
}
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
3968
4594
|
};
|
|
3969
4595
|
function mergeForPersistence(session, existing) {
|
|
3970
4596
|
const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
|
|
@@ -3974,6 +4600,18 @@ function mergeForPersistence(session, existing) {
|
|
|
3974
4600
|
return { name: c.name };
|
|
3975
4601
|
}) : void 0;
|
|
3976
4602
|
const agentCommands = persistedCommands ?? existing?.agentCommands;
|
|
4603
|
+
const sessionModes = session.availableModes();
|
|
4604
|
+
const persistedModes = sessionModes.length > 0 ? sessionModes.map((m) => {
|
|
4605
|
+
const out = { id: m.id };
|
|
4606
|
+
if (m.name !== void 0) {
|
|
4607
|
+
out.name = m.name;
|
|
4608
|
+
}
|
|
4609
|
+
if (m.description !== void 0) {
|
|
4610
|
+
out.description = m.description;
|
|
4611
|
+
}
|
|
4612
|
+
return out;
|
|
4613
|
+
}) : void 0;
|
|
4614
|
+
const agentModes = persistedModes ?? existing?.agentModes;
|
|
3977
4615
|
return recordFromMemorySession({
|
|
3978
4616
|
sessionId: session.sessionId,
|
|
3979
4617
|
lineageId: existing?.lineageId ?? generateLineageId(),
|
|
@@ -3989,6 +4627,7 @@ function mergeForPersistence(session, existing) {
|
|
|
3989
4627
|
currentMode: session.currentMode ?? existing?.currentMode,
|
|
3990
4628
|
currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
|
|
3991
4629
|
agentCommands,
|
|
4630
|
+
agentModes,
|
|
3992
4631
|
createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
|
|
3993
4632
|
});
|
|
3994
4633
|
}
|
|
@@ -4051,9 +4690,103 @@ function asString(value) {
|
|
|
4051
4690
|
const trimmed = value.trim();
|
|
4052
4691
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
4053
4692
|
}
|
|
4693
|
+
function nonEmptyOrUndefined(arr) {
|
|
4694
|
+
return arr.length > 0 ? arr : void 0;
|
|
4695
|
+
}
|
|
4696
|
+
function extractInitialModes(result) {
|
|
4697
|
+
const direct = parseModesList(result.availableModes);
|
|
4698
|
+
if (direct.length > 0) {
|
|
4699
|
+
return direct;
|
|
4700
|
+
}
|
|
4701
|
+
const modes = result.modes;
|
|
4702
|
+
if (modes && typeof modes === "object" && !Array.isArray(modes)) {
|
|
4703
|
+
const fromModesObj = parseModesList(
|
|
4704
|
+
modes.availableModes
|
|
4705
|
+
);
|
|
4706
|
+
if (fromModesObj.length > 0) {
|
|
4707
|
+
return fromModesObj;
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
const meta = result._meta;
|
|
4711
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
4712
|
+
for (const [key, value] of Object.entries(
|
|
4713
|
+
meta
|
|
4714
|
+
)) {
|
|
4715
|
+
if (key === "hydra-acp") {
|
|
4716
|
+
continue;
|
|
4717
|
+
}
|
|
4718
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
4719
|
+
const fromMeta = parseModesList(
|
|
4720
|
+
value.availableModes
|
|
4721
|
+
);
|
|
4722
|
+
if (fromMeta.length > 0) {
|
|
4723
|
+
return fromMeta;
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
return [];
|
|
4729
|
+
}
|
|
4730
|
+
function extractInitialCurrentMode(result) {
|
|
4731
|
+
const direct = asString(result.currentModeId) ?? asString(result.currentMode) ?? asString(result.modeId) ?? asString(result.mode);
|
|
4732
|
+
if (direct) {
|
|
4733
|
+
return direct;
|
|
4734
|
+
}
|
|
4735
|
+
const modes = result.modes;
|
|
4736
|
+
if (modes && typeof modes === "object" && !Array.isArray(modes)) {
|
|
4737
|
+
const m = asString(modes.currentModeId) ?? asString(modes.currentMode);
|
|
4738
|
+
if (m) {
|
|
4739
|
+
return m;
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
const meta = result._meta;
|
|
4743
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
4744
|
+
for (const [key, value] of Object.entries(
|
|
4745
|
+
meta
|
|
4746
|
+
)) {
|
|
4747
|
+
if (key === "hydra-acp") {
|
|
4748
|
+
continue;
|
|
4749
|
+
}
|
|
4750
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
4751
|
+
const m = asString(value.currentModeId) ?? asString(value.currentMode) ?? asString(value.modeId);
|
|
4752
|
+
if (m) {
|
|
4753
|
+
return m;
|
|
4754
|
+
}
|
|
4755
|
+
}
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
return void 0;
|
|
4759
|
+
}
|
|
4760
|
+
function parseModesList(list) {
|
|
4761
|
+
if (!Array.isArray(list)) {
|
|
4762
|
+
return [];
|
|
4763
|
+
}
|
|
4764
|
+
const out = [];
|
|
4765
|
+
for (const raw of list) {
|
|
4766
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
4767
|
+
continue;
|
|
4768
|
+
}
|
|
4769
|
+
const r = raw;
|
|
4770
|
+
const id = asString(r.id) ?? asString(r.modeId);
|
|
4771
|
+
if (!id) {
|
|
4772
|
+
continue;
|
|
4773
|
+
}
|
|
4774
|
+
const mode = { id };
|
|
4775
|
+
const name = asString(r.name);
|
|
4776
|
+
if (name) {
|
|
4777
|
+
mode.name = name;
|
|
4778
|
+
}
|
|
4779
|
+
const description = asString(r.description);
|
|
4780
|
+
if (description) {
|
|
4781
|
+
mode.description = description;
|
|
4782
|
+
}
|
|
4783
|
+
out.push(mode);
|
|
4784
|
+
}
|
|
4785
|
+
return out;
|
|
4786
|
+
}
|
|
4054
4787
|
async function loadPromptHistorySafely(sessionId) {
|
|
4055
4788
|
try {
|
|
4056
|
-
const raw = await
|
|
4789
|
+
const raw = await fs10.readFile(paths.tuiHistoryFile(sessionId), "utf8");
|
|
4057
4790
|
const out = [];
|
|
4058
4791
|
for (const line of raw.split("\n")) {
|
|
4059
4792
|
if (line.length === 0) {
|
|
@@ -4074,7 +4807,7 @@ async function loadPromptHistorySafely(sessionId) {
|
|
|
4074
4807
|
}
|
|
4075
4808
|
async function historyMtimeIso(sessionId) {
|
|
4076
4809
|
try {
|
|
4077
|
-
const st = await
|
|
4810
|
+
const st = await fs10.stat(paths.historyFile(sessionId));
|
|
4078
4811
|
return new Date(st.mtimeMs).toISOString();
|
|
4079
4812
|
} catch {
|
|
4080
4813
|
return void 0;
|
|
@@ -4083,7 +4816,7 @@ async function historyMtimeIso(sessionId) {
|
|
|
4083
4816
|
|
|
4084
4817
|
// src/core/extensions.ts
|
|
4085
4818
|
import { spawn as spawn4 } from "child_process";
|
|
4086
|
-
import * as
|
|
4819
|
+
import * as fs11 from "fs";
|
|
4087
4820
|
import * as fsp3 from "fs/promises";
|
|
4088
4821
|
import * as path7 from "path";
|
|
4089
4822
|
var RESTART_BASE_MS = 1e3;
|
|
@@ -4366,7 +5099,7 @@ var ExtensionManager = class {
|
|
|
4366
5099
|
}
|
|
4367
5100
|
const ext = entry.config;
|
|
4368
5101
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
4369
|
-
const logStream =
|
|
5102
|
+
const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
4370
5103
|
flags: "a"
|
|
4371
5104
|
});
|
|
4372
5105
|
logStream.write(
|
|
@@ -4378,7 +5111,7 @@ var ExtensionManager = class {
|
|
|
4378
5111
|
HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
|
|
4379
5112
|
HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
|
|
4380
5113
|
HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
|
|
4381
|
-
HYDRA_ACP_TOKEN: ctx.
|
|
5114
|
+
HYDRA_ACP_TOKEN: ctx.serviceToken,
|
|
4382
5115
|
HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
|
|
4383
5116
|
HYDRA_ACP_HOME: ctx.hydraHome,
|
|
4384
5117
|
HYDRA_ACP_EXTENSION_NAME: ext.name,
|
|
@@ -4416,7 +5149,7 @@ var ExtensionManager = class {
|
|
|
4416
5149
|
}
|
|
4417
5150
|
if (typeof child.pid === "number") {
|
|
4418
5151
|
try {
|
|
4419
|
-
|
|
5152
|
+
fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
4420
5153
|
`, {
|
|
4421
5154
|
encoding: "utf8",
|
|
4422
5155
|
mode: 384
|
|
@@ -4441,7 +5174,7 @@ var ExtensionManager = class {
|
|
|
4441
5174
|
});
|
|
4442
5175
|
child.on("exit", (code, signal) => {
|
|
4443
5176
|
try {
|
|
4444
|
-
|
|
5177
|
+
fs11.unlinkSync(paths.extensionPidFile(ext.name));
|
|
4445
5178
|
} catch {
|
|
4446
5179
|
}
|
|
4447
5180
|
logStream.write(
|
|
@@ -4497,8 +5230,227 @@ function withCode2(err, code) {
|
|
|
4497
5230
|
return err;
|
|
4498
5231
|
}
|
|
4499
5232
|
|
|
5233
|
+
// src/core/session-tokens.ts
|
|
5234
|
+
import * as fs12 from "fs/promises";
|
|
5235
|
+
import * as path8 from "path";
|
|
5236
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
5237
|
+
var TOKEN_PREFIX = "hydra_session_";
|
|
5238
|
+
var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
|
|
5239
|
+
var ID_LENGTH = 12;
|
|
5240
|
+
var TOKEN_BYTES = 32;
|
|
5241
|
+
var WRITE_DEBOUNCE_MS = 50;
|
|
5242
|
+
function tokensFilePath() {
|
|
5243
|
+
return path8.join(paths.home(), "session-tokens.json");
|
|
5244
|
+
}
|
|
5245
|
+
function sha256Hex(input) {
|
|
5246
|
+
return createHash("sha256").update(input).digest("hex");
|
|
5247
|
+
}
|
|
5248
|
+
function randomHex(bytes) {
|
|
5249
|
+
return randomBytes(bytes).toString("hex");
|
|
5250
|
+
}
|
|
5251
|
+
function generateId() {
|
|
5252
|
+
return randomHex(ID_LENGTH).slice(0, ID_LENGTH * 2);
|
|
5253
|
+
}
|
|
5254
|
+
function generateToken() {
|
|
5255
|
+
return `${TOKEN_PREFIX}${randomHex(TOKEN_BYTES)}`;
|
|
5256
|
+
}
|
|
5257
|
+
var SessionTokenStore = class _SessionTokenStore {
|
|
5258
|
+
records = /* @__PURE__ */ new Map();
|
|
5259
|
+
// keyed by hash
|
|
5260
|
+
writeTimer = null;
|
|
5261
|
+
writeInflight = null;
|
|
5262
|
+
constructor(records) {
|
|
5263
|
+
for (const r of records) {
|
|
5264
|
+
this.records.set(r.hash, r);
|
|
5265
|
+
}
|
|
5266
|
+
}
|
|
5267
|
+
static async load() {
|
|
5268
|
+
let records = [];
|
|
5269
|
+
try {
|
|
5270
|
+
const raw = await fs12.readFile(tokensFilePath(), "utf8");
|
|
5271
|
+
const parsed = JSON.parse(raw);
|
|
5272
|
+
if (parsed && Array.isArray(parsed.records)) {
|
|
5273
|
+
records = parsed.records.filter(isRecord);
|
|
5274
|
+
}
|
|
5275
|
+
} catch (err) {
|
|
5276
|
+
const e = err;
|
|
5277
|
+
if (e.code !== "ENOENT") {
|
|
5278
|
+
throw err;
|
|
5279
|
+
}
|
|
5280
|
+
}
|
|
5281
|
+
const store = new _SessionTokenStore(records);
|
|
5282
|
+
const removed = store.sweepExpired(/* @__PURE__ */ new Date());
|
|
5283
|
+
if (removed > 0) {
|
|
5284
|
+
await store.flush();
|
|
5285
|
+
}
|
|
5286
|
+
return store;
|
|
5287
|
+
}
|
|
5288
|
+
async issue(opts = {}) {
|
|
5289
|
+
const token = generateToken();
|
|
5290
|
+
const hash = sha256Hex(token);
|
|
5291
|
+
const id = generateId();
|
|
5292
|
+
const now = /* @__PURE__ */ new Date();
|
|
5293
|
+
const ttlSec = opts.ttlSec && opts.ttlSec > 0 ? opts.ttlSec : DEFAULT_TTL_SEC;
|
|
5294
|
+
const expiresAt = new Date(now.getTime() + ttlSec * 1e3);
|
|
5295
|
+
const record = {
|
|
5296
|
+
id,
|
|
5297
|
+
hash,
|
|
5298
|
+
label: opts.label,
|
|
5299
|
+
createdAt: now.toISOString(),
|
|
5300
|
+
expiresAt: expiresAt.toISOString(),
|
|
5301
|
+
lastUsedAt: now.toISOString()
|
|
5302
|
+
};
|
|
5303
|
+
this.records.set(hash, record);
|
|
5304
|
+
this.scheduleWrite();
|
|
5305
|
+
return { id, token, expiresAt: record.expiresAt };
|
|
5306
|
+
}
|
|
5307
|
+
// Verifies a presented token. Returns the matching record id (so the
|
|
5308
|
+
// caller can revoke it on logout) and bumps lastUsedAt; returns
|
|
5309
|
+
// undefined when no record matches or when the matched record has
|
|
5310
|
+
// expired.
|
|
5311
|
+
async verify(token) {
|
|
5312
|
+
if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX)) {
|
|
5313
|
+
return void 0;
|
|
5314
|
+
}
|
|
5315
|
+
const hash = sha256Hex(token);
|
|
5316
|
+
const record = this.records.get(hash);
|
|
5317
|
+
if (!record) {
|
|
5318
|
+
return void 0;
|
|
5319
|
+
}
|
|
5320
|
+
const expected = Buffer.from(record.hash, "hex");
|
|
5321
|
+
const actual = Buffer.from(hash, "hex");
|
|
5322
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
5323
|
+
return void 0;
|
|
5324
|
+
}
|
|
5325
|
+
const now = /* @__PURE__ */ new Date();
|
|
5326
|
+
if (new Date(record.expiresAt).getTime() <= now.getTime()) {
|
|
5327
|
+
this.records.delete(hash);
|
|
5328
|
+
this.scheduleWrite();
|
|
5329
|
+
return void 0;
|
|
5330
|
+
}
|
|
5331
|
+
record.lastUsedAt = now.toISOString();
|
|
5332
|
+
this.scheduleWrite();
|
|
5333
|
+
return record.id;
|
|
5334
|
+
}
|
|
5335
|
+
async revoke(id) {
|
|
5336
|
+
for (const [hash, r] of this.records) {
|
|
5337
|
+
if (r.id === id) {
|
|
5338
|
+
this.records.delete(hash);
|
|
5339
|
+
this.scheduleWrite();
|
|
5340
|
+
return true;
|
|
5341
|
+
}
|
|
5342
|
+
}
|
|
5343
|
+
return false;
|
|
5344
|
+
}
|
|
5345
|
+
async revokeAll() {
|
|
5346
|
+
const n = this.records.size;
|
|
5347
|
+
this.records.clear();
|
|
5348
|
+
this.scheduleWrite();
|
|
5349
|
+
return n;
|
|
5350
|
+
}
|
|
5351
|
+
list() {
|
|
5352
|
+
return Array.from(this.records.values()).map(({ id, label, createdAt, expiresAt, lastUsedAt }) => ({
|
|
5353
|
+
id,
|
|
5354
|
+
label,
|
|
5355
|
+
createdAt,
|
|
5356
|
+
expiresAt,
|
|
5357
|
+
lastUsedAt
|
|
5358
|
+
})).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
5359
|
+
}
|
|
5360
|
+
sweepExpired(now = /* @__PURE__ */ new Date()) {
|
|
5361
|
+
let removed = 0;
|
|
5362
|
+
for (const [hash, r] of this.records) {
|
|
5363
|
+
if (new Date(r.expiresAt).getTime() <= now.getTime()) {
|
|
5364
|
+
this.records.delete(hash);
|
|
5365
|
+
removed += 1;
|
|
5366
|
+
}
|
|
5367
|
+
}
|
|
5368
|
+
if (removed > 0) {
|
|
5369
|
+
this.scheduleWrite();
|
|
5370
|
+
}
|
|
5371
|
+
return removed;
|
|
5372
|
+
}
|
|
5373
|
+
// Force any pending write to complete. Useful in tests and at shutdown.
|
|
5374
|
+
async flush() {
|
|
5375
|
+
if (this.writeTimer) {
|
|
5376
|
+
clearTimeout(this.writeTimer);
|
|
5377
|
+
this.writeTimer = null;
|
|
5378
|
+
}
|
|
5379
|
+
await this.persist();
|
|
5380
|
+
}
|
|
5381
|
+
scheduleWrite() {
|
|
5382
|
+
if (this.writeTimer) {
|
|
5383
|
+
return;
|
|
5384
|
+
}
|
|
5385
|
+
this.writeTimer = setTimeout(() => {
|
|
5386
|
+
this.writeTimer = null;
|
|
5387
|
+
this.persist().catch(() => {
|
|
5388
|
+
});
|
|
5389
|
+
}, WRITE_DEBOUNCE_MS);
|
|
5390
|
+
}
|
|
5391
|
+
async persist() {
|
|
5392
|
+
if (this.writeInflight) {
|
|
5393
|
+
await this.writeInflight;
|
|
5394
|
+
}
|
|
5395
|
+
const records = Array.from(this.records.values());
|
|
5396
|
+
const payload = JSON.stringify({ records }, null, 2) + "\n";
|
|
5397
|
+
this.writeInflight = (async () => {
|
|
5398
|
+
await fs12.mkdir(paths.home(), { recursive: true });
|
|
5399
|
+
await fs12.writeFile(tokensFilePath(), payload, {
|
|
5400
|
+
encoding: "utf8",
|
|
5401
|
+
mode: 384
|
|
5402
|
+
});
|
|
5403
|
+
})();
|
|
5404
|
+
try {
|
|
5405
|
+
await this.writeInflight;
|
|
5406
|
+
} finally {
|
|
5407
|
+
this.writeInflight = null;
|
|
5408
|
+
}
|
|
5409
|
+
}
|
|
5410
|
+
};
|
|
5411
|
+
function isRecord(value) {
|
|
5412
|
+
if (!value || typeof value !== "object") {
|
|
5413
|
+
return false;
|
|
5414
|
+
}
|
|
5415
|
+
const v = value;
|
|
5416
|
+
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");
|
|
5417
|
+
}
|
|
5418
|
+
|
|
4500
5419
|
// src/daemon/auth.ts
|
|
4501
5420
|
var BEARER_PREFIX = "Bearer ";
|
|
5421
|
+
var StaticTokenValidator = class {
|
|
5422
|
+
constructor(token) {
|
|
5423
|
+
this.token = token;
|
|
5424
|
+
}
|
|
5425
|
+
token;
|
|
5426
|
+
async validate(token) {
|
|
5427
|
+
return constantTimeEqual(token, this.token) ? "service" : void 0;
|
|
5428
|
+
}
|
|
5429
|
+
};
|
|
5430
|
+
var SessionTokenValidator = class {
|
|
5431
|
+
constructor(store) {
|
|
5432
|
+
this.store = store;
|
|
5433
|
+
}
|
|
5434
|
+
store;
|
|
5435
|
+
async validate(token) {
|
|
5436
|
+
return this.store.verify(token);
|
|
5437
|
+
}
|
|
5438
|
+
};
|
|
5439
|
+
var CompositeTokenValidator = class {
|
|
5440
|
+
constructor(validators) {
|
|
5441
|
+
this.validators = validators;
|
|
5442
|
+
}
|
|
5443
|
+
validators;
|
|
5444
|
+
async validate(token) {
|
|
5445
|
+
for (const v of this.validators) {
|
|
5446
|
+
const id = await v.validate(token);
|
|
5447
|
+
if (id !== void 0) {
|
|
5448
|
+
return id;
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
return void 0;
|
|
5452
|
+
}
|
|
5453
|
+
};
|
|
4502
5454
|
function bearerAuth(opts) {
|
|
4503
5455
|
return async function authMiddleware(request, reply) {
|
|
4504
5456
|
const header = request.headers.authorization;
|
|
@@ -4507,10 +5459,12 @@ function bearerAuth(opts) {
|
|
|
4507
5459
|
return;
|
|
4508
5460
|
}
|
|
4509
5461
|
const token = header.slice(BEARER_PREFIX.length).trim();
|
|
4510
|
-
|
|
5462
|
+
const identity = await opts.validator.validate(token);
|
|
5463
|
+
if (!identity) {
|
|
4511
5464
|
reply.code(403).send({ error: "Invalid token" });
|
|
4512
5465
|
return;
|
|
4513
5466
|
}
|
|
5467
|
+
request.authIdentity = identity;
|
|
4514
5468
|
};
|
|
4515
5469
|
}
|
|
4516
5470
|
function tokenFromUpgradeRequest(req) {
|
|
@@ -4549,6 +5503,40 @@ function constantTimeEqual(a, b) {
|
|
|
4549
5503
|
return mismatch === 0;
|
|
4550
5504
|
}
|
|
4551
5505
|
|
|
5506
|
+
// src/daemon/rate-limit.ts
|
|
5507
|
+
var AuthRateLimiter = class {
|
|
5508
|
+
entries = /* @__PURE__ */ new Map();
|
|
5509
|
+
maxFails;
|
|
5510
|
+
windowMs;
|
|
5511
|
+
constructor(maxFails = 10, windowMs = 15 * 60 * 1e3) {
|
|
5512
|
+
this.maxFails = maxFails;
|
|
5513
|
+
this.windowMs = windowMs;
|
|
5514
|
+
}
|
|
5515
|
+
isBlocked(ip) {
|
|
5516
|
+
const e = this.entries.get(ip);
|
|
5517
|
+
if (!e) {
|
|
5518
|
+
return false;
|
|
5519
|
+
}
|
|
5520
|
+
if (Date.now() - e.windowStart > this.windowMs) {
|
|
5521
|
+
this.entries.delete(ip);
|
|
5522
|
+
return false;
|
|
5523
|
+
}
|
|
5524
|
+
return e.fails >= this.maxFails;
|
|
5525
|
+
}
|
|
5526
|
+
recordFailure(ip) {
|
|
5527
|
+
const now = Date.now();
|
|
5528
|
+
const e = this.entries.get(ip);
|
|
5529
|
+
if (!e || now - e.windowStart > this.windowMs) {
|
|
5530
|
+
this.entries.set(ip, { fails: 1, windowStart: now });
|
|
5531
|
+
return;
|
|
5532
|
+
}
|
|
5533
|
+
e.fails += 1;
|
|
5534
|
+
}
|
|
5535
|
+
recordSuccess(ip) {
|
|
5536
|
+
this.entries.delete(ip);
|
|
5537
|
+
}
|
|
5538
|
+
};
|
|
5539
|
+
|
|
4552
5540
|
// src/daemon/routes/sessions.ts
|
|
4553
5541
|
import * as os3 from "os";
|
|
4554
5542
|
|
|
@@ -4579,6 +5567,7 @@ var BundleSession = z5.object({
|
|
|
4579
5567
|
currentMode: z5.string().optional(),
|
|
4580
5568
|
currentUsage: PersistedUsage.optional(),
|
|
4581
5569
|
agentCommands: z5.array(PersistedAgentCommand).optional(),
|
|
5570
|
+
agentModes: z5.array(PersistedAgentMode).optional(),
|
|
4582
5571
|
createdAt: z5.string(),
|
|
4583
5572
|
updatedAt: z5.string()
|
|
4584
5573
|
});
|
|
@@ -4612,6 +5601,7 @@ function encodeBundle(params) {
|
|
|
4612
5601
|
...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
|
|
4613
5602
|
...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
|
|
4614
5603
|
...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
|
|
5604
|
+
...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
|
|
4615
5605
|
createdAt: params.record.createdAt,
|
|
4616
5606
|
updatedAt: params.record.updatedAt
|
|
4617
5607
|
},
|
|
@@ -4670,6 +5660,8 @@ function mapUpdate(update) {
|
|
|
4670
5660
|
return mapUsage(u);
|
|
4671
5661
|
case "available_commands_update":
|
|
4672
5662
|
return mapAvailableCommands(u);
|
|
5663
|
+
case "available_modes_update":
|
|
5664
|
+
return mapAvailableModes(u);
|
|
4673
5665
|
case "session_info_update":
|
|
4674
5666
|
return mapSessionInfo(u);
|
|
4675
5667
|
default:
|
|
@@ -4731,6 +5723,31 @@ function mapAvailableCommands(u) {
|
|
|
4731
5723
|
}
|
|
4732
5724
|
return { kind: "available-commands", commands: normalizeAdvertisedCommands(list) };
|
|
4733
5725
|
}
|
|
5726
|
+
function mapAvailableModes(u) {
|
|
5727
|
+
const list = u.availableModes;
|
|
5728
|
+
if (!Array.isArray(list)) {
|
|
5729
|
+
return null;
|
|
5730
|
+
}
|
|
5731
|
+
const modes = [];
|
|
5732
|
+
for (const raw of list) {
|
|
5733
|
+
if (!raw || typeof raw !== "object") {
|
|
5734
|
+
continue;
|
|
5735
|
+
}
|
|
5736
|
+
const m = raw;
|
|
5737
|
+
if (typeof m.id !== "string" || m.id.length === 0) {
|
|
5738
|
+
continue;
|
|
5739
|
+
}
|
|
5740
|
+
const mode = { id: sanitizeSingleLine(m.id) };
|
|
5741
|
+
if (typeof m.name === "string") {
|
|
5742
|
+
mode.name = sanitizeSingleLine(m.name);
|
|
5743
|
+
}
|
|
5744
|
+
if (typeof m.description === "string") {
|
|
5745
|
+
mode.description = sanitizeSingleLine(m.description);
|
|
5746
|
+
}
|
|
5747
|
+
modes.push(mode);
|
|
5748
|
+
}
|
|
5749
|
+
return { kind: "available-modes", modes };
|
|
5750
|
+
}
|
|
4734
5751
|
function mapUsage(u) {
|
|
4735
5752
|
const event = { kind: "usage-update" };
|
|
4736
5753
|
if (typeof u.used === "number") {
|
|
@@ -4851,7 +5868,7 @@ function mapPlan(u) {
|
|
|
4851
5868
|
return { kind: "plan", entries: normalized };
|
|
4852
5869
|
}
|
|
4853
5870
|
function mapMode(u) {
|
|
4854
|
-
const mode = readString(u, "currentMode") ?? readString(u, "mode");
|
|
5871
|
+
const mode = readString(u, "currentModeId") ?? readString(u, "currentMode") ?? readString(u, "mode");
|
|
4855
5872
|
if (!mode) {
|
|
4856
5873
|
return null;
|
|
4857
5874
|
}
|
|
@@ -5514,6 +6531,157 @@ function registerConfigRoutes(app, defaults) {
|
|
|
5514
6531
|
});
|
|
5515
6532
|
}
|
|
5516
6533
|
|
|
6534
|
+
// src/daemon/routes/auth.ts
|
|
6535
|
+
import { z as z6 } from "zod";
|
|
6536
|
+
|
|
6537
|
+
// src/core/password.ts
|
|
6538
|
+
import * as fs13 from "fs/promises";
|
|
6539
|
+
import * as path9 from "path";
|
|
6540
|
+
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
6541
|
+
import { promisify } from "util";
|
|
6542
|
+
var scryptAsync = promisify(scrypt);
|
|
6543
|
+
function passwordHashPath() {
|
|
6544
|
+
return path9.join(paths.home(), "password-hash");
|
|
6545
|
+
}
|
|
6546
|
+
var DEFAULT_N = 1 << 15;
|
|
6547
|
+
var MAX_MEM = 128 * 1024 * 1024;
|
|
6548
|
+
async function hasPassword() {
|
|
6549
|
+
try {
|
|
6550
|
+
const text = await fs13.readFile(passwordHashPath(), "utf8");
|
|
6551
|
+
return text.trim().length > 0;
|
|
6552
|
+
} catch (err) {
|
|
6553
|
+
const e = err;
|
|
6554
|
+
if (e.code === "ENOENT") {
|
|
6555
|
+
return false;
|
|
6556
|
+
}
|
|
6557
|
+
throw err;
|
|
6558
|
+
}
|
|
6559
|
+
}
|
|
6560
|
+
async function verifyPassword(plaintext) {
|
|
6561
|
+
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
|
6562
|
+
return false;
|
|
6563
|
+
}
|
|
6564
|
+
let line;
|
|
6565
|
+
try {
|
|
6566
|
+
line = (await fs13.readFile(passwordHashPath(), "utf8")).trim();
|
|
6567
|
+
} catch (err) {
|
|
6568
|
+
const e = err;
|
|
6569
|
+
if (e.code === "ENOENT") {
|
|
6570
|
+
return false;
|
|
6571
|
+
}
|
|
6572
|
+
throw err;
|
|
6573
|
+
}
|
|
6574
|
+
const parts = line.split("$");
|
|
6575
|
+
if (parts.length !== 6 || parts[0] !== "scrypt") {
|
|
6576
|
+
return false;
|
|
6577
|
+
}
|
|
6578
|
+
const N = parseInt(parts[1], 10);
|
|
6579
|
+
const r = parseInt(parts[2], 10);
|
|
6580
|
+
const p = parseInt(parts[3], 10);
|
|
6581
|
+
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) {
|
|
6582
|
+
return false;
|
|
6583
|
+
}
|
|
6584
|
+
const salt = Buffer.from(parts[4], "hex");
|
|
6585
|
+
const expected = Buffer.from(parts[5], "hex");
|
|
6586
|
+
if (salt.length === 0 || expected.length === 0) {
|
|
6587
|
+
return false;
|
|
6588
|
+
}
|
|
6589
|
+
const actual = await scryptAsync(plaintext, salt, expected.length, {
|
|
6590
|
+
N,
|
|
6591
|
+
r,
|
|
6592
|
+
p,
|
|
6593
|
+
maxmem: MAX_MEM
|
|
6594
|
+
});
|
|
6595
|
+
if (actual.length !== expected.length) {
|
|
6596
|
+
return false;
|
|
6597
|
+
}
|
|
6598
|
+
return timingSafeEqual2(actual, expected);
|
|
6599
|
+
}
|
|
6600
|
+
|
|
6601
|
+
// src/daemon/routes/auth.ts
|
|
6602
|
+
var LoginBody = z6.object({
|
|
6603
|
+
password: z6.string().min(1),
|
|
6604
|
+
label: z6.string().min(1).max(256).optional(),
|
|
6605
|
+
ttlSec: z6.number().int().positive().optional()
|
|
6606
|
+
});
|
|
6607
|
+
var LogoutBody = z6.object({
|
|
6608
|
+
id: z6.string().optional()
|
|
6609
|
+
}).optional();
|
|
6610
|
+
function registerAuthRoutes(app, deps) {
|
|
6611
|
+
app.post(
|
|
6612
|
+
"/v1/auth/login",
|
|
6613
|
+
{ config: { skipAuth: true } },
|
|
6614
|
+
async (request, reply) => {
|
|
6615
|
+
const ip = remoteIp(request);
|
|
6616
|
+
if (deps.rateLimiter.isBlocked(ip)) {
|
|
6617
|
+
return reply.code(429).send({
|
|
6618
|
+
error: "Too many failed attempts; try again later."
|
|
6619
|
+
});
|
|
6620
|
+
}
|
|
6621
|
+
let body;
|
|
6622
|
+
try {
|
|
6623
|
+
body = LoginBody.parse(request.body);
|
|
6624
|
+
} catch {
|
|
6625
|
+
return reply.code(400).send({ error: "Invalid request body" });
|
|
6626
|
+
}
|
|
6627
|
+
if (!await hasPassword()) {
|
|
6628
|
+
return reply.code(403).send({
|
|
6629
|
+
error: "No password configured. Run `hydra-acp auth password` on the daemon host."
|
|
6630
|
+
});
|
|
6631
|
+
}
|
|
6632
|
+
const ok = await verifyPassword(body.password);
|
|
6633
|
+
if (!ok) {
|
|
6634
|
+
deps.rateLimiter.recordFailure(ip);
|
|
6635
|
+
return reply.code(401).send({ error: "Invalid password" });
|
|
6636
|
+
}
|
|
6637
|
+
deps.rateLimiter.recordSuccess(ip);
|
|
6638
|
+
const issued = await deps.store.issue({
|
|
6639
|
+
label: body.label,
|
|
6640
|
+
ttlSec: body.ttlSec
|
|
6641
|
+
});
|
|
6642
|
+
return reply.code(200).send({
|
|
6643
|
+
session_token: issued.token,
|
|
6644
|
+
id: issued.id,
|
|
6645
|
+
expires_at: issued.expiresAt
|
|
6646
|
+
});
|
|
6647
|
+
}
|
|
6648
|
+
);
|
|
6649
|
+
app.post("/v1/auth/logout", async (request, reply) => {
|
|
6650
|
+
let body = void 0;
|
|
6651
|
+
try {
|
|
6652
|
+
body = LogoutBody.parse(request.body ?? void 0);
|
|
6653
|
+
} catch {
|
|
6654
|
+
return reply.code(400).send({ error: "Invalid request body" });
|
|
6655
|
+
}
|
|
6656
|
+
const id = body?.id ?? request.authIdentity;
|
|
6657
|
+
if (!id || id === "service") {
|
|
6658
|
+
return reply.code(200).send({ revoked: false });
|
|
6659
|
+
}
|
|
6660
|
+
const revoked = await deps.store.revoke(id);
|
|
6661
|
+
return reply.code(200).send({ revoked });
|
|
6662
|
+
});
|
|
6663
|
+
app.get("/v1/auth/verify", async (_request, reply) => {
|
|
6664
|
+
return reply.code(200).send({ ok: true });
|
|
6665
|
+
});
|
|
6666
|
+
app.get("/v1/auth/sessions", async (_request, reply) => {
|
|
6667
|
+
return reply.code(200).send({ sessions: deps.store.list() });
|
|
6668
|
+
});
|
|
6669
|
+
app.delete(
|
|
6670
|
+
"/v1/auth/sessions/:id",
|
|
6671
|
+
async (request, reply) => {
|
|
6672
|
+
const id = request.params.id;
|
|
6673
|
+
const revoked = await deps.store.revoke(id);
|
|
6674
|
+
if (!revoked) {
|
|
6675
|
+
return reply.code(404).send({ error: "Not found" });
|
|
6676
|
+
}
|
|
6677
|
+
return reply.code(204).send();
|
|
6678
|
+
}
|
|
6679
|
+
);
|
|
6680
|
+
}
|
|
6681
|
+
function remoteIp(request) {
|
|
6682
|
+
return request.ip || "unknown";
|
|
6683
|
+
}
|
|
6684
|
+
|
|
5517
6685
|
// src/daemon/acp-ws.ts
|
|
5518
6686
|
import { nanoid as nanoid2 } from "nanoid";
|
|
5519
6687
|
|
|
@@ -5590,12 +6758,12 @@ function wsToMessageStream(ws) {
|
|
|
5590
6758
|
|
|
5591
6759
|
// src/daemon/acp-ws.ts
|
|
5592
6760
|
function registerAcpWsEndpoint(app, deps) {
|
|
5593
|
-
app.get("/acp", { websocket: true }, (socket, request) => {
|
|
6761
|
+
app.get("/acp", { websocket: true }, async (socket, request) => {
|
|
5594
6762
|
const token = tokenFromUpgradeRequest({
|
|
5595
6763
|
headers: request.headers,
|
|
5596
6764
|
url: request.url
|
|
5597
6765
|
});
|
|
5598
|
-
if (!token || !
|
|
6766
|
+
if (!token || !await deps.validator.validate(token)) {
|
|
5599
6767
|
socket.close(4401, "Unauthorized");
|
|
5600
6768
|
return;
|
|
5601
6769
|
}
|
|
@@ -5646,8 +6814,15 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5646
6814
|
}
|
|
5647
6815
|
})();
|
|
5648
6816
|
});
|
|
6817
|
+
const modesPayload = buildModesPayload(session);
|
|
5649
6818
|
return {
|
|
5650
6819
|
sessionId: session.sessionId,
|
|
6820
|
+
// session/new is implicitly an attach; mirror session/attach's
|
|
6821
|
+
// shape by including the clientId so deferred-echo clients
|
|
6822
|
+
// (TUI's queue work) can recognize their own prompt_queue_added
|
|
6823
|
+
// events without an extra round-trip.
|
|
6824
|
+
clientId: client.clientId,
|
|
6825
|
+
...modesPayload ? { modes: modesPayload } : {},
|
|
5651
6826
|
_meta: buildResponseMeta(session)
|
|
5652
6827
|
};
|
|
5653
6828
|
});
|
|
@@ -5708,6 +6883,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5708
6883
|
await connection.notify(note.method, note.params);
|
|
5709
6884
|
}
|
|
5710
6885
|
session.replayPendingPermissions(client);
|
|
6886
|
+
const modesPayload = buildModesPayload(session);
|
|
5711
6887
|
return {
|
|
5712
6888
|
sessionId: session.sessionId,
|
|
5713
6889
|
clientId: client.clientId,
|
|
@@ -5718,6 +6894,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5718
6894
|
// ran, not what was asked for.
|
|
5719
6895
|
historyPolicy: appliedPolicy,
|
|
5720
6896
|
replayed: replay.length,
|
|
6897
|
+
...modesPayload ? { modes: modesPayload } : {},
|
|
5721
6898
|
_meta: buildResponseMeta(session)
|
|
5722
6899
|
};
|
|
5723
6900
|
});
|
|
@@ -5751,7 +6928,29 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5751
6928
|
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
5752
6929
|
throw err;
|
|
5753
6930
|
}
|
|
5754
|
-
|
|
6931
|
+
let session = deps.manager.get(params.sessionId);
|
|
6932
|
+
if (!session) {
|
|
6933
|
+
const fromDisk = await deps.manager.loadFromDisk(params.sessionId);
|
|
6934
|
+
if (!fromDisk) {
|
|
6935
|
+
const err = new Error(
|
|
6936
|
+
`session ${params.sessionId} not found`
|
|
6937
|
+
);
|
|
6938
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
6939
|
+
throw err;
|
|
6940
|
+
}
|
|
6941
|
+
app.log.info(
|
|
6942
|
+
`session/prompt auto-resurrecting cold sessionId=${params.sessionId}`
|
|
6943
|
+
);
|
|
6944
|
+
session = await deps.manager.resurrect(fromDisk);
|
|
6945
|
+
const client = bindClientToSession(
|
|
6946
|
+
connection,
|
|
6947
|
+
session,
|
|
6948
|
+
state,
|
|
6949
|
+
void 0,
|
|
6950
|
+
att.clientId
|
|
6951
|
+
);
|
|
6952
|
+
await session.attach(client, "none");
|
|
6953
|
+
}
|
|
5755
6954
|
return session.prompt(att.clientId, params);
|
|
5756
6955
|
});
|
|
5757
6956
|
const handleCancelParams = (raw) => {
|
|
@@ -5783,6 +6982,26 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5783
6982
|
handleCancelParams(raw);
|
|
5784
6983
|
return null;
|
|
5785
6984
|
});
|
|
6985
|
+
connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
|
|
6986
|
+
const params = CancelPromptParams.parse(raw);
|
|
6987
|
+
const session = deps.manager.get(params.sessionId);
|
|
6988
|
+
if (!session) {
|
|
6989
|
+
const err = new Error(`session ${params.sessionId} not found`);
|
|
6990
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
6991
|
+
throw err;
|
|
6992
|
+
}
|
|
6993
|
+
return session.cancelQueuedPrompt(params.messageId);
|
|
6994
|
+
});
|
|
6995
|
+
connection.onRequest("hydra-acp/update_prompt", async (raw) => {
|
|
6996
|
+
const params = UpdatePromptParams.parse(raw);
|
|
6997
|
+
const session = deps.manager.get(params.sessionId);
|
|
6998
|
+
if (!session) {
|
|
6999
|
+
const err = new Error(`session ${params.sessionId} not found`);
|
|
7000
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
7001
|
+
throw err;
|
|
7002
|
+
}
|
|
7003
|
+
return session.updateQueuedPrompt(params.messageId, params.prompt);
|
|
7004
|
+
});
|
|
5786
7005
|
connection.onRequest("session/load", async (raw) => {
|
|
5787
7006
|
const rawObj = raw ?? {};
|
|
5788
7007
|
const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
|
|
@@ -5814,8 +7033,13 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5814
7033
|
await connection.notify(note.method, note.params);
|
|
5815
7034
|
}
|
|
5816
7035
|
session.replayPendingPermissions(client);
|
|
7036
|
+
const modesPayload = buildModesPayload(session);
|
|
5817
7037
|
return {
|
|
5818
7038
|
sessionId: session.sessionId,
|
|
7039
|
+
// Same as session/new: include clientId so the deferred-echo
|
|
7040
|
+
// path in queue-aware clients can recognize own broadcasts.
|
|
7041
|
+
clientId: client.clientId,
|
|
7042
|
+
...modesPayload ? { modes: modesPayload } : {},
|
|
5819
7043
|
_meta: buildResponseMeta(session)
|
|
5820
7044
|
};
|
|
5821
7045
|
});
|
|
@@ -5841,6 +7065,26 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
5841
7065
|
});
|
|
5842
7066
|
});
|
|
5843
7067
|
}
|
|
7068
|
+
function buildModesPayload(session) {
|
|
7069
|
+
const modes = session.availableModes();
|
|
7070
|
+
if (modes.length === 0) {
|
|
7071
|
+
return void 0;
|
|
7072
|
+
}
|
|
7073
|
+
const availableModes = modes.map((m) => {
|
|
7074
|
+
const out = {
|
|
7075
|
+
id: m.id,
|
|
7076
|
+
// ACP spec requires `name` — fall back to id when the agent didn't
|
|
7077
|
+
// supply one so we never emit an invalid SessionMode.
|
|
7078
|
+
name: m.name ?? m.id
|
|
7079
|
+
};
|
|
7080
|
+
if (m.description !== void 0) {
|
|
7081
|
+
out.description = m.description;
|
|
7082
|
+
}
|
|
7083
|
+
return out;
|
|
7084
|
+
});
|
|
7085
|
+
const currentModeId = session.currentMode ?? modes[0].id;
|
|
7086
|
+
return { currentModeId, availableModes };
|
|
7087
|
+
}
|
|
5844
7088
|
function buildResponseMeta(session) {
|
|
5845
7089
|
const ours = {
|
|
5846
7090
|
upstreamSessionId: session.upstreamSessionId,
|
|
@@ -5866,9 +7110,17 @@ function buildResponseMeta(session) {
|
|
|
5866
7110
|
if (commands.length > 0) {
|
|
5867
7111
|
ours.availableCommands = commands;
|
|
5868
7112
|
}
|
|
7113
|
+
const modes = session.availableModes();
|
|
7114
|
+
if (modes.length > 0) {
|
|
7115
|
+
ours.availableModes = modes;
|
|
7116
|
+
}
|
|
5869
7117
|
if (session.turnStartedAt !== void 0) {
|
|
5870
7118
|
ours.turnStartedAt = session.turnStartedAt;
|
|
5871
7119
|
}
|
|
7120
|
+
const queue = session.queueSnapshot();
|
|
7121
|
+
if (queue.length > 0) {
|
|
7122
|
+
ours.queue = queue;
|
|
7123
|
+
}
|
|
5872
7124
|
return mergeMeta(session.agentMeta, ours);
|
|
5873
7125
|
}
|
|
5874
7126
|
function buildInitializeResult() {
|
|
@@ -5899,7 +7151,13 @@ function buildInitializeResult() {
|
|
|
5899
7151
|
id: "bearer-token",
|
|
5900
7152
|
description: "Bearer token presented at WS upgrade"
|
|
5901
7153
|
}
|
|
5902
|
-
]
|
|
7154
|
+
],
|
|
7155
|
+
// Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
|
|
7156
|
+
// ACP clients ignore the field; capability-aware clients learn here
|
|
7157
|
+
// that hydra accepts concurrent session/prompt requests and emits
|
|
7158
|
+
// prompt_queue_* notifications so they can stop running their own
|
|
7159
|
+
// local queue.
|
|
7160
|
+
_meta: mergeMeta(void 0, { promptQueueing: true })
|
|
5903
7161
|
};
|
|
5904
7162
|
}
|
|
5905
7163
|
function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
|
|
@@ -5913,7 +7171,7 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
|
|
|
5913
7171
|
}
|
|
5914
7172
|
|
|
5915
7173
|
// src/daemon/server.ts
|
|
5916
|
-
async function startDaemon(config) {
|
|
7174
|
+
async function startDaemon(config, serviceToken) {
|
|
5917
7175
|
ensureLoopbackOrTls(config);
|
|
5918
7176
|
const httpsOptions = config.daemon.tls ? {
|
|
5919
7177
|
key: await fsp4.readFile(config.daemon.tls.key),
|
|
@@ -5940,7 +7198,13 @@ async function startDaemon(config) {
|
|
|
5940
7198
|
setNpmInstallLogger((msg) => {
|
|
5941
7199
|
app.log.info(msg);
|
|
5942
7200
|
});
|
|
5943
|
-
const
|
|
7201
|
+
const sessionTokenStore = await SessionTokenStore.load();
|
|
7202
|
+
const authRateLimiter = new AuthRateLimiter();
|
|
7203
|
+
const validator = new CompositeTokenValidator([
|
|
7204
|
+
new StaticTokenValidator(serviceToken),
|
|
7205
|
+
new SessionTokenValidator(sessionTokenStore)
|
|
7206
|
+
]);
|
|
7207
|
+
const auth = bearerAuth({ validator });
|
|
5944
7208
|
app.addHook("onRequest", async (request, reply) => {
|
|
5945
7209
|
if (request.routeOptions.config?.skipAuth) {
|
|
5946
7210
|
return;
|
|
@@ -5950,6 +7214,13 @@ async function startDaemon(config) {
|
|
|
5950
7214
|
}
|
|
5951
7215
|
await auth(request, reply);
|
|
5952
7216
|
});
|
|
7217
|
+
const sweepInterval = setInterval(
|
|
7218
|
+
() => {
|
|
7219
|
+
sessionTokenStore.sweepExpired();
|
|
7220
|
+
},
|
|
7221
|
+
5 * 60 * 1e3
|
|
7222
|
+
);
|
|
7223
|
+
sweepInterval.unref();
|
|
5953
7224
|
const registry = new Registry(config);
|
|
5954
7225
|
const agentLogger = {
|
|
5955
7226
|
info: (msg) => app.log.info(msg),
|
|
@@ -5964,7 +7235,8 @@ async function startDaemon(config) {
|
|
|
5964
7235
|
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
|
|
5965
7236
|
defaultModels: config.defaultModels,
|
|
5966
7237
|
sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
|
|
5967
|
-
logger: agentLogger
|
|
7238
|
+
logger: agentLogger,
|
|
7239
|
+
npmRegistry: config.npmRegistry
|
|
5968
7240
|
});
|
|
5969
7241
|
const extensions = new ExtensionManager(extensionList(config));
|
|
5970
7242
|
registerHealthRoutes(app, HYDRA_VERSION);
|
|
@@ -5978,8 +7250,12 @@ async function startDaemon(config) {
|
|
|
5978
7250
|
defaultAgent: config.defaultAgent,
|
|
5979
7251
|
defaultCwd: config.defaultCwd
|
|
5980
7252
|
});
|
|
7253
|
+
registerAuthRoutes(app, {
|
|
7254
|
+
store: sessionTokenStore,
|
|
7255
|
+
rateLimiter: authRateLimiter
|
|
7256
|
+
});
|
|
5981
7257
|
registerAcpWsEndpoint(app, {
|
|
5982
|
-
|
|
7258
|
+
validator,
|
|
5983
7259
|
manager,
|
|
5984
7260
|
defaultAgent: config.defaultAgent
|
|
5985
7261
|
});
|
|
@@ -6003,12 +7279,19 @@ async function startDaemon(config) {
|
|
|
6003
7279
|
daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
|
|
6004
7280
|
daemonHost: config.daemon.host,
|
|
6005
7281
|
daemonPort: boundPort,
|
|
6006
|
-
|
|
7282
|
+
serviceToken,
|
|
6007
7283
|
daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
|
|
6008
7284
|
hydraHome: paths.home()
|
|
6009
7285
|
});
|
|
6010
7286
|
await extensions.start();
|
|
7287
|
+
void manager.resurrectPendingQueues().catch((err) => {
|
|
7288
|
+
app.log.warn(
|
|
7289
|
+
`queue replay scan failed: ${err.message}`
|
|
7290
|
+
);
|
|
7291
|
+
});
|
|
6011
7292
|
const shutdown = async () => {
|
|
7293
|
+
clearInterval(sweepInterval);
|
|
7294
|
+
await sessionTokenStore.flush();
|
|
6012
7295
|
await extensions.stop();
|
|
6013
7296
|
await manager.closeAll();
|
|
6014
7297
|
await manager.flushMetaWrites();
|
|
@@ -6016,7 +7299,7 @@ async function startDaemon(config) {
|
|
|
6016
7299
|
setNpmInstallLogger(null);
|
|
6017
7300
|
await app.close();
|
|
6018
7301
|
try {
|
|
6019
|
-
|
|
7302
|
+
fs14.unlinkSync(paths.pidFile());
|
|
6020
7303
|
} catch {
|
|
6021
7304
|
}
|
|
6022
7305
|
try {
|
|
@@ -6057,9 +7340,10 @@ export {
|
|
|
6057
7340
|
Session,
|
|
6058
7341
|
SessionManager,
|
|
6059
7342
|
defaultConfig,
|
|
6060
|
-
|
|
6061
|
-
|
|
7343
|
+
ensureServiceToken,
|
|
7344
|
+
generateServiceToken,
|
|
6062
7345
|
loadConfig,
|
|
7346
|
+
loadServiceToken,
|
|
6063
7347
|
ndjsonStreamFromStdio,
|
|
6064
7348
|
paths,
|
|
6065
7349
|
planSpawn,
|