@hydra-acp/cli 0.1.3 → 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 +25 -1
- package/dist/cli.js +1078 -266
- package/dist/index.d.ts +176 -7
- package/dist/index.js +660 -129
- 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";
|
|
@@ -62,7 +62,7 @@ var DaemonConfig = z.object({
|
|
|
62
62
|
authToken: z.string().min(16),
|
|
63
63
|
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
64
64
|
tls: TlsConfig.optional(),
|
|
65
|
-
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(
|
|
65
|
+
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
|
|
66
66
|
});
|
|
67
67
|
var RegistryConfig = z.object({
|
|
68
68
|
url: z.string().url().default(REGISTRY_URL_DEFAULT),
|
|
@@ -137,8 +137,7 @@ async function ensureConfig() {
|
|
|
137
137
|
if (e.code !== "ENOENT") {
|
|
138
138
|
throw err;
|
|
139
139
|
}
|
|
140
|
-
const config =
|
|
141
|
-
await writeConfig(config);
|
|
140
|
+
const config = await writeMinimalInitConfig();
|
|
142
141
|
process.stderr.write(
|
|
143
142
|
`hydra-acp: initialized ${paths.config()} with a fresh auth token.
|
|
144
143
|
`
|
|
@@ -154,6 +153,16 @@ async function writeConfig(config) {
|
|
|
154
153
|
mode: 384
|
|
155
154
|
});
|
|
156
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
|
+
}
|
|
157
166
|
function generateAuthToken() {
|
|
158
167
|
const bytes = new Uint8Array(32);
|
|
159
168
|
crypto.getRandomValues(bytes);
|
|
@@ -348,7 +357,8 @@ function planSpawn(agent, extraArgs = []) {
|
|
|
348
357
|
}
|
|
349
358
|
|
|
350
359
|
// src/core/session-manager.ts
|
|
351
|
-
import * as
|
|
360
|
+
import * as fs6 from "fs/promises";
|
|
361
|
+
import { customAlphabet as customAlphabet3 } from "nanoid";
|
|
352
362
|
|
|
353
363
|
// src/core/agent-instance.ts
|
|
354
364
|
import { spawn } from "child_process";
|
|
@@ -364,7 +374,8 @@ var JsonRpcErrorCodes = {
|
|
|
364
374
|
SessionNotFound: -32001,
|
|
365
375
|
PermissionDenied: -32002,
|
|
366
376
|
AlreadyAttached: -32003,
|
|
367
|
-
AgentNotInstalled: -32005
|
|
377
|
+
AgentNotInstalled: -32005,
|
|
378
|
+
BundleAlreadyImported: -32010
|
|
368
379
|
};
|
|
369
380
|
var InitializeParams = z3.object({
|
|
370
381
|
protocolVersion: z3.number().optional(),
|
|
@@ -813,6 +824,8 @@ function hydraCommandsAsAdvertised() {
|
|
|
813
824
|
var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
814
825
|
var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
|
|
815
826
|
var HYDRA_SESSION_PREFIX = "hydra_session_";
|
|
827
|
+
var MAX_HISTORY_ENTRIES = 1e3;
|
|
828
|
+
var COMPACT_EVERY = 200;
|
|
816
829
|
var Session = class {
|
|
817
830
|
sessionId;
|
|
818
831
|
cwd;
|
|
@@ -832,8 +845,8 @@ var Session = class {
|
|
|
832
845
|
currentModel;
|
|
833
846
|
currentMode;
|
|
834
847
|
updatedAt;
|
|
848
|
+
createdAt;
|
|
835
849
|
clients = /* @__PURE__ */ new Map();
|
|
836
|
-
history = [];
|
|
837
850
|
historyStore;
|
|
838
851
|
promptQueue = [];
|
|
839
852
|
promptInFlight = false;
|
|
@@ -849,6 +862,15 @@ var Session = class {
|
|
|
849
862
|
// True once we've observed our first session/prompt; gates the
|
|
850
863
|
// first-prompt-seeded title so subsequent prompts don't churn it.
|
|
851
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;
|
|
852
874
|
// Permission requests that have been broadcast to one or more
|
|
853
875
|
// clients but have not yet resolved. Replayed to clients that
|
|
854
876
|
// attach mid-flight so a late joiner sees the prompt instead of an
|
|
@@ -863,6 +885,12 @@ var Session = class {
|
|
|
863
885
|
internalPromptCapture;
|
|
864
886
|
idleTimeoutMs;
|
|
865
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;
|
|
866
894
|
spawnReplacementAgent;
|
|
867
895
|
agentChangeHandlers = [];
|
|
868
896
|
// Last available_commands_update we observed from the agent. Stored
|
|
@@ -897,11 +925,11 @@ var Session = class {
|
|
|
897
925
|
this.firstPromptSeeded = true;
|
|
898
926
|
}
|
|
899
927
|
this.historyStore = init.historyStore;
|
|
900
|
-
if (init.seedHistory && init.seedHistory.length > 0) {
|
|
901
|
-
this.history = [...init.seedHistory];
|
|
902
|
-
}
|
|
903
928
|
this.updatedAt = Date.now();
|
|
929
|
+
this.createdAt = init.createdAt ?? this.updatedAt;
|
|
930
|
+
this.lastRecordedAt = this.updatedAt;
|
|
904
931
|
this.wireAgent(this.agent);
|
|
932
|
+
this.scheduleIdleCheck();
|
|
905
933
|
}
|
|
906
934
|
broadcastMergedCommands() {
|
|
907
935
|
const merged = [
|
|
@@ -961,34 +989,20 @@ var Session = class {
|
|
|
961
989
|
return this.clients.size;
|
|
962
990
|
}
|
|
963
991
|
// Wall-clock when the in-flight agent turn began, or undefined when
|
|
964
|
-
// idle.
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
// so a fresh client reattaching mid-turn boots up with the busy
|
|
968
|
-
// banner showing real elapsed time.
|
|
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.
|
|
969
995
|
get turnStartedAt() {
|
|
970
|
-
|
|
971
|
-
const entry = this.history[i];
|
|
972
|
-
if (!entry) {
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
975
|
-
const params = entry.params;
|
|
976
|
-
const kind = params?.update?.sessionUpdate;
|
|
977
|
-
if (kind === "turn_complete") {
|
|
978
|
-
return void 0;
|
|
979
|
-
}
|
|
980
|
-
if (kind === "prompt_received") {
|
|
981
|
-
return entry.recordedAt;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
return void 0;
|
|
996
|
+
return this.promptStartedAt;
|
|
985
997
|
}
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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(() => []);
|
|
992
1006
|
}
|
|
993
1007
|
// Subscribe to recordable broadcast entries — fires once per entry
|
|
994
1008
|
// that lands in history (so snapshot-shaped session_info/model/mode/
|
|
@@ -1004,6 +1018,10 @@ var Session = class {
|
|
|
1004
1018
|
}
|
|
1005
1019
|
};
|
|
1006
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.
|
|
1007
1025
|
attach(client, historyPolicy) {
|
|
1008
1026
|
if (this.closed) {
|
|
1009
1027
|
throw withCode(
|
|
@@ -1019,14 +1037,10 @@ var Session = class {
|
|
|
1019
1037
|
}
|
|
1020
1038
|
this.clients.set(client.clientId, client);
|
|
1021
1039
|
this.updatedAt = Date.now();
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
return [];
|
|
1040
|
+
if (historyPolicy === "none" || historyPolicy === "pending_only") {
|
|
1041
|
+
return Promise.resolve([]);
|
|
1025
1042
|
}
|
|
1026
|
-
|
|
1027
|
-
return [];
|
|
1028
|
-
}
|
|
1029
|
-
return [...this.history];
|
|
1043
|
+
return this.getHistorySnapshot();
|
|
1030
1044
|
}
|
|
1031
1045
|
// Dispatch in-flight permission requests to a freshly-attached
|
|
1032
1046
|
// client. Called by the daemon's WS handler *after* it finishes
|
|
@@ -1040,7 +1054,6 @@ var Session = class {
|
|
|
1040
1054
|
detach(clientId) {
|
|
1041
1055
|
if (this.clients.delete(clientId)) {
|
|
1042
1056
|
this.updatedAt = Date.now();
|
|
1043
|
-
this.maybeStartIdleTimer();
|
|
1044
1057
|
}
|
|
1045
1058
|
}
|
|
1046
1059
|
async prompt(clientId, params) {
|
|
@@ -1086,6 +1099,7 @@ var Session = class {
|
|
|
1086
1099
|
if (client.clientInfo?.version) {
|
|
1087
1100
|
sentBy.version = client.clientInfo.version;
|
|
1088
1101
|
}
|
|
1102
|
+
this.promptStartedAt = Date.now();
|
|
1089
1103
|
this.recordAndBroadcast(
|
|
1090
1104
|
"session/update",
|
|
1091
1105
|
{
|
|
@@ -1122,6 +1136,7 @@ var Session = class {
|
|
|
1122
1136
|
if (stopReason !== void 0) {
|
|
1123
1137
|
update.stopReason = stopReason;
|
|
1124
1138
|
}
|
|
1139
|
+
this.promptStartedAt = void 0;
|
|
1125
1140
|
this.recordAndBroadcast(
|
|
1126
1141
|
"session/update",
|
|
1127
1142
|
{
|
|
@@ -1307,6 +1322,12 @@ var Session = class {
|
|
|
1307
1322
|
mergedAvailableCommands() {
|
|
1308
1323
|
return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
|
|
1309
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
|
+
}
|
|
1310
1331
|
// Pick up an agent-emitted session_info_update and store its title
|
|
1311
1332
|
// as our canonical record. The notification is also forwarded to
|
|
1312
1333
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -1438,7 +1459,7 @@ var Session = class {
|
|
|
1438
1459
|
const spawnAgent = this.spawnReplacementAgent;
|
|
1439
1460
|
return this.enqueuePrompt(async () => {
|
|
1440
1461
|
const oldAgentId = this.agentId;
|
|
1441
|
-
const transcript = this.buildSwitchTranscript(oldAgentId);
|
|
1462
|
+
const transcript = await this.buildSwitchTranscript(oldAgentId);
|
|
1442
1463
|
const fresh = await spawnAgent({
|
|
1443
1464
|
agentId: newAgentId,
|
|
1444
1465
|
cwd: this.cwd,
|
|
@@ -1470,15 +1491,20 @@ var Session = class {
|
|
|
1470
1491
|
return { stopReason: "end_turn" };
|
|
1471
1492
|
});
|
|
1472
1493
|
}
|
|
1473
|
-
// Walk
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
|
|
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) {
|
|
1480
1505
|
const lines = [];
|
|
1481
|
-
|
|
1506
|
+
const history = await this.getHistorySnapshot();
|
|
1507
|
+
for (const note of history) {
|
|
1482
1508
|
if (note.method !== "session/update") {
|
|
1483
1509
|
continue;
|
|
1484
1510
|
}
|
|
@@ -1532,29 +1558,53 @@ var Session = class {
|
|
|
1532
1558
|
if (current) {
|
|
1533
1559
|
coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
|
|
1534
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.`;
|
|
1535
1563
|
return [
|
|
1536
|
-
|
|
1537
|
-
|
|
1564
|
+
intro,
|
|
1565
|
+
followup,
|
|
1538
1566
|
"",
|
|
1539
1567
|
"--- begin transcript ---",
|
|
1540
1568
|
...coalesced,
|
|
1541
1569
|
"--- end transcript ---"
|
|
1542
1570
|
].join("\n");
|
|
1543
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
|
+
}
|
|
1544
1594
|
// Tell every attached client (a) the agent identity has changed
|
|
1545
|
-
// (session_info_update
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
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
|
|
1550
1601
|
// so a future /hydra switch's transcript builder filters them out.
|
|
1551
1602
|
broadcastAgentSwitch(oldAgentId, newAgentId) {
|
|
1552
1603
|
this.recordAndBroadcast("session/update", {
|
|
1553
1604
|
sessionId: this.sessionId,
|
|
1554
1605
|
update: {
|
|
1555
1606
|
sessionUpdate: "session_info_update",
|
|
1556
|
-
agentId: newAgentId
|
|
1557
|
-
_meta: { "hydra-acp": { synthetic: true } }
|
|
1607
|
+
_meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
|
|
1558
1608
|
}
|
|
1559
1609
|
});
|
|
1560
1610
|
this.recordAndBroadcast("session/update", {
|
|
@@ -1585,22 +1635,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1585
1635
|
handler(opts);
|
|
1586
1636
|
}
|
|
1587
1637
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
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) {
|
|
1590
1654
|
return;
|
|
1591
1655
|
}
|
|
1656
|
+
const dueAt = this.lastActivityAt + this.idleTimeoutMs;
|
|
1657
|
+
this.armIdleTimer(Math.max(0, dueAt - Date.now()));
|
|
1658
|
+
}
|
|
1659
|
+
armIdleTimer(delay) {
|
|
1592
1660
|
if (this.idleTimer) {
|
|
1593
|
-
|
|
1661
|
+
clearTimeout(this.idleTimer);
|
|
1594
1662
|
}
|
|
1595
1663
|
this.idleTimer = setTimeout(() => {
|
|
1596
1664
|
this.idleTimer = void 0;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
}, this.idleTimeoutMs);
|
|
1665
|
+
this.checkIdle();
|
|
1666
|
+
}, delay);
|
|
1600
1667
|
if (typeof this.idleTimer.unref === "function") {
|
|
1601
1668
|
this.idleTimer.unref();
|
|
1602
1669
|
}
|
|
1603
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
|
+
}
|
|
1604
1687
|
cancelIdleTimer() {
|
|
1605
1688
|
if (this.idleTimer) {
|
|
1606
1689
|
clearTimeout(this.idleTimer);
|
|
@@ -1625,17 +1708,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1625
1708
|
params: rewritten,
|
|
1626
1709
|
recordedAt: Date.now()
|
|
1627
1710
|
};
|
|
1628
|
-
this.
|
|
1629
|
-
|
|
1630
|
-
if (this.history.length > 1e3) {
|
|
1631
|
-
this.history = this.history.slice(-500);
|
|
1632
|
-
trimmed = true;
|
|
1633
|
-
}
|
|
1711
|
+
this.lastRecordedAt = entry.recordedAt;
|
|
1712
|
+
this.appendCount += 1;
|
|
1634
1713
|
if (this.historyStore) {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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(
|
|
1639
1719
|
() => void 0
|
|
1640
1720
|
);
|
|
1641
1721
|
}
|
|
@@ -1646,6 +1726,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1646
1726
|
} catch {
|
|
1647
1727
|
}
|
|
1648
1728
|
}
|
|
1729
|
+
this.scheduleIdleCheck();
|
|
1649
1730
|
}
|
|
1650
1731
|
this.updatedAt = Date.now();
|
|
1651
1732
|
for (const client of this.clients.values()) {
|
|
@@ -1836,7 +1917,14 @@ function firstLine(text, max) {
|
|
|
1836
1917
|
// src/core/session-store.ts
|
|
1837
1918
|
import * as fs3 from "fs/promises";
|
|
1838
1919
|
import * as path2 from "path";
|
|
1920
|
+
import { customAlphabet as customAlphabet2 } from "nanoid";
|
|
1839
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
|
+
}
|
|
1840
1928
|
var PersistedAgentCommand = z4.object({
|
|
1841
1929
|
name: z4.string(),
|
|
1842
1930
|
description: z4.string().optional()
|
|
@@ -1844,7 +1932,20 @@ var PersistedAgentCommand = z4.object({
|
|
|
1844
1932
|
var SessionRecord = z4.object({
|
|
1845
1933
|
version: z4.literal(1),
|
|
1846
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(),
|
|
1847
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(),
|
|
1848
1949
|
agentId: z4.string(),
|
|
1849
1950
|
cwd: z4.string(),
|
|
1850
1951
|
title: z4.string().optional(),
|
|
@@ -1917,6 +2018,25 @@ var SessionStore = class {
|
|
|
1917
2018
|
}
|
|
1918
2019
|
}
|
|
1919
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
|
+
}
|
|
1920
2040
|
async list() {
|
|
1921
2041
|
let entries;
|
|
1922
2042
|
try {
|
|
@@ -1942,7 +2062,9 @@ function recordFromMemorySession(args) {
|
|
|
1942
2062
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1943
2063
|
return {
|
|
1944
2064
|
sessionId: args.sessionId,
|
|
2065
|
+
lineageId: args.lineageId,
|
|
1945
2066
|
upstreamSessionId: args.upstreamSessionId,
|
|
2067
|
+
importedFromSessionId: args.importedFromSessionId,
|
|
1946
2068
|
agentId: args.agentId,
|
|
1947
2069
|
cwd: args.cwd,
|
|
1948
2070
|
title: args.title,
|
|
@@ -1990,6 +2112,36 @@ var HistoryStore = class {
|
|
|
1990
2112
|
});
|
|
1991
2113
|
});
|
|
1992
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
|
+
}
|
|
1993
2145
|
async load(sessionId) {
|
|
1994
2146
|
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
1995
2147
|
return [];
|
|
@@ -2077,7 +2229,18 @@ var HistoryStore = class {
|
|
|
2077
2229
|
}
|
|
2078
2230
|
};
|
|
2079
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
|
+
|
|
2080
2241
|
// src/core/session-manager.ts
|
|
2242
|
+
var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
2243
|
+
var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
|
|
2081
2244
|
var SessionManager = class {
|
|
2082
2245
|
constructor(registry, spawner, store, options = {}) {
|
|
2083
2246
|
this.registry = registry;
|
|
@@ -2156,6 +2319,9 @@ var SessionManager = class {
|
|
|
2156
2319
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
2157
2320
|
throw err;
|
|
2158
2321
|
}
|
|
2322
|
+
if (params.upstreamSessionId === "") {
|
|
2323
|
+
return this.doResurrectFromImport(params);
|
|
2324
|
+
}
|
|
2159
2325
|
const plan = planSpawn(agentDef, params.agentArgs ?? []);
|
|
2160
2326
|
const agent = this.spawner({
|
|
2161
2327
|
agentId: params.agentId,
|
|
@@ -2192,15 +2358,55 @@ var SessionManager = class {
|
|
|
2192
2358
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
2193
2359
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
2194
2360
|
historyStore: this.histories,
|
|
2195
|
-
seedHistory: params.seedHistory,
|
|
2196
2361
|
currentModel: params.currentModel,
|
|
2197
2362
|
currentMode: params.currentMode,
|
|
2198
2363
|
agentCommands: params.agentCommands,
|
|
2199
|
-
|
|
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
|
|
2200
2370
|
});
|
|
2201
2371
|
await this.attachManagerHooks(session);
|
|
2202
2372
|
return session;
|
|
2203
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
|
|
2405
|
+
});
|
|
2406
|
+
await this.attachManagerHooks(session);
|
|
2407
|
+
void session.seedFromImport().catch(() => void 0);
|
|
2408
|
+
return session;
|
|
2409
|
+
}
|
|
2204
2410
|
// Bootstrap a fresh agent process: registry resolve → spawn → initialize
|
|
2205
2411
|
// → session/new. Shared by create() and the /hydra switch path so both
|
|
2206
2412
|
// go through the same env / capabilities / error-handling.
|
|
@@ -2280,28 +2486,20 @@ var SessionManager = class {
|
|
|
2280
2486
|
}).catch(() => void 0);
|
|
2281
2487
|
});
|
|
2282
2488
|
this.sessions.set(session.sessionId, session);
|
|
2283
|
-
await this.
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
cwd: session.cwd,
|
|
2289
|
-
title: session.title,
|
|
2290
|
-
agentArgs: session.agentArgs,
|
|
2291
|
-
currentModel: session.currentModel,
|
|
2292
|
-
currentMode: session.currentMode
|
|
2293
|
-
})
|
|
2294
|
-
).catch(() => void 0);
|
|
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);
|
|
2295
2494
|
}
|
|
2296
2495
|
// Resolve a session's recorded history without forcing a resurrect.
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
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".
|
|
2301
2500
|
async getHistory(sessionId) {
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
return live.getHistorySnapshot();
|
|
2501
|
+
if (this.sessions.has(sessionId)) {
|
|
2502
|
+
return this.histories.load(sessionId).catch(() => []);
|
|
2305
2503
|
}
|
|
2306
2504
|
const record = await this.store.read(sessionId);
|
|
2307
2505
|
if (!record) {
|
|
@@ -2314,20 +2512,41 @@ var SessionManager = class {
|
|
|
2314
2512
|
if (!record) {
|
|
2315
2513
|
return void 0;
|
|
2316
2514
|
}
|
|
2317
|
-
|
|
2515
|
+
let title = record.title;
|
|
2516
|
+
if (!title) {
|
|
2517
|
+
title = await this.deriveTitleFromHistory(sessionId);
|
|
2518
|
+
}
|
|
2318
2519
|
return {
|
|
2319
2520
|
hydraSessionId: record.sessionId,
|
|
2320
2521
|
upstreamSessionId: record.upstreamSessionId,
|
|
2321
2522
|
agentId: record.agentId,
|
|
2322
2523
|
cwd: record.cwd,
|
|
2323
|
-
title
|
|
2524
|
+
title,
|
|
2324
2525
|
agentArgs: record.agentArgs,
|
|
2325
|
-
seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
|
|
2326
2526
|
currentModel: record.currentModel,
|
|
2327
2527
|
currentMode: record.currentMode,
|
|
2328
|
-
agentCommands: record.agentCommands
|
|
2528
|
+
agentCommands: record.agentCommands,
|
|
2529
|
+
createdAt: record.createdAt
|
|
2329
2530
|
};
|
|
2330
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
|
+
}
|
|
2331
2550
|
get(sessionId) {
|
|
2332
2551
|
return this.sessions.get(sessionId);
|
|
2333
2552
|
}
|
|
@@ -2402,6 +2621,111 @@ var SessionManager = class {
|
|
|
2402
2621
|
entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
|
|
2403
2622
|
return entries;
|
|
2404
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
|
+
}
|
|
2405
2729
|
async deleteRecord(sessionId) {
|
|
2406
2730
|
const record = await this.store.read(sessionId);
|
|
2407
2731
|
if (!record) {
|
|
@@ -2487,10 +2811,64 @@ var SessionManager = class {
|
|
|
2487
2811
|
await Promise.allSettled(sessions.map((s) => s.close()));
|
|
2488
2812
|
this.sessions.clear();
|
|
2489
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
|
+
}
|
|
2490
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
|
+
}
|
|
2491
2869
|
async function historyMtimeIso(sessionId) {
|
|
2492
2870
|
try {
|
|
2493
|
-
const st = await
|
|
2871
|
+
const st = await fs6.stat(paths.historyFile(sessionId));
|
|
2494
2872
|
return new Date(st.mtimeMs).toISOString();
|
|
2495
2873
|
} catch {
|
|
2496
2874
|
return void 0;
|
|
@@ -2499,9 +2877,9 @@ async function historyMtimeIso(sessionId) {
|
|
|
2499
2877
|
|
|
2500
2878
|
// src/core/extensions.ts
|
|
2501
2879
|
import { spawn as spawn2 } from "child_process";
|
|
2502
|
-
import * as
|
|
2880
|
+
import * as fs7 from "fs";
|
|
2503
2881
|
import * as fsp from "fs/promises";
|
|
2504
|
-
import * as
|
|
2882
|
+
import * as path4 from "path";
|
|
2505
2883
|
var RESTART_BASE_MS = 1e3;
|
|
2506
2884
|
var RESTART_CAP_MS = 6e4;
|
|
2507
2885
|
var STOP_GRACE_MS = 3e3;
|
|
@@ -2743,7 +3121,7 @@ var ExtensionManager = class {
|
|
|
2743
3121
|
if (!entry.endsWith(".pid")) {
|
|
2744
3122
|
continue;
|
|
2745
3123
|
}
|
|
2746
|
-
const pidPath =
|
|
3124
|
+
const pidPath = path4.join(paths.extensionsDir(), entry);
|
|
2747
3125
|
let pid;
|
|
2748
3126
|
try {
|
|
2749
3127
|
const raw = await fsp.readFile(pidPath, "utf8");
|
|
@@ -2782,7 +3160,7 @@ var ExtensionManager = class {
|
|
|
2782
3160
|
}
|
|
2783
3161
|
const ext = entry.config;
|
|
2784
3162
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
2785
|
-
const logStream =
|
|
3163
|
+
const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
2786
3164
|
flags: "a"
|
|
2787
3165
|
});
|
|
2788
3166
|
logStream.write(
|
|
@@ -2832,7 +3210,7 @@ var ExtensionManager = class {
|
|
|
2832
3210
|
}
|
|
2833
3211
|
if (typeof child.pid === "number") {
|
|
2834
3212
|
try {
|
|
2835
|
-
|
|
3213
|
+
fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
2836
3214
|
`, {
|
|
2837
3215
|
encoding: "utf8",
|
|
2838
3216
|
mode: 384
|
|
@@ -2857,7 +3235,7 @@ var ExtensionManager = class {
|
|
|
2857
3235
|
});
|
|
2858
3236
|
child.on("exit", (code, signal) => {
|
|
2859
3237
|
try {
|
|
2860
|
-
|
|
3238
|
+
fs7.unlinkSync(paths.extensionPidFile(ext.name));
|
|
2861
3239
|
} catch {
|
|
2862
3240
|
}
|
|
2863
3241
|
logStream.write(
|
|
@@ -2966,6 +3344,75 @@ function constantTimeEqual(a, b) {
|
|
|
2966
3344
|
}
|
|
2967
3345
|
|
|
2968
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";
|
|
2969
3416
|
function registerSessionRoutes(app, manager, defaults) {
|
|
2970
3417
|
app.get("/v1/sessions", async (request) => {
|
|
2971
3418
|
const query = request.query;
|
|
@@ -3023,6 +3470,61 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
3023
3470
|
}
|
|
3024
3471
|
reply.code(204).send();
|
|
3025
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
|
+
});
|
|
3026
3528
|
app.get("/v1/sessions/:id/history", async (request, reply) => {
|
|
3027
3529
|
const raw = request.params.id;
|
|
3028
3530
|
const query = request.query;
|
|
@@ -3031,16 +3533,22 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
3031
3533
|
const live = manager.get(id);
|
|
3032
3534
|
let snapshot;
|
|
3033
3535
|
let unsubscribe;
|
|
3536
|
+
let snapshotDone = false;
|
|
3537
|
+
const pending = [];
|
|
3034
3538
|
if (live) {
|
|
3035
|
-
snapshot = live.getHistorySnapshot();
|
|
3036
3539
|
if (follow) {
|
|
3037
3540
|
unsubscribe = live.onBroadcast((entry) => {
|
|
3038
3541
|
if (reply.raw.writableEnded) {
|
|
3039
3542
|
return;
|
|
3040
3543
|
}
|
|
3041
|
-
|
|
3544
|
+
if (snapshotDone) {
|
|
3545
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
3546
|
+
} else {
|
|
3547
|
+
pending.push(entry);
|
|
3548
|
+
}
|
|
3042
3549
|
});
|
|
3043
3550
|
}
|
|
3551
|
+
snapshot = await live.getHistorySnapshot();
|
|
3044
3552
|
} else {
|
|
3045
3553
|
const cold = await manager.getHistory(id);
|
|
3046
3554
|
if (cold === void 0) {
|
|
@@ -3052,9 +3560,23 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
3052
3560
|
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
|
3053
3561
|
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
3054
3562
|
reply.raw.statusCode = 200;
|
|
3563
|
+
const snapshotKeys = /* @__PURE__ */ new Set();
|
|
3055
3564
|
for (const entry of snapshot ?? []) {
|
|
3056
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
|
+
}
|
|
3057
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");
|
|
3578
|
+
}
|
|
3579
|
+
snapshotDone = true;
|
|
3058
3580
|
if (!unsubscribe) {
|
|
3059
3581
|
reply.raw.end();
|
|
3060
3582
|
return reply;
|
|
@@ -3299,7 +3821,7 @@ function wsToMessageStream(ws) {
|
|
|
3299
3821
|
}
|
|
3300
3822
|
|
|
3301
3823
|
// src/daemon/acp-ws.ts
|
|
3302
|
-
var
|
|
3824
|
+
var HYDRA_VERSION2 = "0.1.0";
|
|
3303
3825
|
var HYDRA_PROTOCOL_VERSION = 1;
|
|
3304
3826
|
function registerAcpWsEndpoint(app, deps) {
|
|
3305
3827
|
app.get("/acp", { websocket: true }, (socket, request) => {
|
|
@@ -3345,7 +3867,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3345
3867
|
agentArgs: hydraMeta.agentArgs
|
|
3346
3868
|
});
|
|
3347
3869
|
const client = bindClientToSession(connection, session, state);
|
|
3348
|
-
session.attach(client, "full");
|
|
3870
|
+
await session.attach(client, "full");
|
|
3349
3871
|
state.attached.set(session.sessionId, {
|
|
3350
3872
|
sessionId: session.sessionId,
|
|
3351
3873
|
clientId: client.clientId
|
|
@@ -3364,14 +3886,22 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3364
3886
|
const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
|
|
3365
3887
|
let session = deps.manager.get(lookupId);
|
|
3366
3888
|
if (!session) {
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
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
|
+
}
|
|
3375
3905
|
if (!resurrectParams) {
|
|
3376
3906
|
const err = new Error(
|
|
3377
3907
|
`session ${params.sessionId} not found and no resume hints provided`
|
|
@@ -3387,13 +3917,13 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3387
3917
|
state,
|
|
3388
3918
|
params.clientInfo
|
|
3389
3919
|
);
|
|
3390
|
-
const replay = session.attach(client, params.historyPolicy);
|
|
3920
|
+
const replay = await session.attach(client, params.historyPolicy);
|
|
3391
3921
|
state.attached.set(session.sessionId, {
|
|
3392
3922
|
sessionId: session.sessionId,
|
|
3393
3923
|
clientId: client.clientId
|
|
3394
3924
|
});
|
|
3395
3925
|
app.log.info(
|
|
3396
|
-
`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}`
|
|
3397
3927
|
);
|
|
3398
3928
|
for (const note of replay) {
|
|
3399
3929
|
await connection.notify(note.method, note.params);
|
|
@@ -3489,7 +4019,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
3489
4019
|
session = await deps.manager.resurrect(fromDisk);
|
|
3490
4020
|
}
|
|
3491
4021
|
const client = bindClientToSession(connection, session, state);
|
|
3492
|
-
const replay = session.attach(client, "pending_only");
|
|
4022
|
+
const replay = await session.attach(client, "pending_only");
|
|
3493
4023
|
state.attached.set(session.sessionId, {
|
|
3494
4024
|
sessionId: session.sessionId,
|
|
3495
4025
|
clientId: client.clientId
|
|
@@ -3555,7 +4085,7 @@ function buildResponseMeta(session) {
|
|
|
3555
4085
|
function buildInitializeResult() {
|
|
3556
4086
|
return {
|
|
3557
4087
|
protocolVersion: HYDRA_PROTOCOL_VERSION,
|
|
3558
|
-
agentInfo: { name: "hydra", version:
|
|
4088
|
+
agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
|
|
3559
4089
|
agentCapabilities: {
|
|
3560
4090
|
// hydra is a transparent proxy: prompt blocks and MCP server configs are
|
|
3561
4091
|
// forwarded to the underlying agent unchanged. We claim the union of
|
|
@@ -3594,7 +4124,7 @@ function bindClientToSession(connection, session, state, clientInfo) {
|
|
|
3594
4124
|
}
|
|
3595
4125
|
|
|
3596
4126
|
// src/daemon/server.ts
|
|
3597
|
-
var
|
|
4127
|
+
var HYDRA_VERSION3 = "0.1.0";
|
|
3598
4128
|
async function startDaemon(config) {
|
|
3599
4129
|
ensureLoopbackOrTls(config);
|
|
3600
4130
|
const httpsOptions = config.daemon.tls ? {
|
|
@@ -3628,7 +4158,7 @@ async function startDaemon(config) {
|
|
|
3628
4158
|
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
|
|
3629
4159
|
});
|
|
3630
4160
|
const extensions = new ExtensionManager(extensionList(config));
|
|
3631
|
-
registerHealthRoutes(app,
|
|
4161
|
+
registerHealthRoutes(app, HYDRA_VERSION3);
|
|
3632
4162
|
registerSessionRoutes(app, manager, {
|
|
3633
4163
|
agentId: config.defaultAgent,
|
|
3634
4164
|
cwd: config.defaultCwd
|
|
@@ -3672,9 +4202,10 @@ async function startDaemon(config) {
|
|
|
3672
4202
|
const shutdown = async () => {
|
|
3673
4203
|
await extensions.stop();
|
|
3674
4204
|
await manager.closeAll();
|
|
4205
|
+
await manager.flushMetaWrites();
|
|
3675
4206
|
await app.close();
|
|
3676
4207
|
try {
|
|
3677
|
-
|
|
4208
|
+
fs8.unlinkSync(paths.pidFile());
|
|
3678
4209
|
} catch {
|
|
3679
4210
|
}
|
|
3680
4211
|
try {
|