@hydra-acp/cli 0.1.3 → 0.1.5
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 +27 -3
- package/dist/cli.js +1788 -428
- package/dist/index.d.ts +608 -38
- package/dist/index.js +1211 -234
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/daemon/server.ts
|
|
2
|
-
import * as
|
|
3
|
-
import * as
|
|
2
|
+
import * as fs9 from "fs";
|
|
3
|
+
import * as fsp3 from "fs/promises";
|
|
4
4
|
import Fastify from "fastify";
|
|
5
5
|
import websocketPlugin from "@fastify/websocket";
|
|
6
6
|
import pino from "pino";
|
|
@@ -35,7 +35,11 @@ var paths = {
|
|
|
35
35
|
currentLogFile: () => path.join(hydraHome(), "current.log"),
|
|
36
36
|
registryCache: () => path.join(hydraHome(), "registry.json"),
|
|
37
37
|
agentsDir: () => path.join(hydraHome(), "agents"),
|
|
38
|
-
|
|
38
|
+
// <platformKey>/<agentId>/<version>/ — platform at the top so a Hydra
|
|
39
|
+
// home shared between machines (NFS, rsync'd dotfiles) keeps each
|
|
40
|
+
// machine's binaries cleanly separated. `ls agents/` immediately
|
|
41
|
+
// shows which platforms have ever installed anything.
|
|
42
|
+
agentInstallDir: (id, platformKey, version) => path.join(hydraHome(), "agents", platformKey, id, version),
|
|
39
43
|
sessionsDir: () => path.join(hydraHome(), "sessions"),
|
|
40
44
|
// One directory per session id under sessions/. Co-locates the
|
|
41
45
|
// session record, its transcript, and any future per-session state
|
|
@@ -62,7 +66,7 @@ var DaemonConfig = z.object({
|
|
|
62
66
|
authToken: z.string().min(16),
|
|
63
67
|
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
64
68
|
tls: TlsConfig.optional(),
|
|
65
|
-
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(
|
|
69
|
+
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
|
|
66
70
|
});
|
|
67
71
|
var RegistryConfig = z.object({
|
|
68
72
|
url: z.string().url().default(REGISTRY_URL_DEFAULT),
|
|
@@ -95,6 +99,14 @@ var HydraConfig = z.object({
|
|
|
95
99
|
daemon: DaemonConfig,
|
|
96
100
|
registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
|
|
97
101
|
defaultAgent: z.string().default("claude-acp"),
|
|
102
|
+
// Optional per-agent default model id. When a brand-new agent process
|
|
103
|
+
// is spawned (session/new path), hydra issues session/set_model with
|
|
104
|
+
// the matching entry so the user lands on their preferred model from
|
|
105
|
+
// the first prompt. Not applied on resurrect — those sessions keep
|
|
106
|
+
// whatever the user last selected. Keys are agent ids; values are the
|
|
107
|
+
// raw model id strings the agent expects (claude-acp: "claude-opus-4-7",
|
|
108
|
+
// opencode: "openai/gpt-5-codex" or "ncp-anthropic/claude-opus-4-7", …).
|
|
109
|
+
defaultModels: z.record(z.string(), z.string()).default({}),
|
|
98
110
|
// Where new sessions land when POST /v1/sessions omits cwd. Stored as
|
|
99
111
|
// a literal string ("~", "~/dev", "$HOME/work") so the config file is
|
|
100
112
|
// portable across machines; expanded via expandHome at use time.
|
|
@@ -137,8 +149,7 @@ async function ensureConfig() {
|
|
|
137
149
|
if (e.code !== "ENOENT") {
|
|
138
150
|
throw err;
|
|
139
151
|
}
|
|
140
|
-
const config =
|
|
141
|
-
await writeConfig(config);
|
|
152
|
+
const config = await writeMinimalInitConfig();
|
|
142
153
|
process.stderr.write(
|
|
143
154
|
`hydra-acp: initialized ${paths.config()} with a fresh auth token.
|
|
144
155
|
`
|
|
@@ -154,6 +165,16 @@ async function writeConfig(config) {
|
|
|
154
165
|
mode: 384
|
|
155
166
|
});
|
|
156
167
|
}
|
|
168
|
+
async function writeMinimalInitConfig(authToken) {
|
|
169
|
+
const token = authToken ?? generateAuthToken();
|
|
170
|
+
const minimal = { daemon: { authToken: token } };
|
|
171
|
+
await fs.mkdir(paths.home(), { recursive: true });
|
|
172
|
+
await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
|
|
173
|
+
encoding: "utf8",
|
|
174
|
+
mode: 384
|
|
175
|
+
});
|
|
176
|
+
return HydraConfig.parse(minimal);
|
|
177
|
+
}
|
|
157
178
|
function generateAuthToken() {
|
|
158
179
|
const bytes = new Uint8Array(32);
|
|
159
180
|
crypto.getRandomValues(bytes);
|
|
@@ -184,8 +205,213 @@ function expandHome(p) {
|
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
// src/core/registry.ts
|
|
187
|
-
import * as
|
|
208
|
+
import * as fs3 from "fs/promises";
|
|
188
209
|
import { z as z2 } from "zod";
|
|
210
|
+
|
|
211
|
+
// src/core/binary-install.ts
|
|
212
|
+
import * as fs2 from "fs";
|
|
213
|
+
import * as fsp from "fs/promises";
|
|
214
|
+
import * as path2 from "path";
|
|
215
|
+
import { spawn } from "child_process";
|
|
216
|
+
import { Readable } from "stream";
|
|
217
|
+
function currentPlatformKey() {
|
|
218
|
+
const osPart = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : void 0;
|
|
219
|
+
const archPart = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : void 0;
|
|
220
|
+
if (!osPart || !archPart) {
|
|
221
|
+
return void 0;
|
|
222
|
+
}
|
|
223
|
+
return `${osPart}-${archPart}`;
|
|
224
|
+
}
|
|
225
|
+
function pickBinaryTarget(distribution, platformKey = currentPlatformKey()) {
|
|
226
|
+
if (!platformKey) {
|
|
227
|
+
return void 0;
|
|
228
|
+
}
|
|
229
|
+
return distribution[platformKey];
|
|
230
|
+
}
|
|
231
|
+
var logSink = (msg) => {
|
|
232
|
+
process.stderr.write(msg + "\n");
|
|
233
|
+
};
|
|
234
|
+
function setBinaryInstallLogger(log) {
|
|
235
|
+
logSink = log ?? ((msg) => process.stderr.write(msg + "\n"));
|
|
236
|
+
}
|
|
237
|
+
async function ensureBinary(args) {
|
|
238
|
+
if (!args.target.archive) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Agent ${args.agentId} has no archive URL for ${currentPlatformKey() ?? "this platform"}`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (!args.target.cmd) {
|
|
244
|
+
throw new Error(`Agent ${args.agentId} has no cmd in its binary target`);
|
|
245
|
+
}
|
|
246
|
+
const platformKey = currentPlatformKey();
|
|
247
|
+
if (!platformKey) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Agent ${args.agentId}: cannot determine platform key for ${process.platform}/${process.arch}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const installDir = paths.agentInstallDir(
|
|
253
|
+
args.agentId,
|
|
254
|
+
platformKey,
|
|
255
|
+
args.version
|
|
256
|
+
);
|
|
257
|
+
const cmdPath = path2.resolve(installDir, args.target.cmd);
|
|
258
|
+
if (await fileExists(cmdPath)) {
|
|
259
|
+
return cmdPath;
|
|
260
|
+
}
|
|
261
|
+
await downloadAndExtract({
|
|
262
|
+
agentId: args.agentId,
|
|
263
|
+
archiveUrl: args.target.archive,
|
|
264
|
+
installDir
|
|
265
|
+
});
|
|
266
|
+
if (!await fileExists(cmdPath)) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Agent ${args.agentId}: extracted archive did not contain ${args.target.cmd} (looked in ${installDir})`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (process.platform !== "win32") {
|
|
272
|
+
await fsp.chmod(cmdPath, 493).catch(() => void 0);
|
|
273
|
+
}
|
|
274
|
+
return cmdPath;
|
|
275
|
+
}
|
|
276
|
+
async function downloadAndExtract(args) {
|
|
277
|
+
await fsp.mkdir(path2.dirname(args.installDir), { recursive: true });
|
|
278
|
+
const tempDir = await fsp.mkdtemp(`${args.installDir}.partial-`);
|
|
279
|
+
try {
|
|
280
|
+
logSink(`hydra-acp: downloading ${args.agentId} from ${args.archiveUrl}`);
|
|
281
|
+
const archivePath = await downloadTo({
|
|
282
|
+
url: args.archiveUrl,
|
|
283
|
+
dir: tempDir,
|
|
284
|
+
agentId: args.agentId
|
|
285
|
+
});
|
|
286
|
+
logSink(`hydra-acp: extracting ${args.agentId}`);
|
|
287
|
+
await extract(archivePath, tempDir);
|
|
288
|
+
await fsp.unlink(archivePath).catch(() => void 0);
|
|
289
|
+
try {
|
|
290
|
+
await fsp.rename(tempDir, args.installDir);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
const e = err;
|
|
293
|
+
if ((e.code === "EEXIST" || e.code === "ENOTEMPTY") && await fileExists(args.installDir)) {
|
|
294
|
+
await fsp.rm(tempDir, { recursive: true, force: true }).catch(
|
|
295
|
+
() => void 0
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
logSink(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function downloadTo(args) {
|
|
308
|
+
const filename = inferArchiveName(args.url);
|
|
309
|
+
const dest = path2.join(args.dir, filename);
|
|
310
|
+
const response = await fetch(args.url, { redirect: "follow" });
|
|
311
|
+
if (!response.ok || !response.body) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`Failed to download ${args.url}: HTTP ${response.status} ${response.statusText}`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const total = Number(response.headers.get("content-length") ?? "0");
|
|
317
|
+
const out = fs2.createWriteStream(dest);
|
|
318
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
319
|
+
let received = 0;
|
|
320
|
+
let lastEmit = Date.now();
|
|
321
|
+
const EMIT_INTERVAL_MS = 2e3;
|
|
322
|
+
nodeStream.on("data", (chunk) => {
|
|
323
|
+
received += chunk.length;
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
if (now - lastEmit < EMIT_INTERVAL_MS) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
lastEmit = now;
|
|
329
|
+
logSink(formatProgress(args.agentId, received, total));
|
|
330
|
+
});
|
|
331
|
+
await new Promise((resolve3, reject) => {
|
|
332
|
+
nodeStream.on("error", reject);
|
|
333
|
+
out.on("error", reject);
|
|
334
|
+
out.on("finish", () => resolve3());
|
|
335
|
+
nodeStream.pipe(out);
|
|
336
|
+
});
|
|
337
|
+
logSink(formatProgress(
|
|
338
|
+
args.agentId,
|
|
339
|
+
received,
|
|
340
|
+
total,
|
|
341
|
+
/* done */
|
|
342
|
+
true
|
|
343
|
+
));
|
|
344
|
+
return dest;
|
|
345
|
+
}
|
|
346
|
+
function formatProgress(agentId, received, total, done = false) {
|
|
347
|
+
const rxMb = (received / 1e6).toFixed(1);
|
|
348
|
+
if (total > 0) {
|
|
349
|
+
const totalMb = (total / 1e6).toFixed(1);
|
|
350
|
+
const pct = Math.min(100, Math.floor(received / total * 100));
|
|
351
|
+
const tag2 = done ? "downloaded" : "downloading";
|
|
352
|
+
return `hydra-acp: ${tag2} ${agentId} ${rxMb}/${totalMb} MB (${pct}%)`;
|
|
353
|
+
}
|
|
354
|
+
const tag = done ? "downloaded" : "downloading";
|
|
355
|
+
return `hydra-acp: ${tag} ${agentId} ${rxMb} MB`;
|
|
356
|
+
}
|
|
357
|
+
function inferArchiveName(url) {
|
|
358
|
+
const u = new URL(url);
|
|
359
|
+
const base = path2.posix.basename(u.pathname);
|
|
360
|
+
return base || "archive";
|
|
361
|
+
}
|
|
362
|
+
async function extract(archivePath, dest) {
|
|
363
|
+
const lower = archivePath.toLowerCase();
|
|
364
|
+
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".tar")) {
|
|
365
|
+
await run("tar", ["-xf", archivePath, "-C", dest]);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (lower.endsWith(".zip")) {
|
|
369
|
+
if (await hasCommand("unzip")) {
|
|
370
|
+
await run("unzip", ["-q", archivePath, "-d", dest]);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
await run("tar", ["-xf", archivePath, "-C", dest]);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
throw new Error(`Unsupported archive format: ${archivePath}`);
|
|
377
|
+
}
|
|
378
|
+
function run(cmd, args) {
|
|
379
|
+
return new Promise((resolve3, reject) => {
|
|
380
|
+
const child = spawn(cmd, args, {
|
|
381
|
+
stdio: ["ignore", "ignore", "inherit"]
|
|
382
|
+
});
|
|
383
|
+
child.on("error", reject);
|
|
384
|
+
child.on("exit", (code, signal) => {
|
|
385
|
+
if (code === 0) {
|
|
386
|
+
resolve3();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
reject(
|
|
390
|
+
new Error(
|
|
391
|
+
`${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
|
|
392
|
+
)
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function hasCommand(name) {
|
|
398
|
+
return new Promise((resolve3) => {
|
|
399
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
400
|
+
const child = spawn(finder, [name], { stdio: "ignore" });
|
|
401
|
+
child.on("error", () => resolve3(false));
|
|
402
|
+
child.on("exit", (code) => resolve3(code === 0));
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
async function fileExists(p) {
|
|
406
|
+
try {
|
|
407
|
+
await fsp.access(p);
|
|
408
|
+
return true;
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/core/registry.ts
|
|
189
415
|
var NpxDistribution = z2.object({
|
|
190
416
|
package: z2.string(),
|
|
191
417
|
args: z2.array(z2.string()).optional(),
|
|
@@ -193,7 +419,9 @@ var NpxDistribution = z2.object({
|
|
|
193
419
|
});
|
|
194
420
|
var BinaryTarget = z2.object({
|
|
195
421
|
archive: z2.string().url().optional(),
|
|
196
|
-
cmd: z2.string().optional()
|
|
422
|
+
cmd: z2.string().optional(),
|
|
423
|
+
args: z2.array(z2.string()).optional(),
|
|
424
|
+
env: z2.record(z2.string()).optional()
|
|
197
425
|
});
|
|
198
426
|
var BinaryDistribution = z2.object({
|
|
199
427
|
"darwin-aarch64": BinaryTarget.optional(),
|
|
@@ -282,34 +510,59 @@ var Registry = class {
|
|
|
282
510
|
if (!response.ok) {
|
|
283
511
|
throw new Error(`Registry fetch failed: HTTP ${response.status}`);
|
|
284
512
|
}
|
|
285
|
-
const
|
|
286
|
-
const data = RegistryDocument.parse(
|
|
287
|
-
return { fetchedAt: Date.now(), data };
|
|
513
|
+
const raw = await response.json();
|
|
514
|
+
const data = RegistryDocument.parse(raw);
|
|
515
|
+
return { fetchedAt: Date.now(), raw, data };
|
|
288
516
|
}
|
|
289
517
|
async readDiskCache() {
|
|
518
|
+
let text;
|
|
290
519
|
try {
|
|
291
|
-
|
|
292
|
-
const parsed = JSON.parse(raw);
|
|
293
|
-
if (typeof parsed.fetchedAt === "number" && parsed.data && Array.isArray(parsed.data.agents)) {
|
|
294
|
-
return parsed;
|
|
295
|
-
}
|
|
520
|
+
text = await fs3.readFile(paths.registryCache(), "utf8");
|
|
296
521
|
} catch (err) {
|
|
297
522
|
const e = err;
|
|
298
|
-
if (e.code
|
|
299
|
-
|
|
523
|
+
if (e.code === "ENOENT") {
|
|
524
|
+
return void 0;
|
|
300
525
|
}
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
const parsed = JSON.parse(text);
|
|
530
|
+
if (typeof parsed.fetchedAt !== "number" || parsed.data === void 0) {
|
|
531
|
+
return void 0;
|
|
532
|
+
}
|
|
533
|
+
const data = RegistryDocument.parse(parsed.data);
|
|
534
|
+
return { fetchedAt: parsed.fetchedAt, raw: parsed.data, data };
|
|
535
|
+
} catch {
|
|
536
|
+
return void 0;
|
|
301
537
|
}
|
|
302
|
-
return void 0;
|
|
303
538
|
}
|
|
539
|
+
// Atomic write: dump to a sibling temp path, then rename onto the
|
|
540
|
+
// target. POSIX rename is atomic within a filesystem, so readers
|
|
541
|
+
// either see the old file or the fully-written new file — never a
|
|
542
|
+
// truncated middle. This also makes simultaneous writers safe
|
|
543
|
+
// without a lock file: the loser of the rename race just gets its
|
|
544
|
+
// version replaced by the winner's.
|
|
304
545
|
async writeDiskCache(cache) {
|
|
305
|
-
await
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
546
|
+
await fs3.mkdir(paths.home(), { recursive: true });
|
|
547
|
+
const final = paths.registryCache();
|
|
548
|
+
const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
|
|
549
|
+
const body = JSON.stringify(
|
|
550
|
+
{ fetchedAt: cache.fetchedAt, data: cache.raw },
|
|
551
|
+
null,
|
|
552
|
+
2
|
|
553
|
+
) + "\n";
|
|
554
|
+
try {
|
|
555
|
+
await fs3.writeFile(tmp, body, "utf8");
|
|
556
|
+
await fs3.rename(tmp, final);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
await fs3.unlink(tmp).catch(() => void 0);
|
|
559
|
+
throw err;
|
|
560
|
+
}
|
|
311
561
|
}
|
|
312
562
|
};
|
|
563
|
+
function randSuffix() {
|
|
564
|
+
return Math.random().toString(36).slice(2, 10);
|
|
565
|
+
}
|
|
313
566
|
function npxPackageBasename(agent) {
|
|
314
567
|
const pkg = agent.distribution.npx?.package;
|
|
315
568
|
if (!pkg) {
|
|
@@ -320,7 +573,7 @@ function npxPackageBasename(agent) {
|
|
|
320
573
|
const atIdx = afterSlash.lastIndexOf("@");
|
|
321
574
|
return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
|
|
322
575
|
}
|
|
323
|
-
function planSpawn(agent, extraArgs = []) {
|
|
576
|
+
async function planSpawn(agent, extraArgs = []) {
|
|
324
577
|
if (agent.distribution.npx) {
|
|
325
578
|
const npx = agent.distribution.npx;
|
|
326
579
|
const args = ["-y", npx.package, ...npx.args ?? [], ...extraArgs];
|
|
@@ -331,9 +584,22 @@ function planSpawn(agent, extraArgs = []) {
|
|
|
331
584
|
};
|
|
332
585
|
}
|
|
333
586
|
if (agent.distribution.binary) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
587
|
+
const target = pickBinaryTarget(agent.distribution.binary);
|
|
588
|
+
if (!target) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
const cmdPath = await ensureBinary({
|
|
594
|
+
agentId: agent.id,
|
|
595
|
+
version: agent.version ?? "current",
|
|
596
|
+
target
|
|
597
|
+
});
|
|
598
|
+
return {
|
|
599
|
+
command: cmdPath,
|
|
600
|
+
args: [...target.args ?? [], ...extraArgs],
|
|
601
|
+
env: target.env ?? {}
|
|
602
|
+
};
|
|
337
603
|
}
|
|
338
604
|
if (agent.distribution.uvx) {
|
|
339
605
|
const uvx = agent.distribution.uvx;
|
|
@@ -348,10 +614,11 @@ function planSpawn(agent, extraArgs = []) {
|
|
|
348
614
|
}
|
|
349
615
|
|
|
350
616
|
// src/core/session-manager.ts
|
|
351
|
-
import * as
|
|
617
|
+
import * as fs7 from "fs/promises";
|
|
618
|
+
import { customAlphabet as customAlphabet3 } from "nanoid";
|
|
352
619
|
|
|
353
620
|
// src/core/agent-instance.ts
|
|
354
|
-
import { spawn } from "child_process";
|
|
621
|
+
import { spawn as spawn2 } from "child_process";
|
|
355
622
|
|
|
356
623
|
// src/acp/types.ts
|
|
357
624
|
import { z as z3 } from "zod";
|
|
@@ -364,7 +631,8 @@ var JsonRpcErrorCodes = {
|
|
|
364
631
|
SessionNotFound: -32001,
|
|
365
632
|
PermissionDenied: -32002,
|
|
366
633
|
AlreadyAttached: -32003,
|
|
367
|
-
AgentNotInstalled: -32005
|
|
634
|
+
AgentNotInstalled: -32005,
|
|
635
|
+
BundleAlreadyImported: -32010
|
|
368
636
|
};
|
|
369
637
|
var InitializeParams = z3.object({
|
|
370
638
|
protocolVersion: z3.number().optional(),
|
|
@@ -470,12 +738,24 @@ var SessionListParams = z3.object({
|
|
|
470
738
|
cursor: z3.string().optional(),
|
|
471
739
|
limit: z3.number().int().positive().max(200).optional()
|
|
472
740
|
});
|
|
741
|
+
var SessionListUsage = z3.object({
|
|
742
|
+
used: z3.number().optional(),
|
|
743
|
+
size: z3.number().optional(),
|
|
744
|
+
costAmount: z3.number().optional(),
|
|
745
|
+
costCurrency: z3.string().optional()
|
|
746
|
+
});
|
|
473
747
|
var SessionListEntry = z3.object({
|
|
474
748
|
sessionId: z3.string(),
|
|
475
749
|
upstreamSessionId: z3.string().optional(),
|
|
476
750
|
cwd: z3.string(),
|
|
477
751
|
title: z3.string().optional(),
|
|
478
752
|
agentId: z3.string().optional(),
|
|
753
|
+
// Last-known model id, so list views can render `<agent>(<model>)`
|
|
754
|
+
// without resurrecting cold sessions to look it up.
|
|
755
|
+
currentModel: z3.string().optional(),
|
|
756
|
+
// Last-known usage snapshot so list views can show per-session cost
|
|
757
|
+
// (and tokens, in callers that care) without resurrecting cold sessions.
|
|
758
|
+
currentUsage: SessionListUsage.optional(),
|
|
479
759
|
updatedAt: z3.string(),
|
|
480
760
|
attachedClients: z3.number().int().nonnegative(),
|
|
481
761
|
status: z3.enum(["live", "cold"]).default("live"),
|
|
@@ -557,13 +837,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
|
|
|
557
837
|
throw new Error("stream is closed");
|
|
558
838
|
}
|
|
559
839
|
const line = JSON.stringify(message) + "\n";
|
|
560
|
-
await new Promise((
|
|
840
|
+
await new Promise((resolve3, reject) => {
|
|
561
841
|
stdin.write(line, (err) => {
|
|
562
842
|
if (err) {
|
|
563
843
|
reject(err);
|
|
564
844
|
return;
|
|
565
845
|
}
|
|
566
|
-
|
|
846
|
+
resolve3();
|
|
567
847
|
});
|
|
568
848
|
});
|
|
569
849
|
},
|
|
@@ -622,9 +902,9 @@ var JsonRpcConnection = class {
|
|
|
622
902
|
}
|
|
623
903
|
const id = nanoid();
|
|
624
904
|
const message = { jsonrpc: "2.0", id, method, params };
|
|
625
|
-
const response = new Promise((
|
|
905
|
+
const response = new Promise((resolve3, reject) => {
|
|
626
906
|
this.pending.set(id, {
|
|
627
|
-
resolve: (result) =>
|
|
907
|
+
resolve: (result) => resolve3(result),
|
|
628
908
|
reject
|
|
629
909
|
});
|
|
630
910
|
this.stream.send(message).catch((err) => {
|
|
@@ -762,7 +1042,7 @@ var AgentInstance = class _AgentInstance {
|
|
|
762
1042
|
...opts.plan.env,
|
|
763
1043
|
...opts.extraEnv ?? {}
|
|
764
1044
|
};
|
|
765
|
-
const child =
|
|
1045
|
+
const child = spawn2(opts.plan.command, opts.plan.args, {
|
|
766
1046
|
cwd: opts.cwd,
|
|
767
1047
|
env,
|
|
768
1048
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -795,8 +1075,8 @@ var HYDRA_COMMANDS = [
|
|
|
795
1075
|
description: "Regenerate the session title via the agent (or set manually with an arg)"
|
|
796
1076
|
},
|
|
797
1077
|
{
|
|
798
|
-
verb: "
|
|
799
|
-
name: "/hydra
|
|
1078
|
+
verb: "agent",
|
|
1079
|
+
name: "/hydra agent",
|
|
800
1080
|
argsHint: "<agent>",
|
|
801
1081
|
description: "Swap the agent backing this session, preserving context"
|
|
802
1082
|
}
|
|
@@ -813,10 +1093,12 @@ function hydraCommandsAsAdvertised() {
|
|
|
813
1093
|
var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
814
1094
|
var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
|
|
815
1095
|
var HYDRA_SESSION_PREFIX = "hydra_session_";
|
|
1096
|
+
var MAX_HISTORY_ENTRIES = 1e3;
|
|
1097
|
+
var COMPACT_EVERY = 200;
|
|
816
1098
|
var Session = class {
|
|
817
1099
|
sessionId;
|
|
818
1100
|
cwd;
|
|
819
|
-
// agent / agentId / upstreamSessionId are mutable so /hydra
|
|
1101
|
+
// agent / agentId / upstreamSessionId are mutable so /hydra agent can
|
|
820
1102
|
// replace the underlying agent process while keeping the same Session
|
|
821
1103
|
// record. agentMeta is the metadata returned by the agent at session/new
|
|
822
1104
|
// time; it gets refreshed on switch too.
|
|
@@ -831,9 +1113,10 @@ var Session = class {
|
|
|
831
1113
|
// stale-prone for snapshot-shaped events).
|
|
832
1114
|
currentModel;
|
|
833
1115
|
currentMode;
|
|
1116
|
+
currentUsage;
|
|
834
1117
|
updatedAt;
|
|
1118
|
+
createdAt;
|
|
835
1119
|
clients = /* @__PURE__ */ new Map();
|
|
836
|
-
history = [];
|
|
837
1120
|
historyStore;
|
|
838
1121
|
promptQueue = [];
|
|
839
1122
|
promptInFlight = false;
|
|
@@ -849,6 +1132,15 @@ var Session = class {
|
|
|
849
1132
|
// True once we've observed our first session/prompt; gates the
|
|
850
1133
|
// first-prompt-seeded title so subsequent prompts don't churn it.
|
|
851
1134
|
firstPromptSeeded = false;
|
|
1135
|
+
// Wall-clock when the active prompt started, undefined when idle.
|
|
1136
|
+
// Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
|
|
1137
|
+
// Drives the mid-turn elapsed counter delivered to fresh attachers.
|
|
1138
|
+
promptStartedAt;
|
|
1139
|
+
// Counts appends since the last compaction. When it hits COMPACT_EVERY
|
|
1140
|
+
// we ask the history store to trim the file to the most recent
|
|
1141
|
+
// MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
|
|
1142
|
+
// file-size checks.
|
|
1143
|
+
appendCount = 0;
|
|
852
1144
|
// Permission requests that have been broadcast to one or more
|
|
853
1145
|
// clients but have not yet resolved. Replayed to clients that
|
|
854
1146
|
// attach mid-flight so a late joiner sees the prompt instead of an
|
|
@@ -863,6 +1155,12 @@ var Session = class {
|
|
|
863
1155
|
internalPromptCapture;
|
|
864
1156
|
idleTimeoutMs;
|
|
865
1157
|
idleTimer;
|
|
1158
|
+
// Time of the last recordable broadcast (or session creation, if
|
|
1159
|
+
// none yet). Drives the inactivity-based idle close; deliberately
|
|
1160
|
+
// does NOT include snapshot state pings (model/mode/title/commands)
|
|
1161
|
+
// or attach/detach, which would otherwise let passive observers
|
|
1162
|
+
// and noisy state churn keep a quiet session alive forever.
|
|
1163
|
+
lastRecordedAt;
|
|
866
1164
|
spawnReplacementAgent;
|
|
867
1165
|
agentChangeHandlers = [];
|
|
868
1166
|
// Last available_commands_update we observed from the agent. Stored
|
|
@@ -877,6 +1175,7 @@ var Session = class {
|
|
|
877
1175
|
agentCommandsHandlers = [];
|
|
878
1176
|
modelHandlers = [];
|
|
879
1177
|
modeHandlers = [];
|
|
1178
|
+
usageHandlers = [];
|
|
880
1179
|
constructor(init) {
|
|
881
1180
|
this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
|
|
882
1181
|
this.cwd = init.cwd;
|
|
@@ -888,6 +1187,7 @@ var Session = class {
|
|
|
888
1187
|
this.title = init.title;
|
|
889
1188
|
this.currentModel = init.currentModel;
|
|
890
1189
|
this.currentMode = init.currentMode;
|
|
1190
|
+
this.currentUsage = init.currentUsage;
|
|
891
1191
|
if (init.agentCommands && init.agentCommands.length > 0) {
|
|
892
1192
|
this.agentAdvertisedCommands = [...init.agentCommands];
|
|
893
1193
|
}
|
|
@@ -897,11 +1197,11 @@ var Session = class {
|
|
|
897
1197
|
this.firstPromptSeeded = true;
|
|
898
1198
|
}
|
|
899
1199
|
this.historyStore = init.historyStore;
|
|
900
|
-
if (init.seedHistory && init.seedHistory.length > 0) {
|
|
901
|
-
this.history = [...init.seedHistory];
|
|
902
|
-
}
|
|
903
1200
|
this.updatedAt = Date.now();
|
|
1201
|
+
this.createdAt = init.createdAt ?? this.updatedAt;
|
|
1202
|
+
this.lastRecordedAt = this.updatedAt;
|
|
904
1203
|
this.wireAgent(this.agent);
|
|
1204
|
+
this.scheduleIdleCheck();
|
|
905
1205
|
}
|
|
906
1206
|
broadcastMergedCommands() {
|
|
907
1207
|
const merged = [
|
|
@@ -917,7 +1217,7 @@ var Session = class {
|
|
|
917
1217
|
});
|
|
918
1218
|
}
|
|
919
1219
|
// Register session/update, session/request_permission, and onExit
|
|
920
|
-
// handlers on an agent connection. Re-run on every /hydra
|
|
1220
|
+
// handlers on an agent connection. Re-run on every /hydra agent so
|
|
921
1221
|
// the new agent is plumbed identically. The exit handler's identity
|
|
922
1222
|
// check is what makes switching safe: when the *old* agent exits as
|
|
923
1223
|
// part of a swap, this.agent has already been replaced, so we no-op
|
|
@@ -941,6 +1241,10 @@ var Session = class {
|
|
|
941
1241
|
this.recordAndBroadcast("session/update", params);
|
|
942
1242
|
return;
|
|
943
1243
|
}
|
|
1244
|
+
if (this.maybeApplyAgentUsage(params)) {
|
|
1245
|
+
this.recordAndBroadcast("session/update", params);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
944
1248
|
this.maybeApplyAgentSessionInfo(params);
|
|
945
1249
|
this.recordAndBroadcast("session/update", params);
|
|
946
1250
|
});
|
|
@@ -961,34 +1265,20 @@ var Session = class {
|
|
|
961
1265
|
return this.clients.size;
|
|
962
1266
|
}
|
|
963
1267
|
// Wall-clock when the in-flight agent turn began, or undefined when
|
|
964
|
-
// idle.
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
// so a fresh client reattaching mid-turn boots up with the busy
|
|
968
|
-
// banner showing real elapsed time.
|
|
1268
|
+
// idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
|
|
1269
|
+
// so the daemon can hand a fresh attacher mid-turn the right elapsed
|
|
1270
|
+
// time without scanning history.
|
|
969
1271
|
get turnStartedAt() {
|
|
970
|
-
|
|
971
|
-
const entry = this.history[i];
|
|
972
|
-
if (!entry) {
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
975
|
-
const params = entry.params;
|
|
976
|
-
const kind = params?.update?.sessionUpdate;
|
|
977
|
-
if (kind === "turn_complete") {
|
|
978
|
-
return void 0;
|
|
979
|
-
}
|
|
980
|
-
if (kind === "prompt_received") {
|
|
981
|
-
return entry.recordedAt;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
return void 0;
|
|
1272
|
+
return this.promptStartedAt;
|
|
985
1273
|
}
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1274
|
+
// Read the persisted history from disk. Returns [] if no history
|
|
1275
|
+
// file exists (fresh session, never prompted). Used by attach() and
|
|
1276
|
+
// the HTTP /history endpoint.
|
|
1277
|
+
async getHistorySnapshot() {
|
|
1278
|
+
if (!this.historyStore) {
|
|
1279
|
+
return [];
|
|
1280
|
+
}
|
|
1281
|
+
return this.historyStore.load(this.sessionId).catch(() => []);
|
|
992
1282
|
}
|
|
993
1283
|
// Subscribe to recordable broadcast entries — fires once per entry
|
|
994
1284
|
// that lands in history (so snapshot-shaped session_info/model/mode/
|
|
@@ -1004,6 +1294,10 @@ var Session = class {
|
|
|
1004
1294
|
}
|
|
1005
1295
|
};
|
|
1006
1296
|
}
|
|
1297
|
+
// Register a client and (asynchronously) load the replay slice it
|
|
1298
|
+
// should receive. Validation errors throw synchronously so callers
|
|
1299
|
+
// can rely on either the registration being in effect or having
|
|
1300
|
+
// thrown; the disk-load is the only async work.
|
|
1007
1301
|
attach(client, historyPolicy) {
|
|
1008
1302
|
if (this.closed) {
|
|
1009
1303
|
throw withCode(
|
|
@@ -1019,14 +1313,10 @@ var Session = class {
|
|
|
1019
1313
|
}
|
|
1020
1314
|
this.clients.set(client.clientId, client);
|
|
1021
1315
|
this.updatedAt = Date.now();
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
return [];
|
|
1025
|
-
}
|
|
1026
|
-
if (historyPolicy === "pending_only") {
|
|
1027
|
-
return [];
|
|
1316
|
+
if (historyPolicy === "none" || historyPolicy === "pending_only") {
|
|
1317
|
+
return Promise.resolve([]);
|
|
1028
1318
|
}
|
|
1029
|
-
return
|
|
1319
|
+
return this.getHistorySnapshot();
|
|
1030
1320
|
}
|
|
1031
1321
|
// Dispatch in-flight permission requests to a freshly-attached
|
|
1032
1322
|
// client. Called by the daemon's WS handler *after* it finishes
|
|
@@ -1040,7 +1330,6 @@ var Session = class {
|
|
|
1040
1330
|
detach(clientId) {
|
|
1041
1331
|
if (this.clients.delete(clientId)) {
|
|
1042
1332
|
this.updatedAt = Date.now();
|
|
1043
|
-
this.maybeStartIdleTimer();
|
|
1044
1333
|
}
|
|
1045
1334
|
}
|
|
1046
1335
|
async prompt(clientId, params) {
|
|
@@ -1086,6 +1375,7 @@ var Session = class {
|
|
|
1086
1375
|
if (client.clientInfo?.version) {
|
|
1087
1376
|
sentBy.version = client.clientInfo.version;
|
|
1088
1377
|
}
|
|
1378
|
+
this.promptStartedAt = Date.now();
|
|
1089
1379
|
this.recordAndBroadcast(
|
|
1090
1380
|
"session/update",
|
|
1091
1381
|
{
|
|
@@ -1122,6 +1412,7 @@ var Session = class {
|
|
|
1122
1412
|
if (stopReason !== void 0) {
|
|
1123
1413
|
update.stopReason = stopReason;
|
|
1124
1414
|
}
|
|
1415
|
+
this.promptStartedAt = void 0;
|
|
1125
1416
|
this.recordAndBroadcast(
|
|
1126
1417
|
"session/update",
|
|
1127
1418
|
{
|
|
@@ -1271,6 +1562,49 @@ var Session = class {
|
|
|
1271
1562
|
}
|
|
1272
1563
|
return true;
|
|
1273
1564
|
}
|
|
1565
|
+
// usage_update carries any subset of {used, size, cost.amount,
|
|
1566
|
+
// cost.currency}. Merge non-undefined fields onto currentUsage so a
|
|
1567
|
+
// sparse update preserves prior values, and fire usage handlers only
|
|
1568
|
+
// if something actually changed.
|
|
1569
|
+
maybeApplyAgentUsage(params) {
|
|
1570
|
+
const obj = params ?? {};
|
|
1571
|
+
const update = obj.update ?? {};
|
|
1572
|
+
if (update.sessionUpdate !== "usage_update") {
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
const next = { ...this.currentUsage ?? {} };
|
|
1576
|
+
let changed = false;
|
|
1577
|
+
if (typeof update.used === "number" && next.used !== update.used) {
|
|
1578
|
+
next.used = update.used;
|
|
1579
|
+
changed = true;
|
|
1580
|
+
}
|
|
1581
|
+
if (typeof update.size === "number" && next.size !== update.size) {
|
|
1582
|
+
next.size = update.size;
|
|
1583
|
+
changed = true;
|
|
1584
|
+
}
|
|
1585
|
+
if (update.cost && typeof update.cost === "object") {
|
|
1586
|
+
const cost = update.cost;
|
|
1587
|
+
if (typeof cost.amount === "number" && next.costAmount !== cost.amount) {
|
|
1588
|
+
next.costAmount = cost.amount;
|
|
1589
|
+
changed = true;
|
|
1590
|
+
}
|
|
1591
|
+
if (typeof cost.currency === "string" && next.costCurrency !== cost.currency) {
|
|
1592
|
+
next.costCurrency = cost.currency;
|
|
1593
|
+
changed = true;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
if (!changed) {
|
|
1597
|
+
return true;
|
|
1598
|
+
}
|
|
1599
|
+
this.currentUsage = next;
|
|
1600
|
+
for (const handler of this.usageHandlers) {
|
|
1601
|
+
try {
|
|
1602
|
+
handler(next);
|
|
1603
|
+
} catch {
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
return true;
|
|
1607
|
+
}
|
|
1274
1608
|
// Update the cached agent command list, fire persist handlers, and
|
|
1275
1609
|
// broadcast the merged list to attached clients. Idempotent on a
|
|
1276
1610
|
// structurally identical list so we don't churn meta.json on noisy
|
|
@@ -1301,12 +1635,21 @@ var Session = class {
|
|
|
1301
1635
|
onModeChange(handler) {
|
|
1302
1636
|
this.modeHandlers.push(handler);
|
|
1303
1637
|
}
|
|
1638
|
+
onUsageChange(handler) {
|
|
1639
|
+
this.usageHandlers.push(handler);
|
|
1640
|
+
}
|
|
1304
1641
|
// Returns a freshly merged command list (hydra ∪ agent) for callers
|
|
1305
1642
|
// that need a snapshot — notably acp-ws.ts's buildResponseMeta when
|
|
1306
1643
|
// assembling the attach response.
|
|
1307
1644
|
mergedAvailableCommands() {
|
|
1308
1645
|
return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
|
|
1309
1646
|
}
|
|
1647
|
+
// The agent's own advertised commands (not merged with hydra verbs).
|
|
1648
|
+
// Used by SessionManager to persist into meta.json so cold resurrect
|
|
1649
|
+
// can re-deliver via the attach response _meta.
|
|
1650
|
+
agentOnlyAdvertisedCommands() {
|
|
1651
|
+
return [...this.agentAdvertisedCommands];
|
|
1652
|
+
}
|
|
1310
1653
|
// Pick up an agent-emitted session_info_update and store its title
|
|
1311
1654
|
// as our canonical record. The notification is also forwarded to
|
|
1312
1655
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -1357,8 +1700,8 @@ var Session = class {
|
|
|
1357
1700
|
switch (verb) {
|
|
1358
1701
|
case "title":
|
|
1359
1702
|
return this.runTitleCommand(arg);
|
|
1360
|
-
case "
|
|
1361
|
-
return this.
|
|
1703
|
+
case "agent":
|
|
1704
|
+
return this.runAgentCommand(arg);
|
|
1362
1705
|
default: {
|
|
1363
1706
|
const err = new Error(
|
|
1364
1707
|
`no dispatcher for /hydra verb ${verb}`
|
|
@@ -1394,7 +1737,7 @@ var Session = class {
|
|
|
1394
1737
|
}
|
|
1395
1738
|
// Send a prompt to the underlying agent and capture its reply chunks
|
|
1396
1739
|
// privately (no fan-out to clients, no recording into history). Used
|
|
1397
|
-
// by /hydra title's regen path and /hydra
|
|
1740
|
+
// by /hydra title's regen path and /hydra agent's transcript-injection
|
|
1398
1741
|
// path. Returns the joined agent_message_chunk text.
|
|
1399
1742
|
async runInternalPrompt(text) {
|
|
1400
1743
|
if (this.internalPromptCapture) {
|
|
@@ -1416,10 +1759,10 @@ var Session = class {
|
|
|
1416
1759
|
// record. Spawns the new agent first so a failure leaves the old one
|
|
1417
1760
|
// intact; then injects a synthesized transcript so the new agent has
|
|
1418
1761
|
// context for the next turn.
|
|
1419
|
-
|
|
1762
|
+
runAgentCommand(newAgentId) {
|
|
1420
1763
|
if (!newAgentId) {
|
|
1421
1764
|
throw withCode(
|
|
1422
|
-
new Error("/hydra
|
|
1765
|
+
new Error("/hydra agent requires an agent id"),
|
|
1423
1766
|
JsonRpcErrorCodes.InvalidParams
|
|
1424
1767
|
);
|
|
1425
1768
|
}
|
|
@@ -1438,7 +1781,7 @@ var Session = class {
|
|
|
1438
1781
|
const spawnAgent = this.spawnReplacementAgent;
|
|
1439
1782
|
return this.enqueuePrompt(async () => {
|
|
1440
1783
|
const oldAgentId = this.agentId;
|
|
1441
|
-
const transcript = this.buildSwitchTranscript(oldAgentId);
|
|
1784
|
+
const transcript = await this.buildSwitchTranscript(oldAgentId);
|
|
1442
1785
|
const fresh = await spawnAgent({
|
|
1443
1786
|
agentId: newAgentId,
|
|
1444
1787
|
cwd: this.cwd,
|
|
@@ -1470,15 +1813,20 @@ var Session = class {
|
|
|
1470
1813
|
return { stopReason: "end_turn" };
|
|
1471
1814
|
});
|
|
1472
1815
|
}
|
|
1473
|
-
// Walk
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
|
|
1816
|
+
// Walk the persisted history and produce a labeled transcript suitable
|
|
1817
|
+
// for handing to a fresh agent. Includes user prompts, agent replies,
|
|
1818
|
+
// and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
|
|
1819
|
+
// switches don't accumulate banners) and other update kinds we don't
|
|
1820
|
+
// think the next agent benefits from re-seeing (plans, thoughts,
|
|
1821
|
+
// mode/model/usage).
|
|
1822
|
+
//
|
|
1823
|
+
// The header text defaults to the agent-swap framing; callers like
|
|
1824
|
+
// seedFromImport pass a custom header when the new agent is taking
|
|
1825
|
+
// over an imported session rather than swapping mid-conversation.
|
|
1826
|
+
async buildSwitchTranscript(prevAgentId, headerOverride) {
|
|
1480
1827
|
const lines = [];
|
|
1481
|
-
|
|
1828
|
+
const history = await this.getHistorySnapshot();
|
|
1829
|
+
for (const note of history) {
|
|
1482
1830
|
if (note.method !== "session/update") {
|
|
1483
1831
|
continue;
|
|
1484
1832
|
}
|
|
@@ -1532,29 +1880,53 @@ var Session = class {
|
|
|
1532
1880
|
if (current) {
|
|
1533
1881
|
coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
|
|
1534
1882
|
}
|
|
1883
|
+
const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
|
|
1884
|
+
const followup = headerOverride?.followup ?? `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`;
|
|
1535
1885
|
return [
|
|
1536
|
-
|
|
1537
|
-
|
|
1886
|
+
intro,
|
|
1887
|
+
followup,
|
|
1538
1888
|
"",
|
|
1539
1889
|
"--- begin transcript ---",
|
|
1540
1890
|
...coalesced,
|
|
1541
1891
|
"--- end transcript ---"
|
|
1542
1892
|
].join("\n");
|
|
1543
1893
|
}
|
|
1894
|
+
// Replay the persisted history into a freshly-spawned agent so an
|
|
1895
|
+
// imported session has context. Called by SessionManager.doResurrect
|
|
1896
|
+
// on the first wake-up of a session whose meta.json has an empty
|
|
1897
|
+
// upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
|
|
1898
|
+
// any user prompts arriving mid-seed queue behind it (mirrors the
|
|
1899
|
+
// /hydra agent path so the agent isn't asked to respond to a user
|
|
1900
|
+
// turn before it has absorbed the imported transcript). Best-effort:
|
|
1901
|
+
// if the agent fails to absorb the transcript we still leave the
|
|
1902
|
+
// session usable — the user just continues without context.
|
|
1903
|
+
async seedFromImport() {
|
|
1904
|
+
await this.enqueuePrompt(async () => {
|
|
1905
|
+
const transcript = await this.buildSwitchTranscript(this.agentId, {
|
|
1906
|
+
intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
|
|
1907
|
+
followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
|
|
1908
|
+
});
|
|
1909
|
+
if (!transcript) {
|
|
1910
|
+
return void 0;
|
|
1911
|
+
}
|
|
1912
|
+
await this.runInternalPrompt(transcript).catch(() => void 0);
|
|
1913
|
+
return void 0;
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1544
1916
|
// Tell every attached client (a) the agent identity has changed
|
|
1545
|
-
// (session_info_update
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1917
|
+
// (session_info_update carrying agentId inside _meta["hydra-acp"] —
|
|
1918
|
+
// the ACP schema for session_info_update is just title/updatedAt/_meta,
|
|
1919
|
+
// so non-hydra clients harmlessly ignore the extension; hydra-aware
|
|
1920
|
+
// ones read it and relabel) and (b) drop a visible banner into the
|
|
1921
|
+
// transcript so users see the switch rather than just suddenly getting
|
|
1922
|
+
// answers from a different agent. Both updates carry synthetic=true
|
|
1923
|
+
// so a future /hydra agent's transcript builder filters them out.
|
|
1551
1924
|
broadcastAgentSwitch(oldAgentId, newAgentId) {
|
|
1552
1925
|
this.recordAndBroadcast("session/update", {
|
|
1553
1926
|
sessionId: this.sessionId,
|
|
1554
1927
|
update: {
|
|
1555
1928
|
sessionUpdate: "session_info_update",
|
|
1556
|
-
agentId: newAgentId
|
|
1557
|
-
_meta: { "hydra-acp": { synthetic: true } }
|
|
1929
|
+
_meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
|
|
1558
1930
|
}
|
|
1559
1931
|
});
|
|
1560
1932
|
this.recordAndBroadcast("session/update", {
|
|
@@ -1585,22 +1957,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1585
1957
|
handler(opts);
|
|
1586
1958
|
}
|
|
1587
1959
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1960
|
+
// Last meaningful activity timestamp. Bumped only by recordable
|
|
1961
|
+
// broadcasts in recordAndBroadcast — the same signal historyMtimeIso
|
|
1962
|
+
// uses for the picker. Initialized at construction (and seeded from
|
|
1963
|
+
// the newest entry on resurrect) so the inactivity window starts
|
|
1964
|
+
// ticking from a sensible floor when there's no history yet.
|
|
1965
|
+
get lastActivityAt() {
|
|
1966
|
+
return this.lastRecordedAt;
|
|
1967
|
+
}
|
|
1968
|
+
// (Re-)arm the idle timer to fire when the inactivity window
|
|
1969
|
+
// elapses past lastActivityAt. Called once at construction and after
|
|
1970
|
+
// every recorded broadcast. The previous design gated on
|
|
1971
|
+
// clients.size === 0; we drop that gate because extensions
|
|
1972
|
+
// (slack/notifier/approver/browser) hold persistent attaches that
|
|
1973
|
+
// would otherwise keep a quiet session alive forever.
|
|
1974
|
+
scheduleIdleCheck() {
|
|
1975
|
+
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
1590
1976
|
return;
|
|
1591
1977
|
}
|
|
1978
|
+
const dueAt = this.lastActivityAt + this.idleTimeoutMs;
|
|
1979
|
+
this.armIdleTimer(Math.max(0, dueAt - Date.now()));
|
|
1980
|
+
}
|
|
1981
|
+
armIdleTimer(delay) {
|
|
1592
1982
|
if (this.idleTimer) {
|
|
1593
|
-
|
|
1983
|
+
clearTimeout(this.idleTimer);
|
|
1594
1984
|
}
|
|
1595
1985
|
this.idleTimer = setTimeout(() => {
|
|
1596
1986
|
this.idleTimer = void 0;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
}, this.idleTimeoutMs);
|
|
1987
|
+
this.checkIdle();
|
|
1988
|
+
}, delay);
|
|
1600
1989
|
if (typeof this.idleTimer.unref === "function") {
|
|
1601
1990
|
this.idleTimer.unref();
|
|
1602
1991
|
}
|
|
1603
1992
|
}
|
|
1993
|
+
checkIdle() {
|
|
1994
|
+
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
|
|
1998
|
+
this.armIdleTimer(this.idleTimeoutMs);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
const idle = Date.now() - this.lastActivityAt;
|
|
2002
|
+
if (idle < this.idleTimeoutMs) {
|
|
2003
|
+
this.armIdleTimer(this.idleTimeoutMs - idle);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
|
|
2007
|
+
void this.close(opts).catch(() => void 0);
|
|
2008
|
+
}
|
|
1604
2009
|
cancelIdleTimer() {
|
|
1605
2010
|
if (this.idleTimer) {
|
|
1606
2011
|
clearTimeout(this.idleTimer);
|
|
@@ -1625,17 +2030,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1625
2030
|
params: rewritten,
|
|
1626
2031
|
recordedAt: Date.now()
|
|
1627
2032
|
};
|
|
1628
|
-
this.
|
|
1629
|
-
|
|
1630
|
-
if (this.history.length > 1e3) {
|
|
1631
|
-
this.history = this.history.slice(-500);
|
|
1632
|
-
trimmed = true;
|
|
1633
|
-
}
|
|
2033
|
+
this.lastRecordedAt = entry.recordedAt;
|
|
2034
|
+
this.appendCount += 1;
|
|
1634
2035
|
if (this.historyStore) {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
2036
|
+
const store = this.historyStore;
|
|
2037
|
+
void store.append(this.sessionId, entry).catch(() => void 0);
|
|
2038
|
+
if (this.appendCount >= COMPACT_EVERY) {
|
|
2039
|
+
this.appendCount = 0;
|
|
2040
|
+
void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
|
|
1639
2041
|
() => void 0
|
|
1640
2042
|
);
|
|
1641
2043
|
}
|
|
@@ -1646,6 +2048,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1646
2048
|
} catch {
|
|
1647
2049
|
}
|
|
1648
2050
|
}
|
|
2051
|
+
this.scheduleIdleCheck();
|
|
1649
2052
|
}
|
|
1650
2053
|
this.updatedAt = Date.now();
|
|
1651
2054
|
for (const client of this.clients.values()) {
|
|
@@ -1664,7 +2067,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1664
2067
|
);
|
|
1665
2068
|
}
|
|
1666
2069
|
const clientParams = this.rewriteForClient(params);
|
|
1667
|
-
return new Promise((
|
|
2070
|
+
return new Promise((resolve3, reject) => {
|
|
1668
2071
|
let settled = false;
|
|
1669
2072
|
const outbound = [];
|
|
1670
2073
|
const entry = { addClient: sendTo };
|
|
@@ -1699,7 +2102,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1699
2102
|
result
|
|
1700
2103
|
}).catch(() => void 0);
|
|
1701
2104
|
}
|
|
1702
|
-
|
|
2105
|
+
resolve3(result);
|
|
1703
2106
|
});
|
|
1704
2107
|
}).catch((err) => {
|
|
1705
2108
|
settle(() => reject(err));
|
|
@@ -1711,16 +2114,16 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1711
2114
|
});
|
|
1712
2115
|
}
|
|
1713
2116
|
async enqueuePrompt(task) {
|
|
1714
|
-
return new Promise((
|
|
1715
|
-
const
|
|
2117
|
+
return new Promise((resolve3, reject) => {
|
|
2118
|
+
const run2 = async () => {
|
|
1716
2119
|
try {
|
|
1717
2120
|
const result = await task();
|
|
1718
|
-
|
|
2121
|
+
resolve3(result);
|
|
1719
2122
|
} catch (err) {
|
|
1720
2123
|
reject(err);
|
|
1721
2124
|
}
|
|
1722
2125
|
};
|
|
1723
|
-
this.promptQueue.push(
|
|
2126
|
+
this.promptQueue.push(run2);
|
|
1724
2127
|
void this.drainQueue();
|
|
1725
2128
|
});
|
|
1726
2129
|
}
|
|
@@ -1749,7 +2152,8 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
|
|
|
1749
2152
|
"session_info_update",
|
|
1750
2153
|
"current_model_update",
|
|
1751
2154
|
"current_mode_update",
|
|
1752
|
-
"available_commands_update"
|
|
2155
|
+
"available_commands_update",
|
|
2156
|
+
"usage_update"
|
|
1753
2157
|
]);
|
|
1754
2158
|
function isStateUpdate(method, params) {
|
|
1755
2159
|
if (method !== "session/update") {
|
|
@@ -1834,17 +2238,43 @@ function firstLine(text, max) {
|
|
|
1834
2238
|
}
|
|
1835
2239
|
|
|
1836
2240
|
// src/core/session-store.ts
|
|
1837
|
-
import * as
|
|
1838
|
-
import * as
|
|
2241
|
+
import * as fs4 from "fs/promises";
|
|
2242
|
+
import * as path3 from "path";
|
|
2243
|
+
import { customAlphabet as customAlphabet2 } from "nanoid";
|
|
1839
2244
|
import { z as z4 } from "zod";
|
|
2245
|
+
var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
2246
|
+
var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
|
|
2247
|
+
var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
|
|
2248
|
+
function generateLineageId() {
|
|
2249
|
+
return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
|
|
2250
|
+
}
|
|
1840
2251
|
var PersistedAgentCommand = z4.object({
|
|
1841
2252
|
name: z4.string(),
|
|
1842
2253
|
description: z4.string().optional()
|
|
1843
2254
|
});
|
|
2255
|
+
var PersistedUsage = z4.object({
|
|
2256
|
+
used: z4.number().optional(),
|
|
2257
|
+
size: z4.number().optional(),
|
|
2258
|
+
costAmount: z4.number().optional(),
|
|
2259
|
+
costCurrency: z4.string().optional()
|
|
2260
|
+
});
|
|
1844
2261
|
var SessionRecord = z4.object({
|
|
1845
2262
|
version: z4.literal(1),
|
|
1846
2263
|
sessionId: z4.string(),
|
|
2264
|
+
// Optional for back-compat with records written before this field
|
|
2265
|
+
// existed; mergeForPersistence generates one on next write so any
|
|
2266
|
+
// touched session converges to having a lineageId. A record that
|
|
2267
|
+
// never gets written again (truly cold and untouched) just won't
|
|
2268
|
+
// participate in lineage-based dedup, which is correct — it was
|
|
2269
|
+
// never exported, so no incoming bundle can claim its lineage.
|
|
2270
|
+
lineageId: z4.string().optional(),
|
|
1847
2271
|
upstreamSessionId: z4.string(),
|
|
2272
|
+
// When non-empty, marks a session that was created by import and is
|
|
2273
|
+
// waiting for its first attach to bootstrap a fresh upstream agent
|
|
2274
|
+
// and replay the imported history as a takeover transcript. The
|
|
2275
|
+
// origin's local id at export time, kept for debuggability and as a
|
|
2276
|
+
// breadcrumb in `sessions list` (informational, not used for routing).
|
|
2277
|
+
importedFromSessionId: z4.string().optional(),
|
|
1848
2278
|
agentId: z4.string(),
|
|
1849
2279
|
cwd: z4.string(),
|
|
1850
2280
|
title: z4.string().optional(),
|
|
@@ -1855,6 +2285,7 @@ var SessionRecord = z4.object({
|
|
|
1855
2285
|
// replay of a snapshot-shaped notification.
|
|
1856
2286
|
currentModel: z4.string().optional(),
|
|
1857
2287
|
currentMode: z4.string().optional(),
|
|
2288
|
+
currentUsage: PersistedUsage.optional(),
|
|
1858
2289
|
agentCommands: z4.array(PersistedAgentCommand).optional(),
|
|
1859
2290
|
createdAt: z4.string(),
|
|
1860
2291
|
updatedAt: z4.string()
|
|
@@ -1868,9 +2299,9 @@ function assertSafeId(id) {
|
|
|
1868
2299
|
var SessionStore = class {
|
|
1869
2300
|
async write(record) {
|
|
1870
2301
|
assertSafeId(record.sessionId);
|
|
1871
|
-
await
|
|
2302
|
+
await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
|
|
1872
2303
|
const full = { version: 1, ...record };
|
|
1873
|
-
await
|
|
2304
|
+
await fs4.writeFile(
|
|
1874
2305
|
paths.sessionFile(record.sessionId),
|
|
1875
2306
|
JSON.stringify(full, null, 2) + "\n",
|
|
1876
2307
|
{ encoding: "utf8", mode: 384 }
|
|
@@ -1882,7 +2313,7 @@ var SessionStore = class {
|
|
|
1882
2313
|
}
|
|
1883
2314
|
let raw;
|
|
1884
2315
|
try {
|
|
1885
|
-
raw = await
|
|
2316
|
+
raw = await fs4.readFile(paths.sessionFile(sessionId), "utf8");
|
|
1886
2317
|
} catch (err) {
|
|
1887
2318
|
const e = err;
|
|
1888
2319
|
if (e.code === "ENOENT") {
|
|
@@ -1901,7 +2332,7 @@ var SessionStore = class {
|
|
|
1901
2332
|
return;
|
|
1902
2333
|
}
|
|
1903
2334
|
try {
|
|
1904
|
-
await
|
|
2335
|
+
await fs4.unlink(paths.sessionFile(sessionId));
|
|
1905
2336
|
} catch (err) {
|
|
1906
2337
|
const e = err;
|
|
1907
2338
|
if (e.code !== "ENOENT") {
|
|
@@ -1909,7 +2340,7 @@ var SessionStore = class {
|
|
|
1909
2340
|
}
|
|
1910
2341
|
}
|
|
1911
2342
|
try {
|
|
1912
|
-
await
|
|
2343
|
+
await fs4.rmdir(paths.sessionDir(sessionId));
|
|
1913
2344
|
} catch (err) {
|
|
1914
2345
|
const e = err;
|
|
1915
2346
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -1917,10 +2348,29 @@ var SessionStore = class {
|
|
|
1917
2348
|
}
|
|
1918
2349
|
}
|
|
1919
2350
|
}
|
|
2351
|
+
// Find a persisted session by lineageId. Used by SessionManager.import
|
|
2352
|
+
// to detect bundles that have already been imported (lineageId match)
|
|
2353
|
+
// so we can either error out or, with replace:true, overwrite.
|
|
2354
|
+
// Returns undefined if no record has that lineageId. Records that
|
|
2355
|
+
// pre-date the lineageId field simply don't match — which is
|
|
2356
|
+
// correct: they were never exported, so no incoming bundle can
|
|
2357
|
+
// legitimately claim their lineage.
|
|
2358
|
+
async findByLineageId(lineageId) {
|
|
2359
|
+
if (lineageId.length === 0) {
|
|
2360
|
+
return void 0;
|
|
2361
|
+
}
|
|
2362
|
+
const all = await this.list().catch(() => []);
|
|
2363
|
+
for (const record of all) {
|
|
2364
|
+
if (record.lineageId === lineageId) {
|
|
2365
|
+
return record;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
return void 0;
|
|
2369
|
+
}
|
|
1920
2370
|
async list() {
|
|
1921
2371
|
let entries;
|
|
1922
2372
|
try {
|
|
1923
|
-
entries = await
|
|
2373
|
+
entries = await fs4.readdir(paths.sessionsDir());
|
|
1924
2374
|
} catch (err) {
|
|
1925
2375
|
const e = err;
|
|
1926
2376
|
if (e.code === "ENOENT") {
|
|
@@ -1942,13 +2392,16 @@ function recordFromMemorySession(args) {
|
|
|
1942
2392
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1943
2393
|
return {
|
|
1944
2394
|
sessionId: args.sessionId,
|
|
2395
|
+
lineageId: args.lineageId,
|
|
1945
2396
|
upstreamSessionId: args.upstreamSessionId,
|
|
2397
|
+
importedFromSessionId: args.importedFromSessionId,
|
|
1946
2398
|
agentId: args.agentId,
|
|
1947
2399
|
cwd: args.cwd,
|
|
1948
2400
|
title: args.title,
|
|
1949
2401
|
agentArgs: args.agentArgs,
|
|
1950
2402
|
currentModel: args.currentModel,
|
|
1951
2403
|
currentMode: args.currentMode,
|
|
2404
|
+
currentUsage: args.currentUsage,
|
|
1952
2405
|
agentCommands: args.agentCommands,
|
|
1953
2406
|
createdAt: args.createdAt ?? now,
|
|
1954
2407
|
updatedAt: args.updatedAt ?? now
|
|
@@ -1956,7 +2409,7 @@ function recordFromMemorySession(args) {
|
|
|
1956
2409
|
}
|
|
1957
2410
|
|
|
1958
2411
|
// src/core/history-store.ts
|
|
1959
|
-
import * as
|
|
2412
|
+
import * as fs5 from "fs/promises";
|
|
1960
2413
|
var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
|
|
1961
2414
|
var MAX_ENTRIES = 1e3;
|
|
1962
2415
|
var HistoryStore = class {
|
|
@@ -1969,9 +2422,9 @@ var HistoryStore = class {
|
|
|
1969
2422
|
return;
|
|
1970
2423
|
}
|
|
1971
2424
|
return this.enqueue(sessionId, async () => {
|
|
1972
|
-
await
|
|
2425
|
+
await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1973
2426
|
const line = JSON.stringify(entry) + "\n";
|
|
1974
|
-
await
|
|
2427
|
+
await fs5.appendFile(paths.historyFile(sessionId), line, {
|
|
1975
2428
|
encoding: "utf8",
|
|
1976
2429
|
mode: 384
|
|
1977
2430
|
});
|
|
@@ -1982,9 +2435,39 @@ var HistoryStore = class {
|
|
|
1982
2435
|
return;
|
|
1983
2436
|
}
|
|
1984
2437
|
return this.enqueue(sessionId, async () => {
|
|
1985
|
-
await
|
|
2438
|
+
await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1986
2439
|
const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1987
|
-
await
|
|
2440
|
+
await fs5.writeFile(paths.historyFile(sessionId), body, {
|
|
2441
|
+
encoding: "utf8",
|
|
2442
|
+
mode: 384
|
|
2443
|
+
});
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
// Trim the on-disk history file to the most recent maxEntries lines.
|
|
2447
|
+
// Runs through the same per-session write queue as append/rewrite so
|
|
2448
|
+
// it's safe to invoke alongside ongoing writes; a no-op if the file is
|
|
2449
|
+
// already at or below the cap.
|
|
2450
|
+
async compact(sessionId, maxEntries) {
|
|
2451
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
return this.enqueue(sessionId, async () => {
|
|
2455
|
+
let raw;
|
|
2456
|
+
try {
|
|
2457
|
+
raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
|
|
2458
|
+
} catch (err) {
|
|
2459
|
+
const e = err;
|
|
2460
|
+
if (e.code === "ENOENT") {
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
throw err;
|
|
2464
|
+
}
|
|
2465
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
2466
|
+
if (lines.length <= maxEntries) {
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
const trimmed = lines.slice(-maxEntries);
|
|
2470
|
+
await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
|
|
1988
2471
|
encoding: "utf8",
|
|
1989
2472
|
mode: 384
|
|
1990
2473
|
});
|
|
@@ -2000,7 +2483,7 @@ var HistoryStore = class {
|
|
|
2000
2483
|
}
|
|
2001
2484
|
let raw;
|
|
2002
2485
|
try {
|
|
2003
|
-
raw = await
|
|
2486
|
+
raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
|
|
2004
2487
|
} catch (err) {
|
|
2005
2488
|
const e = err;
|
|
2006
2489
|
if (e.code === "ENOENT") {
|
|
@@ -2046,7 +2529,7 @@ var HistoryStore = class {
|
|
|
2046
2529
|
}
|
|
2047
2530
|
return this.enqueue(sessionId, async () => {
|
|
2048
2531
|
try {
|
|
2049
|
-
await
|
|
2532
|
+
await fs5.unlink(paths.historyFile(sessionId));
|
|
2050
2533
|
} catch (err) {
|
|
2051
2534
|
const e = err;
|
|
2052
2535
|
if (e.code !== "ENOENT") {
|
|
@@ -2054,7 +2537,7 @@ var HistoryStore = class {
|
|
|
2054
2537
|
}
|
|
2055
2538
|
}
|
|
2056
2539
|
try {
|
|
2057
|
-
await
|
|
2540
|
+
await fs5.rmdir(paths.sessionDir(sessionId));
|
|
2058
2541
|
} catch (err) {
|
|
2059
2542
|
const e = err;
|
|
2060
2543
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -2077,7 +2560,18 @@ var HistoryStore = class {
|
|
|
2077
2560
|
}
|
|
2078
2561
|
};
|
|
2079
2562
|
|
|
2563
|
+
// src/tui/history.ts
|
|
2564
|
+
import { promises as fs6 } from "fs";
|
|
2565
|
+
import * as path4 from "path";
|
|
2566
|
+
async function saveHistory(file, history) {
|
|
2567
|
+
await fs6.mkdir(path4.dirname(file), { recursive: true });
|
|
2568
|
+
const lines = history.map((entry) => JSON.stringify(entry));
|
|
2569
|
+
await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2080
2572
|
// src/core/session-manager.ts
|
|
2573
|
+
var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
2574
|
+
var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
|
|
2081
2575
|
var SessionManager = class {
|
|
2082
2576
|
constructor(registry, spawner, store, options = {}) {
|
|
2083
2577
|
this.registry = registry;
|
|
@@ -2085,6 +2579,7 @@ var SessionManager = class {
|
|
|
2085
2579
|
this.store = store ?? new SessionStore();
|
|
2086
2580
|
this.histories = new HistoryStore();
|
|
2087
2581
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
2582
|
+
this.defaultModels = options.defaultModels ?? {};
|
|
2088
2583
|
}
|
|
2089
2584
|
registry;
|
|
2090
2585
|
sessions = /* @__PURE__ */ new Map();
|
|
@@ -2093,6 +2588,7 @@ var SessionManager = class {
|
|
|
2093
2588
|
store;
|
|
2094
2589
|
histories;
|
|
2095
2590
|
idleTimeoutMs;
|
|
2591
|
+
defaultModels;
|
|
2096
2592
|
// Serialize meta.json read-modify-write operations per session id so
|
|
2097
2593
|
// concurrent snapshot updates (e.g. an agent emitting model + mode
|
|
2098
2594
|
// back-to-back) don't lose writes via interleaved reads.
|
|
@@ -2114,7 +2610,8 @@ var SessionManager = class {
|
|
|
2114
2610
|
agentArgs: params.agentArgs,
|
|
2115
2611
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
2116
2612
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2117
|
-
historyStore: this.histories
|
|
2613
|
+
historyStore: this.histories,
|
|
2614
|
+
currentModel: fresh.initialModel
|
|
2118
2615
|
});
|
|
2119
2616
|
await this.attachManagerHooks(session);
|
|
2120
2617
|
return session;
|
|
@@ -2156,7 +2653,10 @@ var SessionManager = class {
|
|
|
2156
2653
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
2157
2654
|
throw err;
|
|
2158
2655
|
}
|
|
2159
|
-
|
|
2656
|
+
if (params.upstreamSessionId === "") {
|
|
2657
|
+
return this.doResurrectFromImport(params);
|
|
2658
|
+
}
|
|
2659
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
2160
2660
|
const agent = this.spawner({
|
|
2161
2661
|
agentId: params.agentId,
|
|
2162
2662
|
cwd: params.cwd,
|
|
@@ -2169,11 +2669,14 @@ var SessionManager = class {
|
|
|
2169
2669
|
});
|
|
2170
2670
|
let loadResult;
|
|
2171
2671
|
try {
|
|
2172
|
-
loadResult = await agent.connection.request(
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2672
|
+
loadResult = await agent.connection.request(
|
|
2673
|
+
"session/load",
|
|
2674
|
+
{
|
|
2675
|
+
sessionId: params.upstreamSessionId,
|
|
2676
|
+
cwd: params.cwd,
|
|
2677
|
+
mcpServers: []
|
|
2678
|
+
}
|
|
2679
|
+
);
|
|
2177
2680
|
} catch (err) {
|
|
2178
2681
|
await agent.kill().catch(() => void 0);
|
|
2179
2682
|
throw new Error(
|
|
@@ -2192,17 +2695,65 @@ var SessionManager = class {
|
|
|
2192
2695
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
2193
2696
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2194
2697
|
historyStore: this.histories,
|
|
2195
|
-
|
|
2196
|
-
|
|
2698
|
+
// Prefer what we previously stored from a current_model_update; if
|
|
2699
|
+
// we never captured one (e.g. old opencode sessions on disk before
|
|
2700
|
+
// this fix), fall back to the model the agent ships in its
|
|
2701
|
+
// session/load response body.
|
|
2702
|
+
currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
|
|
2197
2703
|
currentMode: params.currentMode,
|
|
2704
|
+
currentUsage: params.currentUsage,
|
|
2198
2705
|
agentCommands: params.agentCommands,
|
|
2199
|
-
|
|
2706
|
+
// Only gate the first-prompt title heuristic when we actually have
|
|
2707
|
+
// a title to preserve. A title-less session (lost to a write race
|
|
2708
|
+
// or never seeded) should re-derive from the next prompt rather
|
|
2709
|
+
// than stay stuck.
|
|
2710
|
+
firstPromptSeeded: !!params.title,
|
|
2711
|
+
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
2200
2712
|
});
|
|
2201
2713
|
await this.attachManagerHooks(session);
|
|
2202
2714
|
return session;
|
|
2203
2715
|
}
|
|
2716
|
+
// First-attach path for a session that was created via import(). The
|
|
2717
|
+
// on-disk meta.json carries upstreamSessionId="" as the import
|
|
2718
|
+
// marker; bootstrap a fresh agent (gets a real upstream id) and kick
|
|
2719
|
+
// off seedFromImport so the agent absorbs the historical transcript.
|
|
2720
|
+
// attachManagerHooks rewrites meta.json with the new upstreamSessionId,
|
|
2721
|
+
// so subsequent resurrects of this session use the normal session/load
|
|
2722
|
+
// path.
|
|
2723
|
+
async doResurrectFromImport(params) {
|
|
2724
|
+
const fresh = await this.bootstrapAgent({
|
|
2725
|
+
agentId: params.agentId,
|
|
2726
|
+
cwd: params.cwd,
|
|
2727
|
+
agentArgs: params.agentArgs,
|
|
2728
|
+
mcpServers: []
|
|
2729
|
+
});
|
|
2730
|
+
const session = new Session({
|
|
2731
|
+
sessionId: params.hydraSessionId,
|
|
2732
|
+
cwd: params.cwd,
|
|
2733
|
+
agentId: params.agentId,
|
|
2734
|
+
agent: fresh.agent,
|
|
2735
|
+
upstreamSessionId: fresh.upstreamSessionId,
|
|
2736
|
+
agentMeta: fresh.agentMeta,
|
|
2737
|
+
title: params.title,
|
|
2738
|
+
agentArgs: params.agentArgs,
|
|
2739
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
2740
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2741
|
+
historyStore: this.histories,
|
|
2742
|
+
// Prefer the stored value (set by a previous current_model_update);
|
|
2743
|
+
// fall back to whatever the agent ships in its session/new response.
|
|
2744
|
+
currentModel: params.currentModel ?? fresh.initialModel,
|
|
2745
|
+
currentMode: params.currentMode,
|
|
2746
|
+
currentUsage: params.currentUsage,
|
|
2747
|
+
agentCommands: params.agentCommands,
|
|
2748
|
+
firstPromptSeeded: !!params.title,
|
|
2749
|
+
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
2750
|
+
});
|
|
2751
|
+
await this.attachManagerHooks(session);
|
|
2752
|
+
void session.seedFromImport().catch(() => void 0);
|
|
2753
|
+
return session;
|
|
2754
|
+
}
|
|
2204
2755
|
// Bootstrap a fresh agent process: registry resolve → spawn → initialize
|
|
2205
|
-
// → session/new. Shared by create() and the /hydra
|
|
2756
|
+
// → session/new. Shared by create() and the /hydra agent path so both
|
|
2206
2757
|
// go through the same env / capabilities / error-handling.
|
|
2207
2758
|
async bootstrapAgent(params) {
|
|
2208
2759
|
const agentDef = await this.registry.getAgent(params.agentId);
|
|
@@ -2213,7 +2764,7 @@ var SessionManager = class {
|
|
|
2213
2764
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
2214
2765
|
throw err;
|
|
2215
2766
|
}
|
|
2216
|
-
const plan = planSpawn(agentDef, params.agentArgs ?? []);
|
|
2767
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
2217
2768
|
const agent = this.spawner({
|
|
2218
2769
|
agentId: params.agentId,
|
|
2219
2770
|
cwd: params.cwd,
|
|
@@ -2225,14 +2776,36 @@ var SessionManager = class {
|
|
|
2225
2776
|
clientCapabilities: {},
|
|
2226
2777
|
clientInfo: { name: "hydra", version: "0.1.0" }
|
|
2227
2778
|
});
|
|
2228
|
-
const newResult = await agent.connection.request(
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2779
|
+
const newResult = await agent.connection.request(
|
|
2780
|
+
"session/new",
|
|
2781
|
+
{
|
|
2782
|
+
cwd: params.cwd,
|
|
2783
|
+
mcpServers: params.mcpServers ?? []
|
|
2784
|
+
}
|
|
2785
|
+
);
|
|
2786
|
+
const sessionIdRaw = newResult.sessionId;
|
|
2787
|
+
if (typeof sessionIdRaw !== "string") {
|
|
2788
|
+
throw new Error(
|
|
2789
|
+
`agent ${params.agentId} returned a non-string sessionId from session/new`
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
2792
|
+
let initialModel = extractInitialModel(newResult);
|
|
2793
|
+
const desired = this.defaultModels[params.agentId];
|
|
2794
|
+
if (desired && desired !== initialModel) {
|
|
2795
|
+
try {
|
|
2796
|
+
await agent.connection.request("session/set_model", {
|
|
2797
|
+
sessionId: sessionIdRaw,
|
|
2798
|
+
modelId: desired
|
|
2799
|
+
});
|
|
2800
|
+
initialModel = desired;
|
|
2801
|
+
} catch {
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2232
2804
|
return {
|
|
2233
2805
|
agent,
|
|
2234
|
-
upstreamSessionId:
|
|
2235
|
-
agentMeta: newResult._meta
|
|
2806
|
+
upstreamSessionId: sessionIdRaw,
|
|
2807
|
+
agentMeta: newResult._meta,
|
|
2808
|
+
initialModel
|
|
2236
2809
|
};
|
|
2237
2810
|
} catch (err) {
|
|
2238
2811
|
await agent.kill().catch(() => void 0);
|
|
@@ -2243,7 +2816,7 @@ var SessionManager = class {
|
|
|
2243
2816
|
// bookkeeping. Called from both create() and resurrect() so the same
|
|
2244
2817
|
// session record + lifecycle handlers are wired regardless of origin.
|
|
2245
2818
|
// Returns once the initial disk record is written — callers should
|
|
2246
|
-
// await so a subsequent /hydra
|
|
2819
|
+
// await so a subsequent /hydra agent's persistAgentChange (which
|
|
2247
2820
|
// does read-then-write) finds the file in place.
|
|
2248
2821
|
async attachManagerHooks(session) {
|
|
2249
2822
|
session.onClose(({ deleteRecord }) => {
|
|
@@ -2271,6 +2844,11 @@ var SessionManager = class {
|
|
|
2271
2844
|
() => void 0
|
|
2272
2845
|
);
|
|
2273
2846
|
});
|
|
2847
|
+
session.onUsageChange((usage) => {
|
|
2848
|
+
void this.persistSnapshot(session.sessionId, {
|
|
2849
|
+
currentUsage: usageSnapshotToPersisted(usage)
|
|
2850
|
+
}).catch(() => void 0);
|
|
2851
|
+
});
|
|
2274
2852
|
session.onAgentCommandsChange((commands) => {
|
|
2275
2853
|
void this.persistSnapshot(session.sessionId, {
|
|
2276
2854
|
agentCommands: commands.map((c) => ({
|
|
@@ -2280,28 +2858,20 @@ var SessionManager = class {
|
|
|
2280
2858
|
}).catch(() => void 0);
|
|
2281
2859
|
});
|
|
2282
2860
|
this.sessions.set(session.sessionId, session);
|
|
2283
|
-
await this.
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
cwd: session.cwd,
|
|
2289
|
-
title: session.title,
|
|
2290
|
-
agentArgs: session.agentArgs,
|
|
2291
|
-
currentModel: session.currentModel,
|
|
2292
|
-
currentMode: session.currentMode
|
|
2293
|
-
})
|
|
2294
|
-
).catch(() => void 0);
|
|
2861
|
+
await this.enqueueMetaWrite(session.sessionId, async () => {
|
|
2862
|
+
const existing = await this.store.read(session.sessionId);
|
|
2863
|
+
const merged = mergeForPersistence(session, existing);
|
|
2864
|
+
await this.store.write(merged);
|
|
2865
|
+
}).catch(() => void 0);
|
|
2295
2866
|
}
|
|
2296
2867
|
// Resolve a session's recorded history without forcing a resurrect.
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2868
|
+
// Always loads from disk — that's the source of truth whether the
|
|
2869
|
+
// session is hot or cold. Returns undefined if the session id is
|
|
2870
|
+
// unknown to both the live map and disk store, so the caller can
|
|
2871
|
+
// distinguish "no history yet" (empty array) from "404".
|
|
2301
2872
|
async getHistory(sessionId) {
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
return live.getHistorySnapshot();
|
|
2873
|
+
if (this.sessions.has(sessionId)) {
|
|
2874
|
+
return this.histories.load(sessionId).catch(() => []);
|
|
2305
2875
|
}
|
|
2306
2876
|
const record = await this.store.read(sessionId);
|
|
2307
2877
|
if (!record) {
|
|
@@ -2314,20 +2884,42 @@ var SessionManager = class {
|
|
|
2314
2884
|
if (!record) {
|
|
2315
2885
|
return void 0;
|
|
2316
2886
|
}
|
|
2317
|
-
|
|
2887
|
+
let title = record.title;
|
|
2888
|
+
if (!title) {
|
|
2889
|
+
title = await this.deriveTitleFromHistory(sessionId);
|
|
2890
|
+
}
|
|
2318
2891
|
return {
|
|
2319
2892
|
hydraSessionId: record.sessionId,
|
|
2320
2893
|
upstreamSessionId: record.upstreamSessionId,
|
|
2321
2894
|
agentId: record.agentId,
|
|
2322
2895
|
cwd: record.cwd,
|
|
2323
|
-
title
|
|
2896
|
+
title,
|
|
2324
2897
|
agentArgs: record.agentArgs,
|
|
2325
|
-
seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
|
|
2326
2898
|
currentModel: record.currentModel,
|
|
2327
2899
|
currentMode: record.currentMode,
|
|
2328
|
-
|
|
2900
|
+
currentUsage: persistedUsageToSnapshot(record.currentUsage),
|
|
2901
|
+
agentCommands: record.agentCommands,
|
|
2902
|
+
createdAt: record.createdAt
|
|
2329
2903
|
};
|
|
2330
2904
|
}
|
|
2905
|
+
// Best-effort: peek at the persisted history's first prompt and use
|
|
2906
|
+
// its first line (capped to 200 chars) as a session title. Returns
|
|
2907
|
+
// undefined if no usable prompt is found or any I/O fails.
|
|
2908
|
+
async deriveTitleFromHistory(sessionId) {
|
|
2909
|
+
const history = await this.histories.load(sessionId).catch(() => []);
|
|
2910
|
+
for (const entry of history) {
|
|
2911
|
+
const params = entry.params;
|
|
2912
|
+
if (params?.update?.sessionUpdate !== "prompt_received") {
|
|
2913
|
+
continue;
|
|
2914
|
+
}
|
|
2915
|
+
const text = extractPromptText(params.update.prompt);
|
|
2916
|
+
const line = firstLine(text, 200);
|
|
2917
|
+
if (line) {
|
|
2918
|
+
return line;
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
return void 0;
|
|
2922
|
+
}
|
|
2331
2923
|
get(sessionId) {
|
|
2332
2924
|
return this.sessions.get(sessionId);
|
|
2333
2925
|
}
|
|
@@ -2374,6 +2966,8 @@ var SessionManager = class {
|
|
|
2374
2966
|
cwd: session.cwd,
|
|
2375
2967
|
title: session.title,
|
|
2376
2968
|
agentId: session.agentId,
|
|
2969
|
+
currentModel: session.currentModel,
|
|
2970
|
+
currentUsage: session.currentUsage,
|
|
2377
2971
|
updatedAt: used,
|
|
2378
2972
|
attachedClients: session.attachedCount,
|
|
2379
2973
|
status: "live"
|
|
@@ -2394,6 +2988,8 @@ var SessionManager = class {
|
|
|
2394
2988
|
cwd: r.cwd,
|
|
2395
2989
|
title: r.title,
|
|
2396
2990
|
agentId: r.agentId,
|
|
2991
|
+
currentModel: r.currentModel,
|
|
2992
|
+
currentUsage: r.currentUsage,
|
|
2397
2993
|
updatedAt: used,
|
|
2398
2994
|
attachedClients: 0,
|
|
2399
2995
|
status: "cold"
|
|
@@ -2402,6 +2998,112 @@ var SessionManager = class {
|
|
|
2402
2998
|
entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
|
|
2403
2999
|
return entries;
|
|
2404
3000
|
}
|
|
3001
|
+
// Build an export bundle for a session, reading meta + history from
|
|
3002
|
+
// disk. Backfills lineageId if the on-disk record pre-dates that
|
|
3003
|
+
// field. Returns undefined if the session doesn't exist. Callers
|
|
3004
|
+
// populate the bundle's exportedFrom metadata themselves.
|
|
3005
|
+
async exportBundle(sessionId) {
|
|
3006
|
+
const record = await this.store.read(sessionId);
|
|
3007
|
+
if (!record) {
|
|
3008
|
+
return void 0;
|
|
3009
|
+
}
|
|
3010
|
+
let withLineage;
|
|
3011
|
+
if (record.lineageId) {
|
|
3012
|
+
withLineage = record;
|
|
3013
|
+
} else {
|
|
3014
|
+
const lineageId = generateLineageId();
|
|
3015
|
+
const backfilled = { ...record, lineageId };
|
|
3016
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
3017
|
+
const latest = await this.store.read(sessionId);
|
|
3018
|
+
if (!latest) {
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
if (latest.lineageId) {
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
await this.store.write({ ...latest, lineageId });
|
|
3025
|
+
}).catch(() => void 0);
|
|
3026
|
+
withLineage = backfilled;
|
|
3027
|
+
}
|
|
3028
|
+
const history = await this.histories.load(sessionId).catch(() => []);
|
|
3029
|
+
const promptHistory = await loadPromptHistorySafely(sessionId);
|
|
3030
|
+
return { record: withLineage, history, promptHistory };
|
|
3031
|
+
}
|
|
3032
|
+
// Create a local session from an imported bundle. Without `replace`,
|
|
3033
|
+
// a bundle with a lineageId we already have on disk throws
|
|
3034
|
+
// BundleAlreadyImported citing the existing local id. With
|
|
3035
|
+
// `replace: true`, the existing record is overwritten in-place (its
|
|
3036
|
+
// local sessionId is preserved so bookmarks/Slack thread links still
|
|
3037
|
+
// resolve), and any live in-memory session is closed so the next
|
|
3038
|
+
// attach triggers the import-reseed path.
|
|
3039
|
+
async importBundle(bundle, opts = {}) {
|
|
3040
|
+
const existing = await this.store.findByLineageId(bundle.session.lineageId);
|
|
3041
|
+
if (existing) {
|
|
3042
|
+
if (!opts.replace) {
|
|
3043
|
+
const err = new Error(
|
|
3044
|
+
`bundle already imported as ${existing.sessionId}`
|
|
3045
|
+
);
|
|
3046
|
+
err.code = JsonRpcErrorCodes.BundleAlreadyImported;
|
|
3047
|
+
err.existingSessionId = existing.sessionId;
|
|
3048
|
+
throw err;
|
|
3049
|
+
}
|
|
3050
|
+
const live = this.sessions.get(existing.sessionId);
|
|
3051
|
+
if (live) {
|
|
3052
|
+
await live.close({ deleteRecord: false }).catch(() => void 0);
|
|
3053
|
+
}
|
|
3054
|
+
await this.writeImportedRecord({
|
|
3055
|
+
sessionId: existing.sessionId,
|
|
3056
|
+
bundle,
|
|
3057
|
+
preservedCreatedAt: existing.createdAt
|
|
3058
|
+
});
|
|
3059
|
+
return {
|
|
3060
|
+
sessionId: existing.sessionId,
|
|
3061
|
+
importedFromSessionId: bundle.session.sessionId,
|
|
3062
|
+
replaced: true
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
3065
|
+
const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
|
|
3066
|
+
await this.writeImportedRecord({ sessionId: newId, bundle });
|
|
3067
|
+
return {
|
|
3068
|
+
sessionId: newId,
|
|
3069
|
+
importedFromSessionId: bundle.session.sessionId,
|
|
3070
|
+
replaced: false
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
// Write the imported bundle's history.jsonl, prompt-history (if
|
|
3074
|
+
// present), and meta.json. upstreamSessionId is left empty as the
|
|
3075
|
+
// marker that the first attach should bootstrap a fresh agent and
|
|
3076
|
+
// run seedFromImport rather than calling session/load.
|
|
3077
|
+
async writeImportedRecord(args) {
|
|
3078
|
+
await this.histories.rewrite(
|
|
3079
|
+
args.sessionId,
|
|
3080
|
+
args.bundle.history
|
|
3081
|
+
);
|
|
3082
|
+
if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
|
|
3083
|
+
await saveHistory(
|
|
3084
|
+
paths.tuiHistoryFile(args.sessionId),
|
|
3085
|
+
args.bundle.promptHistory
|
|
3086
|
+
).catch(() => void 0);
|
|
3087
|
+
}
|
|
3088
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3089
|
+
await this.enqueueMetaWrite(args.sessionId, async () => {
|
|
3090
|
+
await this.store.write({
|
|
3091
|
+
sessionId: args.sessionId,
|
|
3092
|
+
lineageId: args.bundle.session.lineageId,
|
|
3093
|
+
upstreamSessionId: "",
|
|
3094
|
+
importedFromSessionId: args.bundle.session.sessionId,
|
|
3095
|
+
agentId: args.bundle.session.agentId,
|
|
3096
|
+
cwd: args.bundle.session.cwd,
|
|
3097
|
+
title: args.bundle.session.title,
|
|
3098
|
+
currentModel: args.bundle.session.currentModel,
|
|
3099
|
+
currentMode: args.bundle.session.currentMode,
|
|
3100
|
+
currentUsage: args.bundle.session.currentUsage,
|
|
3101
|
+
agentCommands: args.bundle.session.agentCommands,
|
|
3102
|
+
createdAt: args.preservedCreatedAt ?? now,
|
|
3103
|
+
updatedAt: now
|
|
3104
|
+
});
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
2405
3107
|
async deleteRecord(sessionId) {
|
|
2406
3108
|
const record = await this.store.read(sessionId);
|
|
2407
3109
|
if (!record) {
|
|
@@ -2431,7 +3133,7 @@ var SessionManager = class {
|
|
|
2431
3133
|
});
|
|
2432
3134
|
});
|
|
2433
3135
|
}
|
|
2434
|
-
// Persist an agent swap from /hydra
|
|
3136
|
+
// Persist an agent swap from /hydra agent. The on-disk record's
|
|
2435
3137
|
// agentId + upstreamSessionId both rotate so a daemon restart (and
|
|
2436
3138
|
// later resurrect) brings the session back up on the agent the user
|
|
2437
3139
|
// most recently switched to, not the one it was originally created on.
|
|
@@ -2463,6 +3165,7 @@ var SessionManager = class {
|
|
|
2463
3165
|
...record,
|
|
2464
3166
|
...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
|
|
2465
3167
|
...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
|
|
3168
|
+
...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
|
|
2466
3169
|
...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
|
|
2467
3170
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2468
3171
|
});
|
|
@@ -2487,10 +3190,124 @@ var SessionManager = class {
|
|
|
2487
3190
|
await Promise.allSettled(sessions.map((s) => s.close()));
|
|
2488
3191
|
this.sessions.clear();
|
|
2489
3192
|
}
|
|
3193
|
+
// Wait for every pending meta.json write to settle. Daemon shutdown
|
|
3194
|
+
// hooks call this so a SIGTERM doesn't kill the process mid-write
|
|
3195
|
+
// and lose a freshly-set title (or model/mode/commands).
|
|
3196
|
+
async flushMetaWrites() {
|
|
3197
|
+
const pending = [...this.metaWriteQueues.values()];
|
|
3198
|
+
if (pending.length === 0) {
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
await Promise.allSettled(pending);
|
|
3202
|
+
}
|
|
2490
3203
|
};
|
|
3204
|
+
function mergeForPersistence(session, existing) {
|
|
3205
|
+
const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
|
|
3206
|
+
if (c.description !== void 0) {
|
|
3207
|
+
return { name: c.name, description: c.description };
|
|
3208
|
+
}
|
|
3209
|
+
return { name: c.name };
|
|
3210
|
+
}) : void 0;
|
|
3211
|
+
const agentCommands = persistedCommands ?? existing?.agentCommands;
|
|
3212
|
+
return recordFromMemorySession({
|
|
3213
|
+
sessionId: session.sessionId,
|
|
3214
|
+
lineageId: existing?.lineageId ?? generateLineageId(),
|
|
3215
|
+
upstreamSessionId: session.upstreamSessionId,
|
|
3216
|
+
importedFromSessionId: existing?.importedFromSessionId,
|
|
3217
|
+
agentId: session.agentId,
|
|
3218
|
+
cwd: session.cwd,
|
|
3219
|
+
title: session.title,
|
|
3220
|
+
agentArgs: session.agentArgs,
|
|
3221
|
+
currentModel: session.currentModel ?? existing?.currentModel,
|
|
3222
|
+
currentMode: session.currentMode ?? existing?.currentMode,
|
|
3223
|
+
currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
|
|
3224
|
+
agentCommands,
|
|
3225
|
+
createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3228
|
+
function usageSnapshotToPersisted(usage) {
|
|
3229
|
+
if (!usage) {
|
|
3230
|
+
return void 0;
|
|
3231
|
+
}
|
|
3232
|
+
const out = {};
|
|
3233
|
+
if (usage.used !== void 0) {
|
|
3234
|
+
out.used = usage.used;
|
|
3235
|
+
}
|
|
3236
|
+
if (usage.size !== void 0) {
|
|
3237
|
+
out.size = usage.size;
|
|
3238
|
+
}
|
|
3239
|
+
if (usage.costAmount !== void 0) {
|
|
3240
|
+
out.costAmount = usage.costAmount;
|
|
3241
|
+
}
|
|
3242
|
+
if (usage.costCurrency !== void 0) {
|
|
3243
|
+
out.costCurrency = usage.costCurrency;
|
|
3244
|
+
}
|
|
3245
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
3246
|
+
}
|
|
3247
|
+
function persistedUsageToSnapshot(usage) {
|
|
3248
|
+
return usage ? { ...usage } : void 0;
|
|
3249
|
+
}
|
|
3250
|
+
function extractInitialModel(result) {
|
|
3251
|
+
const direct = asString(result.currentModelId) ?? asString(result.currentModel) ?? asString(result.modelId) ?? asString(result.model);
|
|
3252
|
+
if (direct) {
|
|
3253
|
+
return direct;
|
|
3254
|
+
}
|
|
3255
|
+
const models = result.models;
|
|
3256
|
+
if (models && typeof models === "object" && !Array.isArray(models)) {
|
|
3257
|
+
const m = asString(models.currentModelId) ?? asString(models.currentModel);
|
|
3258
|
+
if (m) {
|
|
3259
|
+
return m;
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
const meta = result._meta;
|
|
3263
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
3264
|
+
for (const [key, value] of Object.entries(
|
|
3265
|
+
meta
|
|
3266
|
+
)) {
|
|
3267
|
+
if (key === "hydra-acp") {
|
|
3268
|
+
continue;
|
|
3269
|
+
}
|
|
3270
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
3271
|
+
const m = asString(value.modelId) ?? asString(value.model) ?? asString(value.currentModelId);
|
|
3272
|
+
if (m) {
|
|
3273
|
+
return m;
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
return void 0;
|
|
3279
|
+
}
|
|
3280
|
+
function asString(value) {
|
|
3281
|
+
if (typeof value !== "string") {
|
|
3282
|
+
return void 0;
|
|
3283
|
+
}
|
|
3284
|
+
const trimmed = value.trim();
|
|
3285
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
3286
|
+
}
|
|
3287
|
+
async function loadPromptHistorySafely(sessionId) {
|
|
3288
|
+
try {
|
|
3289
|
+
const raw = await fs7.readFile(paths.tuiHistoryFile(sessionId), "utf8");
|
|
3290
|
+
const out = [];
|
|
3291
|
+
for (const line of raw.split("\n")) {
|
|
3292
|
+
if (line.length === 0) {
|
|
3293
|
+
continue;
|
|
3294
|
+
}
|
|
3295
|
+
try {
|
|
3296
|
+
const decoded = JSON.parse(line);
|
|
3297
|
+
if (typeof decoded === "string") {
|
|
3298
|
+
out.push(decoded);
|
|
3299
|
+
}
|
|
3300
|
+
} catch {
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return out;
|
|
3304
|
+
} catch {
|
|
3305
|
+
return [];
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
2491
3308
|
async function historyMtimeIso(sessionId) {
|
|
2492
3309
|
try {
|
|
2493
|
-
const st = await
|
|
3310
|
+
const st = await fs7.stat(paths.historyFile(sessionId));
|
|
2494
3311
|
return new Date(st.mtimeMs).toISOString();
|
|
2495
3312
|
} catch {
|
|
2496
3313
|
return void 0;
|
|
@@ -2498,10 +3315,10 @@ async function historyMtimeIso(sessionId) {
|
|
|
2498
3315
|
}
|
|
2499
3316
|
|
|
2500
3317
|
// src/core/extensions.ts
|
|
2501
|
-
import { spawn as
|
|
2502
|
-
import * as
|
|
2503
|
-
import * as
|
|
2504
|
-
import * as
|
|
3318
|
+
import { spawn as spawn3 } from "child_process";
|
|
3319
|
+
import * as fs8 from "fs";
|
|
3320
|
+
import * as fsp2 from "fs/promises";
|
|
3321
|
+
import * as path5 from "path";
|
|
2505
3322
|
var RESTART_BASE_MS = 1e3;
|
|
2506
3323
|
var RESTART_CAP_MS = 6e4;
|
|
2507
3324
|
var STOP_GRACE_MS = 3e3;
|
|
@@ -2522,7 +3339,7 @@ var ExtensionManager = class {
|
|
|
2522
3339
|
if (!this.context) {
|
|
2523
3340
|
throw new Error("ExtensionManager: setContext must be called before start");
|
|
2524
3341
|
}
|
|
2525
|
-
await
|
|
3342
|
+
await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
|
|
2526
3343
|
await this.reapOrphans();
|
|
2527
3344
|
for (const entry of this.entries.values()) {
|
|
2528
3345
|
if (!entry.config.enabled) {
|
|
@@ -2548,9 +3365,9 @@ var ExtensionManager = class {
|
|
|
2548
3365
|
} catch {
|
|
2549
3366
|
}
|
|
2550
3367
|
tasks.push(
|
|
2551
|
-
new Promise((
|
|
3368
|
+
new Promise((resolve3) => {
|
|
2552
3369
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
2553
|
-
|
|
3370
|
+
resolve3();
|
|
2554
3371
|
return;
|
|
2555
3372
|
}
|
|
2556
3373
|
const timer = setTimeout(() => {
|
|
@@ -2558,11 +3375,11 @@ var ExtensionManager = class {
|
|
|
2558
3375
|
child.kill("SIGKILL");
|
|
2559
3376
|
} catch {
|
|
2560
3377
|
}
|
|
2561
|
-
|
|
3378
|
+
resolve3();
|
|
2562
3379
|
}, STOP_GRACE_MS);
|
|
2563
3380
|
child.on("exit", () => {
|
|
2564
3381
|
clearTimeout(timer);
|
|
2565
|
-
|
|
3382
|
+
resolve3();
|
|
2566
3383
|
});
|
|
2567
3384
|
})
|
|
2568
3385
|
);
|
|
@@ -2670,8 +3487,8 @@ var ExtensionManager = class {
|
|
|
2670
3487
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
2671
3488
|
return;
|
|
2672
3489
|
}
|
|
2673
|
-
const exited = new Promise((
|
|
2674
|
-
entry.exitWaiters.push(
|
|
3490
|
+
const exited = new Promise((resolve3) => {
|
|
3491
|
+
entry.exitWaiters.push(resolve3);
|
|
2675
3492
|
});
|
|
2676
3493
|
try {
|
|
2677
3494
|
child.kill("SIGTERM");
|
|
@@ -2731,7 +3548,7 @@ var ExtensionManager = class {
|
|
|
2731
3548
|
async reapOrphans() {
|
|
2732
3549
|
let entries;
|
|
2733
3550
|
try {
|
|
2734
|
-
entries = await
|
|
3551
|
+
entries = await fsp2.readdir(paths.extensionsDir());
|
|
2735
3552
|
} catch (err) {
|
|
2736
3553
|
const e = err;
|
|
2737
3554
|
if (e.code === "ENOENT") {
|
|
@@ -2743,10 +3560,10 @@ var ExtensionManager = class {
|
|
|
2743
3560
|
if (!entry.endsWith(".pid")) {
|
|
2744
3561
|
continue;
|
|
2745
3562
|
}
|
|
2746
|
-
const pidPath =
|
|
3563
|
+
const pidPath = path5.join(paths.extensionsDir(), entry);
|
|
2747
3564
|
let pid;
|
|
2748
3565
|
try {
|
|
2749
|
-
const raw = await
|
|
3566
|
+
const raw = await fsp2.readFile(pidPath, "utf8");
|
|
2750
3567
|
const parsed = Number.parseInt(raw.trim(), 10);
|
|
2751
3568
|
if (Number.isInteger(parsed) && parsed > 0) {
|
|
2752
3569
|
pid = parsed;
|
|
@@ -2769,7 +3586,7 @@ var ExtensionManager = class {
|
|
|
2769
3586
|
}
|
|
2770
3587
|
}
|
|
2771
3588
|
}
|
|
2772
|
-
await
|
|
3589
|
+
await fsp2.unlink(pidPath).catch(() => void 0);
|
|
2773
3590
|
}
|
|
2774
3591
|
}
|
|
2775
3592
|
spawn(entry, attempt) {
|
|
@@ -2782,7 +3599,7 @@ var ExtensionManager = class {
|
|
|
2782
3599
|
}
|
|
2783
3600
|
const ext = entry.config;
|
|
2784
3601
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
2785
|
-
const logStream =
|
|
3602
|
+
const logStream = fs8.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
2786
3603
|
flags: "a"
|
|
2787
3604
|
});
|
|
2788
3605
|
logStream.write(
|
|
@@ -2810,7 +3627,7 @@ var ExtensionManager = class {
|
|
|
2810
3627
|
const args = [...baseArgs, ...ext.args];
|
|
2811
3628
|
let child;
|
|
2812
3629
|
try {
|
|
2813
|
-
child =
|
|
3630
|
+
child = spawn3(cmd, args, {
|
|
2814
3631
|
env,
|
|
2815
3632
|
stdio: ["ignore", "pipe", "pipe"],
|
|
2816
3633
|
detached: false
|
|
@@ -2832,7 +3649,7 @@ var ExtensionManager = class {
|
|
|
2832
3649
|
}
|
|
2833
3650
|
if (typeof child.pid === "number") {
|
|
2834
3651
|
try {
|
|
2835
|
-
|
|
3652
|
+
fs8.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
2836
3653
|
`, {
|
|
2837
3654
|
encoding: "utf8",
|
|
2838
3655
|
mode: 384
|
|
@@ -2857,7 +3674,7 @@ var ExtensionManager = class {
|
|
|
2857
3674
|
});
|
|
2858
3675
|
child.on("exit", (code, signal) => {
|
|
2859
3676
|
try {
|
|
2860
|
-
|
|
3677
|
+
fs8.unlinkSync(paths.extensionPidFile(ext.name));
|
|
2861
3678
|
} catch {
|
|
2862
3679
|
}
|
|
2863
3680
|
logStream.write(
|
|
@@ -2868,8 +3685,8 @@ var ExtensionManager = class {
|
|
|
2868
3685
|
entry.pid = void 0;
|
|
2869
3686
|
entry.lastExitCode = typeof code === "number" ? code : void 0;
|
|
2870
3687
|
const waiters = entry.exitWaiters.splice(0);
|
|
2871
|
-
for (const
|
|
2872
|
-
|
|
3688
|
+
for (const resolve3 of waiters) {
|
|
3689
|
+
resolve3();
|
|
2873
3690
|
}
|
|
2874
3691
|
if (this.stopping || entry.manuallyStopped) {
|
|
2875
3692
|
try {
|
|
@@ -2966,6 +3783,77 @@ function constantTimeEqual(a, b) {
|
|
|
2966
3783
|
}
|
|
2967
3784
|
|
|
2968
3785
|
// src/daemon/routes/sessions.ts
|
|
3786
|
+
import * as os2 from "os";
|
|
3787
|
+
|
|
3788
|
+
// src/core/bundle.ts
|
|
3789
|
+
import { z as z5 } from "zod";
|
|
3790
|
+
var HistoryEntrySchema = z5.object({
|
|
3791
|
+
method: z5.string(),
|
|
3792
|
+
params: z5.unknown(),
|
|
3793
|
+
recordedAt: z5.number()
|
|
3794
|
+
});
|
|
3795
|
+
var BundleSession = z5.object({
|
|
3796
|
+
// The exporter's local id. Regenerated fresh on import (sessionId is
|
|
3797
|
+
// the local namespace; lineageId is what survives across hops).
|
|
3798
|
+
sessionId: z5.string(),
|
|
3799
|
+
// Required on bundles — the export path backfills if the source
|
|
3800
|
+
// record was written before lineageId existed.
|
|
3801
|
+
lineageId: z5.string(),
|
|
3802
|
+
agentId: z5.string(),
|
|
3803
|
+
cwd: z5.string(),
|
|
3804
|
+
title: z5.string().optional(),
|
|
3805
|
+
currentModel: z5.string().optional(),
|
|
3806
|
+
currentMode: z5.string().optional(),
|
|
3807
|
+
currentUsage: PersistedUsage.optional(),
|
|
3808
|
+
agentCommands: z5.array(PersistedAgentCommand).optional(),
|
|
3809
|
+
createdAt: z5.string(),
|
|
3810
|
+
updatedAt: z5.string()
|
|
3811
|
+
});
|
|
3812
|
+
var Bundle = z5.object({
|
|
3813
|
+
version: z5.literal(1),
|
|
3814
|
+
exportedAt: z5.string(),
|
|
3815
|
+
exportedFrom: z5.object({
|
|
3816
|
+
hydraVersion: z5.string(),
|
|
3817
|
+
machine: z5.string()
|
|
3818
|
+
}),
|
|
3819
|
+
session: BundleSession,
|
|
3820
|
+
history: z5.array(HistoryEntrySchema),
|
|
3821
|
+
promptHistory: z5.array(z5.string()).optional()
|
|
3822
|
+
});
|
|
3823
|
+
function encodeBundle(params) {
|
|
3824
|
+
const bundle = {
|
|
3825
|
+
version: 1,
|
|
3826
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3827
|
+
exportedFrom: {
|
|
3828
|
+
hydraVersion: params.hydraVersion,
|
|
3829
|
+
machine: params.machine
|
|
3830
|
+
},
|
|
3831
|
+
session: {
|
|
3832
|
+
sessionId: params.record.sessionId,
|
|
3833
|
+
lineageId: params.record.lineageId,
|
|
3834
|
+
agentId: params.record.agentId,
|
|
3835
|
+
cwd: params.record.cwd,
|
|
3836
|
+
...params.record.title !== void 0 ? { title: params.record.title } : {},
|
|
3837
|
+
...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
|
|
3838
|
+
...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
|
|
3839
|
+
...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
|
|
3840
|
+
...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
|
|
3841
|
+
createdAt: params.record.createdAt,
|
|
3842
|
+
updatedAt: params.record.updatedAt
|
|
3843
|
+
},
|
|
3844
|
+
history: params.history
|
|
3845
|
+
};
|
|
3846
|
+
if (params.promptHistory !== void 0) {
|
|
3847
|
+
bundle.promptHistory = params.promptHistory;
|
|
3848
|
+
}
|
|
3849
|
+
return bundle;
|
|
3850
|
+
}
|
|
3851
|
+
function decodeBundle(raw) {
|
|
3852
|
+
return Bundle.parse(raw);
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
// src/daemon/routes/sessions.ts
|
|
3856
|
+
var HYDRA_VERSION = "0.1.0";
|
|
2969
3857
|
function registerSessionRoutes(app, manager, defaults) {
|
|
2970
3858
|
app.get("/v1/sessions", async (request) => {
|
|
2971
3859
|
const query = request.query;
|
|
@@ -3023,6 +3911,61 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
3023
3911
|
}
|
|
3024
3912
|
reply.code(204).send();
|
|
3025
3913
|
});
|
|
3914
|
+
app.get("/v1/sessions/:id/export", async (request, reply) => {
|
|
3915
|
+
const raw = request.params.id;
|
|
3916
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
3917
|
+
const exported = await manager.exportBundle(id);
|
|
3918
|
+
if (!exported) {
|
|
3919
|
+
reply.code(404).send({ error: "session not found" });
|
|
3920
|
+
return;
|
|
3921
|
+
}
|
|
3922
|
+
const bundle = encodeBundle({
|
|
3923
|
+
record: exported.record,
|
|
3924
|
+
history: exported.history,
|
|
3925
|
+
promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
|
|
3926
|
+
hydraVersion: HYDRA_VERSION,
|
|
3927
|
+
machine: os2.hostname()
|
|
3928
|
+
});
|
|
3929
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3930
|
+
reply.header(
|
|
3931
|
+
"Content-Disposition",
|
|
3932
|
+
`attachment; filename="hydra-${id}-${stamp}.hydra"`
|
|
3933
|
+
);
|
|
3934
|
+
reply.code(200).send(bundle);
|
|
3935
|
+
});
|
|
3936
|
+
app.post("/v1/sessions/import", async (request, reply) => {
|
|
3937
|
+
const body = request.body ?? {};
|
|
3938
|
+
if (body.bundle === void 0) {
|
|
3939
|
+
reply.code(400).send({ error: "missing bundle" });
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3942
|
+
let bundle;
|
|
3943
|
+
try {
|
|
3944
|
+
bundle = decodeBundle(body.bundle);
|
|
3945
|
+
} catch (err) {
|
|
3946
|
+
reply.code(400).send({
|
|
3947
|
+
error: "invalid bundle",
|
|
3948
|
+
details: err.message
|
|
3949
|
+
});
|
|
3950
|
+
return;
|
|
3951
|
+
}
|
|
3952
|
+
try {
|
|
3953
|
+
const result = await manager.importBundle(bundle, {
|
|
3954
|
+
replace: body.replace === true
|
|
3955
|
+
});
|
|
3956
|
+
reply.code(201).send(result);
|
|
3957
|
+
} catch (err) {
|
|
3958
|
+
const e = err;
|
|
3959
|
+
if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
|
|
3960
|
+
reply.code(409).send({
|
|
3961
|
+
error: "bundle already imported",
|
|
3962
|
+
existingSessionId: e.existingSessionId
|
|
3963
|
+
});
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
reply.code(500).send({ error: e.message });
|
|
3967
|
+
}
|
|
3968
|
+
});
|
|
3026
3969
|
app.get("/v1/sessions/:id/history", async (request, reply) => {
|
|
3027
3970
|
const raw = request.params.id;
|
|
3028
3971
|
const query = request.query;
|
|
@@ -3031,16 +3974,22 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
3031
3974
|
const live = manager.get(id);
|
|
3032
3975
|
let snapshot;
|
|
3033
3976
|
let unsubscribe;
|
|
3977
|
+
let snapshotDone = false;
|
|
3978
|
+
const pending = [];
|
|
3034
3979
|
if (live) {
|
|
3035
|
-
snapshot = live.getHistorySnapshot();
|
|
3036
3980
|
if (follow) {
|
|
3037
3981
|
unsubscribe = live.onBroadcast((entry) => {
|
|
3038
3982
|
if (reply.raw.writableEnded) {
|
|
3039
3983
|
return;
|
|
3040
3984
|
}
|
|
3041
|
-
|
|
3985
|
+
if (snapshotDone) {
|
|
3986
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
3987
|
+
} else {
|
|
3988
|
+
pending.push(entry);
|
|
3989
|
+
}
|
|
3042
3990
|
});
|
|
3043
3991
|
}
|
|
3992
|
+
snapshot = await live.getHistorySnapshot();
|
|
3044
3993
|
} else {
|
|
3045
3994
|
const cold = await manager.getHistory(id);
|
|
3046
3995
|
if (cold === void 0) {
|
|
@@ -3052,9 +4001,23 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
3052
4001
|
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
|
3053
4002
|
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
3054
4003
|
reply.raw.statusCode = 200;
|
|
4004
|
+
const snapshotKeys = /* @__PURE__ */ new Set();
|
|
3055
4005
|
for (const entry of snapshot ?? []) {
|
|
3056
4006
|
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
4007
|
+
const e = entry;
|
|
4008
|
+
if (typeof e.recordedAt === "number") {
|
|
4009
|
+
snapshotKeys.add(String(e.recordedAt));
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
for (const entry of pending) {
|
|
4013
|
+
const e = entry;
|
|
4014
|
+
const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
|
|
4015
|
+
if (key && snapshotKeys.has(key)) {
|
|
4016
|
+
continue;
|
|
4017
|
+
}
|
|
4018
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
3057
4019
|
}
|
|
4020
|
+
snapshotDone = true;
|
|
3058
4021
|
if (!unsubscribe) {
|
|
3059
4022
|
reply.raw.end();
|
|
3060
4023
|
return reply;
|
|
@@ -3272,13 +4235,13 @@ function wsToMessageStream(ws) {
|
|
|
3272
4235
|
throw new Error("ws is closed");
|
|
3273
4236
|
}
|
|
3274
4237
|
const text = JSON.stringify(message);
|
|
3275
|
-
await new Promise((
|
|
4238
|
+
await new Promise((resolve3, reject) => {
|
|
3276
4239
|
ws.send(text, (err) => {
|
|
3277
4240
|
if (err) {
|
|
3278
4241
|
reject(err);
|
|
3279
4242
|
return;
|
|
3280
4243
|
}
|
|
3281
|
-
|
|
4244
|
+
resolve3();
|
|
3282
4245
|
});
|
|
3283
4246
|
});
|
|
3284
4247
|
},
|
|
@@ -3299,7 +4262,7 @@ function wsToMessageStream(ws) {
|
|
|
3299
4262
|
}
|
|
3300
4263
|
|
|
3301
4264
|
// src/daemon/acp-ws.ts
|
|
3302
|
-
var
|
|
4265
|
+
var HYDRA_VERSION2 = "0.1.0";
|
|
3303
4266
|
var HYDRA_PROTOCOL_VERSION = 1;
|
|
3304
4267
|
function registerAcpWsEndpoint(app, deps) {
|
|
3305
4268
|
app.get("/acp", { websocket: true }, (socket, request) => {
|
|
@@ -3345,7 +4308,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3345
4308
|
agentArgs: hydraMeta.agentArgs
|
|
3346
4309
|
});
|
|
3347
4310
|
const client = bindClientToSession(connection, session, state);
|
|
3348
|
-
session.attach(client, "full");
|
|
4311
|
+
await session.attach(client, "full");
|
|
3349
4312
|
state.attached.set(session.sessionId, {
|
|
3350
4313
|
sessionId: session.sessionId,
|
|
3351
4314
|
clientId: client.clientId
|
|
@@ -3364,14 +4327,22 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3364
4327
|
const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
|
|
3365
4328
|
let session = deps.manager.get(lookupId);
|
|
3366
4329
|
if (!session) {
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
4330
|
+
const fromDisk = await deps.manager.loadFromDisk(lookupId);
|
|
4331
|
+
let resurrectParams = fromDisk;
|
|
4332
|
+
if (hydraHints) {
|
|
4333
|
+
resurrectParams = {
|
|
4334
|
+
hydraSessionId: params.sessionId,
|
|
4335
|
+
upstreamSessionId: hydraHints.upstreamSessionId,
|
|
4336
|
+
agentId: hydraHints.agentId,
|
|
4337
|
+
cwd: hydraHints.cwd,
|
|
4338
|
+
title: hydraHints.title ?? fromDisk?.title,
|
|
4339
|
+
agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
|
|
4340
|
+
currentModel: fromDisk?.currentModel,
|
|
4341
|
+
currentMode: fromDisk?.currentMode,
|
|
4342
|
+
agentCommands: fromDisk?.agentCommands,
|
|
4343
|
+
createdAt: fromDisk?.createdAt
|
|
4344
|
+
};
|
|
4345
|
+
}
|
|
3375
4346
|
if (!resurrectParams) {
|
|
3376
4347
|
const err = new Error(
|
|
3377
4348
|
`session ${params.sessionId} not found and no resume hints provided`
|
|
@@ -3387,13 +4358,13 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3387
4358
|
state,
|
|
3388
4359
|
params.clientInfo
|
|
3389
4360
|
);
|
|
3390
|
-
const replay = session.attach(client, params.historyPolicy);
|
|
4361
|
+
const replay = await session.attach(client, params.historyPolicy);
|
|
3391
4362
|
state.attached.set(session.sessionId, {
|
|
3392
4363
|
sessionId: session.sessionId,
|
|
3393
4364
|
clientId: client.clientId
|
|
3394
4365
|
});
|
|
3395
4366
|
app.log.info(
|
|
3396
|
-
`session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
|
|
4367
|
+
`session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
|
|
3397
4368
|
);
|
|
3398
4369
|
for (const note of replay) {
|
|
3399
4370
|
await connection.notify(note.method, note.params);
|
|
@@ -3489,7 +4460,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3489
4460
|
session = await deps.manager.resurrect(fromDisk);
|
|
3490
4461
|
}
|
|
3491
4462
|
const client = bindClientToSession(connection, session, state);
|
|
3492
|
-
const replay = session.attach(client, "pending_only");
|
|
4463
|
+
const replay = await session.attach(client, "pending_only");
|
|
3493
4464
|
state.attached.set(session.sessionId, {
|
|
3494
4465
|
sessionId: session.sessionId,
|
|
3495
4466
|
clientId: client.clientId
|
|
@@ -3555,7 +4526,7 @@ function buildResponseMeta(session) {
|
|
|
3555
4526
|
function buildInitializeResult() {
|
|
3556
4527
|
return {
|
|
3557
4528
|
protocolVersion: HYDRA_PROTOCOL_VERSION,
|
|
3558
|
-
agentInfo: { name: "hydra", version:
|
|
4529
|
+
agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
|
|
3559
4530
|
agentCapabilities: {
|
|
3560
4531
|
// hydra is a transparent proxy: prompt blocks and MCP server configs are
|
|
3561
4532
|
// forwarded to the underlying agent unchanged. We claim the union of
|
|
@@ -3594,14 +4565,14 @@ function bindClientToSession(connection, session, state, clientInfo) {
|
|
|
3594
4565
|
}
|
|
3595
4566
|
|
|
3596
4567
|
// src/daemon/server.ts
|
|
3597
|
-
var
|
|
4568
|
+
var HYDRA_VERSION3 = "0.1.0";
|
|
3598
4569
|
async function startDaemon(config) {
|
|
3599
4570
|
ensureLoopbackOrTls(config);
|
|
3600
4571
|
const httpsOptions = config.daemon.tls ? {
|
|
3601
|
-
key: await
|
|
3602
|
-
cert: await
|
|
4572
|
+
key: await fsp3.readFile(config.daemon.tls.key),
|
|
4573
|
+
cert: await fsp3.readFile(config.daemon.tls.cert)
|
|
3603
4574
|
} : void 0;
|
|
3604
|
-
await
|
|
4575
|
+
await fsp3.mkdir(paths.home(), { recursive: true });
|
|
3605
4576
|
const { stream: logStream, fileStream } = await buildLogStream(
|
|
3606
4577
|
config.daemon.logLevel
|
|
3607
4578
|
);
|
|
@@ -3613,6 +4584,9 @@ async function startDaemon(config) {
|
|
|
3613
4584
|
https: httpsOptions ?? null
|
|
3614
4585
|
});
|
|
3615
4586
|
await app.register(websocketPlugin);
|
|
4587
|
+
setBinaryInstallLogger((msg) => {
|
|
4588
|
+
app.log.info(msg);
|
|
4589
|
+
});
|
|
3616
4590
|
const auth = bearerAuth({ config });
|
|
3617
4591
|
app.addHook("onRequest", async (request, reply) => {
|
|
3618
4592
|
if (request.routeOptions.config?.skipAuth) {
|
|
@@ -3625,10 +4599,11 @@ async function startDaemon(config) {
|
|
|
3625
4599
|
});
|
|
3626
4600
|
const registry = new Registry(config);
|
|
3627
4601
|
const manager = new SessionManager(registry, void 0, void 0, {
|
|
3628
|
-
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
|
|
4602
|
+
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
|
|
4603
|
+
defaultModels: config.defaultModels
|
|
3629
4604
|
});
|
|
3630
4605
|
const extensions = new ExtensionManager(extensionList(config));
|
|
3631
|
-
registerHealthRoutes(app,
|
|
4606
|
+
registerHealthRoutes(app, HYDRA_VERSION3);
|
|
3632
4607
|
registerSessionRoutes(app, manager, {
|
|
3633
4608
|
agentId: config.defaultAgent,
|
|
3634
4609
|
cwd: config.defaultCwd
|
|
@@ -3647,8 +4622,8 @@ async function startDaemon(config) {
|
|
|
3647
4622
|
await app.listen({ host: config.daemon.host, port: config.daemon.port });
|
|
3648
4623
|
const address = app.server.address();
|
|
3649
4624
|
const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
|
|
3650
|
-
await
|
|
3651
|
-
await
|
|
4625
|
+
await fsp3.mkdir(paths.home(), { recursive: true });
|
|
4626
|
+
await fsp3.writeFile(
|
|
3652
4627
|
paths.pidFile(),
|
|
3653
4628
|
JSON.stringify({
|
|
3654
4629
|
pid: process.pid,
|
|
@@ -3672,9 +4647,11 @@ async function startDaemon(config) {
|
|
|
3672
4647
|
const shutdown = async () => {
|
|
3673
4648
|
await extensions.stop();
|
|
3674
4649
|
await manager.closeAll();
|
|
4650
|
+
await manager.flushMetaWrites();
|
|
4651
|
+
setBinaryInstallLogger(null);
|
|
3675
4652
|
await app.close();
|
|
3676
4653
|
try {
|
|
3677
|
-
|
|
4654
|
+
fs9.unlinkSync(paths.pidFile());
|
|
3678
4655
|
} catch {
|
|
3679
4656
|
}
|
|
3680
4657
|
try {
|