@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/cli.js
CHANGED
|
@@ -34,7 +34,12 @@ var init_paths = __esm({
|
|
|
34
34
|
agentsDir: () => path.join(hydraHome(), "agents"),
|
|
35
35
|
agentDir: (id) => path.join(hydraHome(), "agents", id),
|
|
36
36
|
sessionsDir: () => path.join(hydraHome(), "sessions"),
|
|
37
|
-
|
|
37
|
+
// One directory per session id under sessions/. Co-locates the
|
|
38
|
+
// session record, its transcript, and any future per-session state
|
|
39
|
+
// (uploads, scratch, etc.) so the lifecycle is just "rm -rf the dir".
|
|
40
|
+
sessionDir: (id) => path.join(hydraHome(), "sessions", id),
|
|
41
|
+
sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
|
|
42
|
+
historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
|
|
38
43
|
extensionsDir: () => path.join(hydraHome(), "extensions"),
|
|
39
44
|
extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
|
|
40
45
|
extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
|
|
@@ -215,6 +220,32 @@ function extractHydraMeta(meta) {
|
|
|
215
220
|
out.resume = parsed.data;
|
|
216
221
|
}
|
|
217
222
|
}
|
|
223
|
+
if (typeof obj.currentModel === "string") {
|
|
224
|
+
out.currentModel = obj.currentModel;
|
|
225
|
+
}
|
|
226
|
+
if (typeof obj.currentMode === "string") {
|
|
227
|
+
out.currentMode = obj.currentMode;
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(obj.availableCommands)) {
|
|
230
|
+
const cmds = [];
|
|
231
|
+
for (const raw of obj.availableCommands) {
|
|
232
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const c = raw;
|
|
236
|
+
if (typeof c.name !== "string") {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const cmd = { name: c.name };
|
|
240
|
+
if (typeof c.description === "string") {
|
|
241
|
+
cmd.description = c.description;
|
|
242
|
+
}
|
|
243
|
+
cmds.push(cmd);
|
|
244
|
+
}
|
|
245
|
+
if (cmds.length > 0) {
|
|
246
|
+
out.availableCommands = cmds;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
218
249
|
return out;
|
|
219
250
|
}
|
|
220
251
|
function mergeMeta(passthrough, ours) {
|
|
@@ -502,6 +533,25 @@ function withCode(err, code) {
|
|
|
502
533
|
err.code = code;
|
|
503
534
|
return err;
|
|
504
535
|
}
|
|
536
|
+
function isStateUpdate(method, params) {
|
|
537
|
+
if (method !== "session/update") {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
const obj = params ?? {};
|
|
541
|
+
const kind = obj.update?.sessionUpdate;
|
|
542
|
+
return typeof kind === "string" && STATE_UPDATE_KINDS.has(kind);
|
|
543
|
+
}
|
|
544
|
+
function sameAdvertisedCommands(a, b) {
|
|
545
|
+
if (a.length !== b.length) {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
for (let i = 0; i < a.length; i++) {
|
|
549
|
+
if (a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
505
555
|
function captureInternalChunk(capture, params) {
|
|
506
556
|
const obj = params ?? {};
|
|
507
557
|
const update = obj.update ?? {};
|
|
@@ -564,7 +614,7 @@ function firstLine(text, max) {
|
|
|
564
614
|
}
|
|
565
615
|
return void 0;
|
|
566
616
|
}
|
|
567
|
-
var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session;
|
|
617
|
+
var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session, STATE_UPDATE_KINDS;
|
|
568
618
|
var init_session = __esm({
|
|
569
619
|
"src/core/session.ts"() {
|
|
570
620
|
"use strict";
|
|
@@ -586,14 +636,26 @@ var init_session = __esm({
|
|
|
586
636
|
agentMeta;
|
|
587
637
|
agentArgs;
|
|
588
638
|
title;
|
|
639
|
+
// Snapshot state delivered to attaching clients via the attach
|
|
640
|
+
// response _meta rather than via history replay (which would be
|
|
641
|
+
// stale-prone for snapshot-shaped events).
|
|
642
|
+
currentModel;
|
|
643
|
+
currentMode;
|
|
589
644
|
updatedAt;
|
|
590
645
|
clients = /* @__PURE__ */ new Map();
|
|
591
646
|
history = [];
|
|
647
|
+
historyStore;
|
|
592
648
|
promptQueue = [];
|
|
593
649
|
promptInFlight = false;
|
|
594
650
|
closed = false;
|
|
595
651
|
closeHandlers = [];
|
|
596
652
|
titleHandlers = [];
|
|
653
|
+
// Subscribers notified after every entry that's actually persisted to
|
|
654
|
+
// history (skipping snapshot-shaped events filtered by
|
|
655
|
+
// recordAndBroadcast). The HTTP /v1/sessions/:id/history?follow=1
|
|
656
|
+
// endpoint uses this to tail a live session's conversation stream
|
|
657
|
+
// without participating in turns or prompts.
|
|
658
|
+
broadcastHandlers = [];
|
|
597
659
|
// True once we've observed our first session/prompt; gates the
|
|
598
660
|
// first-prompt-seeded title so subsequent prompts don't churn it.
|
|
599
661
|
firstPromptSeeded = false;
|
|
@@ -613,12 +675,18 @@ var init_session = __esm({
|
|
|
613
675
|
idleTimer;
|
|
614
676
|
spawnReplacementAgent;
|
|
615
677
|
agentChangeHandlers = [];
|
|
616
|
-
// Last available_commands_update we observed from the agent. Stored
|
|
617
|
-
// we can re-broadcast a merged (hydra ∪ agent) list whenever
|
|
618
|
-
// half changes
|
|
619
|
-
//
|
|
620
|
-
//
|
|
678
|
+
// Last available_commands_update we observed from the agent. Stored
|
|
679
|
+
// so we can re-broadcast a merged (hydra ∪ agent) list whenever
|
|
680
|
+
// either half changes, and persisted to meta.json so a fresh attach
|
|
681
|
+
// can deliver the merged list via _meta without depending on history
|
|
682
|
+
// replay.
|
|
621
683
|
agentAdvertisedCommands = [];
|
|
684
|
+
// Persist hooks for snapshot-shaped state. SessionManager hooks these
|
|
685
|
+
// to mirror changes into meta.json so cold-resurrect attaches can
|
|
686
|
+
// surface the latest snapshot via the attach response _meta.
|
|
687
|
+
agentCommandsHandlers = [];
|
|
688
|
+
modelHandlers = [];
|
|
689
|
+
modeHandlers = [];
|
|
622
690
|
constructor(init) {
|
|
623
691
|
this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
|
|
624
692
|
this.cwd = init.cwd;
|
|
@@ -628,11 +696,19 @@ var init_session = __esm({
|
|
|
628
696
|
this.agentMeta = init.agentMeta;
|
|
629
697
|
this.agentArgs = init.agentArgs;
|
|
630
698
|
this.title = init.title;
|
|
699
|
+
this.currentModel = init.currentModel;
|
|
700
|
+
this.currentMode = init.currentMode;
|
|
701
|
+
if (init.agentCommands && init.agentCommands.length > 0) {
|
|
702
|
+
this.agentAdvertisedCommands = [...init.agentCommands];
|
|
703
|
+
}
|
|
631
704
|
this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
|
|
632
705
|
this.spawnReplacementAgent = init.spawnReplacementAgent;
|
|
706
|
+
this.historyStore = init.historyStore;
|
|
707
|
+
if (init.seedHistory && init.seedHistory.length > 0) {
|
|
708
|
+
this.history = [...init.seedHistory];
|
|
709
|
+
}
|
|
633
710
|
this.updatedAt = Date.now();
|
|
634
711
|
this.wireAgent(this.agent);
|
|
635
|
-
this.broadcastMergedCommands();
|
|
636
712
|
}
|
|
637
713
|
broadcastMergedCommands() {
|
|
638
714
|
const merged = [
|
|
@@ -661,8 +737,15 @@ var init_session = __esm({
|
|
|
661
737
|
}
|
|
662
738
|
const agentCmds = extractAdvertisedCommands(params);
|
|
663
739
|
if (agentCmds !== null) {
|
|
664
|
-
this.
|
|
665
|
-
|
|
740
|
+
this.setAgentAdvertisedCommands(agentCmds);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (this.maybeApplyAgentModel(params)) {
|
|
744
|
+
this.recordAndBroadcast("session/update", params);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (this.maybeApplyAgentMode(params)) {
|
|
748
|
+
this.recordAndBroadcast("session/update", params);
|
|
666
749
|
return;
|
|
667
750
|
}
|
|
668
751
|
this.maybeApplyAgentSessionInfo(params);
|
|
@@ -684,6 +767,27 @@ var init_session = __esm({
|
|
|
684
767
|
get attachedCount() {
|
|
685
768
|
return this.clients.size;
|
|
686
769
|
}
|
|
770
|
+
// Snapshot of the current in-memory replay history. Used by the
|
|
771
|
+
// HTTP history endpoint to deliver the "what's accumulated so far"
|
|
772
|
+
// prefix before optionally tailing with onBroadcast. Returns a copy
|
|
773
|
+
// so callers can't mutate our cache.
|
|
774
|
+
getHistorySnapshot() {
|
|
775
|
+
return [...this.history];
|
|
776
|
+
}
|
|
777
|
+
// Subscribe to recordable broadcast entries — fires once per entry
|
|
778
|
+
// that lands in history (so snapshot-shaped session_info/model/mode/
|
|
779
|
+
// available_commands updates do NOT trigger this; they're broadcast
|
|
780
|
+
// live but not recorded). Returns an unsubscribe function the caller
|
|
781
|
+
// must invoke when done.
|
|
782
|
+
onBroadcast(handler) {
|
|
783
|
+
this.broadcastHandlers.push(handler);
|
|
784
|
+
return () => {
|
|
785
|
+
const i = this.broadcastHandlers.indexOf(handler);
|
|
786
|
+
if (i >= 0) {
|
|
787
|
+
this.broadcastHandlers.splice(i, 1);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
687
791
|
attach(client, historyPolicy) {
|
|
688
792
|
if (this.closed) {
|
|
689
793
|
throw withCode(
|
|
@@ -889,6 +993,91 @@ var init_session = __esm({
|
|
|
889
993
|
this.firstPromptSeeded = true;
|
|
890
994
|
this.setTitle(seed);
|
|
891
995
|
}
|
|
996
|
+
// Apply an agent-emitted current_model_update. Returns true if the
|
|
997
|
+
// notification was a model update (caller still needs to broadcast
|
|
998
|
+
// it). Returns false otherwise so the caller can try the next kind.
|
|
999
|
+
maybeApplyAgentModel(params) {
|
|
1000
|
+
const obj = params ?? {};
|
|
1001
|
+
const update = obj.update ?? {};
|
|
1002
|
+
if (update.sessionUpdate !== "current_model_update") {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
|
|
1006
|
+
if (raw === void 0) {
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
const trimmed = raw.trim();
|
|
1010
|
+
if (!trimmed || trimmed === this.currentModel) {
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
this.currentModel = trimmed;
|
|
1014
|
+
for (const handler of this.modelHandlers) {
|
|
1015
|
+
try {
|
|
1016
|
+
handler(trimmed);
|
|
1017
|
+
} catch {
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
maybeApplyAgentMode(params) {
|
|
1023
|
+
const obj = params ?? {};
|
|
1024
|
+
const update = obj.update ?? {};
|
|
1025
|
+
if (update.sessionUpdate !== "current_mode_update") {
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
|
|
1029
|
+
if (raw === void 0) {
|
|
1030
|
+
return true;
|
|
1031
|
+
}
|
|
1032
|
+
const trimmed = raw.trim();
|
|
1033
|
+
if (!trimmed || trimmed === this.currentMode) {
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
this.currentMode = trimmed;
|
|
1037
|
+
for (const handler of this.modeHandlers) {
|
|
1038
|
+
try {
|
|
1039
|
+
handler(trimmed);
|
|
1040
|
+
} catch {
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
// Update the cached agent command list, fire persist handlers, and
|
|
1046
|
+
// broadcast the merged list to attached clients. Idempotent on a
|
|
1047
|
+
// structurally identical list so we don't churn meta.json on noisy
|
|
1048
|
+
// re-emissions.
|
|
1049
|
+
setAgentAdvertisedCommands(commands) {
|
|
1050
|
+
if (sameAdvertisedCommands(this.agentAdvertisedCommands, commands)) {
|
|
1051
|
+
this.broadcastMergedCommands();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
this.agentAdvertisedCommands = commands;
|
|
1055
|
+
for (const handler of this.agentCommandsHandlers) {
|
|
1056
|
+
try {
|
|
1057
|
+
handler(commands);
|
|
1058
|
+
} catch {
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
this.broadcastMergedCommands();
|
|
1062
|
+
}
|
|
1063
|
+
// Subscribe to snapshot-state updates. SessionManager wires these to
|
|
1064
|
+
// persist the new value into meta.json so cold resurrect can restore
|
|
1065
|
+
// them via the attach response _meta.
|
|
1066
|
+
onAgentCommandsChange(handler) {
|
|
1067
|
+
this.agentCommandsHandlers.push(handler);
|
|
1068
|
+
}
|
|
1069
|
+
onModelChange(handler) {
|
|
1070
|
+
this.modelHandlers.push(handler);
|
|
1071
|
+
}
|
|
1072
|
+
onModeChange(handler) {
|
|
1073
|
+
this.modeHandlers.push(handler);
|
|
1074
|
+
}
|
|
1075
|
+
// Returns a freshly merged command list (hydra ∪ agent) for callers
|
|
1076
|
+
// that need a snapshot — notably acp-ws.ts's buildResponseMeta when
|
|
1077
|
+
// assembling the attach response.
|
|
1078
|
+
mergedAvailableCommands() {
|
|
1079
|
+
return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
|
|
1080
|
+
}
|
|
892
1081
|
// Pick up an agent-emitted session_info_update and store its title
|
|
893
1082
|
// as our canonical record. The notification is also forwarded to
|
|
894
1083
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -1199,9 +1388,34 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1199
1388
|
}
|
|
1200
1389
|
recordAndBroadcast(method, params, excludeClientId) {
|
|
1201
1390
|
const rewritten = this.rewriteForClient(params);
|
|
1202
|
-
|
|
1203
|
-
if (
|
|
1204
|
-
|
|
1391
|
+
const recordable = !isStateUpdate(method, rewritten);
|
|
1392
|
+
if (recordable) {
|
|
1393
|
+
const entry = {
|
|
1394
|
+
method,
|
|
1395
|
+
params: rewritten,
|
|
1396
|
+
recordedAt: Date.now()
|
|
1397
|
+
};
|
|
1398
|
+
this.history.push(entry);
|
|
1399
|
+
let trimmed = false;
|
|
1400
|
+
if (this.history.length > 1e3) {
|
|
1401
|
+
this.history = this.history.slice(-500);
|
|
1402
|
+
trimmed = true;
|
|
1403
|
+
}
|
|
1404
|
+
if (this.historyStore) {
|
|
1405
|
+
if (trimmed) {
|
|
1406
|
+
void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
|
|
1407
|
+
} else {
|
|
1408
|
+
void this.historyStore.append(this.sessionId, entry).catch(
|
|
1409
|
+
() => void 0
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
for (const handler of this.broadcastHandlers) {
|
|
1414
|
+
try {
|
|
1415
|
+
handler(entry);
|
|
1416
|
+
} catch {
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1205
1419
|
}
|
|
1206
1420
|
this.updatedAt = Date.now();
|
|
1207
1421
|
for (const client of this.clients.values()) {
|
|
@@ -1297,6 +1511,12 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1297
1511
|
}
|
|
1298
1512
|
}
|
|
1299
1513
|
};
|
|
1514
|
+
STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
|
|
1515
|
+
"session_info_update",
|
|
1516
|
+
"current_model_update",
|
|
1517
|
+
"current_mode_update",
|
|
1518
|
+
"available_commands_update"
|
|
1519
|
+
]);
|
|
1300
1520
|
}
|
|
1301
1521
|
});
|
|
1302
1522
|
|
|
@@ -1552,13 +1772,248 @@ var init_sessions = __esm({
|
|
|
1552
1772
|
}
|
|
1553
1773
|
});
|
|
1554
1774
|
|
|
1775
|
+
// src/shim/resilient-ws.ts
|
|
1776
|
+
import { setTimeout as sleep3 } from "timers/promises";
|
|
1777
|
+
import { WebSocket } from "ws";
|
|
1778
|
+
function isResponse(msg) {
|
|
1779
|
+
return !("method" in msg) && "id" in msg && msg.id !== void 0;
|
|
1780
|
+
}
|
|
1781
|
+
async function openWs(url, subprotocols) {
|
|
1782
|
+
return new Promise((resolve2, reject) => {
|
|
1783
|
+
const ws = new WebSocket(url, subprotocols);
|
|
1784
|
+
const onOpen = () => {
|
|
1785
|
+
ws.off("error", onError);
|
|
1786
|
+
resolve2(wsToMessageStream(ws));
|
|
1787
|
+
};
|
|
1788
|
+
const onError = (err) => {
|
|
1789
|
+
ws.off("open", onOpen);
|
|
1790
|
+
reject(err);
|
|
1791
|
+
};
|
|
1792
|
+
ws.once("open", onOpen);
|
|
1793
|
+
ws.once("error", onError);
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
var BACKOFF_INITIAL_MS, BACKOFF_MAX_MS, BACKOFF_MULTIPLIER, MAX_RECONNECT_ATTEMPTS, ResilientWsStream;
|
|
1797
|
+
var init_resilient_ws = __esm({
|
|
1798
|
+
"src/shim/resilient-ws.ts"() {
|
|
1799
|
+
"use strict";
|
|
1800
|
+
init_ws_stream();
|
|
1801
|
+
init_types();
|
|
1802
|
+
BACKOFF_INITIAL_MS = 200;
|
|
1803
|
+
BACKOFF_MAX_MS = 5e3;
|
|
1804
|
+
BACKOFF_MULTIPLIER = 2;
|
|
1805
|
+
MAX_RECONNECT_ATTEMPTS = 60;
|
|
1806
|
+
ResilientWsStream = class {
|
|
1807
|
+
constructor(opts) {
|
|
1808
|
+
this.opts = opts;
|
|
1809
|
+
}
|
|
1810
|
+
opts;
|
|
1811
|
+
current;
|
|
1812
|
+
outboundQueue = [];
|
|
1813
|
+
messageHandlers = [];
|
|
1814
|
+
closeHandlers = [];
|
|
1815
|
+
destroyed = false;
|
|
1816
|
+
firstConnect = true;
|
|
1817
|
+
reconnectInFlight;
|
|
1818
|
+
connectGate;
|
|
1819
|
+
releaseConnectGate;
|
|
1820
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
1821
|
+
async start() {
|
|
1822
|
+
await this.connectWithRetry();
|
|
1823
|
+
}
|
|
1824
|
+
onMessage(handler) {
|
|
1825
|
+
this.messageHandlers.push(handler);
|
|
1826
|
+
}
|
|
1827
|
+
onClose(handler) {
|
|
1828
|
+
this.closeHandlers.push(handler);
|
|
1829
|
+
}
|
|
1830
|
+
async send(message) {
|
|
1831
|
+
if (this.destroyed) {
|
|
1832
|
+
throw new Error("resilient ws stream is destroyed");
|
|
1833
|
+
}
|
|
1834
|
+
if (this.connectGate || !this.current) {
|
|
1835
|
+
this.outboundQueue.push(message);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
try {
|
|
1839
|
+
await this.current.send(message);
|
|
1840
|
+
} catch (err) {
|
|
1841
|
+
this.outboundQueue.push(message);
|
|
1842
|
+
this.scheduleReconnect(err);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
// Send a request directly and resolve when the matching response arrives
|
|
1846
|
+
// on the same connection. Used by onConnect handlers to await replay-attach
|
|
1847
|
+
// responses before letting the outbound queue drain. Bypasses the
|
|
1848
|
+
// connectGate intentionally.
|
|
1849
|
+
async request(message) {
|
|
1850
|
+
if (this.destroyed) {
|
|
1851
|
+
throw new Error("resilient ws stream is destroyed");
|
|
1852
|
+
}
|
|
1853
|
+
if (!this.current) {
|
|
1854
|
+
throw new Error("resilient ws stream not connected");
|
|
1855
|
+
}
|
|
1856
|
+
const id = message.id;
|
|
1857
|
+
const promise = new Promise((resolve2, reject) => {
|
|
1858
|
+
this.pendingRequests.set(id, { resolve: resolve2, reject });
|
|
1859
|
+
});
|
|
1860
|
+
try {
|
|
1861
|
+
await this.current.send(message);
|
|
1862
|
+
} catch (err) {
|
|
1863
|
+
this.pendingRequests.delete(id);
|
|
1864
|
+
throw err;
|
|
1865
|
+
}
|
|
1866
|
+
return promise;
|
|
1867
|
+
}
|
|
1868
|
+
async close() {
|
|
1869
|
+
this.destroyed = true;
|
|
1870
|
+
if (this.current) {
|
|
1871
|
+
await this.current.close().catch(() => void 0);
|
|
1872
|
+
}
|
|
1873
|
+
for (const handler of this.closeHandlers) {
|
|
1874
|
+
handler();
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
async connectWithRetry() {
|
|
1878
|
+
let attempt = 0;
|
|
1879
|
+
let backoff = BACKOFF_INITIAL_MS;
|
|
1880
|
+
while (!this.destroyed) {
|
|
1881
|
+
try {
|
|
1882
|
+
const stream = await openWs(this.opts.url, this.opts.subprotocols);
|
|
1883
|
+
this.bindStream(stream);
|
|
1884
|
+
const wasFirst = this.firstConnect;
|
|
1885
|
+
this.firstConnect = false;
|
|
1886
|
+
this.connectGate = new Promise((resolve2) => {
|
|
1887
|
+
this.releaseConnectGate = resolve2;
|
|
1888
|
+
});
|
|
1889
|
+
try {
|
|
1890
|
+
if (this.opts.onConnect) {
|
|
1891
|
+
try {
|
|
1892
|
+
await this.opts.onConnect(wasFirst);
|
|
1893
|
+
} catch (err) {
|
|
1894
|
+
this.log(
|
|
1895
|
+
`hydra-acp: post-connect handler failed: ${err.message}`
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
} finally {
|
|
1900
|
+
this.releaseConnectGate?.();
|
|
1901
|
+
this.releaseConnectGate = void 0;
|
|
1902
|
+
this.connectGate = void 0;
|
|
1903
|
+
}
|
|
1904
|
+
await this.flushQueue();
|
|
1905
|
+
return;
|
|
1906
|
+
} catch (err) {
|
|
1907
|
+
attempt += 1;
|
|
1908
|
+
if (this.opts.onConnectFailure) {
|
|
1909
|
+
this.opts.onConnectFailure(err);
|
|
1910
|
+
}
|
|
1911
|
+
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
|
|
1912
|
+
throw new Error(
|
|
1913
|
+
`hydra-acp: gave up reconnecting after ${attempt} attempts: ${err.message}`
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
this.log(
|
|
1917
|
+
`hydra-acp: connect attempt ${attempt} failed (${err.message}); retrying in ${backoff}ms`
|
|
1918
|
+
);
|
|
1919
|
+
await sleep3(backoff);
|
|
1920
|
+
backoff = Math.min(backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
bindStream(stream) {
|
|
1925
|
+
this.current = stream;
|
|
1926
|
+
stream.onMessage((msg) => {
|
|
1927
|
+
if (isResponse(msg)) {
|
|
1928
|
+
const pending = this.pendingRequests.get(msg.id);
|
|
1929
|
+
if (pending) {
|
|
1930
|
+
this.pendingRequests.delete(msg.id);
|
|
1931
|
+
pending.resolve(msg);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
for (const handler of this.messageHandlers) {
|
|
1935
|
+
handler(msg);
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
stream.onClose((err) => {
|
|
1939
|
+
if (this.destroyed) {
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
this.current = void 0;
|
|
1943
|
+
if (this.pendingRequests.size > 0) {
|
|
1944
|
+
const reason = err ?? new Error("ws closed before response");
|
|
1945
|
+
for (const { reject } of this.pendingRequests.values()) {
|
|
1946
|
+
reject(reason);
|
|
1947
|
+
}
|
|
1948
|
+
this.pendingRequests.clear();
|
|
1949
|
+
}
|
|
1950
|
+
this.scheduleReconnect(err);
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
async flushQueue() {
|
|
1954
|
+
if (!this.current) {
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
const queue = this.outboundQueue;
|
|
1958
|
+
this.outboundQueue = [];
|
|
1959
|
+
for (const msg of queue) {
|
|
1960
|
+
try {
|
|
1961
|
+
await this.current.send(msg);
|
|
1962
|
+
} catch (err) {
|
|
1963
|
+
this.outboundQueue.unshift(msg);
|
|
1964
|
+
this.scheduleReconnect(err);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
scheduleReconnect(err) {
|
|
1970
|
+
if (this.destroyed || this.reconnectInFlight) {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
this.log(
|
|
1974
|
+
`hydra-acp: connection lost (${err?.message ?? "no error"}); reconnecting...`
|
|
1975
|
+
);
|
|
1976
|
+
if (this.opts.onDisconnect) {
|
|
1977
|
+
try {
|
|
1978
|
+
this.opts.onDisconnect(err);
|
|
1979
|
+
} catch (hookErr) {
|
|
1980
|
+
this.log(
|
|
1981
|
+
`hydra-acp: onDisconnect handler threw: ${hookErr.message}`
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
this.reconnectInFlight = (async () => {
|
|
1986
|
+
try {
|
|
1987
|
+
await this.connectWithRetry();
|
|
1988
|
+
} catch (final) {
|
|
1989
|
+
for (const handler of this.closeHandlers) {
|
|
1990
|
+
handler(final);
|
|
1991
|
+
}
|
|
1992
|
+
this.destroyed = true;
|
|
1993
|
+
} finally {
|
|
1994
|
+
this.reconnectInFlight = void 0;
|
|
1995
|
+
}
|
|
1996
|
+
})();
|
|
1997
|
+
}
|
|
1998
|
+
log(line) {
|
|
1999
|
+
if (this.opts.log) {
|
|
2000
|
+
this.opts.log(line);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
process.stderr.write(`${line}
|
|
2004
|
+
`);
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
2009
|
+
|
|
1555
2010
|
// src/tui/history.ts
|
|
1556
|
-
import { promises as
|
|
2011
|
+
import { promises as fs9 } from "fs";
|
|
1557
2012
|
import * as path4 from "path";
|
|
1558
2013
|
async function loadHistory(file) {
|
|
1559
2014
|
let text;
|
|
1560
2015
|
try {
|
|
1561
|
-
text = await
|
|
2016
|
+
text = await fs9.readFile(file, "utf8");
|
|
1562
2017
|
} catch (err) {
|
|
1563
2018
|
if (err.code === "ENOENT") {
|
|
1564
2019
|
return [];
|
|
@@ -1598,9 +2053,9 @@ function appendEntry(history, entry) {
|
|
|
1598
2053
|
return out;
|
|
1599
2054
|
}
|
|
1600
2055
|
async function saveHistory(file, history) {
|
|
1601
|
-
await
|
|
2056
|
+
await fs9.mkdir(path4.dirname(file), { recursive: true });
|
|
1602
2057
|
const lines = history.map((entry) => JSON.stringify(entry));
|
|
1603
|
-
await
|
|
2058
|
+
await fs9.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
|
|
1604
2059
|
}
|
|
1605
2060
|
var HISTORY_CAP;
|
|
1606
2061
|
var init_history = __esm({
|
|
@@ -2622,8 +3077,24 @@ var init_screen = __esm({
|
|
|
2622
3077
|
const w = this.term.width;
|
|
2623
3078
|
this.term.moveTo(1, 1).eraseLineAfter();
|
|
2624
3079
|
const usage = formatUsage(this.header.usage);
|
|
2625
|
-
const
|
|
2626
|
-
|
|
3080
|
+
const sid = shortId(this.header.sessionId);
|
|
3081
|
+
const title = this.header.title?.trim();
|
|
3082
|
+
const fixed = "hydra \xB7 ".length + this.header.agent.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
|
|
3083
|
+
const variableRoom = Math.max(8, w - fixed);
|
|
3084
|
+
let cwdRoom;
|
|
3085
|
+
let titleRoom;
|
|
3086
|
+
if (title) {
|
|
3087
|
+
const cwdCap = Math.max(8, Math.floor(variableRoom / 2));
|
|
3088
|
+
cwdRoom = Math.min(this.header.cwd.length, cwdCap);
|
|
3089
|
+
titleRoom = Math.max(8, variableRoom - cwdRoom);
|
|
3090
|
+
} else {
|
|
3091
|
+
titleRoom = 0;
|
|
3092
|
+
cwdRoom = variableRoom;
|
|
3093
|
+
}
|
|
3094
|
+
this.term.bold("hydra")(" \xB7 ").cyan(this.header.agent)(" \xB7 ").dim(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(sid);
|
|
3095
|
+
if (title) {
|
|
3096
|
+
this.term(" \xB7 ").bold(truncate(title, titleRoom));
|
|
3097
|
+
}
|
|
2627
3098
|
if (usage) {
|
|
2628
3099
|
const col = Math.max(1, w - usage.length + 1);
|
|
2629
3100
|
this.term.moveTo(col, 1);
|
|
@@ -2840,6 +3311,8 @@ var init_screen = __esm({
|
|
|
2840
3311
|
if (this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3) {
|
|
2841
3312
|
this.term(" ").dim(formatElapsed(this.banner.elapsedMs));
|
|
2842
3313
|
}
|
|
3314
|
+
} else if (this.banner.status === "disconnected") {
|
|
3315
|
+
this.term.brightRed(`${dot} ${this.banner.status}`);
|
|
2843
3316
|
} else {
|
|
2844
3317
|
this.term.brightGreen(`${dot} ${this.banner.status}`);
|
|
2845
3318
|
}
|
|
@@ -3340,10 +3813,19 @@ function mapUpdate(update) {
|
|
|
3340
3813
|
return mapUsage(u);
|
|
3341
3814
|
case "available_commands_update":
|
|
3342
3815
|
return mapAvailableCommands(u);
|
|
3816
|
+
case "session_info_update":
|
|
3817
|
+
return mapSessionInfo(u);
|
|
3343
3818
|
default:
|
|
3344
3819
|
return { kind: "unknown", sessionUpdate: tag, raw: update };
|
|
3345
3820
|
}
|
|
3346
3821
|
}
|
|
3822
|
+
function mapSessionInfo(u) {
|
|
3823
|
+
const title = readString(u, "title");
|
|
3824
|
+
if (title === void 0) {
|
|
3825
|
+
return null;
|
|
3826
|
+
}
|
|
3827
|
+
return { kind: "session-info", title };
|
|
3828
|
+
}
|
|
3347
3829
|
function mapAvailableCommands(u) {
|
|
3348
3830
|
const list = u.availableCommands ?? u.commands;
|
|
3349
3831
|
if (!Array.isArray(list)) {
|
|
@@ -3584,6 +4066,8 @@ function formatEvent(event) {
|
|
|
3584
4066
|
return [];
|
|
3585
4067
|
case "available-commands":
|
|
3586
4068
|
return [];
|
|
4069
|
+
case "session-info":
|
|
4070
|
+
return [];
|
|
3587
4071
|
case "unknown":
|
|
3588
4072
|
return [];
|
|
3589
4073
|
}
|
|
@@ -3849,8 +4333,7 @@ var init_format = __esm({
|
|
|
3849
4333
|
});
|
|
3850
4334
|
|
|
3851
4335
|
// src/tui/app.ts
|
|
3852
|
-
import
|
|
3853
|
-
import { once } from "events";
|
|
4336
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
3854
4337
|
import termkit from "terminal-kit";
|
|
3855
4338
|
async function runTuiApp(opts) {
|
|
3856
4339
|
const config = await ensureConfig();
|
|
@@ -3867,9 +4350,31 @@ async function runSession(term, config, opts) {
|
|
|
3867
4350
|
term.grabInput(false);
|
|
3868
4351
|
process.exit(0);
|
|
3869
4352
|
}
|
|
3870
|
-
const
|
|
3871
|
-
const
|
|
4353
|
+
const protocol = config.daemon.tls ? "wss" : "ws";
|
|
4354
|
+
const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
|
|
4355
|
+
const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
|
|
4356
|
+
let onReconnect = null;
|
|
4357
|
+
let onDisconnectHook = null;
|
|
4358
|
+
const stream = new ResilientWsStream({
|
|
4359
|
+
url: wsUrl,
|
|
4360
|
+
subprotocols,
|
|
4361
|
+
onConnect: async (firstConnect) => {
|
|
4362
|
+
if (firstConnect) {
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
if (onReconnect) {
|
|
4366
|
+
await onReconnect();
|
|
4367
|
+
}
|
|
4368
|
+
},
|
|
4369
|
+
onDisconnect: (err) => {
|
|
4370
|
+
if (onDisconnectHook) {
|
|
4371
|
+
onDisconnectHook(err);
|
|
4372
|
+
}
|
|
4373
|
+
},
|
|
4374
|
+
log: () => void 0
|
|
4375
|
+
});
|
|
3872
4376
|
const conn = new JsonRpcConnection(stream);
|
|
4377
|
+
await stream.start();
|
|
3873
4378
|
let bufferedEvents = [];
|
|
3874
4379
|
let applyRenderEvent = null;
|
|
3875
4380
|
const appendRender = (event) => {
|
|
@@ -4033,6 +4538,10 @@ async function runSession(term, config, opts) {
|
|
|
4033
4538
|
let resolvedSessionId = ctx.sessionId;
|
|
4034
4539
|
let resolvedAgentId = ctx.agentId;
|
|
4035
4540
|
let resolvedCwd = ctx.cwd;
|
|
4541
|
+
let resolvedTitle;
|
|
4542
|
+
let initialModel;
|
|
4543
|
+
let initialMode;
|
|
4544
|
+
let initialCommands;
|
|
4036
4545
|
if (ctx.sessionId === "__new__") {
|
|
4037
4546
|
const created = await conn.request("session/new", {
|
|
4038
4547
|
cwd: ctx.cwd,
|
|
@@ -4048,6 +4557,16 @@ async function runSession(term, config, opts) {
|
|
|
4048
4557
|
if (hydraMeta.cwd) {
|
|
4049
4558
|
resolvedCwd = hydraMeta.cwd;
|
|
4050
4559
|
}
|
|
4560
|
+
if (hydraMeta.name) {
|
|
4561
|
+
resolvedTitle = hydraMeta.name;
|
|
4562
|
+
}
|
|
4563
|
+
initialModel = hydraMeta.currentModel;
|
|
4564
|
+
initialMode = hydraMeta.currentMode;
|
|
4565
|
+
if (hydraMeta.availableCommands) {
|
|
4566
|
+
initialCommands = hydraMeta.availableCommands.map(
|
|
4567
|
+
(c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
|
|
4568
|
+
);
|
|
4569
|
+
}
|
|
4051
4570
|
} else {
|
|
4052
4571
|
const attached = await conn.request("session/attach", {
|
|
4053
4572
|
sessionId: ctx.sessionId,
|
|
@@ -4063,8 +4582,17 @@ async function runSession(term, config, opts) {
|
|
|
4063
4582
|
if (hydraMeta.cwd) {
|
|
4064
4583
|
resolvedCwd = hydraMeta.cwd;
|
|
4065
4584
|
}
|
|
4585
|
+
if (hydraMeta.name) {
|
|
4586
|
+
resolvedTitle = hydraMeta.name;
|
|
4587
|
+
}
|
|
4588
|
+
initialModel = hydraMeta.currentModel;
|
|
4589
|
+
initialMode = hydraMeta.currentMode;
|
|
4590
|
+
if (hydraMeta.availableCommands) {
|
|
4591
|
+
initialCommands = hydraMeta.availableCommands.map(
|
|
4592
|
+
(c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
|
|
4593
|
+
);
|
|
4594
|
+
}
|
|
4066
4595
|
}
|
|
4067
|
-
void upstreamSessionId;
|
|
4068
4596
|
const historyFile = paths.tuiHistoryFile();
|
|
4069
4597
|
let history = await loadHistory(historyFile).catch(() => []);
|
|
4070
4598
|
const dispatcher = new InputDispatcher({ history });
|
|
@@ -4102,7 +4630,7 @@ async function runSession(term, config, opts) {
|
|
|
4102
4630
|
{ name: "/demo-plan", description: "Inject synthetic plan events (UI test)" },
|
|
4103
4631
|
{ name: "/demo-tool", description: "Inject a synthetic tool-call sequence (UI test)" }
|
|
4104
4632
|
];
|
|
4105
|
-
let agentCommands = [];
|
|
4633
|
+
let agentCommands = initialCommands ?? [];
|
|
4106
4634
|
const allCommands = () => {
|
|
4107
4635
|
const seen = /* @__PURE__ */ new Set();
|
|
4108
4636
|
const out = [];
|
|
@@ -4226,8 +4754,15 @@ async function runSession(term, config, opts) {
|
|
|
4226
4754
|
screen.setHeader({
|
|
4227
4755
|
agent: headerName,
|
|
4228
4756
|
cwd: resolvedCwd,
|
|
4229
|
-
sessionId: resolvedSessionId
|
|
4757
|
+
sessionId: resolvedSessionId,
|
|
4758
|
+
title: resolvedTitle
|
|
4230
4759
|
});
|
|
4760
|
+
if (initialMode) {
|
|
4761
|
+
screen.appendLines(formatEvent({ kind: "mode-changed", mode: initialMode }));
|
|
4762
|
+
}
|
|
4763
|
+
if (initialModel) {
|
|
4764
|
+
screen.appendLines(formatEvent({ kind: "model-changed", model: initialModel }));
|
|
4765
|
+
}
|
|
4231
4766
|
let finishSession = null;
|
|
4232
4767
|
const sessionDone = new Promise((resolve2) => {
|
|
4233
4768
|
finishSession = resolve2;
|
|
@@ -4313,10 +4848,7 @@ async function runSession(term, config, opts) {
|
|
|
4313
4848
|
process.off("SIGINT", sigintHandler);
|
|
4314
4849
|
screen.stop();
|
|
4315
4850
|
saveHistory(historyFile, history).catch(() => void 0);
|
|
4316
|
-
|
|
4317
|
-
ws.close();
|
|
4318
|
-
} catch {
|
|
4319
|
-
}
|
|
4851
|
+
void stream.close().catch(() => void 0);
|
|
4320
4852
|
};
|
|
4321
4853
|
const stop = (code = 0) => {
|
|
4322
4854
|
teardown();
|
|
@@ -4709,6 +5241,12 @@ async function runSession(term, config, opts) {
|
|
|
4709
5241
|
refreshCompletions();
|
|
4710
5242
|
return;
|
|
4711
5243
|
}
|
|
5244
|
+
if (event.kind === "session-info") {
|
|
5245
|
+
if (event.title !== void 0) {
|
|
5246
|
+
screen.setHeader({ title: event.title });
|
|
5247
|
+
}
|
|
5248
|
+
return;
|
|
5249
|
+
}
|
|
4712
5250
|
if (event.kind === "usage-update") {
|
|
4713
5251
|
let changed = false;
|
|
4714
5252
|
if (event.used !== void 0 && usage.used !== event.used) {
|
|
@@ -4804,6 +5342,95 @@ async function runSession(term, config, opts) {
|
|
|
4804
5342
|
} finally {
|
|
4805
5343
|
screen.resumeRepaint();
|
|
4806
5344
|
}
|
|
5345
|
+
const resetInFlightUiState = () => {
|
|
5346
|
+
if (pendingPermission) {
|
|
5347
|
+
const resolve2 = pendingPermission.resolve;
|
|
5348
|
+
pendingPermission = null;
|
|
5349
|
+
screen.setPermissionPrompt(null);
|
|
5350
|
+
resolve2({ outcome: { outcome: "cancelled" } });
|
|
5351
|
+
}
|
|
5352
|
+
closeAgentText();
|
|
5353
|
+
if (toolsBlockStartedAt !== null) {
|
|
5354
|
+
if (toolCallOrder.length > 0) {
|
|
5355
|
+
toolsBlockEndedAt = Date.now();
|
|
5356
|
+
renderToolsBlock();
|
|
5357
|
+
screen.clearKey("tools");
|
|
5358
|
+
} else {
|
|
5359
|
+
screen.removeBlock("tools");
|
|
5360
|
+
}
|
|
5361
|
+
toolStates.clear();
|
|
5362
|
+
toolCallOrder.length = 0;
|
|
5363
|
+
toolsBlockStartedAt = null;
|
|
5364
|
+
toolsBlockEndedAt = null;
|
|
5365
|
+
toolsExpanded = false;
|
|
5366
|
+
}
|
|
5367
|
+
screen.clearKey("plan");
|
|
5368
|
+
if (pendingTurns > 0) {
|
|
5369
|
+
adjustPendingTurns(-pendingTurns);
|
|
5370
|
+
}
|
|
5371
|
+
};
|
|
5372
|
+
onDisconnectHook = () => {
|
|
5373
|
+
screen.setBanner({ status: "disconnected", elapsedMs: void 0 });
|
|
5374
|
+
};
|
|
5375
|
+
onReconnect = async () => {
|
|
5376
|
+
resetInFlightUiState();
|
|
5377
|
+
const initReq = {
|
|
5378
|
+
jsonrpc: "2.0",
|
|
5379
|
+
id: `tui-reinit-${nanoid3()}`,
|
|
5380
|
+
method: "initialize",
|
|
5381
|
+
params: {
|
|
5382
|
+
protocolVersion: 1,
|
|
5383
|
+
clientCapabilities: {
|
|
5384
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
5385
|
+
terminal: false
|
|
5386
|
+
},
|
|
5387
|
+
clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
|
|
5388
|
+
}
|
|
5389
|
+
};
|
|
5390
|
+
try {
|
|
5391
|
+
await stream.request(initReq);
|
|
5392
|
+
} catch {
|
|
5393
|
+
}
|
|
5394
|
+
const attachReq = {
|
|
5395
|
+
jsonrpc: "2.0",
|
|
5396
|
+
id: `tui-reattach-${nanoid3()}`,
|
|
5397
|
+
method: "session/attach",
|
|
5398
|
+
params: {
|
|
5399
|
+
sessionId: resolvedSessionId,
|
|
5400
|
+
historyPolicy: "none",
|
|
5401
|
+
clientInfo: { name: "hydra-acp-tui", version: "0.1.0" },
|
|
5402
|
+
...upstreamSessionId !== void 0 ? {
|
|
5403
|
+
_meta: {
|
|
5404
|
+
[HYDRA_META_KEY]: {
|
|
5405
|
+
resume: {
|
|
5406
|
+
upstreamSessionId,
|
|
5407
|
+
agentId: resolvedAgentId,
|
|
5408
|
+
cwd: resolvedCwd
|
|
5409
|
+
}
|
|
5410
|
+
}
|
|
5411
|
+
}
|
|
5412
|
+
} : {}
|
|
5413
|
+
}
|
|
5414
|
+
};
|
|
5415
|
+
try {
|
|
5416
|
+
const resp = await stream.request(attachReq);
|
|
5417
|
+
if (resp.error) {
|
|
5418
|
+
throw new Error(resp.error.message);
|
|
5419
|
+
}
|
|
5420
|
+
} catch (err) {
|
|
5421
|
+
screen.appendLines([
|
|
5422
|
+
{
|
|
5423
|
+
prefix: " ",
|
|
5424
|
+
body: `reattach failed: ${err.message}`,
|
|
5425
|
+
bodyStyle: "tool-status-fail"
|
|
5426
|
+
}
|
|
5427
|
+
]);
|
|
5428
|
+
}
|
|
5429
|
+
screen.setBanner({
|
|
5430
|
+
status: pendingTurns > 0 ? "running" : "ready",
|
|
5431
|
+
elapsedMs: pendingTurns > 0 ? 0 : void 0
|
|
5432
|
+
});
|
|
5433
|
+
};
|
|
4807
5434
|
conn.onClose((err) => {
|
|
4808
5435
|
if (err) {
|
|
4809
5436
|
term.red(`
|
|
@@ -4869,23 +5496,13 @@ function newCtx(opts, cwd, config) {
|
|
|
4869
5496
|
cwd
|
|
4870
5497
|
};
|
|
4871
5498
|
}
|
|
4872
|
-
async function openWs2(config) {
|
|
4873
|
-
const protocol = config.daemon.tls ? "wss" : "ws";
|
|
4874
|
-
const url = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
|
|
4875
|
-
const ws = new WebSocket2(url, [
|
|
4876
|
-
"acp.v1",
|
|
4877
|
-
`hydra-acp-token.${config.daemon.authToken}`
|
|
4878
|
-
]);
|
|
4879
|
-
await once(ws, "open");
|
|
4880
|
-
return ws;
|
|
4881
|
-
}
|
|
4882
5499
|
var PLAN_PREFIX_TEXT;
|
|
4883
5500
|
var init_app = __esm({
|
|
4884
5501
|
"src/tui/app.ts"() {
|
|
4885
5502
|
"use strict";
|
|
4886
5503
|
init_connection();
|
|
4887
|
-
init_ws_stream();
|
|
4888
5504
|
init_types();
|
|
5505
|
+
init_resilient_ws();
|
|
4889
5506
|
init_config();
|
|
4890
5507
|
init_daemon_bootstrap();
|
|
4891
5508
|
init_paths();
|
|
@@ -5013,7 +5630,7 @@ import { setTimeout as sleep2 } from "timers/promises";
|
|
|
5013
5630
|
|
|
5014
5631
|
// src/daemon/server.ts
|
|
5015
5632
|
init_config();
|
|
5016
|
-
import * as
|
|
5633
|
+
import * as fs7 from "fs";
|
|
5017
5634
|
import * as fsp2 from "fs/promises";
|
|
5018
5635
|
import Fastify from "fastify";
|
|
5019
5636
|
import websocketPlugin from "@fastify/websocket";
|
|
@@ -5330,6 +5947,10 @@ init_paths();
|
|
|
5330
5947
|
import * as fs4 from "fs/promises";
|
|
5331
5948
|
import * as path2 from "path";
|
|
5332
5949
|
import { z as z4 } from "zod";
|
|
5950
|
+
var PersistedAgentCommand = z4.object({
|
|
5951
|
+
name: z4.string(),
|
|
5952
|
+
description: z4.string().optional()
|
|
5953
|
+
});
|
|
5333
5954
|
var SessionRecord = z4.object({
|
|
5334
5955
|
version: z4.literal(1),
|
|
5335
5956
|
sessionId: z4.string(),
|
|
@@ -5338,6 +5959,13 @@ var SessionRecord = z4.object({
|
|
|
5338
5959
|
cwd: z4.string(),
|
|
5339
5960
|
title: z4.string().optional(),
|
|
5340
5961
|
agentArgs: z4.array(z4.string()).optional(),
|
|
5962
|
+
// Snapshot of "what is currently true about this session" carried in
|
|
5963
|
+
// meta.json so a late-attaching or cold-resurrected client can be
|
|
5964
|
+
// told via the attach response _meta without depending on history
|
|
5965
|
+
// replay of a snapshot-shaped notification.
|
|
5966
|
+
currentModel: z4.string().optional(),
|
|
5967
|
+
currentMode: z4.string().optional(),
|
|
5968
|
+
agentCommands: z4.array(PersistedAgentCommand).optional(),
|
|
5341
5969
|
createdAt: z4.string(),
|
|
5342
5970
|
updatedAt: z4.string()
|
|
5343
5971
|
});
|
|
@@ -5350,7 +5978,7 @@ function assertSafeId(id) {
|
|
|
5350
5978
|
var SessionStore = class {
|
|
5351
5979
|
async write(record) {
|
|
5352
5980
|
assertSafeId(record.sessionId);
|
|
5353
|
-
await fs4.mkdir(paths.
|
|
5981
|
+
await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
|
|
5354
5982
|
const full = { version: 1, ...record };
|
|
5355
5983
|
await fs4.writeFile(
|
|
5356
5984
|
paths.sessionFile(record.sessionId),
|
|
@@ -5390,6 +6018,14 @@ var SessionStore = class {
|
|
|
5390
6018
|
throw err;
|
|
5391
6019
|
}
|
|
5392
6020
|
}
|
|
6021
|
+
try {
|
|
6022
|
+
await fs4.rmdir(paths.sessionDir(sessionId));
|
|
6023
|
+
} catch (err) {
|
|
6024
|
+
const e = err;
|
|
6025
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
6026
|
+
throw err;
|
|
6027
|
+
}
|
|
6028
|
+
}
|
|
5393
6029
|
}
|
|
5394
6030
|
async list() {
|
|
5395
6031
|
let entries;
|
|
@@ -5404,11 +6040,7 @@ var SessionStore = class {
|
|
|
5404
6040
|
}
|
|
5405
6041
|
const records = [];
|
|
5406
6042
|
for (const entry of entries) {
|
|
5407
|
-
|
|
5408
|
-
continue;
|
|
5409
|
-
}
|
|
5410
|
-
const id = entry.slice(0, -".json".length);
|
|
5411
|
-
const record = await this.read(id);
|
|
6043
|
+
const record = await this.read(entry);
|
|
5412
6044
|
if (record) {
|
|
5413
6045
|
records.push(record);
|
|
5414
6046
|
}
|
|
@@ -5425,11 +6057,137 @@ function recordFromMemorySession(args) {
|
|
|
5425
6057
|
cwd: args.cwd,
|
|
5426
6058
|
title: args.title,
|
|
5427
6059
|
agentArgs: args.agentArgs,
|
|
6060
|
+
currentModel: args.currentModel,
|
|
6061
|
+
currentMode: args.currentMode,
|
|
6062
|
+
agentCommands: args.agentCommands,
|
|
5428
6063
|
createdAt: args.createdAt ?? now,
|
|
5429
6064
|
updatedAt: args.updatedAt ?? now
|
|
5430
6065
|
};
|
|
5431
6066
|
}
|
|
5432
6067
|
|
|
6068
|
+
// src/core/history-store.ts
|
|
6069
|
+
init_paths();
|
|
6070
|
+
import * as fs5 from "fs/promises";
|
|
6071
|
+
var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
|
|
6072
|
+
var MAX_ENTRIES = 1e3;
|
|
6073
|
+
var HistoryStore = class {
|
|
6074
|
+
// Serialize writes per session id so appends and rewrites don't
|
|
6075
|
+
// interleave JSONL lines on disk. The chain swallows errors so one
|
|
6076
|
+
// failed append doesn't poison every subsequent write.
|
|
6077
|
+
writeQueues = /* @__PURE__ */ new Map();
|
|
6078
|
+
async append(sessionId, entry) {
|
|
6079
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
6080
|
+
return;
|
|
6081
|
+
}
|
|
6082
|
+
return this.enqueue(sessionId, async () => {
|
|
6083
|
+
await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
6084
|
+
const line = JSON.stringify(entry) + "\n";
|
|
6085
|
+
await fs5.appendFile(paths.historyFile(sessionId), line, {
|
|
6086
|
+
encoding: "utf8",
|
|
6087
|
+
mode: 384
|
|
6088
|
+
});
|
|
6089
|
+
});
|
|
6090
|
+
}
|
|
6091
|
+
async rewrite(sessionId, entries) {
|
|
6092
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
6093
|
+
return;
|
|
6094
|
+
}
|
|
6095
|
+
return this.enqueue(sessionId, async () => {
|
|
6096
|
+
await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
6097
|
+
const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
6098
|
+
await fs5.writeFile(paths.historyFile(sessionId), body, {
|
|
6099
|
+
encoding: "utf8",
|
|
6100
|
+
mode: 384
|
|
6101
|
+
});
|
|
6102
|
+
});
|
|
6103
|
+
}
|
|
6104
|
+
async load(sessionId) {
|
|
6105
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
6106
|
+
return [];
|
|
6107
|
+
}
|
|
6108
|
+
const pending = this.writeQueues.get(sessionId);
|
|
6109
|
+
if (pending) {
|
|
6110
|
+
await pending;
|
|
6111
|
+
}
|
|
6112
|
+
let raw;
|
|
6113
|
+
try {
|
|
6114
|
+
raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
|
|
6115
|
+
} catch (err) {
|
|
6116
|
+
const e = err;
|
|
6117
|
+
if (e.code === "ENOENT") {
|
|
6118
|
+
return [];
|
|
6119
|
+
}
|
|
6120
|
+
throw err;
|
|
6121
|
+
}
|
|
6122
|
+
const out = [];
|
|
6123
|
+
for (const line of raw.split("\n")) {
|
|
6124
|
+
if (line.length === 0) {
|
|
6125
|
+
continue;
|
|
6126
|
+
}
|
|
6127
|
+
let parsed;
|
|
6128
|
+
try {
|
|
6129
|
+
parsed = JSON.parse(line);
|
|
6130
|
+
} catch {
|
|
6131
|
+
continue;
|
|
6132
|
+
}
|
|
6133
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
6134
|
+
continue;
|
|
6135
|
+
}
|
|
6136
|
+
const obj = parsed;
|
|
6137
|
+
if (typeof obj.method !== "string") {
|
|
6138
|
+
continue;
|
|
6139
|
+
}
|
|
6140
|
+
if (typeof obj.recordedAt !== "number") {
|
|
6141
|
+
continue;
|
|
6142
|
+
}
|
|
6143
|
+
out.push({
|
|
6144
|
+
method: obj.method,
|
|
6145
|
+
params: obj.params,
|
|
6146
|
+
recordedAt: obj.recordedAt
|
|
6147
|
+
});
|
|
6148
|
+
}
|
|
6149
|
+
if (out.length > MAX_ENTRIES) {
|
|
6150
|
+
return out.slice(-MAX_ENTRIES);
|
|
6151
|
+
}
|
|
6152
|
+
return out;
|
|
6153
|
+
}
|
|
6154
|
+
async delete(sessionId) {
|
|
6155
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
6156
|
+
return;
|
|
6157
|
+
}
|
|
6158
|
+
return this.enqueue(sessionId, async () => {
|
|
6159
|
+
try {
|
|
6160
|
+
await fs5.unlink(paths.historyFile(sessionId));
|
|
6161
|
+
} catch (err) {
|
|
6162
|
+
const e = err;
|
|
6163
|
+
if (e.code !== "ENOENT") {
|
|
6164
|
+
throw err;
|
|
6165
|
+
}
|
|
6166
|
+
}
|
|
6167
|
+
try {
|
|
6168
|
+
await fs5.rmdir(paths.sessionDir(sessionId));
|
|
6169
|
+
} catch (err) {
|
|
6170
|
+
const e = err;
|
|
6171
|
+
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
6172
|
+
throw err;
|
|
6173
|
+
}
|
|
6174
|
+
}
|
|
6175
|
+
});
|
|
6176
|
+
}
|
|
6177
|
+
enqueue(sessionId, task) {
|
|
6178
|
+
const prev = this.writeQueues.get(sessionId) ?? Promise.resolve();
|
|
6179
|
+
const task$ = prev.then(task, task);
|
|
6180
|
+
const settled = task$.catch(() => void 0);
|
|
6181
|
+
this.writeQueues.set(sessionId, settled);
|
|
6182
|
+
void settled.finally(() => {
|
|
6183
|
+
if (this.writeQueues.get(sessionId) === settled) {
|
|
6184
|
+
this.writeQueues.delete(sessionId);
|
|
6185
|
+
}
|
|
6186
|
+
});
|
|
6187
|
+
return task$;
|
|
6188
|
+
}
|
|
6189
|
+
};
|
|
6190
|
+
|
|
5433
6191
|
// src/core/session-manager.ts
|
|
5434
6192
|
init_types();
|
|
5435
6193
|
var SessionManager = class {
|
|
@@ -5437,6 +6195,7 @@ var SessionManager = class {
|
|
|
5437
6195
|
this.registry = registry;
|
|
5438
6196
|
this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
|
|
5439
6197
|
this.store = store ?? new SessionStore();
|
|
6198
|
+
this.histories = new HistoryStore();
|
|
5440
6199
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
5441
6200
|
}
|
|
5442
6201
|
registry;
|
|
@@ -5444,7 +6203,12 @@ var SessionManager = class {
|
|
|
5444
6203
|
resurrectionInflight = /* @__PURE__ */ new Map();
|
|
5445
6204
|
spawner;
|
|
5446
6205
|
store;
|
|
6206
|
+
histories;
|
|
5447
6207
|
idleTimeoutMs;
|
|
6208
|
+
// Serialize meta.json read-modify-write operations per session id so
|
|
6209
|
+
// concurrent snapshot updates (e.g. an agent emitting model + mode
|
|
6210
|
+
// back-to-back) don't lose writes via interleaved reads.
|
|
6211
|
+
metaWriteQueues = /* @__PURE__ */ new Map();
|
|
5448
6212
|
async create(params) {
|
|
5449
6213
|
const fresh = await this.bootstrapAgent({
|
|
5450
6214
|
agentId: params.agentId,
|
|
@@ -5461,7 +6225,8 @@ var SessionManager = class {
|
|
|
5461
6225
|
title: params.title,
|
|
5462
6226
|
agentArgs: params.agentArgs,
|
|
5463
6227
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
5464
|
-
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
|
|
6228
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
6229
|
+
historyStore: this.histories
|
|
5465
6230
|
});
|
|
5466
6231
|
await this.attachManagerHooks(session);
|
|
5467
6232
|
return session;
|
|
@@ -5537,7 +6302,12 @@ var SessionManager = class {
|
|
|
5537
6302
|
title: params.title,
|
|
5538
6303
|
agentArgs: params.agentArgs,
|
|
5539
6304
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
5540
|
-
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
|
|
6305
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
6306
|
+
historyStore: this.histories,
|
|
6307
|
+
seedHistory: params.seedHistory,
|
|
6308
|
+
currentModel: params.currentModel,
|
|
6309
|
+
currentMode: params.currentMode,
|
|
6310
|
+
agentCommands: params.agentCommands
|
|
5541
6311
|
});
|
|
5542
6312
|
await this.attachManagerHooks(session);
|
|
5543
6313
|
return session;
|
|
@@ -5591,6 +6361,7 @@ var SessionManager = class {
|
|
|
5591
6361
|
this.sessions.delete(session.sessionId);
|
|
5592
6362
|
if (deleteRecord) {
|
|
5593
6363
|
void this.store.delete(session.sessionId).catch(() => void 0);
|
|
6364
|
+
void this.histories.delete(session.sessionId).catch(() => void 0);
|
|
5594
6365
|
}
|
|
5595
6366
|
});
|
|
5596
6367
|
session.onTitleChange((title) => {
|
|
@@ -5601,6 +6372,24 @@ var SessionManager = class {
|
|
|
5601
6372
|
() => void 0
|
|
5602
6373
|
);
|
|
5603
6374
|
});
|
|
6375
|
+
session.onModelChange((model) => {
|
|
6376
|
+
void this.persistSnapshot(session.sessionId, { currentModel: model }).catch(
|
|
6377
|
+
() => void 0
|
|
6378
|
+
);
|
|
6379
|
+
});
|
|
6380
|
+
session.onModeChange((mode) => {
|
|
6381
|
+
void this.persistSnapshot(session.sessionId, { currentMode: mode }).catch(
|
|
6382
|
+
() => void 0
|
|
6383
|
+
);
|
|
6384
|
+
});
|
|
6385
|
+
session.onAgentCommandsChange((commands) => {
|
|
6386
|
+
void this.persistSnapshot(session.sessionId, {
|
|
6387
|
+
agentCommands: commands.map((c) => ({
|
|
6388
|
+
name: c.name,
|
|
6389
|
+
...c.description !== void 0 ? { description: c.description } : {}
|
|
6390
|
+
}))
|
|
6391
|
+
}).catch(() => void 0);
|
|
6392
|
+
});
|
|
5604
6393
|
this.sessions.set(session.sessionId, session);
|
|
5605
6394
|
await this.store.write(
|
|
5606
6395
|
recordFromMemorySession({
|
|
@@ -5609,22 +6398,45 @@ var SessionManager = class {
|
|
|
5609
6398
|
agentId: session.agentId,
|
|
5610
6399
|
cwd: session.cwd,
|
|
5611
6400
|
title: session.title,
|
|
5612
|
-
agentArgs: session.agentArgs
|
|
6401
|
+
agentArgs: session.agentArgs,
|
|
6402
|
+
currentModel: session.currentModel,
|
|
6403
|
+
currentMode: session.currentMode
|
|
5613
6404
|
})
|
|
5614
6405
|
).catch(() => void 0);
|
|
5615
6406
|
}
|
|
6407
|
+
// Resolve a session's recorded history without forcing a resurrect.
|
|
6408
|
+
// Returns the in-memory snapshot if the session is hot, falls back
|
|
6409
|
+
// to the on-disk history file otherwise. Returns undefined if the
|
|
6410
|
+
// session id is unknown to both the live map and disk store, so the
|
|
6411
|
+
// caller can distinguish "no history yet" (empty array) from "404".
|
|
6412
|
+
async getHistory(sessionId) {
|
|
6413
|
+
const live = this.sessions.get(sessionId);
|
|
6414
|
+
if (live) {
|
|
6415
|
+
return live.getHistorySnapshot();
|
|
6416
|
+
}
|
|
6417
|
+
const record = await this.store.read(sessionId);
|
|
6418
|
+
if (!record) {
|
|
6419
|
+
return void 0;
|
|
6420
|
+
}
|
|
6421
|
+
return this.histories.load(sessionId).catch(() => []);
|
|
6422
|
+
}
|
|
5616
6423
|
async loadFromDisk(sessionId) {
|
|
5617
6424
|
const record = await this.store.read(sessionId);
|
|
5618
6425
|
if (!record) {
|
|
5619
6426
|
return void 0;
|
|
5620
6427
|
}
|
|
6428
|
+
const seedHistory = await this.histories.load(sessionId).catch(() => []);
|
|
5621
6429
|
return {
|
|
5622
6430
|
hydraSessionId: record.sessionId,
|
|
5623
6431
|
upstreamSessionId: record.upstreamSessionId,
|
|
5624
6432
|
agentId: record.agentId,
|
|
5625
6433
|
cwd: record.cwd,
|
|
5626
6434
|
title: record.title,
|
|
5627
|
-
agentArgs: record.agentArgs
|
|
6435
|
+
agentArgs: record.agentArgs,
|
|
6436
|
+
seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
|
|
6437
|
+
currentModel: record.currentModel,
|
|
6438
|
+
currentMode: record.currentMode,
|
|
6439
|
+
agentCommands: record.agentCommands
|
|
5628
6440
|
};
|
|
5629
6441
|
}
|
|
5630
6442
|
get(sessionId) {
|
|
@@ -5712,14 +6524,16 @@ var SessionManager = class {
|
|
|
5712
6524
|
// record's title in sync with what was broadcast to clients so a
|
|
5713
6525
|
// daemon restart (and later resurrect) restores the same title.
|
|
5714
6526
|
async persistTitle(sessionId, title) {
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
6527
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
6528
|
+
const record = await this.store.read(sessionId);
|
|
6529
|
+
if (!record) {
|
|
6530
|
+
return;
|
|
6531
|
+
}
|
|
6532
|
+
await this.store.write({
|
|
6533
|
+
...record,
|
|
6534
|
+
title,
|
|
6535
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6536
|
+
});
|
|
5723
6537
|
});
|
|
5724
6538
|
}
|
|
5725
6539
|
// Persist an agent swap from /hydra switch. The on-disk record's
|
|
@@ -5727,17 +6541,52 @@ var SessionManager = class {
|
|
|
5727
6541
|
// later resurrect) brings the session back up on the agent the user
|
|
5728
6542
|
// most recently switched to, not the one it was originally created on.
|
|
5729
6543
|
async persistAgentChange(sessionId, agentId, upstreamSessionId) {
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
6544
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
6545
|
+
const record = await this.store.read(sessionId);
|
|
6546
|
+
if (!record) {
|
|
6547
|
+
return;
|
|
6548
|
+
}
|
|
6549
|
+
await this.store.write({
|
|
6550
|
+
...record,
|
|
6551
|
+
agentId,
|
|
6552
|
+
upstreamSessionId,
|
|
6553
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6554
|
+
});
|
|
5739
6555
|
});
|
|
5740
6556
|
}
|
|
6557
|
+
// Update one or more snapshot fields (model, mode, commands) in
|
|
6558
|
+
// meta.json. Used so cold-resurrect can deliver the latest snapshot
|
|
6559
|
+
// to attaching clients via the attach response _meta. No-op if the
|
|
6560
|
+
// session record has gone away (race with deleteRecord).
|
|
6561
|
+
async persistSnapshot(sessionId, update) {
|
|
6562
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
6563
|
+
const record = await this.store.read(sessionId);
|
|
6564
|
+
if (!record) {
|
|
6565
|
+
return;
|
|
6566
|
+
}
|
|
6567
|
+
await this.store.write({
|
|
6568
|
+
...record,
|
|
6569
|
+
...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
|
|
6570
|
+
...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
|
|
6571
|
+
...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
|
|
6572
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6573
|
+
});
|
|
6574
|
+
});
|
|
6575
|
+
}
|
|
6576
|
+
// Serialize meta.json writes per session id so concurrent
|
|
6577
|
+
// read-modify-write operations don't interleave reads.
|
|
6578
|
+
enqueueMetaWrite(sessionId, task) {
|
|
6579
|
+
const prev = this.metaWriteQueues.get(sessionId) ?? Promise.resolve();
|
|
6580
|
+
const next = prev.then(task, task);
|
|
6581
|
+
const settled = next.catch(() => void 0);
|
|
6582
|
+
this.metaWriteQueues.set(sessionId, settled);
|
|
6583
|
+
void settled.finally(() => {
|
|
6584
|
+
if (this.metaWriteQueues.get(sessionId) === settled) {
|
|
6585
|
+
this.metaWriteQueues.delete(sessionId);
|
|
6586
|
+
}
|
|
6587
|
+
});
|
|
6588
|
+
return next;
|
|
6589
|
+
}
|
|
5741
6590
|
async closeAll() {
|
|
5742
6591
|
const sessions = [...this.sessions.values()];
|
|
5743
6592
|
await Promise.allSettled(sessions.map((s) => s.close()));
|
|
@@ -5748,7 +6597,7 @@ var SessionManager = class {
|
|
|
5748
6597
|
// src/core/extensions.ts
|
|
5749
6598
|
init_paths();
|
|
5750
6599
|
import { spawn as spawn2 } from "child_process";
|
|
5751
|
-
import * as
|
|
6600
|
+
import * as fs6 from "fs";
|
|
5752
6601
|
import * as fsp from "fs/promises";
|
|
5753
6602
|
import * as path3 from "path";
|
|
5754
6603
|
var RESTART_BASE_MS = 1e3;
|
|
@@ -6031,7 +6880,7 @@ var ExtensionManager = class {
|
|
|
6031
6880
|
}
|
|
6032
6881
|
const ext = entry.config;
|
|
6033
6882
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
6034
|
-
const logStream =
|
|
6883
|
+
const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
6035
6884
|
flags: "a"
|
|
6036
6885
|
});
|
|
6037
6886
|
logStream.write(
|
|
@@ -6081,7 +6930,7 @@ var ExtensionManager = class {
|
|
|
6081
6930
|
}
|
|
6082
6931
|
if (typeof child.pid === "number") {
|
|
6083
6932
|
try {
|
|
6084
|
-
|
|
6933
|
+
fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
6085
6934
|
`, {
|
|
6086
6935
|
encoding: "utf8",
|
|
6087
6936
|
mode: 384
|
|
@@ -6106,7 +6955,7 @@ var ExtensionManager = class {
|
|
|
6106
6955
|
});
|
|
6107
6956
|
child.on("exit", (code, signal) => {
|
|
6108
6957
|
try {
|
|
6109
|
-
|
|
6958
|
+
fs6.unlinkSync(paths.extensionPidFile(ext.name));
|
|
6110
6959
|
} catch {
|
|
6111
6960
|
}
|
|
6112
6961
|
logStream.write(
|
|
@@ -6222,8 +7071,7 @@ init_config();
|
|
|
6222
7071
|
function registerSessionRoutes(app, manager, defaults) {
|
|
6223
7072
|
app.get("/v1/sessions", async (request) => {
|
|
6224
7073
|
const query = request.query;
|
|
6225
|
-
const
|
|
6226
|
-
const sessions = await manager.list({ cwd: query?.cwd, all });
|
|
7074
|
+
const sessions = await manager.list({ cwd: query?.cwd });
|
|
6227
7075
|
return { sessions };
|
|
6228
7076
|
});
|
|
6229
7077
|
app.post("/v1/sessions", async (request, reply) => {
|
|
@@ -6261,6 +7109,50 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
6261
7109
|
}
|
|
6262
7110
|
reply.code(204).send();
|
|
6263
7111
|
});
|
|
7112
|
+
app.get("/v1/sessions/:id/history", async (request, reply) => {
|
|
7113
|
+
const raw = request.params.id;
|
|
7114
|
+
const query = request.query;
|
|
7115
|
+
const follow = query?.follow === "1" || query?.follow === "true";
|
|
7116
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
7117
|
+
const live = manager.get(id);
|
|
7118
|
+
let snapshot;
|
|
7119
|
+
let unsubscribe;
|
|
7120
|
+
if (live) {
|
|
7121
|
+
snapshot = live.getHistorySnapshot();
|
|
7122
|
+
if (follow) {
|
|
7123
|
+
unsubscribe = live.onBroadcast((entry) => {
|
|
7124
|
+
if (reply.raw.writableEnded) {
|
|
7125
|
+
return;
|
|
7126
|
+
}
|
|
7127
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
7128
|
+
});
|
|
7129
|
+
}
|
|
7130
|
+
} else {
|
|
7131
|
+
const cold = await manager.getHistory(id);
|
|
7132
|
+
if (cold === void 0) {
|
|
7133
|
+
reply.code(404).send({ error: "session not found" });
|
|
7134
|
+
return reply;
|
|
7135
|
+
}
|
|
7136
|
+
snapshot = cold;
|
|
7137
|
+
}
|
|
7138
|
+
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
|
7139
|
+
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
7140
|
+
reply.raw.statusCode = 200;
|
|
7141
|
+
for (const entry of snapshot ?? []) {
|
|
7142
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
7143
|
+
}
|
|
7144
|
+
if (!unsubscribe) {
|
|
7145
|
+
reply.raw.end();
|
|
7146
|
+
return reply;
|
|
7147
|
+
}
|
|
7148
|
+
request.raw.on("close", () => {
|
|
7149
|
+
unsubscribe?.();
|
|
7150
|
+
if (!reply.raw.writableEnded) {
|
|
7151
|
+
reply.raw.end();
|
|
7152
|
+
}
|
|
7153
|
+
});
|
|
7154
|
+
return reply;
|
|
7155
|
+
});
|
|
6264
7156
|
}
|
|
6265
7157
|
|
|
6266
7158
|
// src/daemon/routes/agents.ts
|
|
@@ -6651,6 +7543,16 @@ function buildResponseMeta(session) {
|
|
|
6651
7543
|
if (session.agentArgs && session.agentArgs.length > 0) {
|
|
6652
7544
|
ours.agentArgs = session.agentArgs;
|
|
6653
7545
|
}
|
|
7546
|
+
if (session.currentModel !== void 0) {
|
|
7547
|
+
ours.currentModel = session.currentModel;
|
|
7548
|
+
}
|
|
7549
|
+
if (session.currentMode !== void 0) {
|
|
7550
|
+
ours.currentMode = session.currentMode;
|
|
7551
|
+
}
|
|
7552
|
+
const commands = session.mergedAvailableCommands();
|
|
7553
|
+
if (commands.length > 0) {
|
|
7554
|
+
ours.availableCommands = commands;
|
|
7555
|
+
}
|
|
6654
7556
|
return mergeMeta(session.agentMeta, ours);
|
|
6655
7557
|
}
|
|
6656
7558
|
function buildInitializeResult() {
|
|
@@ -6771,7 +7673,7 @@ async function startDaemon(config) {
|
|
|
6771
7673
|
await manager.closeAll();
|
|
6772
7674
|
await app.close();
|
|
6773
7675
|
try {
|
|
6774
|
-
|
|
7676
|
+
fs7.unlinkSync(paths.pidFile());
|
|
6775
7677
|
} catch {
|
|
6776
7678
|
}
|
|
6777
7679
|
try {
|
|
@@ -6810,7 +7712,7 @@ function ensureLoopbackOrTls(config) {
|
|
|
6810
7712
|
init_daemon_bootstrap();
|
|
6811
7713
|
|
|
6812
7714
|
// src/cli/commands/log-tail.ts
|
|
6813
|
-
import * as
|
|
7715
|
+
import * as fs8 from "fs";
|
|
6814
7716
|
import * as fsp3 from "fs/promises";
|
|
6815
7717
|
async function runLogTail(logPath, argv, notFoundMessage) {
|
|
6816
7718
|
const opts = parseLogTailFlags(argv);
|
|
@@ -6834,7 +7736,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
|
|
|
6834
7736
|
process.stdout.write(`-- following ${logPath} --
|
|
6835
7737
|
`);
|
|
6836
7738
|
let pending = false;
|
|
6837
|
-
const watcher =
|
|
7739
|
+
const watcher = fs8.watch(logPath, () => {
|
|
6838
7740
|
if (pending) {
|
|
6839
7741
|
return;
|
|
6840
7742
|
}
|
|
@@ -7592,226 +8494,7 @@ function maxLen3(headerCell, values) {
|
|
|
7592
8494
|
// src/shim/proxy.ts
|
|
7593
8495
|
init_config();
|
|
7594
8496
|
init_daemon_bootstrap();
|
|
7595
|
-
|
|
7596
|
-
// src/shim/resilient-ws.ts
|
|
7597
|
-
init_ws_stream();
|
|
7598
|
-
init_types();
|
|
7599
|
-
import { setTimeout as sleep3 } from "timers/promises";
|
|
7600
|
-
import { WebSocket } from "ws";
|
|
7601
|
-
var BACKOFF_INITIAL_MS = 200;
|
|
7602
|
-
var BACKOFF_MAX_MS = 5e3;
|
|
7603
|
-
var BACKOFF_MULTIPLIER = 2;
|
|
7604
|
-
var MAX_RECONNECT_ATTEMPTS = 60;
|
|
7605
|
-
var ResilientWsStream = class {
|
|
7606
|
-
constructor(opts) {
|
|
7607
|
-
this.opts = opts;
|
|
7608
|
-
}
|
|
7609
|
-
opts;
|
|
7610
|
-
current;
|
|
7611
|
-
outboundQueue = [];
|
|
7612
|
-
messageHandlers = [];
|
|
7613
|
-
closeHandlers = [];
|
|
7614
|
-
destroyed = false;
|
|
7615
|
-
firstConnect = true;
|
|
7616
|
-
reconnectInFlight;
|
|
7617
|
-
connectGate;
|
|
7618
|
-
releaseConnectGate;
|
|
7619
|
-
pendingRequests = /* @__PURE__ */ new Map();
|
|
7620
|
-
async start() {
|
|
7621
|
-
await this.connectWithRetry();
|
|
7622
|
-
}
|
|
7623
|
-
onMessage(handler) {
|
|
7624
|
-
this.messageHandlers.push(handler);
|
|
7625
|
-
}
|
|
7626
|
-
onClose(handler) {
|
|
7627
|
-
this.closeHandlers.push(handler);
|
|
7628
|
-
}
|
|
7629
|
-
async send(message) {
|
|
7630
|
-
if (this.destroyed) {
|
|
7631
|
-
throw new Error("resilient ws stream is destroyed");
|
|
7632
|
-
}
|
|
7633
|
-
if (this.connectGate || !this.current) {
|
|
7634
|
-
this.outboundQueue.push(message);
|
|
7635
|
-
return;
|
|
7636
|
-
}
|
|
7637
|
-
try {
|
|
7638
|
-
await this.current.send(message);
|
|
7639
|
-
} catch (err) {
|
|
7640
|
-
this.outboundQueue.push(message);
|
|
7641
|
-
this.scheduleReconnect(err);
|
|
7642
|
-
}
|
|
7643
|
-
}
|
|
7644
|
-
// Send a request directly and resolve when the matching response arrives
|
|
7645
|
-
// on the same connection. Used by onConnect handlers to await replay-attach
|
|
7646
|
-
// responses before letting the outbound queue drain. Bypasses the
|
|
7647
|
-
// connectGate intentionally.
|
|
7648
|
-
async request(message) {
|
|
7649
|
-
if (this.destroyed) {
|
|
7650
|
-
throw new Error("resilient ws stream is destroyed");
|
|
7651
|
-
}
|
|
7652
|
-
if (!this.current) {
|
|
7653
|
-
throw new Error("resilient ws stream not connected");
|
|
7654
|
-
}
|
|
7655
|
-
const id = message.id;
|
|
7656
|
-
const promise = new Promise((resolve2, reject) => {
|
|
7657
|
-
this.pendingRequests.set(id, { resolve: resolve2, reject });
|
|
7658
|
-
});
|
|
7659
|
-
try {
|
|
7660
|
-
await this.current.send(message);
|
|
7661
|
-
} catch (err) {
|
|
7662
|
-
this.pendingRequests.delete(id);
|
|
7663
|
-
throw err;
|
|
7664
|
-
}
|
|
7665
|
-
return promise;
|
|
7666
|
-
}
|
|
7667
|
-
async close() {
|
|
7668
|
-
this.destroyed = true;
|
|
7669
|
-
if (this.current) {
|
|
7670
|
-
await this.current.close().catch(() => void 0);
|
|
7671
|
-
}
|
|
7672
|
-
for (const handler of this.closeHandlers) {
|
|
7673
|
-
handler();
|
|
7674
|
-
}
|
|
7675
|
-
}
|
|
7676
|
-
async connectWithRetry() {
|
|
7677
|
-
let attempt = 0;
|
|
7678
|
-
let backoff = BACKOFF_INITIAL_MS;
|
|
7679
|
-
while (!this.destroyed) {
|
|
7680
|
-
try {
|
|
7681
|
-
const stream = await openWs(this.opts.url, this.opts.subprotocols);
|
|
7682
|
-
this.bindStream(stream);
|
|
7683
|
-
const wasFirst = this.firstConnect;
|
|
7684
|
-
this.firstConnect = false;
|
|
7685
|
-
this.connectGate = new Promise((resolve2) => {
|
|
7686
|
-
this.releaseConnectGate = resolve2;
|
|
7687
|
-
});
|
|
7688
|
-
try {
|
|
7689
|
-
if (this.opts.onConnect) {
|
|
7690
|
-
try {
|
|
7691
|
-
await this.opts.onConnect(wasFirst);
|
|
7692
|
-
} catch (err) {
|
|
7693
|
-
this.log(
|
|
7694
|
-
`hydra-acp: post-connect handler failed: ${err.message}`
|
|
7695
|
-
);
|
|
7696
|
-
}
|
|
7697
|
-
}
|
|
7698
|
-
} finally {
|
|
7699
|
-
this.releaseConnectGate?.();
|
|
7700
|
-
this.releaseConnectGate = void 0;
|
|
7701
|
-
this.connectGate = void 0;
|
|
7702
|
-
}
|
|
7703
|
-
await this.flushQueue();
|
|
7704
|
-
return;
|
|
7705
|
-
} catch (err) {
|
|
7706
|
-
attempt += 1;
|
|
7707
|
-
if (this.opts.onConnectFailure) {
|
|
7708
|
-
this.opts.onConnectFailure(err);
|
|
7709
|
-
}
|
|
7710
|
-
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
|
|
7711
|
-
throw new Error(
|
|
7712
|
-
`hydra-acp: gave up reconnecting after ${attempt} attempts: ${err.message}`
|
|
7713
|
-
);
|
|
7714
|
-
}
|
|
7715
|
-
this.log(
|
|
7716
|
-
`hydra-acp: connect attempt ${attempt} failed (${err.message}); retrying in ${backoff}ms`
|
|
7717
|
-
);
|
|
7718
|
-
await sleep3(backoff);
|
|
7719
|
-
backoff = Math.min(backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
|
|
7720
|
-
}
|
|
7721
|
-
}
|
|
7722
|
-
}
|
|
7723
|
-
bindStream(stream) {
|
|
7724
|
-
this.current = stream;
|
|
7725
|
-
stream.onMessage((msg) => {
|
|
7726
|
-
if (isResponse(msg)) {
|
|
7727
|
-
const pending = this.pendingRequests.get(msg.id);
|
|
7728
|
-
if (pending) {
|
|
7729
|
-
this.pendingRequests.delete(msg.id);
|
|
7730
|
-
pending.resolve(msg);
|
|
7731
|
-
}
|
|
7732
|
-
}
|
|
7733
|
-
for (const handler of this.messageHandlers) {
|
|
7734
|
-
handler(msg);
|
|
7735
|
-
}
|
|
7736
|
-
});
|
|
7737
|
-
stream.onClose((err) => {
|
|
7738
|
-
if (this.destroyed) {
|
|
7739
|
-
return;
|
|
7740
|
-
}
|
|
7741
|
-
this.current = void 0;
|
|
7742
|
-
if (this.pendingRequests.size > 0) {
|
|
7743
|
-
const reason = err ?? new Error("ws closed before response");
|
|
7744
|
-
for (const { reject } of this.pendingRequests.values()) {
|
|
7745
|
-
reject(reason);
|
|
7746
|
-
}
|
|
7747
|
-
this.pendingRequests.clear();
|
|
7748
|
-
}
|
|
7749
|
-
this.scheduleReconnect(err);
|
|
7750
|
-
});
|
|
7751
|
-
}
|
|
7752
|
-
async flushQueue() {
|
|
7753
|
-
if (!this.current) {
|
|
7754
|
-
return;
|
|
7755
|
-
}
|
|
7756
|
-
const queue = this.outboundQueue;
|
|
7757
|
-
this.outboundQueue = [];
|
|
7758
|
-
for (const msg of queue) {
|
|
7759
|
-
try {
|
|
7760
|
-
await this.current.send(msg);
|
|
7761
|
-
} catch (err) {
|
|
7762
|
-
this.outboundQueue.unshift(msg);
|
|
7763
|
-
this.scheduleReconnect(err);
|
|
7764
|
-
return;
|
|
7765
|
-
}
|
|
7766
|
-
}
|
|
7767
|
-
}
|
|
7768
|
-
scheduleReconnect(err) {
|
|
7769
|
-
if (this.destroyed || this.reconnectInFlight) {
|
|
7770
|
-
return;
|
|
7771
|
-
}
|
|
7772
|
-
this.log(
|
|
7773
|
-
`hydra-acp: connection lost (${err?.message ?? "no error"}); reconnecting...`
|
|
7774
|
-
);
|
|
7775
|
-
this.reconnectInFlight = (async () => {
|
|
7776
|
-
try {
|
|
7777
|
-
await this.connectWithRetry();
|
|
7778
|
-
} catch (final) {
|
|
7779
|
-
for (const handler of this.closeHandlers) {
|
|
7780
|
-
handler(final);
|
|
7781
|
-
}
|
|
7782
|
-
this.destroyed = true;
|
|
7783
|
-
} finally {
|
|
7784
|
-
this.reconnectInFlight = void 0;
|
|
7785
|
-
}
|
|
7786
|
-
})();
|
|
7787
|
-
}
|
|
7788
|
-
log(line) {
|
|
7789
|
-
if (this.opts.log) {
|
|
7790
|
-
this.opts.log(line);
|
|
7791
|
-
return;
|
|
7792
|
-
}
|
|
7793
|
-
process.stderr.write(`${line}
|
|
7794
|
-
`);
|
|
7795
|
-
}
|
|
7796
|
-
};
|
|
7797
|
-
function isResponse(msg) {
|
|
7798
|
-
return !("method" in msg) && "id" in msg && msg.id !== void 0;
|
|
7799
|
-
}
|
|
7800
|
-
async function openWs(url, subprotocols) {
|
|
7801
|
-
return new Promise((resolve2, reject) => {
|
|
7802
|
-
const ws = new WebSocket(url, subprotocols);
|
|
7803
|
-
const onOpen = () => {
|
|
7804
|
-
ws.off("error", onError);
|
|
7805
|
-
resolve2(wsToMessageStream(ws));
|
|
7806
|
-
};
|
|
7807
|
-
const onError = (err) => {
|
|
7808
|
-
ws.off("open", onOpen);
|
|
7809
|
-
reject(err);
|
|
7810
|
-
};
|
|
7811
|
-
ws.once("open", onOpen);
|
|
7812
|
-
ws.once("error", onError);
|
|
7813
|
-
});
|
|
7814
|
-
}
|
|
8497
|
+
init_resilient_ws();
|
|
7815
8498
|
|
|
7816
8499
|
// src/shim/session-tracker.ts
|
|
7817
8500
|
init_types();
|