@hydra-acp/cli 0.1.1 → 0.1.3
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 +4 -2
- package/dist/cli.js +2139 -635
- package/dist/index.d.ts +88 -6
- package/dist/index.js +669 -61
- 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 fs7 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 = {
|
|
@@ -32,11 +37,17 @@ var paths = {
|
|
|
32
37
|
agentsDir: () => path.join(hydraHome(), "agents"),
|
|
33
38
|
agentDir: (id) => path.join(hydraHome(), "agents", id),
|
|
34
39
|
sessionsDir: () => path.join(hydraHome(), "sessions"),
|
|
35
|
-
|
|
40
|
+
// One directory per session id under sessions/. Co-locates the
|
|
41
|
+
// session record, its transcript, and any future per-session state
|
|
42
|
+
// (uploads, scratch, etc.) so the lifecycle is just "rm -rf the dir".
|
|
43
|
+
sessionDir: (id) => path.join(hydraHome(), "sessions", id),
|
|
44
|
+
sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
|
|
45
|
+
historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
|
|
36
46
|
extensionsDir: () => path.join(hydraHome(), "extensions"),
|
|
37
47
|
extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
|
|
38
48
|
extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
|
|
39
|
-
tuiHistoryFile: () => path.join(hydraHome(), "
|
|
49
|
+
tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
|
|
50
|
+
tuiLogFile: () => path.join(hydraHome(), "tui.log")
|
|
40
51
|
};
|
|
41
52
|
|
|
42
53
|
// src/core/config.ts
|
|
@@ -64,7 +75,11 @@ var TuiConfig = z.object({
|
|
|
64
75
|
// /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
|
|
65
76
|
// CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
|
|
66
77
|
// or 0 to disable throttling entirely.
|
|
67
|
-
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)
|
|
68
83
|
});
|
|
69
84
|
var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
|
|
70
85
|
var ExtensionBody = z.object({
|
|
@@ -89,7 +104,7 @@ var HydraConfig = z.object({
|
|
|
89
104
|
// recency and truncated to this count. `--all` overrides in the CLI.
|
|
90
105
|
sessionListColdLimit: z.number().int().nonnegative().default(20),
|
|
91
106
|
extensions: z.record(ExtensionName, ExtensionBody).default({}),
|
|
92
|
-
tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
|
|
107
|
+
tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
|
|
93
108
|
});
|
|
94
109
|
function extensionList(config) {
|
|
95
110
|
return Object.entries(config.extensions).map(([name, body]) => ({
|
|
@@ -332,6 +347,9 @@ function planSpawn(agent, extraArgs = []) {
|
|
|
332
347
|
throw new Error(`Agent ${agent.id} has no usable distribution method.`);
|
|
333
348
|
}
|
|
334
349
|
|
|
350
|
+
// src/core/session-manager.ts
|
|
351
|
+
import * as fs5 from "fs/promises";
|
|
352
|
+
|
|
335
353
|
// src/core/agent-instance.ts
|
|
336
354
|
import { spawn } from "child_process";
|
|
337
355
|
|
|
@@ -410,6 +428,35 @@ function extractHydraMeta(meta) {
|
|
|
410
428
|
out.resume = parsed.data;
|
|
411
429
|
}
|
|
412
430
|
}
|
|
431
|
+
if (typeof obj.currentModel === "string") {
|
|
432
|
+
out.currentModel = obj.currentModel;
|
|
433
|
+
}
|
|
434
|
+
if (typeof obj.currentMode === "string") {
|
|
435
|
+
out.currentMode = obj.currentMode;
|
|
436
|
+
}
|
|
437
|
+
if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
|
|
438
|
+
out.turnStartedAt = obj.turnStartedAt;
|
|
439
|
+
}
|
|
440
|
+
if (Array.isArray(obj.availableCommands)) {
|
|
441
|
+
const cmds = [];
|
|
442
|
+
for (const raw of obj.availableCommands) {
|
|
443
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const c = raw;
|
|
447
|
+
if (typeof c.name !== "string") {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const cmd = { name: c.name };
|
|
451
|
+
if (typeof c.description === "string") {
|
|
452
|
+
cmd.description = c.description;
|
|
453
|
+
}
|
|
454
|
+
cmds.push(cmd);
|
|
455
|
+
}
|
|
456
|
+
if (cmds.length > 0) {
|
|
457
|
+
out.availableCommands = cmds;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
413
460
|
return out;
|
|
414
461
|
}
|
|
415
462
|
function mergeMeta(passthrough, ours) {
|
|
@@ -779,14 +826,26 @@ var Session = class {
|
|
|
779
826
|
agentMeta;
|
|
780
827
|
agentArgs;
|
|
781
828
|
title;
|
|
829
|
+
// Snapshot state delivered to attaching clients via the attach
|
|
830
|
+
// response _meta rather than via history replay (which would be
|
|
831
|
+
// stale-prone for snapshot-shaped events).
|
|
832
|
+
currentModel;
|
|
833
|
+
currentMode;
|
|
782
834
|
updatedAt;
|
|
783
835
|
clients = /* @__PURE__ */ new Map();
|
|
784
836
|
history = [];
|
|
837
|
+
historyStore;
|
|
785
838
|
promptQueue = [];
|
|
786
839
|
promptInFlight = false;
|
|
787
840
|
closed = false;
|
|
788
841
|
closeHandlers = [];
|
|
789
842
|
titleHandlers = [];
|
|
843
|
+
// Subscribers notified after every entry that's actually persisted to
|
|
844
|
+
// history (skipping snapshot-shaped events filtered by
|
|
845
|
+
// recordAndBroadcast). The HTTP /v1/sessions/:id/history?follow=1
|
|
846
|
+
// endpoint uses this to tail a live session's conversation stream
|
|
847
|
+
// without participating in turns or prompts.
|
|
848
|
+
broadcastHandlers = [];
|
|
790
849
|
// True once we've observed our first session/prompt; gates the
|
|
791
850
|
// first-prompt-seeded title so subsequent prompts don't churn it.
|
|
792
851
|
firstPromptSeeded = false;
|
|
@@ -806,12 +865,18 @@ var Session = class {
|
|
|
806
865
|
idleTimer;
|
|
807
866
|
spawnReplacementAgent;
|
|
808
867
|
agentChangeHandlers = [];
|
|
809
|
-
// Last available_commands_update we observed from the agent. Stored
|
|
810
|
-
// we can re-broadcast a merged (hydra ∪ agent) list whenever
|
|
811
|
-
// half changes
|
|
812
|
-
//
|
|
813
|
-
//
|
|
868
|
+
// Last available_commands_update we observed from the agent. Stored
|
|
869
|
+
// so we can re-broadcast a merged (hydra ∪ agent) list whenever
|
|
870
|
+
// either half changes, and persisted to meta.json so a fresh attach
|
|
871
|
+
// can deliver the merged list via _meta without depending on history
|
|
872
|
+
// replay.
|
|
814
873
|
agentAdvertisedCommands = [];
|
|
874
|
+
// Persist hooks for snapshot-shaped state. SessionManager hooks these
|
|
875
|
+
// to mirror changes into meta.json so cold-resurrect attaches can
|
|
876
|
+
// surface the latest snapshot via the attach response _meta.
|
|
877
|
+
agentCommandsHandlers = [];
|
|
878
|
+
modelHandlers = [];
|
|
879
|
+
modeHandlers = [];
|
|
815
880
|
constructor(init) {
|
|
816
881
|
this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
|
|
817
882
|
this.cwd = init.cwd;
|
|
@@ -821,11 +886,22 @@ var Session = class {
|
|
|
821
886
|
this.agentMeta = init.agentMeta;
|
|
822
887
|
this.agentArgs = init.agentArgs;
|
|
823
888
|
this.title = init.title;
|
|
889
|
+
this.currentModel = init.currentModel;
|
|
890
|
+
this.currentMode = init.currentMode;
|
|
891
|
+
if (init.agentCommands && init.agentCommands.length > 0) {
|
|
892
|
+
this.agentAdvertisedCommands = [...init.agentCommands];
|
|
893
|
+
}
|
|
824
894
|
this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
|
|
825
895
|
this.spawnReplacementAgent = init.spawnReplacementAgent;
|
|
896
|
+
if (init.firstPromptSeeded) {
|
|
897
|
+
this.firstPromptSeeded = true;
|
|
898
|
+
}
|
|
899
|
+
this.historyStore = init.historyStore;
|
|
900
|
+
if (init.seedHistory && init.seedHistory.length > 0) {
|
|
901
|
+
this.history = [...init.seedHistory];
|
|
902
|
+
}
|
|
826
903
|
this.updatedAt = Date.now();
|
|
827
904
|
this.wireAgent(this.agent);
|
|
828
|
-
this.broadcastMergedCommands();
|
|
829
905
|
}
|
|
830
906
|
broadcastMergedCommands() {
|
|
831
907
|
const merged = [
|
|
@@ -854,8 +930,15 @@ var Session = class {
|
|
|
854
930
|
}
|
|
855
931
|
const agentCmds = extractAdvertisedCommands(params);
|
|
856
932
|
if (agentCmds !== null) {
|
|
857
|
-
this.
|
|
858
|
-
|
|
933
|
+
this.setAgentAdvertisedCommands(agentCmds);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (this.maybeApplyAgentModel(params)) {
|
|
937
|
+
this.recordAndBroadcast("session/update", params);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (this.maybeApplyAgentMode(params)) {
|
|
941
|
+
this.recordAndBroadcast("session/update", params);
|
|
859
942
|
return;
|
|
860
943
|
}
|
|
861
944
|
this.maybeApplyAgentSessionInfo(params);
|
|
@@ -877,6 +960,50 @@ var Session = class {
|
|
|
877
960
|
get attachedCount() {
|
|
878
961
|
return this.clients.size;
|
|
879
962
|
}
|
|
963
|
+
// Wall-clock when the in-flight agent turn began, or undefined when
|
|
964
|
+
// idle. Derived from history: the most recent prompt_received without
|
|
965
|
+
// a later turn_complete is the outstanding turn, and its recordedAt
|
|
966
|
+
// is when the prompt was first broadcast. Used by buildResponseMeta
|
|
967
|
+
// so a fresh client reattaching mid-turn boots up with the busy
|
|
968
|
+
// banner showing real elapsed time.
|
|
969
|
+
get turnStartedAt() {
|
|
970
|
+
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
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;
|
|
985
|
+
}
|
|
986
|
+
// Snapshot of the current in-memory replay history. Used by the
|
|
987
|
+
// HTTP history endpoint to deliver the "what's accumulated so far"
|
|
988
|
+
// prefix before optionally tailing with onBroadcast. Returns a copy
|
|
989
|
+
// so callers can't mutate our cache.
|
|
990
|
+
getHistorySnapshot() {
|
|
991
|
+
return [...this.history];
|
|
992
|
+
}
|
|
993
|
+
// Subscribe to recordable broadcast entries — fires once per entry
|
|
994
|
+
// that lands in history (so snapshot-shaped session_info/model/mode/
|
|
995
|
+
// available_commands updates do NOT trigger this; they're broadcast
|
|
996
|
+
// live but not recorded). Returns an unsubscribe function the caller
|
|
997
|
+
// must invoke when done.
|
|
998
|
+
onBroadcast(handler) {
|
|
999
|
+
this.broadcastHandlers.push(handler);
|
|
1000
|
+
return () => {
|
|
1001
|
+
const i = this.broadcastHandlers.indexOf(handler);
|
|
1002
|
+
if (i >= 0) {
|
|
1003
|
+
this.broadcastHandlers.splice(i, 1);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
880
1007
|
attach(client, historyPolicy) {
|
|
881
1008
|
if (this.closed) {
|
|
882
1009
|
throw withCode(
|
|
@@ -933,13 +1060,19 @@ var Session = class {
|
|
|
933
1060
|
this.broadcastPromptReceived(client, params);
|
|
934
1061
|
this.maybeSeedTitleFromPrompt(params);
|
|
935
1062
|
return this.enqueuePrompt(async () => {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1063
|
+
let response;
|
|
1064
|
+
try {
|
|
1065
|
+
response = await this.agent.connection.request(
|
|
1066
|
+
"session/prompt",
|
|
1067
|
+
{
|
|
1068
|
+
...params,
|
|
1069
|
+
sessionId: this.upstreamSessionId
|
|
1070
|
+
}
|
|
1071
|
+
);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
|
|
1074
|
+
throw err;
|
|
1075
|
+
}
|
|
943
1076
|
this.broadcastTurnComplete(client.clientId, response);
|
|
944
1077
|
return response;
|
|
945
1078
|
});
|
|
@@ -1027,6 +1160,13 @@ var Session = class {
|
|
|
1027
1160
|
return;
|
|
1028
1161
|
}
|
|
1029
1162
|
this.cancelIdleTimer();
|
|
1163
|
+
if (opts.regenTitle && this.firstPromptSeeded) {
|
|
1164
|
+
const timeoutMs = opts.regenTitleTimeoutMs ?? 5e3;
|
|
1165
|
+
await Promise.race([
|
|
1166
|
+
this.runTitleRegen().catch(() => void 0),
|
|
1167
|
+
new Promise((r) => setTimeout(r, timeoutMs).unref?.())
|
|
1168
|
+
]);
|
|
1169
|
+
}
|
|
1030
1170
|
await this.agent.kill().catch(() => void 0);
|
|
1031
1171
|
this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
|
|
1032
1172
|
}
|
|
@@ -1075,13 +1215,98 @@ var Session = class {
|
|
|
1075
1215
|
}
|
|
1076
1216
|
const promptParams = params ?? {};
|
|
1077
1217
|
const text = extractPromptText(promptParams.prompt);
|
|
1078
|
-
const seed = firstLine(text,
|
|
1218
|
+
const seed = firstLine(text, 200);
|
|
1079
1219
|
if (!seed) {
|
|
1080
1220
|
return;
|
|
1081
1221
|
}
|
|
1082
1222
|
this.firstPromptSeeded = true;
|
|
1083
1223
|
this.setTitle(seed);
|
|
1084
1224
|
}
|
|
1225
|
+
// Apply an agent-emitted current_model_update. Returns true if the
|
|
1226
|
+
// notification was a model update (caller still needs to broadcast
|
|
1227
|
+
// it). Returns false otherwise so the caller can try the next kind.
|
|
1228
|
+
maybeApplyAgentModel(params) {
|
|
1229
|
+
const obj = params ?? {};
|
|
1230
|
+
const update = obj.update ?? {};
|
|
1231
|
+
if (update.sessionUpdate !== "current_model_update") {
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
|
|
1235
|
+
if (raw === void 0) {
|
|
1236
|
+
return true;
|
|
1237
|
+
}
|
|
1238
|
+
const trimmed = raw.trim();
|
|
1239
|
+
if (!trimmed || trimmed === this.currentModel) {
|
|
1240
|
+
return true;
|
|
1241
|
+
}
|
|
1242
|
+
this.currentModel = trimmed;
|
|
1243
|
+
for (const handler of this.modelHandlers) {
|
|
1244
|
+
try {
|
|
1245
|
+
handler(trimmed);
|
|
1246
|
+
} catch {
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
maybeApplyAgentMode(params) {
|
|
1252
|
+
const obj = params ?? {};
|
|
1253
|
+
const update = obj.update ?? {};
|
|
1254
|
+
if (update.sessionUpdate !== "current_mode_update") {
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
|
|
1258
|
+
if (raw === void 0) {
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
const trimmed = raw.trim();
|
|
1262
|
+
if (!trimmed || trimmed === this.currentMode) {
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
this.currentMode = trimmed;
|
|
1266
|
+
for (const handler of this.modeHandlers) {
|
|
1267
|
+
try {
|
|
1268
|
+
handler(trimmed);
|
|
1269
|
+
} catch {
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
// Update the cached agent command list, fire persist handlers, and
|
|
1275
|
+
// broadcast the merged list to attached clients. Idempotent on a
|
|
1276
|
+
// structurally identical list so we don't churn meta.json on noisy
|
|
1277
|
+
// re-emissions.
|
|
1278
|
+
setAgentAdvertisedCommands(commands) {
|
|
1279
|
+
if (sameAdvertisedCommands(this.agentAdvertisedCommands, commands)) {
|
|
1280
|
+
this.broadcastMergedCommands();
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
this.agentAdvertisedCommands = commands;
|
|
1284
|
+
for (const handler of this.agentCommandsHandlers) {
|
|
1285
|
+
try {
|
|
1286
|
+
handler(commands);
|
|
1287
|
+
} catch {
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
this.broadcastMergedCommands();
|
|
1291
|
+
}
|
|
1292
|
+
// Subscribe to snapshot-state updates. SessionManager wires these to
|
|
1293
|
+
// persist the new value into meta.json so cold resurrect can restore
|
|
1294
|
+
// them via the attach response _meta.
|
|
1295
|
+
onAgentCommandsChange(handler) {
|
|
1296
|
+
this.agentCommandsHandlers.push(handler);
|
|
1297
|
+
}
|
|
1298
|
+
onModelChange(handler) {
|
|
1299
|
+
this.modelHandlers.push(handler);
|
|
1300
|
+
}
|
|
1301
|
+
onModeChange(handler) {
|
|
1302
|
+
this.modeHandlers.push(handler);
|
|
1303
|
+
}
|
|
1304
|
+
// Returns a freshly merged command list (hydra ∪ agent) for callers
|
|
1305
|
+
// that need a snapshot — notably acp-ws.ts's buildResponseMeta when
|
|
1306
|
+
// assembling the attach response.
|
|
1307
|
+
mergedAvailableCommands() {
|
|
1308
|
+
return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
|
|
1309
|
+
}
|
|
1085
1310
|
// Pick up an agent-emitted session_info_update and store its title
|
|
1086
1311
|
// as our canonical record. The notification is also forwarded to
|
|
1087
1312
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -1369,7 +1594,8 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1369
1594
|
}
|
|
1370
1595
|
this.idleTimer = setTimeout(() => {
|
|
1371
1596
|
this.idleTimer = void 0;
|
|
1372
|
-
|
|
1597
|
+
const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
|
|
1598
|
+
void this.close(opts).catch(() => void 0);
|
|
1373
1599
|
}, this.idleTimeoutMs);
|
|
1374
1600
|
if (typeof this.idleTimer.unref === "function") {
|
|
1375
1601
|
this.idleTimer.unref();
|
|
@@ -1392,9 +1618,34 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1392
1618
|
}
|
|
1393
1619
|
recordAndBroadcast(method, params, excludeClientId) {
|
|
1394
1620
|
const rewritten = this.rewriteForClient(params);
|
|
1395
|
-
|
|
1396
|
-
if (
|
|
1397
|
-
|
|
1621
|
+
const recordable = !isStateUpdate(method, rewritten);
|
|
1622
|
+
if (recordable) {
|
|
1623
|
+
const entry = {
|
|
1624
|
+
method,
|
|
1625
|
+
params: rewritten,
|
|
1626
|
+
recordedAt: Date.now()
|
|
1627
|
+
};
|
|
1628
|
+
this.history.push(entry);
|
|
1629
|
+
let trimmed = false;
|
|
1630
|
+
if (this.history.length > 1e3) {
|
|
1631
|
+
this.history = this.history.slice(-500);
|
|
1632
|
+
trimmed = true;
|
|
1633
|
+
}
|
|
1634
|
+
if (this.historyStore) {
|
|
1635
|
+
if (trimmed) {
|
|
1636
|
+
void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
|
|
1637
|
+
} else {
|
|
1638
|
+
void this.historyStore.append(this.sessionId, entry).catch(
|
|
1639
|
+
() => void 0
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
for (const handler of this.broadcastHandlers) {
|
|
1644
|
+
try {
|
|
1645
|
+
handler(entry);
|
|
1646
|
+
} catch {
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1398
1649
|
}
|
|
1399
1650
|
this.updatedAt = Date.now();
|
|
1400
1651
|
for (const client of this.clients.values()) {
|
|
@@ -1494,6 +1745,31 @@ function withCode(err, code) {
|
|
|
1494
1745
|
err.code = code;
|
|
1495
1746
|
return err;
|
|
1496
1747
|
}
|
|
1748
|
+
var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
|
|
1749
|
+
"session_info_update",
|
|
1750
|
+
"current_model_update",
|
|
1751
|
+
"current_mode_update",
|
|
1752
|
+
"available_commands_update"
|
|
1753
|
+
]);
|
|
1754
|
+
function isStateUpdate(method, params) {
|
|
1755
|
+
if (method !== "session/update") {
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
const obj = params ?? {};
|
|
1759
|
+
const kind = obj.update?.sessionUpdate;
|
|
1760
|
+
return typeof kind === "string" && STATE_UPDATE_KINDS.has(kind);
|
|
1761
|
+
}
|
|
1762
|
+
function sameAdvertisedCommands(a, b) {
|
|
1763
|
+
if (a.length !== b.length) {
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
for (let i = 0; i < a.length; i++) {
|
|
1767
|
+
if (a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
|
|
1768
|
+
return false;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return true;
|
|
1772
|
+
}
|
|
1497
1773
|
function captureInternalChunk(capture, params) {
|
|
1498
1774
|
const obj = params ?? {};
|
|
1499
1775
|
const update = obj.update ?? {};
|
|
@@ -1561,6 +1837,10 @@ function firstLine(text, max) {
|
|
|
1561
1837
|
import * as fs3 from "fs/promises";
|
|
1562
1838
|
import * as path2 from "path";
|
|
1563
1839
|
import { z as z4 } from "zod";
|
|
1840
|
+
var PersistedAgentCommand = z4.object({
|
|
1841
|
+
name: z4.string(),
|
|
1842
|
+
description: z4.string().optional()
|
|
1843
|
+
});
|
|
1564
1844
|
var SessionRecord = z4.object({
|
|
1565
1845
|
version: z4.literal(1),
|
|
1566
1846
|
sessionId: z4.string(),
|
|
@@ -1569,6 +1849,13 @@ var SessionRecord = z4.object({
|
|
|
1569
1849
|
cwd: z4.string(),
|
|
1570
1850
|
title: z4.string().optional(),
|
|
1571
1851
|
agentArgs: z4.array(z4.string()).optional(),
|
|
1852
|
+
// Snapshot of "what is currently true about this session" carried in
|
|
1853
|
+
// meta.json so a late-attaching or cold-resurrected client can be
|
|
1854
|
+
// told via the attach response _meta without depending on history
|
|
1855
|
+
// replay of a snapshot-shaped notification.
|
|
1856
|
+
currentModel: z4.string().optional(),
|
|
1857
|
+
currentMode: z4.string().optional(),
|
|
1858
|
+
agentCommands: z4.array(PersistedAgentCommand).optional(),
|
|
1572
1859
|
createdAt: z4.string(),
|
|
1573
1860
|
updatedAt: z4.string()
|
|
1574
1861
|
});
|
|
@@ -1581,7 +1868,7 @@ function assertSafeId(id) {
|
|
|
1581
1868
|
var SessionStore = class {
|
|
1582
1869
|
async write(record) {
|
|
1583
1870
|
assertSafeId(record.sessionId);
|
|
1584
|
-
await fs3.mkdir(paths.
|
|
1871
|
+
await fs3.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
|
|
1585
1872
|
const full = { version: 1, ...record };
|
|
1586
1873
|
await fs3.writeFile(
|
|
1587
1874
|
paths.sessionFile(record.sessionId),
|
|
@@ -1621,6 +1908,14 @@ var SessionStore = class {
|
|
|
1621
1908
|
throw err;
|
|
1622
1909
|
}
|
|
1623
1910
|
}
|
|
1911
|
+
try {
|
|
1912
|
+
await fs3.rmdir(paths.sessionDir(sessionId));
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
const e = err;
|
|
1915
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
1916
|
+
throw err;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1624
1919
|
}
|
|
1625
1920
|
async list() {
|
|
1626
1921
|
let entries;
|
|
@@ -1635,11 +1930,7 @@ var SessionStore = class {
|
|
|
1635
1930
|
}
|
|
1636
1931
|
const records = [];
|
|
1637
1932
|
for (const entry of entries) {
|
|
1638
|
-
|
|
1639
|
-
continue;
|
|
1640
|
-
}
|
|
1641
|
-
const id = entry.slice(0, -".json".length);
|
|
1642
|
-
const record = await this.read(id);
|
|
1933
|
+
const record = await this.read(entry);
|
|
1643
1934
|
if (record) {
|
|
1644
1935
|
records.push(record);
|
|
1645
1936
|
}
|
|
@@ -1656,17 +1947,143 @@ function recordFromMemorySession(args) {
|
|
|
1656
1947
|
cwd: args.cwd,
|
|
1657
1948
|
title: args.title,
|
|
1658
1949
|
agentArgs: args.agentArgs,
|
|
1950
|
+
currentModel: args.currentModel,
|
|
1951
|
+
currentMode: args.currentMode,
|
|
1952
|
+
agentCommands: args.agentCommands,
|
|
1659
1953
|
createdAt: args.createdAt ?? now,
|
|
1660
1954
|
updatedAt: args.updatedAt ?? now
|
|
1661
1955
|
};
|
|
1662
1956
|
}
|
|
1663
1957
|
|
|
1958
|
+
// src/core/history-store.ts
|
|
1959
|
+
import * as fs4 from "fs/promises";
|
|
1960
|
+
var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
|
|
1961
|
+
var MAX_ENTRIES = 1e3;
|
|
1962
|
+
var HistoryStore = class {
|
|
1963
|
+
// Serialize writes per session id so appends and rewrites don't
|
|
1964
|
+
// interleave JSONL lines on disk. The chain swallows errors so one
|
|
1965
|
+
// failed append doesn't poison every subsequent write.
|
|
1966
|
+
writeQueues = /* @__PURE__ */ new Map();
|
|
1967
|
+
async append(sessionId, entry) {
|
|
1968
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
return this.enqueue(sessionId, async () => {
|
|
1972
|
+
await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1973
|
+
const line = JSON.stringify(entry) + "\n";
|
|
1974
|
+
await fs4.appendFile(paths.historyFile(sessionId), line, {
|
|
1975
|
+
encoding: "utf8",
|
|
1976
|
+
mode: 384
|
|
1977
|
+
});
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
async rewrite(sessionId, entries) {
|
|
1981
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
return this.enqueue(sessionId, async () => {
|
|
1985
|
+
await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1986
|
+
const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1987
|
+
await fs4.writeFile(paths.historyFile(sessionId), body, {
|
|
1988
|
+
encoding: "utf8",
|
|
1989
|
+
mode: 384
|
|
1990
|
+
});
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
async load(sessionId) {
|
|
1994
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1995
|
+
return [];
|
|
1996
|
+
}
|
|
1997
|
+
const pending = this.writeQueues.get(sessionId);
|
|
1998
|
+
if (pending) {
|
|
1999
|
+
await pending;
|
|
2000
|
+
}
|
|
2001
|
+
let raw;
|
|
2002
|
+
try {
|
|
2003
|
+
raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
const e = err;
|
|
2006
|
+
if (e.code === "ENOENT") {
|
|
2007
|
+
return [];
|
|
2008
|
+
}
|
|
2009
|
+
throw err;
|
|
2010
|
+
}
|
|
2011
|
+
const out = [];
|
|
2012
|
+
for (const line of raw.split("\n")) {
|
|
2013
|
+
if (line.length === 0) {
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
let parsed;
|
|
2017
|
+
try {
|
|
2018
|
+
parsed = JSON.parse(line);
|
|
2019
|
+
} catch {
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
const obj = parsed;
|
|
2026
|
+
if (typeof obj.method !== "string") {
|
|
2027
|
+
continue;
|
|
2028
|
+
}
|
|
2029
|
+
if (typeof obj.recordedAt !== "number") {
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
out.push({
|
|
2033
|
+
method: obj.method,
|
|
2034
|
+
params: obj.params,
|
|
2035
|
+
recordedAt: obj.recordedAt
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
if (out.length > MAX_ENTRIES) {
|
|
2039
|
+
return out.slice(-MAX_ENTRIES);
|
|
2040
|
+
}
|
|
2041
|
+
return out;
|
|
2042
|
+
}
|
|
2043
|
+
async delete(sessionId) {
|
|
2044
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
return this.enqueue(sessionId, async () => {
|
|
2048
|
+
try {
|
|
2049
|
+
await fs4.unlink(paths.historyFile(sessionId));
|
|
2050
|
+
} catch (err) {
|
|
2051
|
+
const e = err;
|
|
2052
|
+
if (e.code !== "ENOENT") {
|
|
2053
|
+
throw err;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
try {
|
|
2057
|
+
await fs4.rmdir(paths.sessionDir(sessionId));
|
|
2058
|
+
} catch (err) {
|
|
2059
|
+
const e = err;
|
|
2060
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
2061
|
+
throw err;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
enqueue(sessionId, task) {
|
|
2067
|
+
const prev = this.writeQueues.get(sessionId) ?? Promise.resolve();
|
|
2068
|
+
const task$ = prev.then(task, task);
|
|
2069
|
+
const settled = task$.catch(() => void 0);
|
|
2070
|
+
this.writeQueues.set(sessionId, settled);
|
|
2071
|
+
void settled.finally(() => {
|
|
2072
|
+
if (this.writeQueues.get(sessionId) === settled) {
|
|
2073
|
+
this.writeQueues.delete(sessionId);
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
return task$;
|
|
2077
|
+
}
|
|
2078
|
+
};
|
|
2079
|
+
|
|
1664
2080
|
// src/core/session-manager.ts
|
|
1665
2081
|
var SessionManager = class {
|
|
1666
2082
|
constructor(registry, spawner, store, options = {}) {
|
|
1667
2083
|
this.registry = registry;
|
|
1668
2084
|
this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
|
|
1669
2085
|
this.store = store ?? new SessionStore();
|
|
2086
|
+
this.histories = new HistoryStore();
|
|
1670
2087
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
1671
2088
|
}
|
|
1672
2089
|
registry;
|
|
@@ -1674,7 +2091,12 @@ var SessionManager = class {
|
|
|
1674
2091
|
resurrectionInflight = /* @__PURE__ */ new Map();
|
|
1675
2092
|
spawner;
|
|
1676
2093
|
store;
|
|
2094
|
+
histories;
|
|
1677
2095
|
idleTimeoutMs;
|
|
2096
|
+
// Serialize meta.json read-modify-write operations per session id so
|
|
2097
|
+
// concurrent snapshot updates (e.g. an agent emitting model + mode
|
|
2098
|
+
// back-to-back) don't lose writes via interleaved reads.
|
|
2099
|
+
metaWriteQueues = /* @__PURE__ */ new Map();
|
|
1678
2100
|
async create(params) {
|
|
1679
2101
|
const fresh = await this.bootstrapAgent({
|
|
1680
2102
|
agentId: params.agentId,
|
|
@@ -1691,7 +2113,8 @@ var SessionManager = class {
|
|
|
1691
2113
|
title: params.title,
|
|
1692
2114
|
agentArgs: params.agentArgs,
|
|
1693
2115
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
1694
|
-
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
|
|
2116
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2117
|
+
historyStore: this.histories
|
|
1695
2118
|
});
|
|
1696
2119
|
await this.attachManagerHooks(session);
|
|
1697
2120
|
return session;
|
|
@@ -1767,7 +2190,13 @@ var SessionManager = class {
|
|
|
1767
2190
|
title: params.title,
|
|
1768
2191
|
agentArgs: params.agentArgs,
|
|
1769
2192
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
1770
|
-
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
|
|
2193
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2194
|
+
historyStore: this.histories,
|
|
2195
|
+
seedHistory: params.seedHistory,
|
|
2196
|
+
currentModel: params.currentModel,
|
|
2197
|
+
currentMode: params.currentMode,
|
|
2198
|
+
agentCommands: params.agentCommands,
|
|
2199
|
+
firstPromptSeeded: true
|
|
1771
2200
|
});
|
|
1772
2201
|
await this.attachManagerHooks(session);
|
|
1773
2202
|
return session;
|
|
@@ -1821,6 +2250,7 @@ var SessionManager = class {
|
|
|
1821
2250
|
this.sessions.delete(session.sessionId);
|
|
1822
2251
|
if (deleteRecord) {
|
|
1823
2252
|
void this.store.delete(session.sessionId).catch(() => void 0);
|
|
2253
|
+
void this.histories.delete(session.sessionId).catch(() => void 0);
|
|
1824
2254
|
}
|
|
1825
2255
|
});
|
|
1826
2256
|
session.onTitleChange((title) => {
|
|
@@ -1831,6 +2261,24 @@ var SessionManager = class {
|
|
|
1831
2261
|
() => void 0
|
|
1832
2262
|
);
|
|
1833
2263
|
});
|
|
2264
|
+
session.onModelChange((model) => {
|
|
2265
|
+
void this.persistSnapshot(session.sessionId, { currentModel: model }).catch(
|
|
2266
|
+
() => void 0
|
|
2267
|
+
);
|
|
2268
|
+
});
|
|
2269
|
+
session.onModeChange((mode) => {
|
|
2270
|
+
void this.persistSnapshot(session.sessionId, { currentMode: mode }).catch(
|
|
2271
|
+
() => void 0
|
|
2272
|
+
);
|
|
2273
|
+
});
|
|
2274
|
+
session.onAgentCommandsChange((commands) => {
|
|
2275
|
+
void this.persistSnapshot(session.sessionId, {
|
|
2276
|
+
agentCommands: commands.map((c) => ({
|
|
2277
|
+
name: c.name,
|
|
2278
|
+
...c.description !== void 0 ? { description: c.description } : {}
|
|
2279
|
+
}))
|
|
2280
|
+
}).catch(() => void 0);
|
|
2281
|
+
});
|
|
1834
2282
|
this.sessions.set(session.sessionId, session);
|
|
1835
2283
|
await this.store.write(
|
|
1836
2284
|
recordFromMemorySession({
|
|
@@ -1839,22 +2287,45 @@ var SessionManager = class {
|
|
|
1839
2287
|
agentId: session.agentId,
|
|
1840
2288
|
cwd: session.cwd,
|
|
1841
2289
|
title: session.title,
|
|
1842
|
-
agentArgs: session.agentArgs
|
|
2290
|
+
agentArgs: session.agentArgs,
|
|
2291
|
+
currentModel: session.currentModel,
|
|
2292
|
+
currentMode: session.currentMode
|
|
1843
2293
|
})
|
|
1844
2294
|
).catch(() => void 0);
|
|
1845
2295
|
}
|
|
2296
|
+
// Resolve a session's recorded history without forcing a resurrect.
|
|
2297
|
+
// Returns the in-memory snapshot if the session is hot, falls back
|
|
2298
|
+
// to the on-disk history file otherwise. Returns undefined if the
|
|
2299
|
+
// session id is unknown to both the live map and disk store, so the
|
|
2300
|
+
// caller can distinguish "no history yet" (empty array) from "404".
|
|
2301
|
+
async getHistory(sessionId) {
|
|
2302
|
+
const live = this.sessions.get(sessionId);
|
|
2303
|
+
if (live) {
|
|
2304
|
+
return live.getHistorySnapshot();
|
|
2305
|
+
}
|
|
2306
|
+
const record = await this.store.read(sessionId);
|
|
2307
|
+
if (!record) {
|
|
2308
|
+
return void 0;
|
|
2309
|
+
}
|
|
2310
|
+
return this.histories.load(sessionId).catch(() => []);
|
|
2311
|
+
}
|
|
1846
2312
|
async loadFromDisk(sessionId) {
|
|
1847
2313
|
const record = await this.store.read(sessionId);
|
|
1848
2314
|
if (!record) {
|
|
1849
2315
|
return void 0;
|
|
1850
2316
|
}
|
|
2317
|
+
const seedHistory = await this.histories.load(sessionId).catch(() => []);
|
|
1851
2318
|
return {
|
|
1852
2319
|
hydraSessionId: record.sessionId,
|
|
1853
2320
|
upstreamSessionId: record.upstreamSessionId,
|
|
1854
2321
|
agentId: record.agentId,
|
|
1855
2322
|
cwd: record.cwd,
|
|
1856
2323
|
title: record.title,
|
|
1857
|
-
agentArgs: record.agentArgs
|
|
2324
|
+
agentArgs: record.agentArgs,
|
|
2325
|
+
seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
|
|
2326
|
+
currentModel: record.currentModel,
|
|
2327
|
+
currentMode: record.currentMode,
|
|
2328
|
+
agentCommands: record.agentCommands
|
|
1858
2329
|
};
|
|
1859
2330
|
}
|
|
1860
2331
|
get(sessionId) {
|
|
@@ -1896,13 +2367,14 @@ var SessionManager = class {
|
|
|
1896
2367
|
continue;
|
|
1897
2368
|
}
|
|
1898
2369
|
liveIds.add(session.sessionId);
|
|
2370
|
+
const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
|
|
1899
2371
|
entries.push({
|
|
1900
2372
|
sessionId: session.sessionId,
|
|
1901
2373
|
upstreamSessionId: session.upstreamSessionId,
|
|
1902
2374
|
cwd: session.cwd,
|
|
1903
2375
|
title: session.title,
|
|
1904
2376
|
agentId: session.agentId,
|
|
1905
|
-
updatedAt:
|
|
2377
|
+
updatedAt: used,
|
|
1906
2378
|
attachedClients: session.attachedCount,
|
|
1907
2379
|
status: "live"
|
|
1908
2380
|
});
|
|
@@ -1915,13 +2387,14 @@ var SessionManager = class {
|
|
|
1915
2387
|
if (filter.cwd && r.cwd !== filter.cwd) {
|
|
1916
2388
|
continue;
|
|
1917
2389
|
}
|
|
2390
|
+
const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
|
|
1918
2391
|
entries.push({
|
|
1919
2392
|
sessionId: r.sessionId,
|
|
1920
2393
|
upstreamSessionId: r.upstreamSessionId,
|
|
1921
2394
|
cwd: r.cwd,
|
|
1922
2395
|
title: r.title,
|
|
1923
2396
|
agentId: r.agentId,
|
|
1924
|
-
updatedAt:
|
|
2397
|
+
updatedAt: used,
|
|
1925
2398
|
attachedClients: 0,
|
|
1926
2399
|
status: "cold"
|
|
1927
2400
|
});
|
|
@@ -1937,19 +2410,25 @@ var SessionManager = class {
|
|
|
1937
2410
|
await this.store.delete(sessionId).catch(() => void 0);
|
|
1938
2411
|
return true;
|
|
1939
2412
|
}
|
|
2413
|
+
async hasRecord(sessionId) {
|
|
2414
|
+
const record = await this.store.read(sessionId).catch(() => void 0);
|
|
2415
|
+
return record !== void 0;
|
|
2416
|
+
}
|
|
1940
2417
|
// Persist a title update from Session.setTitle. The on-disk record
|
|
1941
2418
|
// was written at create time; updating it here keeps the session
|
|
1942
2419
|
// record's title in sync with what was broadcast to clients so a
|
|
1943
2420
|
// daemon restart (and later resurrect) restores the same title.
|
|
1944
2421
|
async persistTitle(sessionId, title) {
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2422
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
2423
|
+
const record = await this.store.read(sessionId);
|
|
2424
|
+
if (!record) {
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
await this.store.write({
|
|
2428
|
+
...record,
|
|
2429
|
+
title,
|
|
2430
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2431
|
+
});
|
|
1953
2432
|
});
|
|
1954
2433
|
}
|
|
1955
2434
|
// Persist an agent swap from /hydra switch. The on-disk record's
|
|
@@ -1957,16 +2436,51 @@ var SessionManager = class {
|
|
|
1957
2436
|
// later resurrect) brings the session back up on the agent the user
|
|
1958
2437
|
// most recently switched to, not the one it was originally created on.
|
|
1959
2438
|
async persistAgentChange(sessionId, agentId, upstreamSessionId) {
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2439
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
2440
|
+
const record = await this.store.read(sessionId);
|
|
2441
|
+
if (!record) {
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
await this.store.write({
|
|
2445
|
+
...record,
|
|
2446
|
+
agentId,
|
|
2447
|
+
upstreamSessionId,
|
|
2448
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2449
|
+
});
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
// Update one or more snapshot fields (model, mode, commands) in
|
|
2453
|
+
// meta.json. Used so cold-resurrect can deliver the latest snapshot
|
|
2454
|
+
// to attaching clients via the attach response _meta. No-op if the
|
|
2455
|
+
// session record has gone away (race with deleteRecord).
|
|
2456
|
+
async persistSnapshot(sessionId, update) {
|
|
2457
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
2458
|
+
const record = await this.store.read(sessionId);
|
|
2459
|
+
if (!record) {
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
await this.store.write({
|
|
2463
|
+
...record,
|
|
2464
|
+
...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
|
|
2465
|
+
...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
|
|
2466
|
+
...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
|
|
2467
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2468
|
+
});
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
// Serialize meta.json writes per session id so concurrent
|
|
2472
|
+
// read-modify-write operations don't interleave reads.
|
|
2473
|
+
enqueueMetaWrite(sessionId, task) {
|
|
2474
|
+
const prev = this.metaWriteQueues.get(sessionId) ?? Promise.resolve();
|
|
2475
|
+
const next = prev.then(task, task);
|
|
2476
|
+
const settled = next.catch(() => void 0);
|
|
2477
|
+
this.metaWriteQueues.set(sessionId, settled);
|
|
2478
|
+
void settled.finally(() => {
|
|
2479
|
+
if (this.metaWriteQueues.get(sessionId) === settled) {
|
|
2480
|
+
this.metaWriteQueues.delete(sessionId);
|
|
2481
|
+
}
|
|
1969
2482
|
});
|
|
2483
|
+
return next;
|
|
1970
2484
|
}
|
|
1971
2485
|
async closeAll() {
|
|
1972
2486
|
const sessions = [...this.sessions.values()];
|
|
@@ -1974,10 +2488,18 @@ var SessionManager = class {
|
|
|
1974
2488
|
this.sessions.clear();
|
|
1975
2489
|
}
|
|
1976
2490
|
};
|
|
2491
|
+
async function historyMtimeIso(sessionId) {
|
|
2492
|
+
try {
|
|
2493
|
+
const st = await fs5.stat(paths.historyFile(sessionId));
|
|
2494
|
+
return new Date(st.mtimeMs).toISOString();
|
|
2495
|
+
} catch {
|
|
2496
|
+
return void 0;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
1977
2499
|
|
|
1978
2500
|
// src/core/extensions.ts
|
|
1979
2501
|
import { spawn as spawn2 } from "child_process";
|
|
1980
|
-
import * as
|
|
2502
|
+
import * as fs6 from "fs";
|
|
1981
2503
|
import * as fsp from "fs/promises";
|
|
1982
2504
|
import * as path3 from "path";
|
|
1983
2505
|
var RESTART_BASE_MS = 1e3;
|
|
@@ -2260,7 +2782,7 @@ var ExtensionManager = class {
|
|
|
2260
2782
|
}
|
|
2261
2783
|
const ext = entry.config;
|
|
2262
2784
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
2263
|
-
const logStream =
|
|
2785
|
+
const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
2264
2786
|
flags: "a"
|
|
2265
2787
|
});
|
|
2266
2788
|
logStream.write(
|
|
@@ -2310,7 +2832,7 @@ var ExtensionManager = class {
|
|
|
2310
2832
|
}
|
|
2311
2833
|
if (typeof child.pid === "number") {
|
|
2312
2834
|
try {
|
|
2313
|
-
|
|
2835
|
+
fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
2314
2836
|
`, {
|
|
2315
2837
|
encoding: "utf8",
|
|
2316
2838
|
mode: 384
|
|
@@ -2335,7 +2857,7 @@ var ExtensionManager = class {
|
|
|
2335
2857
|
});
|
|
2336
2858
|
child.on("exit", (code, signal) => {
|
|
2337
2859
|
try {
|
|
2338
|
-
|
|
2860
|
+
fs6.unlinkSync(paths.extensionPidFile(ext.name));
|
|
2339
2861
|
} catch {
|
|
2340
2862
|
}
|
|
2341
2863
|
logStream.write(
|
|
@@ -2447,8 +2969,7 @@ function constantTimeEqual(a, b) {
|
|
|
2447
2969
|
function registerSessionRoutes(app, manager, defaults) {
|
|
2448
2970
|
app.get("/v1/sessions", async (request) => {
|
|
2449
2971
|
const query = request.query;
|
|
2450
|
-
const
|
|
2451
|
-
const sessions = await manager.list({ cwd: query?.cwd, all });
|
|
2972
|
+
const sessions = await manager.list({ cwd: query?.cwd });
|
|
2452
2973
|
return { sessions };
|
|
2453
2974
|
});
|
|
2454
2975
|
app.post("/v1/sessions", async (request, reply) => {
|
|
@@ -2470,6 +2991,22 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
2470
2991
|
reply.code(500).send({ error: err.message });
|
|
2471
2992
|
}
|
|
2472
2993
|
});
|
|
2994
|
+
app.post("/v1/sessions/:id/kill", async (request, reply) => {
|
|
2995
|
+
const raw = request.params.id;
|
|
2996
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
2997
|
+
const session = manager.get(id);
|
|
2998
|
+
if (session) {
|
|
2999
|
+
await session.close({ deleteRecord: false });
|
|
3000
|
+
reply.code(204).send();
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
const exists = await manager.hasRecord(id);
|
|
3004
|
+
if (!exists) {
|
|
3005
|
+
reply.code(404).send({ error: "session not found" });
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
reply.code(204).send();
|
|
3009
|
+
});
|
|
2473
3010
|
app.delete("/v1/sessions/:id", async (request, reply) => {
|
|
2474
3011
|
const raw = request.params.id;
|
|
2475
3012
|
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
@@ -2486,6 +3023,50 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
2486
3023
|
}
|
|
2487
3024
|
reply.code(204).send();
|
|
2488
3025
|
});
|
|
3026
|
+
app.get("/v1/sessions/:id/history", async (request, reply) => {
|
|
3027
|
+
const raw = request.params.id;
|
|
3028
|
+
const query = request.query;
|
|
3029
|
+
const follow = query?.follow === "1" || query?.follow === "true";
|
|
3030
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
3031
|
+
const live = manager.get(id);
|
|
3032
|
+
let snapshot;
|
|
3033
|
+
let unsubscribe;
|
|
3034
|
+
if (live) {
|
|
3035
|
+
snapshot = live.getHistorySnapshot();
|
|
3036
|
+
if (follow) {
|
|
3037
|
+
unsubscribe = live.onBroadcast((entry) => {
|
|
3038
|
+
if (reply.raw.writableEnded) {
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
} else {
|
|
3045
|
+
const cold = await manager.getHistory(id);
|
|
3046
|
+
if (cold === void 0) {
|
|
3047
|
+
reply.code(404).send({ error: "session not found" });
|
|
3048
|
+
return reply;
|
|
3049
|
+
}
|
|
3050
|
+
snapshot = cold;
|
|
3051
|
+
}
|
|
3052
|
+
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
|
3053
|
+
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
3054
|
+
reply.raw.statusCode = 200;
|
|
3055
|
+
for (const entry of snapshot ?? []) {
|
|
3056
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
3057
|
+
}
|
|
3058
|
+
if (!unsubscribe) {
|
|
3059
|
+
reply.raw.end();
|
|
3060
|
+
return reply;
|
|
3061
|
+
}
|
|
3062
|
+
request.raw.on("close", () => {
|
|
3063
|
+
unsubscribe?.();
|
|
3064
|
+
if (!reply.raw.writableEnded) {
|
|
3065
|
+
reply.raw.end();
|
|
3066
|
+
}
|
|
3067
|
+
});
|
|
3068
|
+
return reply;
|
|
3069
|
+
});
|
|
2489
3070
|
}
|
|
2490
3071
|
|
|
2491
3072
|
// src/daemon/routes/agents.ts
|
|
@@ -2633,6 +3214,16 @@ function parseRegisterBody(body) {
|
|
|
2633
3214
|
};
|
|
2634
3215
|
}
|
|
2635
3216
|
|
|
3217
|
+
// src/daemon/routes/config.ts
|
|
3218
|
+
function registerConfigRoutes(app, defaults) {
|
|
3219
|
+
app.get("/v1/config", async () => {
|
|
3220
|
+
return {
|
|
3221
|
+
defaultAgent: defaults.defaultAgent,
|
|
3222
|
+
defaultCwd: defaults.defaultCwd
|
|
3223
|
+
};
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
|
|
2636
3227
|
// src/daemon/acp-ws.ts
|
|
2637
3228
|
import { nanoid as nanoid2 } from "nanoid";
|
|
2638
3229
|
|
|
@@ -2946,6 +3537,19 @@ function buildResponseMeta(session) {
|
|
|
2946
3537
|
if (session.agentArgs && session.agentArgs.length > 0) {
|
|
2947
3538
|
ours.agentArgs = session.agentArgs;
|
|
2948
3539
|
}
|
|
3540
|
+
if (session.currentModel !== void 0) {
|
|
3541
|
+
ours.currentModel = session.currentModel;
|
|
3542
|
+
}
|
|
3543
|
+
if (session.currentMode !== void 0) {
|
|
3544
|
+
ours.currentMode = session.currentMode;
|
|
3545
|
+
}
|
|
3546
|
+
const commands = session.mergedAvailableCommands();
|
|
3547
|
+
if (commands.length > 0) {
|
|
3548
|
+
ours.availableCommands = commands;
|
|
3549
|
+
}
|
|
3550
|
+
if (session.turnStartedAt !== void 0) {
|
|
3551
|
+
ours.turnStartedAt = session.turnStartedAt;
|
|
3552
|
+
}
|
|
2949
3553
|
return mergeMeta(session.agentMeta, ours);
|
|
2950
3554
|
}
|
|
2951
3555
|
function buildInitializeResult() {
|
|
@@ -3031,6 +3635,10 @@ async function startDaemon(config) {
|
|
|
3031
3635
|
});
|
|
3032
3636
|
registerAgentRoutes(app, registry);
|
|
3033
3637
|
registerExtensionRoutes(app, extensions);
|
|
3638
|
+
registerConfigRoutes(app, {
|
|
3639
|
+
defaultAgent: config.defaultAgent,
|
|
3640
|
+
defaultCwd: config.defaultCwd
|
|
3641
|
+
});
|
|
3034
3642
|
registerAcpWsEndpoint(app, {
|
|
3035
3643
|
config,
|
|
3036
3644
|
manager,
|
|
@@ -3066,7 +3674,7 @@ async function startDaemon(config) {
|
|
|
3066
3674
|
await manager.closeAll();
|
|
3067
3675
|
await app.close();
|
|
3068
3676
|
try {
|
|
3069
|
-
|
|
3677
|
+
fs7.unlinkSync(paths.pidFile());
|
|
3070
3678
|
} catch {
|
|
3071
3679
|
}
|
|
3072
3680
|
try {
|