@hydra-acp/cli 0.1.2 → 0.1.4
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 +29 -3
- package/dist/cli.js +2234 -601
- package/dist/index.d.ts +188 -8
- package/dist/index.js +754 -119
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/daemon/server.ts
|
|
2
|
-
import * as
|
|
2
|
+
import * as fs8 from "fs";
|
|
3
3
|
import * as fsp2 from "fs/promises";
|
|
4
4
|
import Fastify from "fastify";
|
|
5
5
|
import websocketPlugin from "@fastify/websocket";
|
|
@@ -20,6 +20,11 @@ function hydraHome() {
|
|
|
20
20
|
if (override && override.length > 0) {
|
|
21
21
|
return path.resolve(override);
|
|
22
22
|
}
|
|
23
|
+
if (process.env.VITEST) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"HYDRA_ACP_HOME is unset under VITEST; vitest.setup.ts must run first"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
23
28
|
return path.join(os.homedir(), ".hydra-acp");
|
|
24
29
|
}
|
|
25
30
|
var paths = {
|
|
@@ -41,7 +46,8 @@ var paths = {
|
|
|
41
46
|
extensionsDir: () => path.join(hydraHome(), "extensions"),
|
|
42
47
|
extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
|
|
43
48
|
extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
|
|
44
|
-
tuiHistoryFile: () => path.join(hydraHome(), "
|
|
49
|
+
tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
|
|
50
|
+
tuiLogFile: () => path.join(hydraHome(), "tui.log")
|
|
45
51
|
};
|
|
46
52
|
|
|
47
53
|
// src/core/config.ts
|
|
@@ -56,7 +62,7 @@ var DaemonConfig = z.object({
|
|
|
56
62
|
authToken: z.string().min(16),
|
|
57
63
|
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
58
64
|
tls: TlsConfig.optional(),
|
|
59
|
-
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(
|
|
65
|
+
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
|
|
60
66
|
});
|
|
61
67
|
var RegistryConfig = z.object({
|
|
62
68
|
url: z.string().url().default(REGISTRY_URL_DEFAULT),
|
|
@@ -69,7 +75,11 @@ var TuiConfig = z.object({
|
|
|
69
75
|
// /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
|
|
70
76
|
// CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
|
|
71
77
|
// or 0 to disable throttling entirely.
|
|
72
|
-
repaintThrottleMs: z.number().int().nonnegative().default(1e3)
|
|
78
|
+
repaintThrottleMs: z.number().int().nonnegative().default(1e3),
|
|
79
|
+
// Cap on logical lines retained in the in-memory scrollback render
|
|
80
|
+
// buffer. Oldest lines are dropped on overflow. The on-disk session
|
|
81
|
+
// history is unaffected; this only bounds the TUI's local view buffer.
|
|
82
|
+
maxScrollbackLines: z.number().int().positive().default(1e4)
|
|
73
83
|
});
|
|
74
84
|
var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
|
|
75
85
|
var ExtensionBody = z.object({
|
|
@@ -94,7 +104,7 @@ var HydraConfig = z.object({
|
|
|
94
104
|
// recency and truncated to this count. `--all` overrides in the CLI.
|
|
95
105
|
sessionListColdLimit: z.number().int().nonnegative().default(20),
|
|
96
106
|
extensions: z.record(ExtensionName, ExtensionBody).default({}),
|
|
97
|
-
tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
|
|
107
|
+
tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
|
|
98
108
|
});
|
|
99
109
|
function extensionList(config) {
|
|
100
110
|
return Object.entries(config.extensions).map(([name, body]) => ({
|
|
@@ -127,8 +137,7 @@ async function ensureConfig() {
|
|
|
127
137
|
if (e.code !== "ENOENT") {
|
|
128
138
|
throw err;
|
|
129
139
|
}
|
|
130
|
-
const config =
|
|
131
|
-
await writeConfig(config);
|
|
140
|
+
const config = await writeMinimalInitConfig();
|
|
132
141
|
process.stderr.write(
|
|
133
142
|
`hydra-acp: initialized ${paths.config()} with a fresh auth token.
|
|
134
143
|
`
|
|
@@ -144,6 +153,16 @@ async function writeConfig(config) {
|
|
|
144
153
|
mode: 384
|
|
145
154
|
});
|
|
146
155
|
}
|
|
156
|
+
async function writeMinimalInitConfig(authToken) {
|
|
157
|
+
const token = authToken ?? generateAuthToken();
|
|
158
|
+
const minimal = { daemon: { authToken: token } };
|
|
159
|
+
await fs.mkdir(paths.home(), { recursive: true });
|
|
160
|
+
await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
|
|
161
|
+
encoding: "utf8",
|
|
162
|
+
mode: 384
|
|
163
|
+
});
|
|
164
|
+
return HydraConfig.parse(minimal);
|
|
165
|
+
}
|
|
147
166
|
function generateAuthToken() {
|
|
148
167
|
const bytes = new Uint8Array(32);
|
|
149
168
|
crypto.getRandomValues(bytes);
|
|
@@ -337,6 +356,10 @@ function planSpawn(agent, extraArgs = []) {
|
|
|
337
356
|
throw new Error(`Agent ${agent.id} has no usable distribution method.`);
|
|
338
357
|
}
|
|
339
358
|
|
|
359
|
+
// src/core/session-manager.ts
|
|
360
|
+
import * as fs6 from "fs/promises";
|
|
361
|
+
import { customAlphabet as customAlphabet3 } from "nanoid";
|
|
362
|
+
|
|
340
363
|
// src/core/agent-instance.ts
|
|
341
364
|
import { spawn } from "child_process";
|
|
342
365
|
|
|
@@ -351,7 +374,8 @@ var JsonRpcErrorCodes = {
|
|
|
351
374
|
SessionNotFound: -32001,
|
|
352
375
|
PermissionDenied: -32002,
|
|
353
376
|
AlreadyAttached: -32003,
|
|
354
|
-
AgentNotInstalled: -32005
|
|
377
|
+
AgentNotInstalled: -32005,
|
|
378
|
+
BundleAlreadyImported: -32010
|
|
355
379
|
};
|
|
356
380
|
var InitializeParams = z3.object({
|
|
357
381
|
protocolVersion: z3.number().optional(),
|
|
@@ -421,6 +445,9 @@ function extractHydraMeta(meta) {
|
|
|
421
445
|
if (typeof obj.currentMode === "string") {
|
|
422
446
|
out.currentMode = obj.currentMode;
|
|
423
447
|
}
|
|
448
|
+
if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
|
|
449
|
+
out.turnStartedAt = obj.turnStartedAt;
|
|
450
|
+
}
|
|
424
451
|
if (Array.isArray(obj.availableCommands)) {
|
|
425
452
|
const cmds = [];
|
|
426
453
|
for (const raw of obj.availableCommands) {
|
|
@@ -797,6 +824,8 @@ function hydraCommandsAsAdvertised() {
|
|
|
797
824
|
var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
798
825
|
var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
|
|
799
826
|
var HYDRA_SESSION_PREFIX = "hydra_session_";
|
|
827
|
+
var MAX_HISTORY_ENTRIES = 1e3;
|
|
828
|
+
var COMPACT_EVERY = 200;
|
|
800
829
|
var Session = class {
|
|
801
830
|
sessionId;
|
|
802
831
|
cwd;
|
|
@@ -816,8 +845,8 @@ var Session = class {
|
|
|
816
845
|
currentModel;
|
|
817
846
|
currentMode;
|
|
818
847
|
updatedAt;
|
|
848
|
+
createdAt;
|
|
819
849
|
clients = /* @__PURE__ */ new Map();
|
|
820
|
-
history = [];
|
|
821
850
|
historyStore;
|
|
822
851
|
promptQueue = [];
|
|
823
852
|
promptInFlight = false;
|
|
@@ -833,6 +862,15 @@ var Session = class {
|
|
|
833
862
|
// True once we've observed our first session/prompt; gates the
|
|
834
863
|
// first-prompt-seeded title so subsequent prompts don't churn it.
|
|
835
864
|
firstPromptSeeded = false;
|
|
865
|
+
// Wall-clock when the active prompt started, undefined when idle.
|
|
866
|
+
// Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
|
|
867
|
+
// Drives the mid-turn elapsed counter delivered to fresh attachers.
|
|
868
|
+
promptStartedAt;
|
|
869
|
+
// Counts appends since the last compaction. When it hits COMPACT_EVERY
|
|
870
|
+
// we ask the history store to trim the file to the most recent
|
|
871
|
+
// MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
|
|
872
|
+
// file-size checks.
|
|
873
|
+
appendCount = 0;
|
|
836
874
|
// Permission requests that have been broadcast to one or more
|
|
837
875
|
// clients but have not yet resolved. Replayed to clients that
|
|
838
876
|
// attach mid-flight so a late joiner sees the prompt instead of an
|
|
@@ -847,6 +885,12 @@ var Session = class {
|
|
|
847
885
|
internalPromptCapture;
|
|
848
886
|
idleTimeoutMs;
|
|
849
887
|
idleTimer;
|
|
888
|
+
// Time of the last recordable broadcast (or session creation, if
|
|
889
|
+
// none yet). Drives the inactivity-based idle close; deliberately
|
|
890
|
+
// does NOT include snapshot state pings (model/mode/title/commands)
|
|
891
|
+
// or attach/detach, which would otherwise let passive observers
|
|
892
|
+
// and noisy state churn keep a quiet session alive forever.
|
|
893
|
+
lastRecordedAt;
|
|
850
894
|
spawnReplacementAgent;
|
|
851
895
|
agentChangeHandlers = [];
|
|
852
896
|
// Last available_commands_update we observed from the agent. Stored
|
|
@@ -877,12 +921,15 @@ var Session = class {
|
|
|
877
921
|
}
|
|
878
922
|
this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
|
|
879
923
|
this.spawnReplacementAgent = init.spawnReplacementAgent;
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
this.history = [...init.seedHistory];
|
|
924
|
+
if (init.firstPromptSeeded) {
|
|
925
|
+
this.firstPromptSeeded = true;
|
|
883
926
|
}
|
|
927
|
+
this.historyStore = init.historyStore;
|
|
884
928
|
this.updatedAt = Date.now();
|
|
929
|
+
this.createdAt = init.createdAt ?? this.updatedAt;
|
|
930
|
+
this.lastRecordedAt = this.updatedAt;
|
|
885
931
|
this.wireAgent(this.agent);
|
|
932
|
+
this.scheduleIdleCheck();
|
|
886
933
|
}
|
|
887
934
|
broadcastMergedCommands() {
|
|
888
935
|
const merged = [
|
|
@@ -941,12 +988,21 @@ var Session = class {
|
|
|
941
988
|
get attachedCount() {
|
|
942
989
|
return this.clients.size;
|
|
943
990
|
}
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
|
|
949
|
-
return
|
|
991
|
+
// Wall-clock when the in-flight agent turn began, or undefined when
|
|
992
|
+
// idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
|
|
993
|
+
// so the daemon can hand a fresh attacher mid-turn the right elapsed
|
|
994
|
+
// time without scanning history.
|
|
995
|
+
get turnStartedAt() {
|
|
996
|
+
return this.promptStartedAt;
|
|
997
|
+
}
|
|
998
|
+
// Read the persisted history from disk. Returns [] if no history
|
|
999
|
+
// file exists (fresh session, never prompted). Used by attach() and
|
|
1000
|
+
// the HTTP /history endpoint.
|
|
1001
|
+
async getHistorySnapshot() {
|
|
1002
|
+
if (!this.historyStore) {
|
|
1003
|
+
return [];
|
|
1004
|
+
}
|
|
1005
|
+
return this.historyStore.load(this.sessionId).catch(() => []);
|
|
950
1006
|
}
|
|
951
1007
|
// Subscribe to recordable broadcast entries — fires once per entry
|
|
952
1008
|
// that lands in history (so snapshot-shaped session_info/model/mode/
|
|
@@ -962,6 +1018,10 @@ var Session = class {
|
|
|
962
1018
|
}
|
|
963
1019
|
};
|
|
964
1020
|
}
|
|
1021
|
+
// Register a client and (asynchronously) load the replay slice it
|
|
1022
|
+
// should receive. Validation errors throw synchronously so callers
|
|
1023
|
+
// can rely on either the registration being in effect or having
|
|
1024
|
+
// thrown; the disk-load is the only async work.
|
|
965
1025
|
attach(client, historyPolicy) {
|
|
966
1026
|
if (this.closed) {
|
|
967
1027
|
throw withCode(
|
|
@@ -977,14 +1037,10 @@ var Session = class {
|
|
|
977
1037
|
}
|
|
978
1038
|
this.clients.set(client.clientId, client);
|
|
979
1039
|
this.updatedAt = Date.now();
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
return [];
|
|
983
|
-
}
|
|
984
|
-
if (historyPolicy === "pending_only") {
|
|
985
|
-
return [];
|
|
1040
|
+
if (historyPolicy === "none" || historyPolicy === "pending_only") {
|
|
1041
|
+
return Promise.resolve([]);
|
|
986
1042
|
}
|
|
987
|
-
return
|
|
1043
|
+
return this.getHistorySnapshot();
|
|
988
1044
|
}
|
|
989
1045
|
// Dispatch in-flight permission requests to a freshly-attached
|
|
990
1046
|
// client. Called by the daemon's WS handler *after* it finishes
|
|
@@ -998,7 +1054,6 @@ var Session = class {
|
|
|
998
1054
|
detach(clientId) {
|
|
999
1055
|
if (this.clients.delete(clientId)) {
|
|
1000
1056
|
this.updatedAt = Date.now();
|
|
1001
|
-
this.maybeStartIdleTimer();
|
|
1002
1057
|
}
|
|
1003
1058
|
}
|
|
1004
1059
|
async prompt(clientId, params) {
|
|
@@ -1018,13 +1073,19 @@ var Session = class {
|
|
|
1018
1073
|
this.broadcastPromptReceived(client, params);
|
|
1019
1074
|
this.maybeSeedTitleFromPrompt(params);
|
|
1020
1075
|
return this.enqueuePrompt(async () => {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1076
|
+
let response;
|
|
1077
|
+
try {
|
|
1078
|
+
response = await this.agent.connection.request(
|
|
1079
|
+
"session/prompt",
|
|
1080
|
+
{
|
|
1081
|
+
...params,
|
|
1082
|
+
sessionId: this.upstreamSessionId
|
|
1083
|
+
}
|
|
1084
|
+
);
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
|
|
1087
|
+
throw err;
|
|
1088
|
+
}
|
|
1028
1089
|
this.broadcastTurnComplete(client.clientId, response);
|
|
1029
1090
|
return response;
|
|
1030
1091
|
});
|
|
@@ -1038,6 +1099,7 @@ var Session = class {
|
|
|
1038
1099
|
if (client.clientInfo?.version) {
|
|
1039
1100
|
sentBy.version = client.clientInfo.version;
|
|
1040
1101
|
}
|
|
1102
|
+
this.promptStartedAt = Date.now();
|
|
1041
1103
|
this.recordAndBroadcast(
|
|
1042
1104
|
"session/update",
|
|
1043
1105
|
{
|
|
@@ -1074,6 +1136,7 @@ var Session = class {
|
|
|
1074
1136
|
if (stopReason !== void 0) {
|
|
1075
1137
|
update.stopReason = stopReason;
|
|
1076
1138
|
}
|
|
1139
|
+
this.promptStartedAt = void 0;
|
|
1077
1140
|
this.recordAndBroadcast(
|
|
1078
1141
|
"session/update",
|
|
1079
1142
|
{
|
|
@@ -1112,6 +1175,13 @@ var Session = class {
|
|
|
1112
1175
|
return;
|
|
1113
1176
|
}
|
|
1114
1177
|
this.cancelIdleTimer();
|
|
1178
|
+
if (opts.regenTitle && this.firstPromptSeeded) {
|
|
1179
|
+
const timeoutMs = opts.regenTitleTimeoutMs ?? 5e3;
|
|
1180
|
+
await Promise.race([
|
|
1181
|
+
this.runTitleRegen().catch(() => void 0),
|
|
1182
|
+
new Promise((r) => setTimeout(r, timeoutMs).unref?.())
|
|
1183
|
+
]);
|
|
1184
|
+
}
|
|
1115
1185
|
await this.agent.kill().catch(() => void 0);
|
|
1116
1186
|
this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
|
|
1117
1187
|
}
|
|
@@ -1160,7 +1230,7 @@ var Session = class {
|
|
|
1160
1230
|
}
|
|
1161
1231
|
const promptParams = params ?? {};
|
|
1162
1232
|
const text = extractPromptText(promptParams.prompt);
|
|
1163
|
-
const seed = firstLine(text,
|
|
1233
|
+
const seed = firstLine(text, 200);
|
|
1164
1234
|
if (!seed) {
|
|
1165
1235
|
return;
|
|
1166
1236
|
}
|
|
@@ -1252,6 +1322,12 @@ var Session = class {
|
|
|
1252
1322
|
mergedAvailableCommands() {
|
|
1253
1323
|
return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
|
|
1254
1324
|
}
|
|
1325
|
+
// The agent's own advertised commands (not merged with hydra verbs).
|
|
1326
|
+
// Used by SessionManager to persist into meta.json so cold resurrect
|
|
1327
|
+
// can re-deliver via the attach response _meta.
|
|
1328
|
+
agentOnlyAdvertisedCommands() {
|
|
1329
|
+
return [...this.agentAdvertisedCommands];
|
|
1330
|
+
}
|
|
1255
1331
|
// Pick up an agent-emitted session_info_update and store its title
|
|
1256
1332
|
// as our canonical record. The notification is also forwarded to
|
|
1257
1333
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -1383,7 +1459,7 @@ var Session = class {
|
|
|
1383
1459
|
const spawnAgent = this.spawnReplacementAgent;
|
|
1384
1460
|
return this.enqueuePrompt(async () => {
|
|
1385
1461
|
const oldAgentId = this.agentId;
|
|
1386
|
-
const transcript = this.buildSwitchTranscript(oldAgentId);
|
|
1462
|
+
const transcript = await this.buildSwitchTranscript(oldAgentId);
|
|
1387
1463
|
const fresh = await spawnAgent({
|
|
1388
1464
|
agentId: newAgentId,
|
|
1389
1465
|
cwd: this.cwd,
|
|
@@ -1415,15 +1491,20 @@ var Session = class {
|
|
|
1415
1491
|
return { stopReason: "end_turn" };
|
|
1416
1492
|
});
|
|
1417
1493
|
}
|
|
1418
|
-
// Walk
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
|
|
1494
|
+
// Walk the persisted history and produce a labeled transcript suitable
|
|
1495
|
+
// for handing to a fresh agent. Includes user prompts, agent replies,
|
|
1496
|
+
// and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
|
|
1497
|
+
// switches don't accumulate banners) and other update kinds we don't
|
|
1498
|
+
// think the next agent benefits from re-seeing (plans, thoughts,
|
|
1499
|
+
// mode/model/usage).
|
|
1500
|
+
//
|
|
1501
|
+
// The header text defaults to the agent-swap framing; callers like
|
|
1502
|
+
// seedFromImport pass a custom header when the new agent is taking
|
|
1503
|
+
// over an imported session rather than swapping mid-conversation.
|
|
1504
|
+
async buildSwitchTranscript(prevAgentId, headerOverride) {
|
|
1425
1505
|
const lines = [];
|
|
1426
|
-
|
|
1506
|
+
const history = await this.getHistorySnapshot();
|
|
1507
|
+
for (const note of history) {
|
|
1427
1508
|
if (note.method !== "session/update") {
|
|
1428
1509
|
continue;
|
|
1429
1510
|
}
|
|
@@ -1477,29 +1558,53 @@ var Session = class {
|
|
|
1477
1558
|
if (current) {
|
|
1478
1559
|
coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
|
|
1479
1560
|
}
|
|
1561
|
+
const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
|
|
1562
|
+
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.`;
|
|
1480
1563
|
return [
|
|
1481
|
-
|
|
1482
|
-
|
|
1564
|
+
intro,
|
|
1565
|
+
followup,
|
|
1483
1566
|
"",
|
|
1484
1567
|
"--- begin transcript ---",
|
|
1485
1568
|
...coalesced,
|
|
1486
1569
|
"--- end transcript ---"
|
|
1487
1570
|
].join("\n");
|
|
1488
1571
|
}
|
|
1572
|
+
// Replay the persisted history into a freshly-spawned agent so an
|
|
1573
|
+
// imported session has context. Called by SessionManager.doResurrect
|
|
1574
|
+
// on the first wake-up of a session whose meta.json has an empty
|
|
1575
|
+
// upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
|
|
1576
|
+
// any user prompts arriving mid-seed queue behind it (mirrors the
|
|
1577
|
+
// /hydra switch path so the agent isn't asked to respond to a user
|
|
1578
|
+
// turn before it has absorbed the imported transcript). Best-effort:
|
|
1579
|
+
// if the agent fails to absorb the transcript we still leave the
|
|
1580
|
+
// session usable — the user just continues without context.
|
|
1581
|
+
async seedFromImport() {
|
|
1582
|
+
await this.enqueuePrompt(async () => {
|
|
1583
|
+
const transcript = await this.buildSwitchTranscript(this.agentId, {
|
|
1584
|
+
intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
|
|
1585
|
+
followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
|
|
1586
|
+
});
|
|
1587
|
+
if (!transcript) {
|
|
1588
|
+
return void 0;
|
|
1589
|
+
}
|
|
1590
|
+
await this.runInternalPrompt(transcript).catch(() => void 0);
|
|
1591
|
+
return void 0;
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1489
1594
|
// Tell every attached client (a) the agent identity has changed
|
|
1490
|
-
// (session_info_update
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1595
|
+
// (session_info_update carrying agentId inside _meta["hydra-acp"] —
|
|
1596
|
+
// the ACP schema for session_info_update is just title/updatedAt/_meta,
|
|
1597
|
+
// so non-hydra clients harmlessly ignore the extension; hydra-aware
|
|
1598
|
+
// ones read it and relabel) and (b) drop a visible banner into the
|
|
1599
|
+
// transcript so users see the switch rather than just suddenly getting
|
|
1600
|
+
// answers from a different agent. Both updates carry synthetic=true
|
|
1495
1601
|
// so a future /hydra switch's transcript builder filters them out.
|
|
1496
1602
|
broadcastAgentSwitch(oldAgentId, newAgentId) {
|
|
1497
1603
|
this.recordAndBroadcast("session/update", {
|
|
1498
1604
|
sessionId: this.sessionId,
|
|
1499
1605
|
update: {
|
|
1500
1606
|
sessionUpdate: "session_info_update",
|
|
1501
|
-
agentId: newAgentId
|
|
1502
|
-
_meta: { "hydra-acp": { synthetic: true } }
|
|
1607
|
+
_meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
|
|
1503
1608
|
}
|
|
1504
1609
|
});
|
|
1505
1610
|
this.recordAndBroadcast("session/update", {
|
|
@@ -1530,21 +1635,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1530
1635
|
handler(opts);
|
|
1531
1636
|
}
|
|
1532
1637
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1638
|
+
// Last meaningful activity timestamp. Bumped only by recordable
|
|
1639
|
+
// broadcasts in recordAndBroadcast — the same signal historyMtimeIso
|
|
1640
|
+
// uses for the picker. Initialized at construction (and seeded from
|
|
1641
|
+
// the newest entry on resurrect) so the inactivity window starts
|
|
1642
|
+
// ticking from a sensible floor when there's no history yet.
|
|
1643
|
+
get lastActivityAt() {
|
|
1644
|
+
return this.lastRecordedAt;
|
|
1645
|
+
}
|
|
1646
|
+
// (Re-)arm the idle timer to fire when the inactivity window
|
|
1647
|
+
// elapses past lastActivityAt. Called once at construction and after
|
|
1648
|
+
// every recorded broadcast. The previous design gated on
|
|
1649
|
+
// clients.size === 0; we drop that gate because extensions
|
|
1650
|
+
// (slack/notifier/approver/browser) hold persistent attaches that
|
|
1651
|
+
// would otherwise keep a quiet session alive forever.
|
|
1652
|
+
scheduleIdleCheck() {
|
|
1653
|
+
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
1535
1654
|
return;
|
|
1536
1655
|
}
|
|
1656
|
+
const dueAt = this.lastActivityAt + this.idleTimeoutMs;
|
|
1657
|
+
this.armIdleTimer(Math.max(0, dueAt - Date.now()));
|
|
1658
|
+
}
|
|
1659
|
+
armIdleTimer(delay) {
|
|
1537
1660
|
if (this.idleTimer) {
|
|
1538
|
-
|
|
1661
|
+
clearTimeout(this.idleTimer);
|
|
1539
1662
|
}
|
|
1540
1663
|
this.idleTimer = setTimeout(() => {
|
|
1541
1664
|
this.idleTimer = void 0;
|
|
1542
|
-
|
|
1543
|
-
},
|
|
1665
|
+
this.checkIdle();
|
|
1666
|
+
}, delay);
|
|
1544
1667
|
if (typeof this.idleTimer.unref === "function") {
|
|
1545
1668
|
this.idleTimer.unref();
|
|
1546
1669
|
}
|
|
1547
1670
|
}
|
|
1671
|
+
checkIdle() {
|
|
1672
|
+
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
|
|
1676
|
+
this.armIdleTimer(this.idleTimeoutMs);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
const idle = Date.now() - this.lastActivityAt;
|
|
1680
|
+
if (idle < this.idleTimeoutMs) {
|
|
1681
|
+
this.armIdleTimer(this.idleTimeoutMs - idle);
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
|
|
1685
|
+
void this.close(opts).catch(() => void 0);
|
|
1686
|
+
}
|
|
1548
1687
|
cancelIdleTimer() {
|
|
1549
1688
|
if (this.idleTimer) {
|
|
1550
1689
|
clearTimeout(this.idleTimer);
|
|
@@ -1569,17 +1708,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1569
1708
|
params: rewritten,
|
|
1570
1709
|
recordedAt: Date.now()
|
|
1571
1710
|
};
|
|
1572
|
-
this.
|
|
1573
|
-
|
|
1574
|
-
if (this.history.length > 1e3) {
|
|
1575
|
-
this.history = this.history.slice(-500);
|
|
1576
|
-
trimmed = true;
|
|
1577
|
-
}
|
|
1711
|
+
this.lastRecordedAt = entry.recordedAt;
|
|
1712
|
+
this.appendCount += 1;
|
|
1578
1713
|
if (this.historyStore) {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1714
|
+
const store = this.historyStore;
|
|
1715
|
+
void store.append(this.sessionId, entry).catch(() => void 0);
|
|
1716
|
+
if (this.appendCount >= COMPACT_EVERY) {
|
|
1717
|
+
this.appendCount = 0;
|
|
1718
|
+
void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
|
|
1583
1719
|
() => void 0
|
|
1584
1720
|
);
|
|
1585
1721
|
}
|
|
@@ -1590,6 +1726,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1590
1726
|
} catch {
|
|
1591
1727
|
}
|
|
1592
1728
|
}
|
|
1729
|
+
this.scheduleIdleCheck();
|
|
1593
1730
|
}
|
|
1594
1731
|
this.updatedAt = Date.now();
|
|
1595
1732
|
for (const client of this.clients.values()) {
|
|
@@ -1780,7 +1917,14 @@ function firstLine(text, max) {
|
|
|
1780
1917
|
// src/core/session-store.ts
|
|
1781
1918
|
import * as fs3 from "fs/promises";
|
|
1782
1919
|
import * as path2 from "path";
|
|
1920
|
+
import { customAlphabet as customAlphabet2 } from "nanoid";
|
|
1783
1921
|
import { z as z4 } from "zod";
|
|
1922
|
+
var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
1923
|
+
var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
|
|
1924
|
+
var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
|
|
1925
|
+
function generateLineageId() {
|
|
1926
|
+
return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
|
|
1927
|
+
}
|
|
1784
1928
|
var PersistedAgentCommand = z4.object({
|
|
1785
1929
|
name: z4.string(),
|
|
1786
1930
|
description: z4.string().optional()
|
|
@@ -1788,7 +1932,20 @@ var PersistedAgentCommand = z4.object({
|
|
|
1788
1932
|
var SessionRecord = z4.object({
|
|
1789
1933
|
version: z4.literal(1),
|
|
1790
1934
|
sessionId: z4.string(),
|
|
1935
|
+
// Optional for back-compat with records written before this field
|
|
1936
|
+
// existed; mergeForPersistence generates one on next write so any
|
|
1937
|
+
// touched session converges to having a lineageId. A record that
|
|
1938
|
+
// never gets written again (truly cold and untouched) just won't
|
|
1939
|
+
// participate in lineage-based dedup, which is correct — it was
|
|
1940
|
+
// never exported, so no incoming bundle can claim its lineage.
|
|
1941
|
+
lineageId: z4.string().optional(),
|
|
1791
1942
|
upstreamSessionId: z4.string(),
|
|
1943
|
+
// When non-empty, marks a session that was created by import and is
|
|
1944
|
+
// waiting for its first attach to bootstrap a fresh upstream agent
|
|
1945
|
+
// and replay the imported history as a takeover transcript. The
|
|
1946
|
+
// origin's local id at export time, kept for debuggability and as a
|
|
1947
|
+
// breadcrumb in `sessions list` (informational, not used for routing).
|
|
1948
|
+
importedFromSessionId: z4.string().optional(),
|
|
1792
1949
|
agentId: z4.string(),
|
|
1793
1950
|
cwd: z4.string(),
|
|
1794
1951
|
title: z4.string().optional(),
|
|
@@ -1861,6 +2018,25 @@ var SessionStore = class {
|
|
|
1861
2018
|
}
|
|
1862
2019
|
}
|
|
1863
2020
|
}
|
|
2021
|
+
// Find a persisted session by lineageId. Used by SessionManager.import
|
|
2022
|
+
// to detect bundles that have already been imported (lineageId match)
|
|
2023
|
+
// so we can either error out or, with replace:true, overwrite.
|
|
2024
|
+
// Returns undefined if no record has that lineageId. Records that
|
|
2025
|
+
// pre-date the lineageId field simply don't match — which is
|
|
2026
|
+
// correct: they were never exported, so no incoming bundle can
|
|
2027
|
+
// legitimately claim their lineage.
|
|
2028
|
+
async findByLineageId(lineageId) {
|
|
2029
|
+
if (lineageId.length === 0) {
|
|
2030
|
+
return void 0;
|
|
2031
|
+
}
|
|
2032
|
+
const all = await this.list().catch(() => []);
|
|
2033
|
+
for (const record of all) {
|
|
2034
|
+
if (record.lineageId === lineageId) {
|
|
2035
|
+
return record;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
return void 0;
|
|
2039
|
+
}
|
|
1864
2040
|
async list() {
|
|
1865
2041
|
let entries;
|
|
1866
2042
|
try {
|
|
@@ -1886,7 +2062,9 @@ function recordFromMemorySession(args) {
|
|
|
1886
2062
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1887
2063
|
return {
|
|
1888
2064
|
sessionId: args.sessionId,
|
|
2065
|
+
lineageId: args.lineageId,
|
|
1889
2066
|
upstreamSessionId: args.upstreamSessionId,
|
|
2067
|
+
importedFromSessionId: args.importedFromSessionId,
|
|
1890
2068
|
agentId: args.agentId,
|
|
1891
2069
|
cwd: args.cwd,
|
|
1892
2070
|
title: args.title,
|
|
@@ -1934,6 +2112,36 @@ var HistoryStore = class {
|
|
|
1934
2112
|
});
|
|
1935
2113
|
});
|
|
1936
2114
|
}
|
|
2115
|
+
// Trim the on-disk history file to the most recent maxEntries lines.
|
|
2116
|
+
// Runs through the same per-session write queue as append/rewrite so
|
|
2117
|
+
// it's safe to invoke alongside ongoing writes; a no-op if the file is
|
|
2118
|
+
// already at or below the cap.
|
|
2119
|
+
async compact(sessionId, maxEntries) {
|
|
2120
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
return this.enqueue(sessionId, async () => {
|
|
2124
|
+
let raw;
|
|
2125
|
+
try {
|
|
2126
|
+
raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
|
|
2127
|
+
} catch (err) {
|
|
2128
|
+
const e = err;
|
|
2129
|
+
if (e.code === "ENOENT") {
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
throw err;
|
|
2133
|
+
}
|
|
2134
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
2135
|
+
if (lines.length <= maxEntries) {
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
const trimmed = lines.slice(-maxEntries);
|
|
2139
|
+
await fs4.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
|
|
2140
|
+
encoding: "utf8",
|
|
2141
|
+
mode: 384
|
|
2142
|
+
});
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
1937
2145
|
async load(sessionId) {
|
|
1938
2146
|
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1939
2147
|
return [];
|
|
@@ -2021,7 +2229,18 @@ var HistoryStore = class {
|
|
|
2021
2229
|
}
|
|
2022
2230
|
};
|
|
2023
2231
|
|
|
2232
|
+
// src/tui/history.ts
|
|
2233
|
+
import { promises as fs5 } from "fs";
|
|
2234
|
+
import * as path3 from "path";
|
|
2235
|
+
async function saveHistory(file, history) {
|
|
2236
|
+
await fs5.mkdir(path3.dirname(file), { recursive: true });
|
|
2237
|
+
const lines = history.map((entry) => JSON.stringify(entry));
|
|
2238
|
+
await fs5.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2024
2241
|
// src/core/session-manager.ts
|
|
2242
|
+
var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
2243
|
+
var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
|
|
2025
2244
|
var SessionManager = class {
|
|
2026
2245
|
constructor(registry, spawner, store, options = {}) {
|
|
2027
2246
|
this.registry = registry;
|
|
@@ -2100,6 +2319,9 @@ var SessionManager = class {
|
|
|
2100
2319
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
2101
2320
|
throw err;
|
|
2102
2321
|
}
|
|
2322
|
+
if (params.upstreamSessionId === "") {
|
|
2323
|
+
return this.doResurrectFromImport(params);
|
|
2324
|
+
}
|
|
2103
2325
|
const plan = planSpawn(agentDef, params.agentArgs ?? []);
|
|
2104
2326
|
const agent = this.spawner({
|
|
2105
2327
|
agentId: params.agentId,
|
|
@@ -2136,12 +2358,53 @@ var SessionManager = class {
|
|
|
2136
2358
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
2137
2359
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2138
2360
|
historyStore: this.histories,
|
|
2139
|
-
seedHistory: params.seedHistory,
|
|
2140
2361
|
currentModel: params.currentModel,
|
|
2141
2362
|
currentMode: params.currentMode,
|
|
2142
|
-
agentCommands: params.agentCommands
|
|
2363
|
+
agentCommands: params.agentCommands,
|
|
2364
|
+
// Only gate the first-prompt title heuristic when we actually have
|
|
2365
|
+
// a title to preserve. A title-less session (lost to a write race
|
|
2366
|
+
// or never seeded) should re-derive from the next prompt rather
|
|
2367
|
+
// than stay stuck.
|
|
2368
|
+
firstPromptSeeded: !!params.title,
|
|
2369
|
+
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
2370
|
+
});
|
|
2371
|
+
await this.attachManagerHooks(session);
|
|
2372
|
+
return session;
|
|
2373
|
+
}
|
|
2374
|
+
// First-attach path for a session that was created via import(). The
|
|
2375
|
+
// on-disk meta.json carries upstreamSessionId="" as the import
|
|
2376
|
+
// marker; bootstrap a fresh agent (gets a real upstream id) and kick
|
|
2377
|
+
// off seedFromImport so the agent absorbs the historical transcript.
|
|
2378
|
+
// attachManagerHooks rewrites meta.json with the new upstreamSessionId,
|
|
2379
|
+
// so subsequent resurrects of this session use the normal session/load
|
|
2380
|
+
// path.
|
|
2381
|
+
async doResurrectFromImport(params) {
|
|
2382
|
+
const fresh = await this.bootstrapAgent({
|
|
2383
|
+
agentId: params.agentId,
|
|
2384
|
+
cwd: params.cwd,
|
|
2385
|
+
agentArgs: params.agentArgs,
|
|
2386
|
+
mcpServers: []
|
|
2387
|
+
});
|
|
2388
|
+
const session = new Session({
|
|
2389
|
+
sessionId: params.hydraSessionId,
|
|
2390
|
+
cwd: params.cwd,
|
|
2391
|
+
agentId: params.agentId,
|
|
2392
|
+
agent: fresh.agent,
|
|
2393
|
+
upstreamSessionId: fresh.upstreamSessionId,
|
|
2394
|
+
agentMeta: fresh.agentMeta,
|
|
2395
|
+
title: params.title,
|
|
2396
|
+
agentArgs: params.agentArgs,
|
|
2397
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
2398
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2399
|
+
historyStore: this.histories,
|
|
2400
|
+
currentModel: params.currentModel,
|
|
2401
|
+
currentMode: params.currentMode,
|
|
2402
|
+
agentCommands: params.agentCommands,
|
|
2403
|
+
firstPromptSeeded: !!params.title,
|
|
2404
|
+
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
2143
2405
|
});
|
|
2144
2406
|
await this.attachManagerHooks(session);
|
|
2407
|
+
void session.seedFromImport().catch(() => void 0);
|
|
2145
2408
|
return session;
|
|
2146
2409
|
}
|
|
2147
2410
|
// Bootstrap a fresh agent process: registry resolve → spawn → initialize
|
|
@@ -2223,28 +2486,20 @@ var SessionManager = class {
|
|
|
2223
2486
|
}).catch(() => void 0);
|
|
2224
2487
|
});
|
|
2225
2488
|
this.sessions.set(session.sessionId, session);
|
|
2226
|
-
await this.
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
cwd: session.cwd,
|
|
2232
|
-
title: session.title,
|
|
2233
|
-
agentArgs: session.agentArgs,
|
|
2234
|
-
currentModel: session.currentModel,
|
|
2235
|
-
currentMode: session.currentMode
|
|
2236
|
-
})
|
|
2237
|
-
).catch(() => void 0);
|
|
2489
|
+
await this.enqueueMetaWrite(session.sessionId, async () => {
|
|
2490
|
+
const existing = await this.store.read(session.sessionId);
|
|
2491
|
+
const merged = mergeForPersistence(session, existing);
|
|
2492
|
+
await this.store.write(merged);
|
|
2493
|
+
}).catch(() => void 0);
|
|
2238
2494
|
}
|
|
2239
2495
|
// Resolve a session's recorded history without forcing a resurrect.
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2496
|
+
// Always loads from disk — that's the source of truth whether the
|
|
2497
|
+
// session is hot or cold. Returns undefined if the session id is
|
|
2498
|
+
// unknown to both the live map and disk store, so the caller can
|
|
2499
|
+
// distinguish "no history yet" (empty array) from "404".
|
|
2244
2500
|
async getHistory(sessionId) {
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
return live.getHistorySnapshot();
|
|
2501
|
+
if (this.sessions.has(sessionId)) {
|
|
2502
|
+
return this.histories.load(sessionId).catch(() => []);
|
|
2248
2503
|
}
|
|
2249
2504
|
const record = await this.store.read(sessionId);
|
|
2250
2505
|
if (!record) {
|
|
@@ -2257,20 +2512,41 @@ var SessionManager = class {
|
|
|
2257
2512
|
if (!record) {
|
|
2258
2513
|
return void 0;
|
|
2259
2514
|
}
|
|
2260
|
-
|
|
2515
|
+
let title = record.title;
|
|
2516
|
+
if (!title) {
|
|
2517
|
+
title = await this.deriveTitleFromHistory(sessionId);
|
|
2518
|
+
}
|
|
2261
2519
|
return {
|
|
2262
2520
|
hydraSessionId: record.sessionId,
|
|
2263
2521
|
upstreamSessionId: record.upstreamSessionId,
|
|
2264
2522
|
agentId: record.agentId,
|
|
2265
2523
|
cwd: record.cwd,
|
|
2266
|
-
title
|
|
2524
|
+
title,
|
|
2267
2525
|
agentArgs: record.agentArgs,
|
|
2268
|
-
seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
|
|
2269
2526
|
currentModel: record.currentModel,
|
|
2270
2527
|
currentMode: record.currentMode,
|
|
2271
|
-
agentCommands: record.agentCommands
|
|
2528
|
+
agentCommands: record.agentCommands,
|
|
2529
|
+
createdAt: record.createdAt
|
|
2272
2530
|
};
|
|
2273
2531
|
}
|
|
2532
|
+
// Best-effort: peek at the persisted history's first prompt and use
|
|
2533
|
+
// its first line (capped to 200 chars) as a session title. Returns
|
|
2534
|
+
// undefined if no usable prompt is found or any I/O fails.
|
|
2535
|
+
async deriveTitleFromHistory(sessionId) {
|
|
2536
|
+
const history = await this.histories.load(sessionId).catch(() => []);
|
|
2537
|
+
for (const entry of history) {
|
|
2538
|
+
const params = entry.params;
|
|
2539
|
+
if (params?.update?.sessionUpdate !== "prompt_received") {
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
const text = extractPromptText(params.update.prompt);
|
|
2543
|
+
const line = firstLine(text, 200);
|
|
2544
|
+
if (line) {
|
|
2545
|
+
return line;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return void 0;
|
|
2549
|
+
}
|
|
2274
2550
|
get(sessionId) {
|
|
2275
2551
|
return this.sessions.get(sessionId);
|
|
2276
2552
|
}
|
|
@@ -2310,13 +2586,14 @@ var SessionManager = class {
|
|
|
2310
2586
|
continue;
|
|
2311
2587
|
}
|
|
2312
2588
|
liveIds.add(session.sessionId);
|
|
2589
|
+
const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
|
|
2313
2590
|
entries.push({
|
|
2314
2591
|
sessionId: session.sessionId,
|
|
2315
2592
|
upstreamSessionId: session.upstreamSessionId,
|
|
2316
2593
|
cwd: session.cwd,
|
|
2317
2594
|
title: session.title,
|
|
2318
2595
|
agentId: session.agentId,
|
|
2319
|
-
updatedAt:
|
|
2596
|
+
updatedAt: used,
|
|
2320
2597
|
attachedClients: session.attachedCount,
|
|
2321
2598
|
status: "live"
|
|
2322
2599
|
});
|
|
@@ -2329,13 +2606,14 @@ var SessionManager = class {
|
|
|
2329
2606
|
if (filter.cwd && r.cwd !== filter.cwd) {
|
|
2330
2607
|
continue;
|
|
2331
2608
|
}
|
|
2609
|
+
const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
|
|
2332
2610
|
entries.push({
|
|
2333
2611
|
sessionId: r.sessionId,
|
|
2334
2612
|
upstreamSessionId: r.upstreamSessionId,
|
|
2335
2613
|
cwd: r.cwd,
|
|
2336
2614
|
title: r.title,
|
|
2337
2615
|
agentId: r.agentId,
|
|
2338
|
-
updatedAt:
|
|
2616
|
+
updatedAt: used,
|
|
2339
2617
|
attachedClients: 0,
|
|
2340
2618
|
status: "cold"
|
|
2341
2619
|
});
|
|
@@ -2343,6 +2621,111 @@ var SessionManager = class {
|
|
|
2343
2621
|
entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
|
|
2344
2622
|
return entries;
|
|
2345
2623
|
}
|
|
2624
|
+
// Build an export bundle for a session, reading meta + history from
|
|
2625
|
+
// disk. Backfills lineageId if the on-disk record pre-dates that
|
|
2626
|
+
// field. Returns undefined if the session doesn't exist. Callers
|
|
2627
|
+
// populate the bundle's exportedFrom metadata themselves.
|
|
2628
|
+
async exportBundle(sessionId) {
|
|
2629
|
+
const record = await this.store.read(sessionId);
|
|
2630
|
+
if (!record) {
|
|
2631
|
+
return void 0;
|
|
2632
|
+
}
|
|
2633
|
+
let withLineage;
|
|
2634
|
+
if (record.lineageId) {
|
|
2635
|
+
withLineage = record;
|
|
2636
|
+
} else {
|
|
2637
|
+
const lineageId = generateLineageId();
|
|
2638
|
+
const backfilled = { ...record, lineageId };
|
|
2639
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
2640
|
+
const latest = await this.store.read(sessionId);
|
|
2641
|
+
if (!latest) {
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
if (latest.lineageId) {
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
await this.store.write({ ...latest, lineageId });
|
|
2648
|
+
}).catch(() => void 0);
|
|
2649
|
+
withLineage = backfilled;
|
|
2650
|
+
}
|
|
2651
|
+
const history = await this.histories.load(sessionId).catch(() => []);
|
|
2652
|
+
const promptHistory = await loadPromptHistorySafely(sessionId);
|
|
2653
|
+
return { record: withLineage, history, promptHistory };
|
|
2654
|
+
}
|
|
2655
|
+
// Create a local session from an imported bundle. Without `replace`,
|
|
2656
|
+
// a bundle with a lineageId we already have on disk throws
|
|
2657
|
+
// BundleAlreadyImported citing the existing local id. With
|
|
2658
|
+
// `replace: true`, the existing record is overwritten in-place (its
|
|
2659
|
+
// local sessionId is preserved so bookmarks/Slack thread links still
|
|
2660
|
+
// resolve), and any live in-memory session is closed so the next
|
|
2661
|
+
// attach triggers the import-reseed path.
|
|
2662
|
+
async importBundle(bundle, opts = {}) {
|
|
2663
|
+
const existing = await this.store.findByLineageId(bundle.session.lineageId);
|
|
2664
|
+
if (existing) {
|
|
2665
|
+
if (!opts.replace) {
|
|
2666
|
+
const err = new Error(
|
|
2667
|
+
`bundle already imported as ${existing.sessionId}`
|
|
2668
|
+
);
|
|
2669
|
+
err.code = JsonRpcErrorCodes.BundleAlreadyImported;
|
|
2670
|
+
err.existingSessionId = existing.sessionId;
|
|
2671
|
+
throw err;
|
|
2672
|
+
}
|
|
2673
|
+
const live = this.sessions.get(existing.sessionId);
|
|
2674
|
+
if (live) {
|
|
2675
|
+
await live.close({ deleteRecord: false }).catch(() => void 0);
|
|
2676
|
+
}
|
|
2677
|
+
await this.writeImportedRecord({
|
|
2678
|
+
sessionId: existing.sessionId,
|
|
2679
|
+
bundle,
|
|
2680
|
+
preservedCreatedAt: existing.createdAt
|
|
2681
|
+
});
|
|
2682
|
+
return {
|
|
2683
|
+
sessionId: existing.sessionId,
|
|
2684
|
+
importedFromSessionId: bundle.session.sessionId,
|
|
2685
|
+
replaced: true
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
|
|
2689
|
+
await this.writeImportedRecord({ sessionId: newId, bundle });
|
|
2690
|
+
return {
|
|
2691
|
+
sessionId: newId,
|
|
2692
|
+
importedFromSessionId: bundle.session.sessionId,
|
|
2693
|
+
replaced: false
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
// Write the imported bundle's history.jsonl, prompt-history (if
|
|
2697
|
+
// present), and meta.json. upstreamSessionId is left empty as the
|
|
2698
|
+
// marker that the first attach should bootstrap a fresh agent and
|
|
2699
|
+
// run seedFromImport rather than calling session/load.
|
|
2700
|
+
async writeImportedRecord(args) {
|
|
2701
|
+
await this.histories.rewrite(
|
|
2702
|
+
args.sessionId,
|
|
2703
|
+
args.bundle.history
|
|
2704
|
+
);
|
|
2705
|
+
if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
|
|
2706
|
+
await saveHistory(
|
|
2707
|
+
paths.tuiHistoryFile(args.sessionId),
|
|
2708
|
+
args.bundle.promptHistory
|
|
2709
|
+
).catch(() => void 0);
|
|
2710
|
+
}
|
|
2711
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2712
|
+
await this.enqueueMetaWrite(args.sessionId, async () => {
|
|
2713
|
+
await this.store.write({
|
|
2714
|
+
sessionId: args.sessionId,
|
|
2715
|
+
lineageId: args.bundle.session.lineageId,
|
|
2716
|
+
upstreamSessionId: "",
|
|
2717
|
+
importedFromSessionId: args.bundle.session.sessionId,
|
|
2718
|
+
agentId: args.bundle.session.agentId,
|
|
2719
|
+
cwd: args.bundle.session.cwd,
|
|
2720
|
+
title: args.bundle.session.title,
|
|
2721
|
+
currentModel: args.bundle.session.currentModel,
|
|
2722
|
+
currentMode: args.bundle.session.currentMode,
|
|
2723
|
+
agentCommands: args.bundle.session.agentCommands,
|
|
2724
|
+
createdAt: args.preservedCreatedAt ?? now,
|
|
2725
|
+
updatedAt: now
|
|
2726
|
+
});
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2346
2729
|
async deleteRecord(sessionId) {
|
|
2347
2730
|
const record = await this.store.read(sessionId);
|
|
2348
2731
|
if (!record) {
|
|
@@ -2351,6 +2734,10 @@ var SessionManager = class {
|
|
|
2351
2734
|
await this.store.delete(sessionId).catch(() => void 0);
|
|
2352
2735
|
return true;
|
|
2353
2736
|
}
|
|
2737
|
+
async hasRecord(sessionId) {
|
|
2738
|
+
const record = await this.store.read(sessionId).catch(() => void 0);
|
|
2739
|
+
return record !== void 0;
|
|
2740
|
+
}
|
|
2354
2741
|
// Persist a title update from Session.setTitle. The on-disk record
|
|
2355
2742
|
// was written at create time; updating it here keeps the session
|
|
2356
2743
|
// record's title in sync with what was broadcast to clients so a
|
|
@@ -2424,13 +2811,75 @@ var SessionManager = class {
|
|
|
2424
2811
|
await Promise.allSettled(sessions.map((s) => s.close()));
|
|
2425
2812
|
this.sessions.clear();
|
|
2426
2813
|
}
|
|
2814
|
+
// Wait for every pending meta.json write to settle. Daemon shutdown
|
|
2815
|
+
// hooks call this so a SIGTERM doesn't kill the process mid-write
|
|
2816
|
+
// and lose a freshly-set title (or model/mode/commands).
|
|
2817
|
+
async flushMetaWrites() {
|
|
2818
|
+
const pending = [...this.metaWriteQueues.values()];
|
|
2819
|
+
if (pending.length === 0) {
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
await Promise.allSettled(pending);
|
|
2823
|
+
}
|
|
2427
2824
|
};
|
|
2825
|
+
function mergeForPersistence(session, existing) {
|
|
2826
|
+
const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
|
|
2827
|
+
if (c.description !== void 0) {
|
|
2828
|
+
return { name: c.name, description: c.description };
|
|
2829
|
+
}
|
|
2830
|
+
return { name: c.name };
|
|
2831
|
+
}) : void 0;
|
|
2832
|
+
const agentCommands = persistedCommands ?? existing?.agentCommands;
|
|
2833
|
+
return recordFromMemorySession({
|
|
2834
|
+
sessionId: session.sessionId,
|
|
2835
|
+
lineageId: existing?.lineageId ?? generateLineageId(),
|
|
2836
|
+
upstreamSessionId: session.upstreamSessionId,
|
|
2837
|
+
importedFromSessionId: existing?.importedFromSessionId,
|
|
2838
|
+
agentId: session.agentId,
|
|
2839
|
+
cwd: session.cwd,
|
|
2840
|
+
title: session.title,
|
|
2841
|
+
agentArgs: session.agentArgs,
|
|
2842
|
+
currentModel: session.currentModel ?? existing?.currentModel,
|
|
2843
|
+
currentMode: session.currentMode ?? existing?.currentMode,
|
|
2844
|
+
agentCommands,
|
|
2845
|
+
createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
async function loadPromptHistorySafely(sessionId) {
|
|
2849
|
+
try {
|
|
2850
|
+
const raw = await fs6.readFile(paths.tuiHistoryFile(sessionId), "utf8");
|
|
2851
|
+
const out = [];
|
|
2852
|
+
for (const line of raw.split("\n")) {
|
|
2853
|
+
if (line.length === 0) {
|
|
2854
|
+
continue;
|
|
2855
|
+
}
|
|
2856
|
+
try {
|
|
2857
|
+
const decoded = JSON.parse(line);
|
|
2858
|
+
if (typeof decoded === "string") {
|
|
2859
|
+
out.push(decoded);
|
|
2860
|
+
}
|
|
2861
|
+
} catch {
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
return out;
|
|
2865
|
+
} catch {
|
|
2866
|
+
return [];
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
async function historyMtimeIso(sessionId) {
|
|
2870
|
+
try {
|
|
2871
|
+
const st = await fs6.stat(paths.historyFile(sessionId));
|
|
2872
|
+
return new Date(st.mtimeMs).toISOString();
|
|
2873
|
+
} catch {
|
|
2874
|
+
return void 0;
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2428
2877
|
|
|
2429
2878
|
// src/core/extensions.ts
|
|
2430
2879
|
import { spawn as spawn2 } from "child_process";
|
|
2431
|
-
import * as
|
|
2880
|
+
import * as fs7 from "fs";
|
|
2432
2881
|
import * as fsp from "fs/promises";
|
|
2433
|
-
import * as
|
|
2882
|
+
import * as path4 from "path";
|
|
2434
2883
|
var RESTART_BASE_MS = 1e3;
|
|
2435
2884
|
var RESTART_CAP_MS = 6e4;
|
|
2436
2885
|
var STOP_GRACE_MS = 3e3;
|
|
@@ -2672,7 +3121,7 @@ var ExtensionManager = class {
|
|
|
2672
3121
|
if (!entry.endsWith(".pid")) {
|
|
2673
3122
|
continue;
|
|
2674
3123
|
}
|
|
2675
|
-
const pidPath =
|
|
3124
|
+
const pidPath = path4.join(paths.extensionsDir(), entry);
|
|
2676
3125
|
let pid;
|
|
2677
3126
|
try {
|
|
2678
3127
|
const raw = await fsp.readFile(pidPath, "utf8");
|
|
@@ -2711,7 +3160,7 @@ var ExtensionManager = class {
|
|
|
2711
3160
|
}
|
|
2712
3161
|
const ext = entry.config;
|
|
2713
3162
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
2714
|
-
const logStream =
|
|
3163
|
+
const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
2715
3164
|
flags: "a"
|
|
2716
3165
|
});
|
|
2717
3166
|
logStream.write(
|
|
@@ -2761,7 +3210,7 @@ var ExtensionManager = class {
|
|
|
2761
3210
|
}
|
|
2762
3211
|
if (typeof child.pid === "number") {
|
|
2763
3212
|
try {
|
|
2764
|
-
|
|
3213
|
+
fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
2765
3214
|
`, {
|
|
2766
3215
|
encoding: "utf8",
|
|
2767
3216
|
mode: 384
|
|
@@ -2786,7 +3235,7 @@ var ExtensionManager = class {
|
|
|
2786
3235
|
});
|
|
2787
3236
|
child.on("exit", (code, signal) => {
|
|
2788
3237
|
try {
|
|
2789
|
-
|
|
3238
|
+
fs7.unlinkSync(paths.extensionPidFile(ext.name));
|
|
2790
3239
|
} catch {
|
|
2791
3240
|
}
|
|
2792
3241
|
logStream.write(
|
|
@@ -2895,6 +3344,75 @@ function constantTimeEqual(a, b) {
|
|
|
2895
3344
|
}
|
|
2896
3345
|
|
|
2897
3346
|
// src/daemon/routes/sessions.ts
|
|
3347
|
+
import * as os2 from "os";
|
|
3348
|
+
|
|
3349
|
+
// src/core/bundle.ts
|
|
3350
|
+
import { z as z5 } from "zod";
|
|
3351
|
+
var HistoryEntrySchema = z5.object({
|
|
3352
|
+
method: z5.string(),
|
|
3353
|
+
params: z5.unknown(),
|
|
3354
|
+
recordedAt: z5.number()
|
|
3355
|
+
});
|
|
3356
|
+
var BundleSession = z5.object({
|
|
3357
|
+
// The exporter's local id. Regenerated fresh on import (sessionId is
|
|
3358
|
+
// the local namespace; lineageId is what survives across hops).
|
|
3359
|
+
sessionId: z5.string(),
|
|
3360
|
+
// Required on bundles — the export path backfills if the source
|
|
3361
|
+
// record was written before lineageId existed.
|
|
3362
|
+
lineageId: z5.string(),
|
|
3363
|
+
agentId: z5.string(),
|
|
3364
|
+
cwd: z5.string(),
|
|
3365
|
+
title: z5.string().optional(),
|
|
3366
|
+
currentModel: z5.string().optional(),
|
|
3367
|
+
currentMode: z5.string().optional(),
|
|
3368
|
+
agentCommands: z5.array(PersistedAgentCommand).optional(),
|
|
3369
|
+
createdAt: z5.string(),
|
|
3370
|
+
updatedAt: z5.string()
|
|
3371
|
+
});
|
|
3372
|
+
var Bundle = z5.object({
|
|
3373
|
+
version: z5.literal(1),
|
|
3374
|
+
exportedAt: z5.string(),
|
|
3375
|
+
exportedFrom: z5.object({
|
|
3376
|
+
hydraVersion: z5.string(),
|
|
3377
|
+
machine: z5.string()
|
|
3378
|
+
}),
|
|
3379
|
+
session: BundleSession,
|
|
3380
|
+
history: z5.array(HistoryEntrySchema),
|
|
3381
|
+
promptHistory: z5.array(z5.string()).optional()
|
|
3382
|
+
});
|
|
3383
|
+
function encodeBundle(params) {
|
|
3384
|
+
const bundle = {
|
|
3385
|
+
version: 1,
|
|
3386
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3387
|
+
exportedFrom: {
|
|
3388
|
+
hydraVersion: params.hydraVersion,
|
|
3389
|
+
machine: params.machine
|
|
3390
|
+
},
|
|
3391
|
+
session: {
|
|
3392
|
+
sessionId: params.record.sessionId,
|
|
3393
|
+
lineageId: params.record.lineageId,
|
|
3394
|
+
agentId: params.record.agentId,
|
|
3395
|
+
cwd: params.record.cwd,
|
|
3396
|
+
...params.record.title !== void 0 ? { title: params.record.title } : {},
|
|
3397
|
+
...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
|
|
3398
|
+
...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
|
|
3399
|
+
...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
|
|
3400
|
+
createdAt: params.record.createdAt,
|
|
3401
|
+
updatedAt: params.record.updatedAt
|
|
3402
|
+
},
|
|
3403
|
+
history: params.history
|
|
3404
|
+
};
|
|
3405
|
+
if (params.promptHistory !== void 0) {
|
|
3406
|
+
bundle.promptHistory = params.promptHistory;
|
|
3407
|
+
}
|
|
3408
|
+
return bundle;
|
|
3409
|
+
}
|
|
3410
|
+
function decodeBundle(raw) {
|
|
3411
|
+
return Bundle.parse(raw);
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
// src/daemon/routes/sessions.ts
|
|
3415
|
+
var HYDRA_VERSION = "0.1.0";
|
|
2898
3416
|
function registerSessionRoutes(app, manager, defaults) {
|
|
2899
3417
|
app.get("/v1/sessions", async (request) => {
|
|
2900
3418
|
const query = request.query;
|
|
@@ -2920,6 +3438,22 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
2920
3438
|
reply.code(500).send({ error: err.message });
|
|
2921
3439
|
}
|
|
2922
3440
|
});
|
|
3441
|
+
app.post("/v1/sessions/:id/kill", async (request, reply) => {
|
|
3442
|
+
const raw = request.params.id;
|
|
3443
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
3444
|
+
const session = manager.get(id);
|
|
3445
|
+
if (session) {
|
|
3446
|
+
await session.close({ deleteRecord: false });
|
|
3447
|
+
reply.code(204).send();
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
const exists = await manager.hasRecord(id);
|
|
3451
|
+
if (!exists) {
|
|
3452
|
+
reply.code(404).send({ error: "session not found" });
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
reply.code(204).send();
|
|
3456
|
+
});
|
|
2923
3457
|
app.delete("/v1/sessions/:id", async (request, reply) => {
|
|
2924
3458
|
const raw = request.params.id;
|
|
2925
3459
|
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
@@ -2936,6 +3470,61 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
2936
3470
|
}
|
|
2937
3471
|
reply.code(204).send();
|
|
2938
3472
|
});
|
|
3473
|
+
app.get("/v1/sessions/:id/export", async (request, reply) => {
|
|
3474
|
+
const raw = request.params.id;
|
|
3475
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
3476
|
+
const exported = await manager.exportBundle(id);
|
|
3477
|
+
if (!exported) {
|
|
3478
|
+
reply.code(404).send({ error: "session not found" });
|
|
3479
|
+
return;
|
|
3480
|
+
}
|
|
3481
|
+
const bundle = encodeBundle({
|
|
3482
|
+
record: exported.record,
|
|
3483
|
+
history: exported.history,
|
|
3484
|
+
promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
|
|
3485
|
+
hydraVersion: HYDRA_VERSION,
|
|
3486
|
+
machine: os2.hostname()
|
|
3487
|
+
});
|
|
3488
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
3489
|
+
reply.header(
|
|
3490
|
+
"Content-Disposition",
|
|
3491
|
+
`attachment; filename="hydra-${id}-${stamp}.hydra"`
|
|
3492
|
+
);
|
|
3493
|
+
reply.code(200).send(bundle);
|
|
3494
|
+
});
|
|
3495
|
+
app.post("/v1/sessions/import", async (request, reply) => {
|
|
3496
|
+
const body = request.body ?? {};
|
|
3497
|
+
if (body.bundle === void 0) {
|
|
3498
|
+
reply.code(400).send({ error: "missing bundle" });
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
let bundle;
|
|
3502
|
+
try {
|
|
3503
|
+
bundle = decodeBundle(body.bundle);
|
|
3504
|
+
} catch (err) {
|
|
3505
|
+
reply.code(400).send({
|
|
3506
|
+
error: "invalid bundle",
|
|
3507
|
+
details: err.message
|
|
3508
|
+
});
|
|
3509
|
+
return;
|
|
3510
|
+
}
|
|
3511
|
+
try {
|
|
3512
|
+
const result = await manager.importBundle(bundle, {
|
|
3513
|
+
replace: body.replace === true
|
|
3514
|
+
});
|
|
3515
|
+
reply.code(201).send(result);
|
|
3516
|
+
} catch (err) {
|
|
3517
|
+
const e = err;
|
|
3518
|
+
if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
|
|
3519
|
+
reply.code(409).send({
|
|
3520
|
+
error: "bundle already imported",
|
|
3521
|
+
existingSessionId: e.existingSessionId
|
|
3522
|
+
});
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
3525
|
+
reply.code(500).send({ error: e.message });
|
|
3526
|
+
}
|
|
3527
|
+
});
|
|
2939
3528
|
app.get("/v1/sessions/:id/history", async (request, reply) => {
|
|
2940
3529
|
const raw = request.params.id;
|
|
2941
3530
|
const query = request.query;
|
|
@@ -2944,16 +3533,22 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
2944
3533
|
const live = manager.get(id);
|
|
2945
3534
|
let snapshot;
|
|
2946
3535
|
let unsubscribe;
|
|
3536
|
+
let snapshotDone = false;
|
|
3537
|
+
const pending = [];
|
|
2947
3538
|
if (live) {
|
|
2948
|
-
snapshot = live.getHistorySnapshot();
|
|
2949
3539
|
if (follow) {
|
|
2950
3540
|
unsubscribe = live.onBroadcast((entry) => {
|
|
2951
3541
|
if (reply.raw.writableEnded) {
|
|
2952
3542
|
return;
|
|
2953
3543
|
}
|
|
2954
|
-
|
|
3544
|
+
if (snapshotDone) {
|
|
3545
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
3546
|
+
} else {
|
|
3547
|
+
pending.push(entry);
|
|
3548
|
+
}
|
|
2955
3549
|
});
|
|
2956
3550
|
}
|
|
3551
|
+
snapshot = await live.getHistorySnapshot();
|
|
2957
3552
|
} else {
|
|
2958
3553
|
const cold = await manager.getHistory(id);
|
|
2959
3554
|
if (cold === void 0) {
|
|
@@ -2965,9 +3560,23 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
2965
3560
|
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
|
2966
3561
|
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
2967
3562
|
reply.raw.statusCode = 200;
|
|
3563
|
+
const snapshotKeys = /* @__PURE__ */ new Set();
|
|
2968
3564
|
for (const entry of snapshot ?? []) {
|
|
2969
3565
|
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
3566
|
+
const e = entry;
|
|
3567
|
+
if (typeof e.recordedAt === "number") {
|
|
3568
|
+
snapshotKeys.add(String(e.recordedAt));
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
for (const entry of pending) {
|
|
3572
|
+
const e = entry;
|
|
3573
|
+
const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
|
|
3574
|
+
if (key && snapshotKeys.has(key)) {
|
|
3575
|
+
continue;
|
|
3576
|
+
}
|
|
3577
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
2970
3578
|
}
|
|
3579
|
+
snapshotDone = true;
|
|
2971
3580
|
if (!unsubscribe) {
|
|
2972
3581
|
reply.raw.end();
|
|
2973
3582
|
return reply;
|
|
@@ -3127,6 +3736,16 @@ function parseRegisterBody(body) {
|
|
|
3127
3736
|
};
|
|
3128
3737
|
}
|
|
3129
3738
|
|
|
3739
|
+
// src/daemon/routes/config.ts
|
|
3740
|
+
function registerConfigRoutes(app, defaults) {
|
|
3741
|
+
app.get("/v1/config", async () => {
|
|
3742
|
+
return {
|
|
3743
|
+
defaultAgent: defaults.defaultAgent,
|
|
3744
|
+
defaultCwd: defaults.defaultCwd
|
|
3745
|
+
};
|
|
3746
|
+
});
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3130
3749
|
// src/daemon/acp-ws.ts
|
|
3131
3750
|
import { nanoid as nanoid2 } from "nanoid";
|
|
3132
3751
|
|
|
@@ -3202,7 +3821,7 @@ function wsToMessageStream(ws) {
|
|
|
3202
3821
|
}
|
|
3203
3822
|
|
|
3204
3823
|
// src/daemon/acp-ws.ts
|
|
3205
|
-
var
|
|
3824
|
+
var HYDRA_VERSION2 = "0.1.0";
|
|
3206
3825
|
var HYDRA_PROTOCOL_VERSION = 1;
|
|
3207
3826
|
function registerAcpWsEndpoint(app, deps) {
|
|
3208
3827
|
app.get("/acp", { websocket: true }, (socket, request) => {
|
|
@@ -3248,7 +3867,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3248
3867
|
agentArgs: hydraMeta.agentArgs
|
|
3249
3868
|
});
|
|
3250
3869
|
const client = bindClientToSession(connection, session, state);
|
|
3251
|
-
session.attach(client, "full");
|
|
3870
|
+
await session.attach(client, "full");
|
|
3252
3871
|
state.attached.set(session.sessionId, {
|
|
3253
3872
|
sessionId: session.sessionId,
|
|
3254
3873
|
clientId: client.clientId
|
|
@@ -3267,14 +3886,22 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3267
3886
|
const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
|
|
3268
3887
|
let session = deps.manager.get(lookupId);
|
|
3269
3888
|
if (!session) {
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3889
|
+
const fromDisk = await deps.manager.loadFromDisk(lookupId);
|
|
3890
|
+
let resurrectParams = fromDisk;
|
|
3891
|
+
if (hydraHints) {
|
|
3892
|
+
resurrectParams = {
|
|
3893
|
+
hydraSessionId: params.sessionId,
|
|
3894
|
+
upstreamSessionId: hydraHints.upstreamSessionId,
|
|
3895
|
+
agentId: hydraHints.agentId,
|
|
3896
|
+
cwd: hydraHints.cwd,
|
|
3897
|
+
title: hydraHints.title ?? fromDisk?.title,
|
|
3898
|
+
agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
|
|
3899
|
+
currentModel: fromDisk?.currentModel,
|
|
3900
|
+
currentMode: fromDisk?.currentMode,
|
|
3901
|
+
agentCommands: fromDisk?.agentCommands,
|
|
3902
|
+
createdAt: fromDisk?.createdAt
|
|
3903
|
+
};
|
|
3904
|
+
}
|
|
3278
3905
|
if (!resurrectParams) {
|
|
3279
3906
|
const err = new Error(
|
|
3280
3907
|
`session ${params.sessionId} not found and no resume hints provided`
|
|
@@ -3290,13 +3917,13 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3290
3917
|
state,
|
|
3291
3918
|
params.clientInfo
|
|
3292
3919
|
);
|
|
3293
|
-
const replay = session.attach(client, params.historyPolicy);
|
|
3920
|
+
const replay = await session.attach(client, params.historyPolicy);
|
|
3294
3921
|
state.attached.set(session.sessionId, {
|
|
3295
3922
|
sessionId: session.sessionId,
|
|
3296
3923
|
clientId: client.clientId
|
|
3297
3924
|
});
|
|
3298
3925
|
app.log.info(
|
|
3299
|
-
`session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
|
|
3926
|
+
`session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
|
|
3300
3927
|
);
|
|
3301
3928
|
for (const note of replay) {
|
|
3302
3929
|
await connection.notify(note.method, note.params);
|
|
@@ -3392,7 +4019,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3392
4019
|
session = await deps.manager.resurrect(fromDisk);
|
|
3393
4020
|
}
|
|
3394
4021
|
const client = bindClientToSession(connection, session, state);
|
|
3395
|
-
const replay = session.attach(client, "pending_only");
|
|
4022
|
+
const replay = await session.attach(client, "pending_only");
|
|
3396
4023
|
state.attached.set(session.sessionId, {
|
|
3397
4024
|
sessionId: session.sessionId,
|
|
3398
4025
|
clientId: client.clientId
|
|
@@ -3450,12 +4077,15 @@ function buildResponseMeta(session) {
|
|
|
3450
4077
|
if (commands.length > 0) {
|
|
3451
4078
|
ours.availableCommands = commands;
|
|
3452
4079
|
}
|
|
4080
|
+
if (session.turnStartedAt !== void 0) {
|
|
4081
|
+
ours.turnStartedAt = session.turnStartedAt;
|
|
4082
|
+
}
|
|
3453
4083
|
return mergeMeta(session.agentMeta, ours);
|
|
3454
4084
|
}
|
|
3455
4085
|
function buildInitializeResult() {
|
|
3456
4086
|
return {
|
|
3457
4087
|
protocolVersion: HYDRA_PROTOCOL_VERSION,
|
|
3458
|
-
agentInfo: { name: "hydra", version:
|
|
4088
|
+
agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
|
|
3459
4089
|
agentCapabilities: {
|
|
3460
4090
|
// hydra is a transparent proxy: prompt blocks and MCP server configs are
|
|
3461
4091
|
// forwarded to the underlying agent unchanged. We claim the union of
|
|
@@ -3494,7 +4124,7 @@ function bindClientToSession(connection, session, state, clientInfo) {
|
|
|
3494
4124
|
}
|
|
3495
4125
|
|
|
3496
4126
|
// src/daemon/server.ts
|
|
3497
|
-
var
|
|
4127
|
+
var HYDRA_VERSION3 = "0.1.0";
|
|
3498
4128
|
async function startDaemon(config) {
|
|
3499
4129
|
ensureLoopbackOrTls(config);
|
|
3500
4130
|
const httpsOptions = config.daemon.tls ? {
|
|
@@ -3528,13 +4158,17 @@ async function startDaemon(config) {
|
|
|
3528
4158
|
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
|
|
3529
4159
|
});
|
|
3530
4160
|
const extensions = new ExtensionManager(extensionList(config));
|
|
3531
|
-
registerHealthRoutes(app,
|
|
4161
|
+
registerHealthRoutes(app, HYDRA_VERSION3);
|
|
3532
4162
|
registerSessionRoutes(app, manager, {
|
|
3533
4163
|
agentId: config.defaultAgent,
|
|
3534
4164
|
cwd: config.defaultCwd
|
|
3535
4165
|
});
|
|
3536
4166
|
registerAgentRoutes(app, registry);
|
|
3537
4167
|
registerExtensionRoutes(app, extensions);
|
|
4168
|
+
registerConfigRoutes(app, {
|
|
4169
|
+
defaultAgent: config.defaultAgent,
|
|
4170
|
+
defaultCwd: config.defaultCwd
|
|
4171
|
+
});
|
|
3538
4172
|
registerAcpWsEndpoint(app, {
|
|
3539
4173
|
config,
|
|
3540
4174
|
manager,
|
|
@@ -3568,9 +4202,10 @@ async function startDaemon(config) {
|
|
|
3568
4202
|
const shutdown = async () => {
|
|
3569
4203
|
await extensions.stop();
|
|
3570
4204
|
await manager.closeAll();
|
|
4205
|
+
await manager.flushMetaWrites();
|
|
3571
4206
|
await app.close();
|
|
3572
4207
|
try {
|
|
3573
|
-
|
|
4208
|
+
fs8.unlinkSync(paths.pidFile());
|
|
3574
4209
|
} catch {
|
|
3575
4210
|
}
|
|
3576
4211
|
try {
|