@hydra-acp/cli 0.1.1 → 0.1.2
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/dist/cli.js +981 -298
- package/dist/index.d.ts +76 -5
- package/dist/index.js +551 -47
- 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 fs6 from "fs";
|
|
3
3
|
import * as fsp2 from "fs/promises";
|
|
4
4
|
import Fastify from "fastify";
|
|
5
5
|
import websocketPlugin from "@fastify/websocket";
|
|
@@ -32,7 +32,12 @@ var paths = {
|
|
|
32
32
|
agentsDir: () => path.join(hydraHome(), "agents"),
|
|
33
33
|
agentDir: (id) => path.join(hydraHome(), "agents", id),
|
|
34
34
|
sessionsDir: () => path.join(hydraHome(), "sessions"),
|
|
35
|
-
|
|
35
|
+
// One directory per session id under sessions/. Co-locates the
|
|
36
|
+
// session record, its transcript, and any future per-session state
|
|
37
|
+
// (uploads, scratch, etc.) so the lifecycle is just "rm -rf the dir".
|
|
38
|
+
sessionDir: (id) => path.join(hydraHome(), "sessions", id),
|
|
39
|
+
sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
|
|
40
|
+
historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
|
|
36
41
|
extensionsDir: () => path.join(hydraHome(), "extensions"),
|
|
37
42
|
extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
|
|
38
43
|
extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
|
|
@@ -410,6 +415,32 @@ function extractHydraMeta(meta) {
|
|
|
410
415
|
out.resume = parsed.data;
|
|
411
416
|
}
|
|
412
417
|
}
|
|
418
|
+
if (typeof obj.currentModel === "string") {
|
|
419
|
+
out.currentModel = obj.currentModel;
|
|
420
|
+
}
|
|
421
|
+
if (typeof obj.currentMode === "string") {
|
|
422
|
+
out.currentMode = obj.currentMode;
|
|
423
|
+
}
|
|
424
|
+
if (Array.isArray(obj.availableCommands)) {
|
|
425
|
+
const cmds = [];
|
|
426
|
+
for (const raw of obj.availableCommands) {
|
|
427
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const c = raw;
|
|
431
|
+
if (typeof c.name !== "string") {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const cmd = { name: c.name };
|
|
435
|
+
if (typeof c.description === "string") {
|
|
436
|
+
cmd.description = c.description;
|
|
437
|
+
}
|
|
438
|
+
cmds.push(cmd);
|
|
439
|
+
}
|
|
440
|
+
if (cmds.length > 0) {
|
|
441
|
+
out.availableCommands = cmds;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
413
444
|
return out;
|
|
414
445
|
}
|
|
415
446
|
function mergeMeta(passthrough, ours) {
|
|
@@ -779,14 +810,26 @@ var Session = class {
|
|
|
779
810
|
agentMeta;
|
|
780
811
|
agentArgs;
|
|
781
812
|
title;
|
|
813
|
+
// Snapshot state delivered to attaching clients via the attach
|
|
814
|
+
// response _meta rather than via history replay (which would be
|
|
815
|
+
// stale-prone for snapshot-shaped events).
|
|
816
|
+
currentModel;
|
|
817
|
+
currentMode;
|
|
782
818
|
updatedAt;
|
|
783
819
|
clients = /* @__PURE__ */ new Map();
|
|
784
820
|
history = [];
|
|
821
|
+
historyStore;
|
|
785
822
|
promptQueue = [];
|
|
786
823
|
promptInFlight = false;
|
|
787
824
|
closed = false;
|
|
788
825
|
closeHandlers = [];
|
|
789
826
|
titleHandlers = [];
|
|
827
|
+
// Subscribers notified after every entry that's actually persisted to
|
|
828
|
+
// history (skipping snapshot-shaped events filtered by
|
|
829
|
+
// recordAndBroadcast). The HTTP /v1/sessions/:id/history?follow=1
|
|
830
|
+
// endpoint uses this to tail a live session's conversation stream
|
|
831
|
+
// without participating in turns or prompts.
|
|
832
|
+
broadcastHandlers = [];
|
|
790
833
|
// True once we've observed our first session/prompt; gates the
|
|
791
834
|
// first-prompt-seeded title so subsequent prompts don't churn it.
|
|
792
835
|
firstPromptSeeded = false;
|
|
@@ -806,12 +849,18 @@ var Session = class {
|
|
|
806
849
|
idleTimer;
|
|
807
850
|
spawnReplacementAgent;
|
|
808
851
|
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
|
-
//
|
|
852
|
+
// Last available_commands_update we observed from the agent. Stored
|
|
853
|
+
// so we can re-broadcast a merged (hydra ∪ agent) list whenever
|
|
854
|
+
// either half changes, and persisted to meta.json so a fresh attach
|
|
855
|
+
// can deliver the merged list via _meta without depending on history
|
|
856
|
+
// replay.
|
|
814
857
|
agentAdvertisedCommands = [];
|
|
858
|
+
// Persist hooks for snapshot-shaped state. SessionManager hooks these
|
|
859
|
+
// to mirror changes into meta.json so cold-resurrect attaches can
|
|
860
|
+
// surface the latest snapshot via the attach response _meta.
|
|
861
|
+
agentCommandsHandlers = [];
|
|
862
|
+
modelHandlers = [];
|
|
863
|
+
modeHandlers = [];
|
|
815
864
|
constructor(init) {
|
|
816
865
|
this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
|
|
817
866
|
this.cwd = init.cwd;
|
|
@@ -821,11 +870,19 @@ var Session = class {
|
|
|
821
870
|
this.agentMeta = init.agentMeta;
|
|
822
871
|
this.agentArgs = init.agentArgs;
|
|
823
872
|
this.title = init.title;
|
|
873
|
+
this.currentModel = init.currentModel;
|
|
874
|
+
this.currentMode = init.currentMode;
|
|
875
|
+
if (init.agentCommands && init.agentCommands.length > 0) {
|
|
876
|
+
this.agentAdvertisedCommands = [...init.agentCommands];
|
|
877
|
+
}
|
|
824
878
|
this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
|
|
825
879
|
this.spawnReplacementAgent = init.spawnReplacementAgent;
|
|
880
|
+
this.historyStore = init.historyStore;
|
|
881
|
+
if (init.seedHistory && init.seedHistory.length > 0) {
|
|
882
|
+
this.history = [...init.seedHistory];
|
|
883
|
+
}
|
|
826
884
|
this.updatedAt = Date.now();
|
|
827
885
|
this.wireAgent(this.agent);
|
|
828
|
-
this.broadcastMergedCommands();
|
|
829
886
|
}
|
|
830
887
|
broadcastMergedCommands() {
|
|
831
888
|
const merged = [
|
|
@@ -854,8 +911,15 @@ var Session = class {
|
|
|
854
911
|
}
|
|
855
912
|
const agentCmds = extractAdvertisedCommands(params);
|
|
856
913
|
if (agentCmds !== null) {
|
|
857
|
-
this.
|
|
858
|
-
|
|
914
|
+
this.setAgentAdvertisedCommands(agentCmds);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (this.maybeApplyAgentModel(params)) {
|
|
918
|
+
this.recordAndBroadcast("session/update", params);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (this.maybeApplyAgentMode(params)) {
|
|
922
|
+
this.recordAndBroadcast("session/update", params);
|
|
859
923
|
return;
|
|
860
924
|
}
|
|
861
925
|
this.maybeApplyAgentSessionInfo(params);
|
|
@@ -877,6 +941,27 @@ var Session = class {
|
|
|
877
941
|
get attachedCount() {
|
|
878
942
|
return this.clients.size;
|
|
879
943
|
}
|
|
944
|
+
// Snapshot of the current in-memory replay history. Used by the
|
|
945
|
+
// HTTP history endpoint to deliver the "what's accumulated so far"
|
|
946
|
+
// prefix before optionally tailing with onBroadcast. Returns a copy
|
|
947
|
+
// so callers can't mutate our cache.
|
|
948
|
+
getHistorySnapshot() {
|
|
949
|
+
return [...this.history];
|
|
950
|
+
}
|
|
951
|
+
// Subscribe to recordable broadcast entries — fires once per entry
|
|
952
|
+
// that lands in history (so snapshot-shaped session_info/model/mode/
|
|
953
|
+
// available_commands updates do NOT trigger this; they're broadcast
|
|
954
|
+
// live but not recorded). Returns an unsubscribe function the caller
|
|
955
|
+
// must invoke when done.
|
|
956
|
+
onBroadcast(handler) {
|
|
957
|
+
this.broadcastHandlers.push(handler);
|
|
958
|
+
return () => {
|
|
959
|
+
const i = this.broadcastHandlers.indexOf(handler);
|
|
960
|
+
if (i >= 0) {
|
|
961
|
+
this.broadcastHandlers.splice(i, 1);
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
}
|
|
880
965
|
attach(client, historyPolicy) {
|
|
881
966
|
if (this.closed) {
|
|
882
967
|
throw withCode(
|
|
@@ -1082,6 +1167,91 @@ var Session = class {
|
|
|
1082
1167
|
this.firstPromptSeeded = true;
|
|
1083
1168
|
this.setTitle(seed);
|
|
1084
1169
|
}
|
|
1170
|
+
// Apply an agent-emitted current_model_update. Returns true if the
|
|
1171
|
+
// notification was a model update (caller still needs to broadcast
|
|
1172
|
+
// it). Returns false otherwise so the caller can try the next kind.
|
|
1173
|
+
maybeApplyAgentModel(params) {
|
|
1174
|
+
const obj = params ?? {};
|
|
1175
|
+
const update = obj.update ?? {};
|
|
1176
|
+
if (update.sessionUpdate !== "current_model_update") {
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
|
|
1180
|
+
if (raw === void 0) {
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
const trimmed = raw.trim();
|
|
1184
|
+
if (!trimmed || trimmed === this.currentModel) {
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
this.currentModel = trimmed;
|
|
1188
|
+
for (const handler of this.modelHandlers) {
|
|
1189
|
+
try {
|
|
1190
|
+
handler(trimmed);
|
|
1191
|
+
} catch {
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return true;
|
|
1195
|
+
}
|
|
1196
|
+
maybeApplyAgentMode(params) {
|
|
1197
|
+
const obj = params ?? {};
|
|
1198
|
+
const update = obj.update ?? {};
|
|
1199
|
+
if (update.sessionUpdate !== "current_mode_update") {
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
|
|
1203
|
+
if (raw === void 0) {
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
const trimmed = raw.trim();
|
|
1207
|
+
if (!trimmed || trimmed === this.currentMode) {
|
|
1208
|
+
return true;
|
|
1209
|
+
}
|
|
1210
|
+
this.currentMode = trimmed;
|
|
1211
|
+
for (const handler of this.modeHandlers) {
|
|
1212
|
+
try {
|
|
1213
|
+
handler(trimmed);
|
|
1214
|
+
} catch {
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return true;
|
|
1218
|
+
}
|
|
1219
|
+
// Update the cached agent command list, fire persist handlers, and
|
|
1220
|
+
// broadcast the merged list to attached clients. Idempotent on a
|
|
1221
|
+
// structurally identical list so we don't churn meta.json on noisy
|
|
1222
|
+
// re-emissions.
|
|
1223
|
+
setAgentAdvertisedCommands(commands) {
|
|
1224
|
+
if (sameAdvertisedCommands(this.agentAdvertisedCommands, commands)) {
|
|
1225
|
+
this.broadcastMergedCommands();
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
this.agentAdvertisedCommands = commands;
|
|
1229
|
+
for (const handler of this.agentCommandsHandlers) {
|
|
1230
|
+
try {
|
|
1231
|
+
handler(commands);
|
|
1232
|
+
} catch {
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
this.broadcastMergedCommands();
|
|
1236
|
+
}
|
|
1237
|
+
// Subscribe to snapshot-state updates. SessionManager wires these to
|
|
1238
|
+
// persist the new value into meta.json so cold resurrect can restore
|
|
1239
|
+
// them via the attach response _meta.
|
|
1240
|
+
onAgentCommandsChange(handler) {
|
|
1241
|
+
this.agentCommandsHandlers.push(handler);
|
|
1242
|
+
}
|
|
1243
|
+
onModelChange(handler) {
|
|
1244
|
+
this.modelHandlers.push(handler);
|
|
1245
|
+
}
|
|
1246
|
+
onModeChange(handler) {
|
|
1247
|
+
this.modeHandlers.push(handler);
|
|
1248
|
+
}
|
|
1249
|
+
// Returns a freshly merged command list (hydra ∪ agent) for callers
|
|
1250
|
+
// that need a snapshot — notably acp-ws.ts's buildResponseMeta when
|
|
1251
|
+
// assembling the attach response.
|
|
1252
|
+
mergedAvailableCommands() {
|
|
1253
|
+
return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
|
|
1254
|
+
}
|
|
1085
1255
|
// Pick up an agent-emitted session_info_update and store its title
|
|
1086
1256
|
// as our canonical record. The notification is also forwarded to
|
|
1087
1257
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -1392,9 +1562,34 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1392
1562
|
}
|
|
1393
1563
|
recordAndBroadcast(method, params, excludeClientId) {
|
|
1394
1564
|
const rewritten = this.rewriteForClient(params);
|
|
1395
|
-
|
|
1396
|
-
if (
|
|
1397
|
-
|
|
1565
|
+
const recordable = !isStateUpdate(method, rewritten);
|
|
1566
|
+
if (recordable) {
|
|
1567
|
+
const entry = {
|
|
1568
|
+
method,
|
|
1569
|
+
params: rewritten,
|
|
1570
|
+
recordedAt: Date.now()
|
|
1571
|
+
};
|
|
1572
|
+
this.history.push(entry);
|
|
1573
|
+
let trimmed = false;
|
|
1574
|
+
if (this.history.length > 1e3) {
|
|
1575
|
+
this.history = this.history.slice(-500);
|
|
1576
|
+
trimmed = true;
|
|
1577
|
+
}
|
|
1578
|
+
if (this.historyStore) {
|
|
1579
|
+
if (trimmed) {
|
|
1580
|
+
void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
|
|
1581
|
+
} else {
|
|
1582
|
+
void this.historyStore.append(this.sessionId, entry).catch(
|
|
1583
|
+
() => void 0
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
for (const handler of this.broadcastHandlers) {
|
|
1588
|
+
try {
|
|
1589
|
+
handler(entry);
|
|
1590
|
+
} catch {
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1398
1593
|
}
|
|
1399
1594
|
this.updatedAt = Date.now();
|
|
1400
1595
|
for (const client of this.clients.values()) {
|
|
@@ -1494,6 +1689,31 @@ function withCode(err, code) {
|
|
|
1494
1689
|
err.code = code;
|
|
1495
1690
|
return err;
|
|
1496
1691
|
}
|
|
1692
|
+
var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
|
|
1693
|
+
"session_info_update",
|
|
1694
|
+
"current_model_update",
|
|
1695
|
+
"current_mode_update",
|
|
1696
|
+
"available_commands_update"
|
|
1697
|
+
]);
|
|
1698
|
+
function isStateUpdate(method, params) {
|
|
1699
|
+
if (method !== "session/update") {
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
const obj = params ?? {};
|
|
1703
|
+
const kind = obj.update?.sessionUpdate;
|
|
1704
|
+
return typeof kind === "string" && STATE_UPDATE_KINDS.has(kind);
|
|
1705
|
+
}
|
|
1706
|
+
function sameAdvertisedCommands(a, b) {
|
|
1707
|
+
if (a.length !== b.length) {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
for (let i = 0; i < a.length; i++) {
|
|
1711
|
+
if (a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1497
1717
|
function captureInternalChunk(capture, params) {
|
|
1498
1718
|
const obj = params ?? {};
|
|
1499
1719
|
const update = obj.update ?? {};
|
|
@@ -1561,6 +1781,10 @@ function firstLine(text, max) {
|
|
|
1561
1781
|
import * as fs3 from "fs/promises";
|
|
1562
1782
|
import * as path2 from "path";
|
|
1563
1783
|
import { z as z4 } from "zod";
|
|
1784
|
+
var PersistedAgentCommand = z4.object({
|
|
1785
|
+
name: z4.string(),
|
|
1786
|
+
description: z4.string().optional()
|
|
1787
|
+
});
|
|
1564
1788
|
var SessionRecord = z4.object({
|
|
1565
1789
|
version: z4.literal(1),
|
|
1566
1790
|
sessionId: z4.string(),
|
|
@@ -1569,6 +1793,13 @@ var SessionRecord = z4.object({
|
|
|
1569
1793
|
cwd: z4.string(),
|
|
1570
1794
|
title: z4.string().optional(),
|
|
1571
1795
|
agentArgs: z4.array(z4.string()).optional(),
|
|
1796
|
+
// Snapshot of "what is currently true about this session" carried in
|
|
1797
|
+
// meta.json so a late-attaching or cold-resurrected client can be
|
|
1798
|
+
// told via the attach response _meta without depending on history
|
|
1799
|
+
// replay of a snapshot-shaped notification.
|
|
1800
|
+
currentModel: z4.string().optional(),
|
|
1801
|
+
currentMode: z4.string().optional(),
|
|
1802
|
+
agentCommands: z4.array(PersistedAgentCommand).optional(),
|
|
1572
1803
|
createdAt: z4.string(),
|
|
1573
1804
|
updatedAt: z4.string()
|
|
1574
1805
|
});
|
|
@@ -1581,7 +1812,7 @@ function assertSafeId(id) {
|
|
|
1581
1812
|
var SessionStore = class {
|
|
1582
1813
|
async write(record) {
|
|
1583
1814
|
assertSafeId(record.sessionId);
|
|
1584
|
-
await fs3.mkdir(paths.
|
|
1815
|
+
await fs3.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
|
|
1585
1816
|
const full = { version: 1, ...record };
|
|
1586
1817
|
await fs3.writeFile(
|
|
1587
1818
|
paths.sessionFile(record.sessionId),
|
|
@@ -1621,6 +1852,14 @@ var SessionStore = class {
|
|
|
1621
1852
|
throw err;
|
|
1622
1853
|
}
|
|
1623
1854
|
}
|
|
1855
|
+
try {
|
|
1856
|
+
await fs3.rmdir(paths.sessionDir(sessionId));
|
|
1857
|
+
} catch (err) {
|
|
1858
|
+
const e = err;
|
|
1859
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
1860
|
+
throw err;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1624
1863
|
}
|
|
1625
1864
|
async list() {
|
|
1626
1865
|
let entries;
|
|
@@ -1635,11 +1874,7 @@ var SessionStore = class {
|
|
|
1635
1874
|
}
|
|
1636
1875
|
const records = [];
|
|
1637
1876
|
for (const entry of entries) {
|
|
1638
|
-
|
|
1639
|
-
continue;
|
|
1640
|
-
}
|
|
1641
|
-
const id = entry.slice(0, -".json".length);
|
|
1642
|
-
const record = await this.read(id);
|
|
1877
|
+
const record = await this.read(entry);
|
|
1643
1878
|
if (record) {
|
|
1644
1879
|
records.push(record);
|
|
1645
1880
|
}
|
|
@@ -1656,17 +1891,143 @@ function recordFromMemorySession(args) {
|
|
|
1656
1891
|
cwd: args.cwd,
|
|
1657
1892
|
title: args.title,
|
|
1658
1893
|
agentArgs: args.agentArgs,
|
|
1894
|
+
currentModel: args.currentModel,
|
|
1895
|
+
currentMode: args.currentMode,
|
|
1896
|
+
agentCommands: args.agentCommands,
|
|
1659
1897
|
createdAt: args.createdAt ?? now,
|
|
1660
1898
|
updatedAt: args.updatedAt ?? now
|
|
1661
1899
|
};
|
|
1662
1900
|
}
|
|
1663
1901
|
|
|
1902
|
+
// src/core/history-store.ts
|
|
1903
|
+
import * as fs4 from "fs/promises";
|
|
1904
|
+
var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
|
|
1905
|
+
var MAX_ENTRIES = 1e3;
|
|
1906
|
+
var HistoryStore = class {
|
|
1907
|
+
// Serialize writes per session id so appends and rewrites don't
|
|
1908
|
+
// interleave JSONL lines on disk. The chain swallows errors so one
|
|
1909
|
+
// failed append doesn't poison every subsequent write.
|
|
1910
|
+
writeQueues = /* @__PURE__ */ new Map();
|
|
1911
|
+
async append(sessionId, entry) {
|
|
1912
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
return this.enqueue(sessionId, async () => {
|
|
1916
|
+
await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1917
|
+
const line = JSON.stringify(entry) + "\n";
|
|
1918
|
+
await fs4.appendFile(paths.historyFile(sessionId), line, {
|
|
1919
|
+
encoding: "utf8",
|
|
1920
|
+
mode: 384
|
|
1921
|
+
});
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
async rewrite(sessionId, entries) {
|
|
1925
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
return this.enqueue(sessionId, async () => {
|
|
1929
|
+
await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
1930
|
+
const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1931
|
+
await fs4.writeFile(paths.historyFile(sessionId), body, {
|
|
1932
|
+
encoding: "utf8",
|
|
1933
|
+
mode: 384
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
async load(sessionId) {
|
|
1938
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1939
|
+
return [];
|
|
1940
|
+
}
|
|
1941
|
+
const pending = this.writeQueues.get(sessionId);
|
|
1942
|
+
if (pending) {
|
|
1943
|
+
await pending;
|
|
1944
|
+
}
|
|
1945
|
+
let raw;
|
|
1946
|
+
try {
|
|
1947
|
+
raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
const e = err;
|
|
1950
|
+
if (e.code === "ENOENT") {
|
|
1951
|
+
return [];
|
|
1952
|
+
}
|
|
1953
|
+
throw err;
|
|
1954
|
+
}
|
|
1955
|
+
const out = [];
|
|
1956
|
+
for (const line of raw.split("\n")) {
|
|
1957
|
+
if (line.length === 0) {
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
let parsed;
|
|
1961
|
+
try {
|
|
1962
|
+
parsed = JSON.parse(line);
|
|
1963
|
+
} catch {
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
const obj = parsed;
|
|
1970
|
+
if (typeof obj.method !== "string") {
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
if (typeof obj.recordedAt !== "number") {
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
out.push({
|
|
1977
|
+
method: obj.method,
|
|
1978
|
+
params: obj.params,
|
|
1979
|
+
recordedAt: obj.recordedAt
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
if (out.length > MAX_ENTRIES) {
|
|
1983
|
+
return out.slice(-MAX_ENTRIES);
|
|
1984
|
+
}
|
|
1985
|
+
return out;
|
|
1986
|
+
}
|
|
1987
|
+
async delete(sessionId) {
|
|
1988
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
return this.enqueue(sessionId, async () => {
|
|
1992
|
+
try {
|
|
1993
|
+
await fs4.unlink(paths.historyFile(sessionId));
|
|
1994
|
+
} catch (err) {
|
|
1995
|
+
const e = err;
|
|
1996
|
+
if (e.code !== "ENOENT") {
|
|
1997
|
+
throw err;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
try {
|
|
2001
|
+
await fs4.rmdir(paths.sessionDir(sessionId));
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
const e = err;
|
|
2004
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
2005
|
+
throw err;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
enqueue(sessionId, task) {
|
|
2011
|
+
const prev = this.writeQueues.get(sessionId) ?? Promise.resolve();
|
|
2012
|
+
const task$ = prev.then(task, task);
|
|
2013
|
+
const settled = task$.catch(() => void 0);
|
|
2014
|
+
this.writeQueues.set(sessionId, settled);
|
|
2015
|
+
void settled.finally(() => {
|
|
2016
|
+
if (this.writeQueues.get(sessionId) === settled) {
|
|
2017
|
+
this.writeQueues.delete(sessionId);
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
return task$;
|
|
2021
|
+
}
|
|
2022
|
+
};
|
|
2023
|
+
|
|
1664
2024
|
// src/core/session-manager.ts
|
|
1665
2025
|
var SessionManager = class {
|
|
1666
2026
|
constructor(registry, spawner, store, options = {}) {
|
|
1667
2027
|
this.registry = registry;
|
|
1668
2028
|
this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
|
|
1669
2029
|
this.store = store ?? new SessionStore();
|
|
2030
|
+
this.histories = new HistoryStore();
|
|
1670
2031
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
1671
2032
|
}
|
|
1672
2033
|
registry;
|
|
@@ -1674,7 +2035,12 @@ var SessionManager = class {
|
|
|
1674
2035
|
resurrectionInflight = /* @__PURE__ */ new Map();
|
|
1675
2036
|
spawner;
|
|
1676
2037
|
store;
|
|
2038
|
+
histories;
|
|
1677
2039
|
idleTimeoutMs;
|
|
2040
|
+
// Serialize meta.json read-modify-write operations per session id so
|
|
2041
|
+
// concurrent snapshot updates (e.g. an agent emitting model + mode
|
|
2042
|
+
// back-to-back) don't lose writes via interleaved reads.
|
|
2043
|
+
metaWriteQueues = /* @__PURE__ */ new Map();
|
|
1678
2044
|
async create(params) {
|
|
1679
2045
|
const fresh = await this.bootstrapAgent({
|
|
1680
2046
|
agentId: params.agentId,
|
|
@@ -1691,7 +2057,8 @@ var SessionManager = class {
|
|
|
1691
2057
|
title: params.title,
|
|
1692
2058
|
agentArgs: params.agentArgs,
|
|
1693
2059
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
1694
|
-
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
|
|
2060
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2061
|
+
historyStore: this.histories
|
|
1695
2062
|
});
|
|
1696
2063
|
await this.attachManagerHooks(session);
|
|
1697
2064
|
return session;
|
|
@@ -1767,7 +2134,12 @@ var SessionManager = class {
|
|
|
1767
2134
|
title: params.title,
|
|
1768
2135
|
agentArgs: params.agentArgs,
|
|
1769
2136
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
1770
|
-
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
|
|
2137
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2138
|
+
historyStore: this.histories,
|
|
2139
|
+
seedHistory: params.seedHistory,
|
|
2140
|
+
currentModel: params.currentModel,
|
|
2141
|
+
currentMode: params.currentMode,
|
|
2142
|
+
agentCommands: params.agentCommands
|
|
1771
2143
|
});
|
|
1772
2144
|
await this.attachManagerHooks(session);
|
|
1773
2145
|
return session;
|
|
@@ -1821,6 +2193,7 @@ var SessionManager = class {
|
|
|
1821
2193
|
this.sessions.delete(session.sessionId);
|
|
1822
2194
|
if (deleteRecord) {
|
|
1823
2195
|
void this.store.delete(session.sessionId).catch(() => void 0);
|
|
2196
|
+
void this.histories.delete(session.sessionId).catch(() => void 0);
|
|
1824
2197
|
}
|
|
1825
2198
|
});
|
|
1826
2199
|
session.onTitleChange((title) => {
|
|
@@ -1831,6 +2204,24 @@ var SessionManager = class {
|
|
|
1831
2204
|
() => void 0
|
|
1832
2205
|
);
|
|
1833
2206
|
});
|
|
2207
|
+
session.onModelChange((model) => {
|
|
2208
|
+
void this.persistSnapshot(session.sessionId, { currentModel: model }).catch(
|
|
2209
|
+
() => void 0
|
|
2210
|
+
);
|
|
2211
|
+
});
|
|
2212
|
+
session.onModeChange((mode) => {
|
|
2213
|
+
void this.persistSnapshot(session.sessionId, { currentMode: mode }).catch(
|
|
2214
|
+
() => void 0
|
|
2215
|
+
);
|
|
2216
|
+
});
|
|
2217
|
+
session.onAgentCommandsChange((commands) => {
|
|
2218
|
+
void this.persistSnapshot(session.sessionId, {
|
|
2219
|
+
agentCommands: commands.map((c) => ({
|
|
2220
|
+
name: c.name,
|
|
2221
|
+
...c.description !== void 0 ? { description: c.description } : {}
|
|
2222
|
+
}))
|
|
2223
|
+
}).catch(() => void 0);
|
|
2224
|
+
});
|
|
1834
2225
|
this.sessions.set(session.sessionId, session);
|
|
1835
2226
|
await this.store.write(
|
|
1836
2227
|
recordFromMemorySession({
|
|
@@ -1839,22 +2230,45 @@ var SessionManager = class {
|
|
|
1839
2230
|
agentId: session.agentId,
|
|
1840
2231
|
cwd: session.cwd,
|
|
1841
2232
|
title: session.title,
|
|
1842
|
-
agentArgs: session.agentArgs
|
|
2233
|
+
agentArgs: session.agentArgs,
|
|
2234
|
+
currentModel: session.currentModel,
|
|
2235
|
+
currentMode: session.currentMode
|
|
1843
2236
|
})
|
|
1844
2237
|
).catch(() => void 0);
|
|
1845
2238
|
}
|
|
2239
|
+
// Resolve a session's recorded history without forcing a resurrect.
|
|
2240
|
+
// Returns the in-memory snapshot if the session is hot, falls back
|
|
2241
|
+
// to the on-disk history file otherwise. Returns undefined if the
|
|
2242
|
+
// session id is unknown to both the live map and disk store, so the
|
|
2243
|
+
// caller can distinguish "no history yet" (empty array) from "404".
|
|
2244
|
+
async getHistory(sessionId) {
|
|
2245
|
+
const live = this.sessions.get(sessionId);
|
|
2246
|
+
if (live) {
|
|
2247
|
+
return live.getHistorySnapshot();
|
|
2248
|
+
}
|
|
2249
|
+
const record = await this.store.read(sessionId);
|
|
2250
|
+
if (!record) {
|
|
2251
|
+
return void 0;
|
|
2252
|
+
}
|
|
2253
|
+
return this.histories.load(sessionId).catch(() => []);
|
|
2254
|
+
}
|
|
1846
2255
|
async loadFromDisk(sessionId) {
|
|
1847
2256
|
const record = await this.store.read(sessionId);
|
|
1848
2257
|
if (!record) {
|
|
1849
2258
|
return void 0;
|
|
1850
2259
|
}
|
|
2260
|
+
const seedHistory = await this.histories.load(sessionId).catch(() => []);
|
|
1851
2261
|
return {
|
|
1852
2262
|
hydraSessionId: record.sessionId,
|
|
1853
2263
|
upstreamSessionId: record.upstreamSessionId,
|
|
1854
2264
|
agentId: record.agentId,
|
|
1855
2265
|
cwd: record.cwd,
|
|
1856
2266
|
title: record.title,
|
|
1857
|
-
agentArgs: record.agentArgs
|
|
2267
|
+
agentArgs: record.agentArgs,
|
|
2268
|
+
seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
|
|
2269
|
+
currentModel: record.currentModel,
|
|
2270
|
+
currentMode: record.currentMode,
|
|
2271
|
+
agentCommands: record.agentCommands
|
|
1858
2272
|
};
|
|
1859
2273
|
}
|
|
1860
2274
|
get(sessionId) {
|
|
@@ -1942,14 +2356,16 @@ var SessionManager = class {
|
|
|
1942
2356
|
// record's title in sync with what was broadcast to clients so a
|
|
1943
2357
|
// daemon restart (and later resurrect) restores the same title.
|
|
1944
2358
|
async persistTitle(sessionId, title) {
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2359
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
2360
|
+
const record = await this.store.read(sessionId);
|
|
2361
|
+
if (!record) {
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
await this.store.write({
|
|
2365
|
+
...record,
|
|
2366
|
+
title,
|
|
2367
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2368
|
+
});
|
|
1953
2369
|
});
|
|
1954
2370
|
}
|
|
1955
2371
|
// Persist an agent swap from /hydra switch. The on-disk record's
|
|
@@ -1957,17 +2373,52 @@ var SessionManager = class {
|
|
|
1957
2373
|
// later resurrect) brings the session back up on the agent the user
|
|
1958
2374
|
// most recently switched to, not the one it was originally created on.
|
|
1959
2375
|
async persistAgentChange(sessionId, agentId, upstreamSessionId) {
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2376
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
2377
|
+
const record = await this.store.read(sessionId);
|
|
2378
|
+
if (!record) {
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
await this.store.write({
|
|
2382
|
+
...record,
|
|
2383
|
+
agentId,
|
|
2384
|
+
upstreamSessionId,
|
|
2385
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2386
|
+
});
|
|
1969
2387
|
});
|
|
1970
2388
|
}
|
|
2389
|
+
// Update one or more snapshot fields (model, mode, commands) in
|
|
2390
|
+
// meta.json. Used so cold-resurrect can deliver the latest snapshot
|
|
2391
|
+
// to attaching clients via the attach response _meta. No-op if the
|
|
2392
|
+
// session record has gone away (race with deleteRecord).
|
|
2393
|
+
async persistSnapshot(sessionId, update) {
|
|
2394
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
2395
|
+
const record = await this.store.read(sessionId);
|
|
2396
|
+
if (!record) {
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
await this.store.write({
|
|
2400
|
+
...record,
|
|
2401
|
+
...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
|
|
2402
|
+
...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
|
|
2403
|
+
...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
|
|
2404
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2405
|
+
});
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
// Serialize meta.json writes per session id so concurrent
|
|
2409
|
+
// read-modify-write operations don't interleave reads.
|
|
2410
|
+
enqueueMetaWrite(sessionId, task) {
|
|
2411
|
+
const prev = this.metaWriteQueues.get(sessionId) ?? Promise.resolve();
|
|
2412
|
+
const next = prev.then(task, task);
|
|
2413
|
+
const settled = next.catch(() => void 0);
|
|
2414
|
+
this.metaWriteQueues.set(sessionId, settled);
|
|
2415
|
+
void settled.finally(() => {
|
|
2416
|
+
if (this.metaWriteQueues.get(sessionId) === settled) {
|
|
2417
|
+
this.metaWriteQueues.delete(sessionId);
|
|
2418
|
+
}
|
|
2419
|
+
});
|
|
2420
|
+
return next;
|
|
2421
|
+
}
|
|
1971
2422
|
async closeAll() {
|
|
1972
2423
|
const sessions = [...this.sessions.values()];
|
|
1973
2424
|
await Promise.allSettled(sessions.map((s) => s.close()));
|
|
@@ -1977,7 +2428,7 @@ var SessionManager = class {
|
|
|
1977
2428
|
|
|
1978
2429
|
// src/core/extensions.ts
|
|
1979
2430
|
import { spawn as spawn2 } from "child_process";
|
|
1980
|
-
import * as
|
|
2431
|
+
import * as fs5 from "fs";
|
|
1981
2432
|
import * as fsp from "fs/promises";
|
|
1982
2433
|
import * as path3 from "path";
|
|
1983
2434
|
var RESTART_BASE_MS = 1e3;
|
|
@@ -2260,7 +2711,7 @@ var ExtensionManager = class {
|
|
|
2260
2711
|
}
|
|
2261
2712
|
const ext = entry.config;
|
|
2262
2713
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
2263
|
-
const logStream =
|
|
2714
|
+
const logStream = fs5.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
2264
2715
|
flags: "a"
|
|
2265
2716
|
});
|
|
2266
2717
|
logStream.write(
|
|
@@ -2310,7 +2761,7 @@ var ExtensionManager = class {
|
|
|
2310
2761
|
}
|
|
2311
2762
|
if (typeof child.pid === "number") {
|
|
2312
2763
|
try {
|
|
2313
|
-
|
|
2764
|
+
fs5.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
2314
2765
|
`, {
|
|
2315
2766
|
encoding: "utf8",
|
|
2316
2767
|
mode: 384
|
|
@@ -2335,7 +2786,7 @@ var ExtensionManager = class {
|
|
|
2335
2786
|
});
|
|
2336
2787
|
child.on("exit", (code, signal) => {
|
|
2337
2788
|
try {
|
|
2338
|
-
|
|
2789
|
+
fs5.unlinkSync(paths.extensionPidFile(ext.name));
|
|
2339
2790
|
} catch {
|
|
2340
2791
|
}
|
|
2341
2792
|
logStream.write(
|
|
@@ -2447,8 +2898,7 @@ function constantTimeEqual(a, b) {
|
|
|
2447
2898
|
function registerSessionRoutes(app, manager, defaults) {
|
|
2448
2899
|
app.get("/v1/sessions", async (request) => {
|
|
2449
2900
|
const query = request.query;
|
|
2450
|
-
const
|
|
2451
|
-
const sessions = await manager.list({ cwd: query?.cwd, all });
|
|
2901
|
+
const sessions = await manager.list({ cwd: query?.cwd });
|
|
2452
2902
|
return { sessions };
|
|
2453
2903
|
});
|
|
2454
2904
|
app.post("/v1/sessions", async (request, reply) => {
|
|
@@ -2486,6 +2936,50 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
2486
2936
|
}
|
|
2487
2937
|
reply.code(204).send();
|
|
2488
2938
|
});
|
|
2939
|
+
app.get("/v1/sessions/:id/history", async (request, reply) => {
|
|
2940
|
+
const raw = request.params.id;
|
|
2941
|
+
const query = request.query;
|
|
2942
|
+
const follow = query?.follow === "1" || query?.follow === "true";
|
|
2943
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
2944
|
+
const live = manager.get(id);
|
|
2945
|
+
let snapshot;
|
|
2946
|
+
let unsubscribe;
|
|
2947
|
+
if (live) {
|
|
2948
|
+
snapshot = live.getHistorySnapshot();
|
|
2949
|
+
if (follow) {
|
|
2950
|
+
unsubscribe = live.onBroadcast((entry) => {
|
|
2951
|
+
if (reply.raw.writableEnded) {
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
} else {
|
|
2958
|
+
const cold = await manager.getHistory(id);
|
|
2959
|
+
if (cold === void 0) {
|
|
2960
|
+
reply.code(404).send({ error: "session not found" });
|
|
2961
|
+
return reply;
|
|
2962
|
+
}
|
|
2963
|
+
snapshot = cold;
|
|
2964
|
+
}
|
|
2965
|
+
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
|
2966
|
+
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
2967
|
+
reply.raw.statusCode = 200;
|
|
2968
|
+
for (const entry of snapshot ?? []) {
|
|
2969
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
2970
|
+
}
|
|
2971
|
+
if (!unsubscribe) {
|
|
2972
|
+
reply.raw.end();
|
|
2973
|
+
return reply;
|
|
2974
|
+
}
|
|
2975
|
+
request.raw.on("close", () => {
|
|
2976
|
+
unsubscribe?.();
|
|
2977
|
+
if (!reply.raw.writableEnded) {
|
|
2978
|
+
reply.raw.end();
|
|
2979
|
+
}
|
|
2980
|
+
});
|
|
2981
|
+
return reply;
|
|
2982
|
+
});
|
|
2489
2983
|
}
|
|
2490
2984
|
|
|
2491
2985
|
// src/daemon/routes/agents.ts
|
|
@@ -2946,6 +3440,16 @@ function buildResponseMeta(session) {
|
|
|
2946
3440
|
if (session.agentArgs && session.agentArgs.length > 0) {
|
|
2947
3441
|
ours.agentArgs = session.agentArgs;
|
|
2948
3442
|
}
|
|
3443
|
+
if (session.currentModel !== void 0) {
|
|
3444
|
+
ours.currentModel = session.currentModel;
|
|
3445
|
+
}
|
|
3446
|
+
if (session.currentMode !== void 0) {
|
|
3447
|
+
ours.currentMode = session.currentMode;
|
|
3448
|
+
}
|
|
3449
|
+
const commands = session.mergedAvailableCommands();
|
|
3450
|
+
if (commands.length > 0) {
|
|
3451
|
+
ours.availableCommands = commands;
|
|
3452
|
+
}
|
|
2949
3453
|
return mergeMeta(session.agentMeta, ours);
|
|
2950
3454
|
}
|
|
2951
3455
|
function buildInitializeResult() {
|
|
@@ -3066,7 +3570,7 @@ async function startDaemon(config) {
|
|
|
3066
3570
|
await manager.closeAll();
|
|
3067
3571
|
await app.close();
|
|
3068
3572
|
try {
|
|
3069
|
-
|
|
3573
|
+
fs6.unlinkSync(paths.pidFile());
|
|
3070
3574
|
} catch {
|
|
3071
3575
|
}
|
|
3072
3576
|
try {
|