@hydra-acp/cli 0.1.3 → 0.1.5
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 +27 -3
- package/dist/cli.js +1788 -428
- package/dist/index.d.ts +608 -38
- package/dist/index.js +1211 -234
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -37,7 +37,11 @@ var init_paths = __esm({
|
|
|
37
37
|
currentLogFile: () => path.join(hydraHome(), "current.log"),
|
|
38
38
|
registryCache: () => path.join(hydraHome(), "registry.json"),
|
|
39
39
|
agentsDir: () => path.join(hydraHome(), "agents"),
|
|
40
|
-
|
|
40
|
+
// <platformKey>/<agentId>/<version>/ — platform at the top so a Hydra
|
|
41
|
+
// home shared between machines (NFS, rsync'd dotfiles) keeps each
|
|
42
|
+
// machine's binaries cleanly separated. `ls agents/` immediately
|
|
43
|
+
// shows which platforms have ever installed anything.
|
|
44
|
+
agentInstallDir: (id, platformKey, version) => path.join(hydraHome(), "agents", platformKey, id, version),
|
|
41
45
|
sessionsDir: () => path.join(hydraHome(), "sessions"),
|
|
42
46
|
// One directory per session id under sessions/. Co-locates the
|
|
43
47
|
// session record, its transcript, and any future per-session state
|
|
@@ -89,8 +93,7 @@ async function ensureConfig() {
|
|
|
89
93
|
if (e.code !== "ENOENT") {
|
|
90
94
|
throw err;
|
|
91
95
|
}
|
|
92
|
-
const config =
|
|
93
|
-
await writeConfig(config);
|
|
96
|
+
const config = await writeMinimalInitConfig();
|
|
94
97
|
process.stderr.write(
|
|
95
98
|
`hydra-acp: initialized ${paths.config()} with a fresh auth token.
|
|
96
99
|
`
|
|
@@ -99,9 +102,23 @@ async function ensureConfig() {
|
|
|
99
102
|
}
|
|
100
103
|
return loadConfig();
|
|
101
104
|
}
|
|
102
|
-
async function
|
|
105
|
+
async function writeMinimalInitConfig(authToken) {
|
|
106
|
+
const token = authToken ?? generateAuthToken();
|
|
107
|
+
const minimal = { daemon: { authToken: token } };
|
|
103
108
|
await fs.mkdir(paths.home(), { recursive: true });
|
|
104
|
-
await fs.writeFile(paths.config(), JSON.stringify(
|
|
109
|
+
await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
mode: 384
|
|
112
|
+
});
|
|
113
|
+
return HydraConfig.parse(minimal);
|
|
114
|
+
}
|
|
115
|
+
async function updateConfigField(mutate) {
|
|
116
|
+
const path7 = paths.config();
|
|
117
|
+
const text = await fs.readFile(path7, "utf8");
|
|
118
|
+
const raw = JSON.parse(text);
|
|
119
|
+
mutate(raw);
|
|
120
|
+
HydraConfig.parse(raw);
|
|
121
|
+
await fs.writeFile(path7, JSON.stringify(raw, null, 2) + "\n", {
|
|
105
122
|
encoding: "utf8",
|
|
106
123
|
mode: 384
|
|
107
124
|
});
|
|
@@ -115,13 +132,6 @@ function generateAuthToken() {
|
|
|
115
132
|
}
|
|
116
133
|
return `hydra_token_${hex}`;
|
|
117
134
|
}
|
|
118
|
-
function defaultConfig() {
|
|
119
|
-
return HydraConfig.parse({
|
|
120
|
-
daemon: {
|
|
121
|
-
authToken: generateAuthToken()
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
135
|
function expandHome(p) {
|
|
126
136
|
if (p === "~" || p === "$HOME") {
|
|
127
137
|
return homedir2();
|
|
@@ -150,7 +160,7 @@ var init_config = __esm({
|
|
|
150
160
|
authToken: z.string().min(16),
|
|
151
161
|
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
152
162
|
tls: TlsConfig.optional(),
|
|
153
|
-
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(
|
|
163
|
+
sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
|
|
154
164
|
});
|
|
155
165
|
RegistryConfig = z.object({
|
|
156
166
|
url: z.string().url().default(REGISTRY_URL_DEFAULT),
|
|
@@ -183,6 +193,14 @@ var init_config = __esm({
|
|
|
183
193
|
daemon: DaemonConfig,
|
|
184
194
|
registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
|
|
185
195
|
defaultAgent: z.string().default("claude-acp"),
|
|
196
|
+
// Optional per-agent default model id. When a brand-new agent process
|
|
197
|
+
// is spawned (session/new path), hydra issues session/set_model with
|
|
198
|
+
// the matching entry so the user lands on their preferred model from
|
|
199
|
+
// the first prompt. Not applied on resurrect — those sessions keep
|
|
200
|
+
// whatever the user last selected. Keys are agent ids; values are the
|
|
201
|
+
// raw model id strings the agent expects (claude-acp: "claude-opus-4-7",
|
|
202
|
+
// opencode: "openai/gpt-5-codex" or "ncp-anthropic/claude-opus-4-7", …).
|
|
203
|
+
defaultModels: z.record(z.string(), z.string()).default({}),
|
|
186
204
|
// Where new sessions land when POST /v1/sessions omits cwd. Stored as
|
|
187
205
|
// a literal string ("~", "~/dev", "$HOME/work") so the config file is
|
|
188
206
|
// portable across machines; expanded via expandHome at use time.
|
|
@@ -264,7 +282,7 @@ function extractHydraMeta(meta) {
|
|
|
264
282
|
function mergeMeta(passthrough, ours) {
|
|
265
283
|
return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
|
|
266
284
|
}
|
|
267
|
-
var JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
|
|
285
|
+
var JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
|
|
268
286
|
var init_types = __esm({
|
|
269
287
|
"src/acp/types.ts"() {
|
|
270
288
|
"use strict";
|
|
@@ -277,7 +295,8 @@ var init_types = __esm({
|
|
|
277
295
|
SessionNotFound: -32001,
|
|
278
296
|
PermissionDenied: -32002,
|
|
279
297
|
AlreadyAttached: -32003,
|
|
280
|
-
AgentNotInstalled: -32005
|
|
298
|
+
AgentNotInstalled: -32005,
|
|
299
|
+
BundleAlreadyImported: -32010
|
|
281
300
|
};
|
|
282
301
|
InitializeParams = z3.object({
|
|
283
302
|
protocolVersion: z3.number().optional(),
|
|
@@ -318,12 +337,24 @@ var init_types = __esm({
|
|
|
318
337
|
cursor: z3.string().optional(),
|
|
319
338
|
limit: z3.number().int().positive().max(200).optional()
|
|
320
339
|
});
|
|
340
|
+
SessionListUsage = z3.object({
|
|
341
|
+
used: z3.number().optional(),
|
|
342
|
+
size: z3.number().optional(),
|
|
343
|
+
costAmount: z3.number().optional(),
|
|
344
|
+
costCurrency: z3.string().optional()
|
|
345
|
+
});
|
|
321
346
|
SessionListEntry = z3.object({
|
|
322
347
|
sessionId: z3.string(),
|
|
323
348
|
upstreamSessionId: z3.string().optional(),
|
|
324
349
|
cwd: z3.string(),
|
|
325
350
|
title: z3.string().optional(),
|
|
326
351
|
agentId: z3.string().optional(),
|
|
352
|
+
// Last-known model id, so list views can render `<agent>(<model>)`
|
|
353
|
+
// without resurrecting cold sessions to look it up.
|
|
354
|
+
currentModel: z3.string().optional(),
|
|
355
|
+
// Last-known usage snapshot so list views can show per-session cost
|
|
356
|
+
// (and tokens, in callers that care) without resurrecting cold sessions.
|
|
357
|
+
currentUsage: SessionListUsage.optional(),
|
|
327
358
|
updatedAt: z3.string(),
|
|
328
359
|
attachedClients: z3.number().int().nonnegative(),
|
|
329
360
|
status: z3.enum(["live", "cold"]).default("live"),
|
|
@@ -401,9 +432,9 @@ var init_connection = __esm({
|
|
|
401
432
|
}
|
|
402
433
|
const id = nanoid();
|
|
403
434
|
const message = { jsonrpc: "2.0", id, method, params };
|
|
404
|
-
const response = new Promise((
|
|
435
|
+
const response = new Promise((resolve5, reject) => {
|
|
405
436
|
this.pending.set(id, {
|
|
406
|
-
resolve: (result) =>
|
|
437
|
+
resolve: (result) => resolve5(result),
|
|
407
438
|
reject
|
|
408
439
|
});
|
|
409
440
|
this.stream.send(message).catch((err) => {
|
|
@@ -527,8 +558,8 @@ var init_hydra_commands = __esm({
|
|
|
527
558
|
description: "Regenerate the session title via the agent (or set manually with an arg)"
|
|
528
559
|
},
|
|
529
560
|
{
|
|
530
|
-
verb: "
|
|
531
|
-
name: "/hydra
|
|
561
|
+
verb: "agent",
|
|
562
|
+
name: "/hydra agent",
|
|
532
563
|
argsHint: "<agent>",
|
|
533
564
|
description: "Swap the agent backing this session, preserving context"
|
|
534
565
|
}
|
|
@@ -627,7 +658,7 @@ function firstLine(text, max) {
|
|
|
627
658
|
}
|
|
628
659
|
return void 0;
|
|
629
660
|
}
|
|
630
|
-
var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session, STATE_UPDATE_KINDS;
|
|
661
|
+
var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, MAX_HISTORY_ENTRIES, COMPACT_EVERY, Session, STATE_UPDATE_KINDS;
|
|
631
662
|
var init_session = __esm({
|
|
632
663
|
"src/core/session.ts"() {
|
|
633
664
|
"use strict";
|
|
@@ -636,10 +667,12 @@ var init_session = __esm({
|
|
|
636
667
|
HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
637
668
|
generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
|
|
638
669
|
HYDRA_SESSION_PREFIX = "hydra_session_";
|
|
670
|
+
MAX_HISTORY_ENTRIES = 1e3;
|
|
671
|
+
COMPACT_EVERY = 200;
|
|
639
672
|
Session = class {
|
|
640
673
|
sessionId;
|
|
641
674
|
cwd;
|
|
642
|
-
// agent / agentId / upstreamSessionId are mutable so /hydra
|
|
675
|
+
// agent / agentId / upstreamSessionId are mutable so /hydra agent can
|
|
643
676
|
// replace the underlying agent process while keeping the same Session
|
|
644
677
|
// record. agentMeta is the metadata returned by the agent at session/new
|
|
645
678
|
// time; it gets refreshed on switch too.
|
|
@@ -654,9 +687,10 @@ var init_session = __esm({
|
|
|
654
687
|
// stale-prone for snapshot-shaped events).
|
|
655
688
|
currentModel;
|
|
656
689
|
currentMode;
|
|
690
|
+
currentUsage;
|
|
657
691
|
updatedAt;
|
|
692
|
+
createdAt;
|
|
658
693
|
clients = /* @__PURE__ */ new Map();
|
|
659
|
-
history = [];
|
|
660
694
|
historyStore;
|
|
661
695
|
promptQueue = [];
|
|
662
696
|
promptInFlight = false;
|
|
@@ -672,6 +706,15 @@ var init_session = __esm({
|
|
|
672
706
|
// True once we've observed our first session/prompt; gates the
|
|
673
707
|
// first-prompt-seeded title so subsequent prompts don't churn it.
|
|
674
708
|
firstPromptSeeded = false;
|
|
709
|
+
// Wall-clock when the active prompt started, undefined when idle.
|
|
710
|
+
// Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
|
|
711
|
+
// Drives the mid-turn elapsed counter delivered to fresh attachers.
|
|
712
|
+
promptStartedAt;
|
|
713
|
+
// Counts appends since the last compaction. When it hits COMPACT_EVERY
|
|
714
|
+
// we ask the history store to trim the file to the most recent
|
|
715
|
+
// MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
|
|
716
|
+
// file-size checks.
|
|
717
|
+
appendCount = 0;
|
|
675
718
|
// Permission requests that have been broadcast to one or more
|
|
676
719
|
// clients but have not yet resolved. Replayed to clients that
|
|
677
720
|
// attach mid-flight so a late joiner sees the prompt instead of an
|
|
@@ -686,6 +729,12 @@ var init_session = __esm({
|
|
|
686
729
|
internalPromptCapture;
|
|
687
730
|
idleTimeoutMs;
|
|
688
731
|
idleTimer;
|
|
732
|
+
// Time of the last recordable broadcast (or session creation, if
|
|
733
|
+
// none yet). Drives the inactivity-based idle close; deliberately
|
|
734
|
+
// does NOT include snapshot state pings (model/mode/title/commands)
|
|
735
|
+
// or attach/detach, which would otherwise let passive observers
|
|
736
|
+
// and noisy state churn keep a quiet session alive forever.
|
|
737
|
+
lastRecordedAt;
|
|
689
738
|
spawnReplacementAgent;
|
|
690
739
|
agentChangeHandlers = [];
|
|
691
740
|
// Last available_commands_update we observed from the agent. Stored
|
|
@@ -700,6 +749,7 @@ var init_session = __esm({
|
|
|
700
749
|
agentCommandsHandlers = [];
|
|
701
750
|
modelHandlers = [];
|
|
702
751
|
modeHandlers = [];
|
|
752
|
+
usageHandlers = [];
|
|
703
753
|
constructor(init) {
|
|
704
754
|
this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
|
|
705
755
|
this.cwd = init.cwd;
|
|
@@ -711,6 +761,7 @@ var init_session = __esm({
|
|
|
711
761
|
this.title = init.title;
|
|
712
762
|
this.currentModel = init.currentModel;
|
|
713
763
|
this.currentMode = init.currentMode;
|
|
764
|
+
this.currentUsage = init.currentUsage;
|
|
714
765
|
if (init.agentCommands && init.agentCommands.length > 0) {
|
|
715
766
|
this.agentAdvertisedCommands = [...init.agentCommands];
|
|
716
767
|
}
|
|
@@ -720,11 +771,11 @@ var init_session = __esm({
|
|
|
720
771
|
this.firstPromptSeeded = true;
|
|
721
772
|
}
|
|
722
773
|
this.historyStore = init.historyStore;
|
|
723
|
-
if (init.seedHistory && init.seedHistory.length > 0) {
|
|
724
|
-
this.history = [...init.seedHistory];
|
|
725
|
-
}
|
|
726
774
|
this.updatedAt = Date.now();
|
|
775
|
+
this.createdAt = init.createdAt ?? this.updatedAt;
|
|
776
|
+
this.lastRecordedAt = this.updatedAt;
|
|
727
777
|
this.wireAgent(this.agent);
|
|
778
|
+
this.scheduleIdleCheck();
|
|
728
779
|
}
|
|
729
780
|
broadcastMergedCommands() {
|
|
730
781
|
const merged = [
|
|
@@ -740,7 +791,7 @@ var init_session = __esm({
|
|
|
740
791
|
});
|
|
741
792
|
}
|
|
742
793
|
// Register session/update, session/request_permission, and onExit
|
|
743
|
-
// handlers on an agent connection. Re-run on every /hydra
|
|
794
|
+
// handlers on an agent connection. Re-run on every /hydra agent so
|
|
744
795
|
// the new agent is plumbed identically. The exit handler's identity
|
|
745
796
|
// check is what makes switching safe: when the *old* agent exits as
|
|
746
797
|
// part of a swap, this.agent has already been replaced, so we no-op
|
|
@@ -764,6 +815,10 @@ var init_session = __esm({
|
|
|
764
815
|
this.recordAndBroadcast("session/update", params);
|
|
765
816
|
return;
|
|
766
817
|
}
|
|
818
|
+
if (this.maybeApplyAgentUsage(params)) {
|
|
819
|
+
this.recordAndBroadcast("session/update", params);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
767
822
|
this.maybeApplyAgentSessionInfo(params);
|
|
768
823
|
this.recordAndBroadcast("session/update", params);
|
|
769
824
|
});
|
|
@@ -784,34 +839,20 @@ var init_session = __esm({
|
|
|
784
839
|
return this.clients.size;
|
|
785
840
|
}
|
|
786
841
|
// Wall-clock when the in-flight agent turn began, or undefined when
|
|
787
|
-
// idle.
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
// so a fresh client reattaching mid-turn boots up with the busy
|
|
791
|
-
// banner showing real elapsed time.
|
|
842
|
+
// idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
|
|
843
|
+
// so the daemon can hand a fresh attacher mid-turn the right elapsed
|
|
844
|
+
// time without scanning history.
|
|
792
845
|
get turnStartedAt() {
|
|
793
|
-
|
|
794
|
-
const entry = this.history[i];
|
|
795
|
-
if (!entry) {
|
|
796
|
-
continue;
|
|
797
|
-
}
|
|
798
|
-
const params = entry.params;
|
|
799
|
-
const kind = params?.update?.sessionUpdate;
|
|
800
|
-
if (kind === "turn_complete") {
|
|
801
|
-
return void 0;
|
|
802
|
-
}
|
|
803
|
-
if (kind === "prompt_received") {
|
|
804
|
-
return entry.recordedAt;
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
return void 0;
|
|
846
|
+
return this.promptStartedAt;
|
|
808
847
|
}
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
848
|
+
// Read the persisted history from disk. Returns [] if no history
|
|
849
|
+
// file exists (fresh session, never prompted). Used by attach() and
|
|
850
|
+
// the HTTP /history endpoint.
|
|
851
|
+
async getHistorySnapshot() {
|
|
852
|
+
if (!this.historyStore) {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
return this.historyStore.load(this.sessionId).catch(() => []);
|
|
815
856
|
}
|
|
816
857
|
// Subscribe to recordable broadcast entries — fires once per entry
|
|
817
858
|
// that lands in history (so snapshot-shaped session_info/model/mode/
|
|
@@ -827,6 +868,10 @@ var init_session = __esm({
|
|
|
827
868
|
}
|
|
828
869
|
};
|
|
829
870
|
}
|
|
871
|
+
// Register a client and (asynchronously) load the replay slice it
|
|
872
|
+
// should receive. Validation errors throw synchronously so callers
|
|
873
|
+
// can rely on either the registration being in effect or having
|
|
874
|
+
// thrown; the disk-load is the only async work.
|
|
830
875
|
attach(client, historyPolicy) {
|
|
831
876
|
if (this.closed) {
|
|
832
877
|
throw withCode(
|
|
@@ -842,14 +887,10 @@ var init_session = __esm({
|
|
|
842
887
|
}
|
|
843
888
|
this.clients.set(client.clientId, client);
|
|
844
889
|
this.updatedAt = Date.now();
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
return [];
|
|
848
|
-
}
|
|
849
|
-
if (historyPolicy === "pending_only") {
|
|
850
|
-
return [];
|
|
890
|
+
if (historyPolicy === "none" || historyPolicy === "pending_only") {
|
|
891
|
+
return Promise.resolve([]);
|
|
851
892
|
}
|
|
852
|
-
return
|
|
893
|
+
return this.getHistorySnapshot();
|
|
853
894
|
}
|
|
854
895
|
// Dispatch in-flight permission requests to a freshly-attached
|
|
855
896
|
// client. Called by the daemon's WS handler *after* it finishes
|
|
@@ -863,7 +904,6 @@ var init_session = __esm({
|
|
|
863
904
|
detach(clientId) {
|
|
864
905
|
if (this.clients.delete(clientId)) {
|
|
865
906
|
this.updatedAt = Date.now();
|
|
866
|
-
this.maybeStartIdleTimer();
|
|
867
907
|
}
|
|
868
908
|
}
|
|
869
909
|
async prompt(clientId, params) {
|
|
@@ -909,6 +949,7 @@ var init_session = __esm({
|
|
|
909
949
|
if (client.clientInfo?.version) {
|
|
910
950
|
sentBy.version = client.clientInfo.version;
|
|
911
951
|
}
|
|
952
|
+
this.promptStartedAt = Date.now();
|
|
912
953
|
this.recordAndBroadcast(
|
|
913
954
|
"session/update",
|
|
914
955
|
{
|
|
@@ -945,6 +986,7 @@ var init_session = __esm({
|
|
|
945
986
|
if (stopReason !== void 0) {
|
|
946
987
|
update.stopReason = stopReason;
|
|
947
988
|
}
|
|
989
|
+
this.promptStartedAt = void 0;
|
|
948
990
|
this.recordAndBroadcast(
|
|
949
991
|
"session/update",
|
|
950
992
|
{
|
|
@@ -1094,6 +1136,49 @@ var init_session = __esm({
|
|
|
1094
1136
|
}
|
|
1095
1137
|
return true;
|
|
1096
1138
|
}
|
|
1139
|
+
// usage_update carries any subset of {used, size, cost.amount,
|
|
1140
|
+
// cost.currency}. Merge non-undefined fields onto currentUsage so a
|
|
1141
|
+
// sparse update preserves prior values, and fire usage handlers only
|
|
1142
|
+
// if something actually changed.
|
|
1143
|
+
maybeApplyAgentUsage(params) {
|
|
1144
|
+
const obj = params ?? {};
|
|
1145
|
+
const update = obj.update ?? {};
|
|
1146
|
+
if (update.sessionUpdate !== "usage_update") {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
const next = { ...this.currentUsage ?? {} };
|
|
1150
|
+
let changed = false;
|
|
1151
|
+
if (typeof update.used === "number" && next.used !== update.used) {
|
|
1152
|
+
next.used = update.used;
|
|
1153
|
+
changed = true;
|
|
1154
|
+
}
|
|
1155
|
+
if (typeof update.size === "number" && next.size !== update.size) {
|
|
1156
|
+
next.size = update.size;
|
|
1157
|
+
changed = true;
|
|
1158
|
+
}
|
|
1159
|
+
if (update.cost && typeof update.cost === "object") {
|
|
1160
|
+
const cost = update.cost;
|
|
1161
|
+
if (typeof cost.amount === "number" && next.costAmount !== cost.amount) {
|
|
1162
|
+
next.costAmount = cost.amount;
|
|
1163
|
+
changed = true;
|
|
1164
|
+
}
|
|
1165
|
+
if (typeof cost.currency === "string" && next.costCurrency !== cost.currency) {
|
|
1166
|
+
next.costCurrency = cost.currency;
|
|
1167
|
+
changed = true;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (!changed) {
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
this.currentUsage = next;
|
|
1174
|
+
for (const handler of this.usageHandlers) {
|
|
1175
|
+
try {
|
|
1176
|
+
handler(next);
|
|
1177
|
+
} catch {
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1097
1182
|
// Update the cached agent command list, fire persist handlers, and
|
|
1098
1183
|
// broadcast the merged list to attached clients. Idempotent on a
|
|
1099
1184
|
// structurally identical list so we don't churn meta.json on noisy
|
|
@@ -1124,12 +1209,21 @@ var init_session = __esm({
|
|
|
1124
1209
|
onModeChange(handler) {
|
|
1125
1210
|
this.modeHandlers.push(handler);
|
|
1126
1211
|
}
|
|
1212
|
+
onUsageChange(handler) {
|
|
1213
|
+
this.usageHandlers.push(handler);
|
|
1214
|
+
}
|
|
1127
1215
|
// Returns a freshly merged command list (hydra ∪ agent) for callers
|
|
1128
1216
|
// that need a snapshot — notably acp-ws.ts's buildResponseMeta when
|
|
1129
1217
|
// assembling the attach response.
|
|
1130
1218
|
mergedAvailableCommands() {
|
|
1131
1219
|
return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
|
|
1132
1220
|
}
|
|
1221
|
+
// The agent's own advertised commands (not merged with hydra verbs).
|
|
1222
|
+
// Used by SessionManager to persist into meta.json so cold resurrect
|
|
1223
|
+
// can re-deliver via the attach response _meta.
|
|
1224
|
+
agentOnlyAdvertisedCommands() {
|
|
1225
|
+
return [...this.agentAdvertisedCommands];
|
|
1226
|
+
}
|
|
1133
1227
|
// Pick up an agent-emitted session_info_update and store its title
|
|
1134
1228
|
// as our canonical record. The notification is also forwarded to
|
|
1135
1229
|
// clients via the surrounding recordAndBroadcast call. Authoritative
|
|
@@ -1180,8 +1274,8 @@ var init_session = __esm({
|
|
|
1180
1274
|
switch (verb) {
|
|
1181
1275
|
case "title":
|
|
1182
1276
|
return this.runTitleCommand(arg);
|
|
1183
|
-
case "
|
|
1184
|
-
return this.
|
|
1277
|
+
case "agent":
|
|
1278
|
+
return this.runAgentCommand(arg);
|
|
1185
1279
|
default: {
|
|
1186
1280
|
const err = new Error(
|
|
1187
1281
|
`no dispatcher for /hydra verb ${verb}`
|
|
@@ -1217,7 +1311,7 @@ var init_session = __esm({
|
|
|
1217
1311
|
}
|
|
1218
1312
|
// Send a prompt to the underlying agent and capture its reply chunks
|
|
1219
1313
|
// privately (no fan-out to clients, no recording into history). Used
|
|
1220
|
-
// by /hydra title's regen path and /hydra
|
|
1314
|
+
// by /hydra title's regen path and /hydra agent's transcript-injection
|
|
1221
1315
|
// path. Returns the joined agent_message_chunk text.
|
|
1222
1316
|
async runInternalPrompt(text) {
|
|
1223
1317
|
if (this.internalPromptCapture) {
|
|
@@ -1239,10 +1333,10 @@ var init_session = __esm({
|
|
|
1239
1333
|
// record. Spawns the new agent first so a failure leaves the old one
|
|
1240
1334
|
// intact; then injects a synthesized transcript so the new agent has
|
|
1241
1335
|
// context for the next turn.
|
|
1242
|
-
|
|
1336
|
+
runAgentCommand(newAgentId) {
|
|
1243
1337
|
if (!newAgentId) {
|
|
1244
1338
|
throw withCode(
|
|
1245
|
-
new Error("/hydra
|
|
1339
|
+
new Error("/hydra agent requires an agent id"),
|
|
1246
1340
|
JsonRpcErrorCodes.InvalidParams
|
|
1247
1341
|
);
|
|
1248
1342
|
}
|
|
@@ -1261,7 +1355,7 @@ var init_session = __esm({
|
|
|
1261
1355
|
const spawnAgent = this.spawnReplacementAgent;
|
|
1262
1356
|
return this.enqueuePrompt(async () => {
|
|
1263
1357
|
const oldAgentId = this.agentId;
|
|
1264
|
-
const transcript = this.buildSwitchTranscript(oldAgentId);
|
|
1358
|
+
const transcript = await this.buildSwitchTranscript(oldAgentId);
|
|
1265
1359
|
const fresh = await spawnAgent({
|
|
1266
1360
|
agentId: newAgentId,
|
|
1267
1361
|
cwd: this.cwd,
|
|
@@ -1293,15 +1387,20 @@ var init_session = __esm({
|
|
|
1293
1387
|
return { stopReason: "end_turn" };
|
|
1294
1388
|
});
|
|
1295
1389
|
}
|
|
1296
|
-
// Walk
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
|
|
1390
|
+
// Walk the persisted history and produce a labeled transcript suitable
|
|
1391
|
+
// for handing to a fresh agent. Includes user prompts, agent replies,
|
|
1392
|
+
// and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
|
|
1393
|
+
// switches don't accumulate banners) and other update kinds we don't
|
|
1394
|
+
// think the next agent benefits from re-seeing (plans, thoughts,
|
|
1395
|
+
// mode/model/usage).
|
|
1396
|
+
//
|
|
1397
|
+
// The header text defaults to the agent-swap framing; callers like
|
|
1398
|
+
// seedFromImport pass a custom header when the new agent is taking
|
|
1399
|
+
// over an imported session rather than swapping mid-conversation.
|
|
1400
|
+
async buildSwitchTranscript(prevAgentId, headerOverride) {
|
|
1303
1401
|
const lines = [];
|
|
1304
|
-
|
|
1402
|
+
const history = await this.getHistorySnapshot();
|
|
1403
|
+
for (const note of history) {
|
|
1305
1404
|
if (note.method !== "session/update") {
|
|
1306
1405
|
continue;
|
|
1307
1406
|
}
|
|
@@ -1355,29 +1454,53 @@ var init_session = __esm({
|
|
|
1355
1454
|
if (current) {
|
|
1356
1455
|
coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
|
|
1357
1456
|
}
|
|
1457
|
+
const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
|
|
1458
|
+
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.`;
|
|
1358
1459
|
return [
|
|
1359
|
-
|
|
1360
|
-
|
|
1460
|
+
intro,
|
|
1461
|
+
followup,
|
|
1361
1462
|
"",
|
|
1362
1463
|
"--- begin transcript ---",
|
|
1363
1464
|
...coalesced,
|
|
1364
1465
|
"--- end transcript ---"
|
|
1365
1466
|
].join("\n");
|
|
1366
1467
|
}
|
|
1468
|
+
// Replay the persisted history into a freshly-spawned agent so an
|
|
1469
|
+
// imported session has context. Called by SessionManager.doResurrect
|
|
1470
|
+
// on the first wake-up of a session whose meta.json has an empty
|
|
1471
|
+
// upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
|
|
1472
|
+
// any user prompts arriving mid-seed queue behind it (mirrors the
|
|
1473
|
+
// /hydra agent path so the agent isn't asked to respond to a user
|
|
1474
|
+
// turn before it has absorbed the imported transcript). Best-effort:
|
|
1475
|
+
// if the agent fails to absorb the transcript we still leave the
|
|
1476
|
+
// session usable — the user just continues without context.
|
|
1477
|
+
async seedFromImport() {
|
|
1478
|
+
await this.enqueuePrompt(async () => {
|
|
1479
|
+
const transcript = await this.buildSwitchTranscript(this.agentId, {
|
|
1480
|
+
intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
|
|
1481
|
+
followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
|
|
1482
|
+
});
|
|
1483
|
+
if (!transcript) {
|
|
1484
|
+
return void 0;
|
|
1485
|
+
}
|
|
1486
|
+
await this.runInternalPrompt(transcript).catch(() => void 0);
|
|
1487
|
+
return void 0;
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1367
1490
|
// Tell every attached client (a) the agent identity has changed
|
|
1368
|
-
// (session_info_update
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1491
|
+
// (session_info_update carrying agentId inside _meta["hydra-acp"] —
|
|
1492
|
+
// the ACP schema for session_info_update is just title/updatedAt/_meta,
|
|
1493
|
+
// so non-hydra clients harmlessly ignore the extension; hydra-aware
|
|
1494
|
+
// ones read it and relabel) and (b) drop a visible banner into the
|
|
1495
|
+
// transcript so users see the switch rather than just suddenly getting
|
|
1496
|
+
// answers from a different agent. Both updates carry synthetic=true
|
|
1497
|
+
// so a future /hydra agent's transcript builder filters them out.
|
|
1374
1498
|
broadcastAgentSwitch(oldAgentId, newAgentId) {
|
|
1375
1499
|
this.recordAndBroadcast("session/update", {
|
|
1376
1500
|
sessionId: this.sessionId,
|
|
1377
1501
|
update: {
|
|
1378
1502
|
sessionUpdate: "session_info_update",
|
|
1379
|
-
agentId: newAgentId
|
|
1380
|
-
_meta: { "hydra-acp": { synthetic: true } }
|
|
1503
|
+
_meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
|
|
1381
1504
|
}
|
|
1382
1505
|
});
|
|
1383
1506
|
this.recordAndBroadcast("session/update", {
|
|
@@ -1408,22 +1531,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1408
1531
|
handler(opts);
|
|
1409
1532
|
}
|
|
1410
1533
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1534
|
+
// Last meaningful activity timestamp. Bumped only by recordable
|
|
1535
|
+
// broadcasts in recordAndBroadcast — the same signal historyMtimeIso
|
|
1536
|
+
// uses for the picker. Initialized at construction (and seeded from
|
|
1537
|
+
// the newest entry on resurrect) so the inactivity window starts
|
|
1538
|
+
// ticking from a sensible floor when there's no history yet.
|
|
1539
|
+
get lastActivityAt() {
|
|
1540
|
+
return this.lastRecordedAt;
|
|
1541
|
+
}
|
|
1542
|
+
// (Re-)arm the idle timer to fire when the inactivity window
|
|
1543
|
+
// elapses past lastActivityAt. Called once at construction and after
|
|
1544
|
+
// every recorded broadcast. The previous design gated on
|
|
1545
|
+
// clients.size === 0; we drop that gate because extensions
|
|
1546
|
+
// (slack/notifier/approver/browser) hold persistent attaches that
|
|
1547
|
+
// would otherwise keep a quiet session alive forever.
|
|
1548
|
+
scheduleIdleCheck() {
|
|
1549
|
+
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
1413
1550
|
return;
|
|
1414
1551
|
}
|
|
1552
|
+
const dueAt = this.lastActivityAt + this.idleTimeoutMs;
|
|
1553
|
+
this.armIdleTimer(Math.max(0, dueAt - Date.now()));
|
|
1554
|
+
}
|
|
1555
|
+
armIdleTimer(delay) {
|
|
1415
1556
|
if (this.idleTimer) {
|
|
1416
|
-
|
|
1557
|
+
clearTimeout(this.idleTimer);
|
|
1417
1558
|
}
|
|
1418
1559
|
this.idleTimer = setTimeout(() => {
|
|
1419
1560
|
this.idleTimer = void 0;
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
}, this.idleTimeoutMs);
|
|
1561
|
+
this.checkIdle();
|
|
1562
|
+
}, delay);
|
|
1423
1563
|
if (typeof this.idleTimer.unref === "function") {
|
|
1424
1564
|
this.idleTimer.unref();
|
|
1425
1565
|
}
|
|
1426
1566
|
}
|
|
1567
|
+
checkIdle() {
|
|
1568
|
+
if (this.closed || this.idleTimeoutMs <= 0) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
|
|
1572
|
+
this.armIdleTimer(this.idleTimeoutMs);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
const idle = Date.now() - this.lastActivityAt;
|
|
1576
|
+
if (idle < this.idleTimeoutMs) {
|
|
1577
|
+
this.armIdleTimer(this.idleTimeoutMs - idle);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
|
|
1581
|
+
void this.close(opts).catch(() => void 0);
|
|
1582
|
+
}
|
|
1427
1583
|
cancelIdleTimer() {
|
|
1428
1584
|
if (this.idleTimer) {
|
|
1429
1585
|
clearTimeout(this.idleTimer);
|
|
@@ -1448,17 +1604,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1448
1604
|
params: rewritten,
|
|
1449
1605
|
recordedAt: Date.now()
|
|
1450
1606
|
};
|
|
1451
|
-
this.
|
|
1452
|
-
|
|
1453
|
-
if (this.history.length > 1e3) {
|
|
1454
|
-
this.history = this.history.slice(-500);
|
|
1455
|
-
trimmed = true;
|
|
1456
|
-
}
|
|
1607
|
+
this.lastRecordedAt = entry.recordedAt;
|
|
1608
|
+
this.appendCount += 1;
|
|
1457
1609
|
if (this.historyStore) {
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1610
|
+
const store = this.historyStore;
|
|
1611
|
+
void store.append(this.sessionId, entry).catch(() => void 0);
|
|
1612
|
+
if (this.appendCount >= COMPACT_EVERY) {
|
|
1613
|
+
this.appendCount = 0;
|
|
1614
|
+
void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
|
|
1462
1615
|
() => void 0
|
|
1463
1616
|
);
|
|
1464
1617
|
}
|
|
@@ -1469,6 +1622,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1469
1622
|
} catch {
|
|
1470
1623
|
}
|
|
1471
1624
|
}
|
|
1625
|
+
this.scheduleIdleCheck();
|
|
1472
1626
|
}
|
|
1473
1627
|
this.updatedAt = Date.now();
|
|
1474
1628
|
for (const client of this.clients.values()) {
|
|
@@ -1487,7 +1641,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1487
1641
|
);
|
|
1488
1642
|
}
|
|
1489
1643
|
const clientParams = this.rewriteForClient(params);
|
|
1490
|
-
return new Promise((
|
|
1644
|
+
return new Promise((resolve5, reject) => {
|
|
1491
1645
|
let settled = false;
|
|
1492
1646
|
const outbound = [];
|
|
1493
1647
|
const entry = { addClient: sendTo };
|
|
@@ -1522,7 +1676,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1522
1676
|
result
|
|
1523
1677
|
}).catch(() => void 0);
|
|
1524
1678
|
}
|
|
1525
|
-
|
|
1679
|
+
resolve5(result);
|
|
1526
1680
|
});
|
|
1527
1681
|
}).catch((err) => {
|
|
1528
1682
|
settle(() => reject(err));
|
|
@@ -1534,16 +1688,16 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1534
1688
|
});
|
|
1535
1689
|
}
|
|
1536
1690
|
async enqueuePrompt(task) {
|
|
1537
|
-
return new Promise((
|
|
1538
|
-
const
|
|
1691
|
+
return new Promise((resolve5, reject) => {
|
|
1692
|
+
const run2 = async () => {
|
|
1539
1693
|
try {
|
|
1540
1694
|
const result = await task();
|
|
1541
|
-
|
|
1695
|
+
resolve5(result);
|
|
1542
1696
|
} catch (err) {
|
|
1543
1697
|
reject(err);
|
|
1544
1698
|
}
|
|
1545
1699
|
};
|
|
1546
|
-
this.promptQueue.push(
|
|
1700
|
+
this.promptQueue.push(run2);
|
|
1547
1701
|
void this.drainQueue();
|
|
1548
1702
|
});
|
|
1549
1703
|
}
|
|
@@ -1568,11 +1722,70 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
1568
1722
|
"session_info_update",
|
|
1569
1723
|
"current_model_update",
|
|
1570
1724
|
"current_mode_update",
|
|
1571
|
-
"available_commands_update"
|
|
1725
|
+
"available_commands_update",
|
|
1726
|
+
"usage_update"
|
|
1572
1727
|
]);
|
|
1573
1728
|
}
|
|
1574
1729
|
});
|
|
1575
1730
|
|
|
1731
|
+
// src/tui/history.ts
|
|
1732
|
+
import { promises as fs7 } from "fs";
|
|
1733
|
+
import * as path4 from "path";
|
|
1734
|
+
async function loadHistory(file) {
|
|
1735
|
+
let text;
|
|
1736
|
+
try {
|
|
1737
|
+
text = await fs7.readFile(file, "utf8");
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
if (err.code === "ENOENT") {
|
|
1740
|
+
return [];
|
|
1741
|
+
}
|
|
1742
|
+
throw err;
|
|
1743
|
+
}
|
|
1744
|
+
return parseHistory(text);
|
|
1745
|
+
}
|
|
1746
|
+
function parseHistory(text) {
|
|
1747
|
+
const out = [];
|
|
1748
|
+
for (const rawLine of text.split("\n")) {
|
|
1749
|
+
if (rawLine.length === 0) {
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
try {
|
|
1753
|
+
const decoded = JSON.parse(rawLine);
|
|
1754
|
+
if (typeof decoded === "string") {
|
|
1755
|
+
out.push(decoded);
|
|
1756
|
+
}
|
|
1757
|
+
} catch {
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return out;
|
|
1761
|
+
}
|
|
1762
|
+
function appendEntry(history, entry) {
|
|
1763
|
+
const trimmed = entry.replace(/\n+$/, "");
|
|
1764
|
+
if (trimmed.length === 0) {
|
|
1765
|
+
return history;
|
|
1766
|
+
}
|
|
1767
|
+
if (history.length > 0 && history[history.length - 1] === trimmed) {
|
|
1768
|
+
return history;
|
|
1769
|
+
}
|
|
1770
|
+
const out = history.concat(trimmed);
|
|
1771
|
+
if (out.length > HISTORY_CAP) {
|
|
1772
|
+
return out.slice(out.length - HISTORY_CAP);
|
|
1773
|
+
}
|
|
1774
|
+
return out;
|
|
1775
|
+
}
|
|
1776
|
+
async function saveHistory(file, history) {
|
|
1777
|
+
await fs7.mkdir(path4.dirname(file), { recursive: true });
|
|
1778
|
+
const lines = history.map((entry) => JSON.stringify(entry));
|
|
1779
|
+
await fs7.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
|
|
1780
|
+
}
|
|
1781
|
+
var HISTORY_CAP;
|
|
1782
|
+
var init_history = __esm({
|
|
1783
|
+
"src/tui/history.ts"() {
|
|
1784
|
+
"use strict";
|
|
1785
|
+
HISTORY_CAP = 500;
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1576
1789
|
// src/acp/ws-stream.ts
|
|
1577
1790
|
function wsToMessageStream(ws) {
|
|
1578
1791
|
const messageHandlers = [];
|
|
@@ -1618,13 +1831,13 @@ function wsToMessageStream(ws) {
|
|
|
1618
1831
|
throw new Error("ws is closed");
|
|
1619
1832
|
}
|
|
1620
1833
|
const text = JSON.stringify(message);
|
|
1621
|
-
await new Promise((
|
|
1834
|
+
await new Promise((resolve5, reject) => {
|
|
1622
1835
|
ws.send(text, (err) => {
|
|
1623
1836
|
if (err) {
|
|
1624
1837
|
reject(err);
|
|
1625
1838
|
return;
|
|
1626
1839
|
}
|
|
1627
|
-
|
|
1840
|
+
resolve5();
|
|
1628
1841
|
});
|
|
1629
1842
|
});
|
|
1630
1843
|
},
|
|
@@ -1651,7 +1864,7 @@ var init_ws_stream = __esm({
|
|
|
1651
1864
|
});
|
|
1652
1865
|
|
|
1653
1866
|
// src/core/daemon-bootstrap.ts
|
|
1654
|
-
import { spawn as
|
|
1867
|
+
import { spawn as spawn4 } from "child_process";
|
|
1655
1868
|
import { setTimeout as sleep } from "timers/promises";
|
|
1656
1869
|
async function ensureDaemonReachable(config) {
|
|
1657
1870
|
if (await pingHealth(config)) {
|
|
@@ -1678,11 +1891,15 @@ function spawnDaemonDetached() {
|
|
|
1678
1891
|
if (!cliPath) {
|
|
1679
1892
|
throw new Error("Cannot determine hydra-acp binary path to spawn daemon");
|
|
1680
1893
|
}
|
|
1681
|
-
const child =
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1894
|
+
const child = spawn4(
|
|
1895
|
+
process.execPath,
|
|
1896
|
+
[cliPath, "daemon", "start", "--foreground"],
|
|
1897
|
+
{
|
|
1898
|
+
detached: true,
|
|
1899
|
+
stdio: "ignore",
|
|
1900
|
+
env: process.env
|
|
1901
|
+
}
|
|
1902
|
+
);
|
|
1686
1903
|
child.unref();
|
|
1687
1904
|
}
|
|
1688
1905
|
async function waitForDaemonReady(config, timeoutMs = 15e3) {
|
|
@@ -1703,25 +1920,80 @@ var init_daemon_bootstrap = __esm({
|
|
|
1703
1920
|
}
|
|
1704
1921
|
});
|
|
1705
1922
|
|
|
1923
|
+
// src/core/agent-display.ts
|
|
1924
|
+
function shortenModel(model) {
|
|
1925
|
+
if (!model) {
|
|
1926
|
+
return void 0;
|
|
1927
|
+
}
|
|
1928
|
+
const idx = model.lastIndexOf("/");
|
|
1929
|
+
if (idx === -1) {
|
|
1930
|
+
return model;
|
|
1931
|
+
}
|
|
1932
|
+
return model.slice(idx + 1);
|
|
1933
|
+
}
|
|
1934
|
+
function formatAgentWithModel(agentId, model) {
|
|
1935
|
+
const agent = agentId ?? "?";
|
|
1936
|
+
const short = shortenModel(model);
|
|
1937
|
+
if (!short) {
|
|
1938
|
+
return agent;
|
|
1939
|
+
}
|
|
1940
|
+
return `${agent}${AGENT_MODEL_SEP}${short}`;
|
|
1941
|
+
}
|
|
1942
|
+
function formatAgentCell(agentId, model, usage) {
|
|
1943
|
+
const base = formatAgentWithModel(agentId, model);
|
|
1944
|
+
if (!usage || typeof usage.costAmount !== "number") {
|
|
1945
|
+
return base;
|
|
1946
|
+
}
|
|
1947
|
+
const compact = formatCostCompact(usage.costAmount, usage.costCurrency);
|
|
1948
|
+
if (compact === null) {
|
|
1949
|
+
return base;
|
|
1950
|
+
}
|
|
1951
|
+
return `${base} ${compact}`;
|
|
1952
|
+
}
|
|
1953
|
+
function formatCost(amount, currency) {
|
|
1954
|
+
const sign = currency === "USD" || currency === void 0 ? "$" : "";
|
|
1955
|
+
const decimals = amount >= 1 ? 2 : 4;
|
|
1956
|
+
return `${sign}${amount.toFixed(decimals)}${currency && currency !== "USD" ? ` ${currency}` : ""}`;
|
|
1957
|
+
}
|
|
1958
|
+
function formatCostCompact(amount, currency) {
|
|
1959
|
+
const whole = Math.round(amount);
|
|
1960
|
+
if (whole === 0) {
|
|
1961
|
+
return null;
|
|
1962
|
+
}
|
|
1963
|
+
const sign = currency === "USD" || currency === void 0 ? "$" : "";
|
|
1964
|
+
return `${sign}${whole}${currency && currency !== "USD" ? ` ${currency}` : ""}`;
|
|
1965
|
+
}
|
|
1966
|
+
var AGENT_MODEL_SEP;
|
|
1967
|
+
var init_agent_display = __esm({
|
|
1968
|
+
"src/core/agent-display.ts"() {
|
|
1969
|
+
"use strict";
|
|
1970
|
+
AGENT_MODEL_SEP = "\u2022";
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1706
1974
|
// src/cli/session-row.ts
|
|
1707
1975
|
function toRow(s, now = Date.now()) {
|
|
1708
1976
|
return {
|
|
1709
1977
|
session: stripHydraSessionPrefix(s.sessionId),
|
|
1710
1978
|
upstream: s.upstreamSessionId ?? "-",
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
agent: s.agentId ?? "?",
|
|
1979
|
+
state: formatState(s.status, s.attachedClients),
|
|
1980
|
+
agent: formatAgentCell(s.agentId, s.currentModel, s.currentUsage),
|
|
1714
1981
|
age: formatRelativeAge(s.updatedAt, now),
|
|
1715
1982
|
title: s.title ?? "-",
|
|
1716
1983
|
cwd: s.cwd
|
|
1717
1984
|
};
|
|
1718
1985
|
}
|
|
1986
|
+
function formatState(status, clients) {
|
|
1987
|
+
if (status === "cold") {
|
|
1988
|
+
return "COLD";
|
|
1989
|
+
}
|
|
1990
|
+
return `LIVE(${clients})`;
|
|
1991
|
+
}
|
|
1719
1992
|
function computeWidths(rows) {
|
|
1720
1993
|
return {
|
|
1721
1994
|
session: maxLen(HEADER.session, rows.map((r) => r.session)),
|
|
1722
1995
|
upstream: maxLen(HEADER.upstream, rows.map((r) => r.upstream)),
|
|
1723
|
-
|
|
1724
|
-
clients: maxLen(HEADER.clients, rows.map((r) => r.clients)),
|
|
1996
|
+
state: maxLen(HEADER.state, rows.map((r) => r.state)),
|
|
1725
1997
|
agent: maxLen(HEADER.agent, rows.map((r) => r.agent)),
|
|
1726
1998
|
age: maxLen(HEADER.age, rows.map((r) => r.age)),
|
|
1727
1999
|
title: maxLen(HEADER.title, rows.map((r) => r.title))
|
|
@@ -1776,8 +2048,7 @@ function formatRow(r, w, maxWidth) {
|
|
|
1776
2048
|
const fixed = [
|
|
1777
2049
|
r.session.padEnd(w.session),
|
|
1778
2050
|
r.upstream.padEnd(w.upstream),
|
|
1779
|
-
r.
|
|
1780
|
-
r.clients.padStart(w.clients),
|
|
2051
|
+
r.state.padEnd(w.state),
|
|
1781
2052
|
r.agent.padEnd(w.agent),
|
|
1782
2053
|
r.age.padStart(w.age)
|
|
1783
2054
|
].join(SEP);
|
|
@@ -1827,12 +2098,12 @@ var HEADER, SEP, MIN_CWD, TITLE_MAX_WIDTH;
|
|
|
1827
2098
|
var init_session_row = __esm({
|
|
1828
2099
|
"src/cli/session-row.ts"() {
|
|
1829
2100
|
"use strict";
|
|
2101
|
+
init_agent_display();
|
|
1830
2102
|
init_session();
|
|
1831
2103
|
HEADER = {
|
|
1832
2104
|
session: "SESSION",
|
|
1833
2105
|
upstream: "UPSTREAM",
|
|
1834
|
-
|
|
1835
|
-
clients: "CLIENTS",
|
|
2106
|
+
state: "STATE",
|
|
1836
2107
|
agent: "AGENT",
|
|
1837
2108
|
age: "AGE",
|
|
1838
2109
|
title: "TITLE",
|
|
@@ -1845,6 +2116,8 @@ var init_session_row = __esm({
|
|
|
1845
2116
|
});
|
|
1846
2117
|
|
|
1847
2118
|
// src/cli/commands/sessions.ts
|
|
2119
|
+
import * as fs12 from "fs/promises";
|
|
2120
|
+
import * as path6 from "path";
|
|
1848
2121
|
async function runSessionsList(opts = {}) {
|
|
1849
2122
|
const config = await loadConfig();
|
|
1850
2123
|
const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
|
|
@@ -1933,6 +2206,111 @@ async function runSessionsRm(id) {
|
|
|
1933
2206
|
process.stdout.write(`Removed ${id}
|
|
1934
2207
|
`);
|
|
1935
2208
|
}
|
|
2209
|
+
async function runSessionsExport(id, outPath) {
|
|
2210
|
+
if (!id) {
|
|
2211
|
+
process.stderr.write(
|
|
2212
|
+
"Usage: hydra-acp sessions export <session-id> [--out <file>]\n"
|
|
2213
|
+
);
|
|
2214
|
+
process.exit(2);
|
|
2215
|
+
}
|
|
2216
|
+
const config = await loadConfig();
|
|
2217
|
+
const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
|
|
2218
|
+
const response = await fetch(
|
|
2219
|
+
`${baseUrl}/v1/sessions/${encodeURIComponent(id)}/export`,
|
|
2220
|
+
{
|
|
2221
|
+
headers: { Authorization: `Bearer ${config.daemon.authToken}` }
|
|
2222
|
+
}
|
|
2223
|
+
);
|
|
2224
|
+
if (!response.ok) {
|
|
2225
|
+
const text = await response.text().catch(() => "");
|
|
2226
|
+
process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
|
|
2227
|
+
`);
|
|
2228
|
+
process.exit(1);
|
|
2229
|
+
}
|
|
2230
|
+
const body = await response.text();
|
|
2231
|
+
if (!outPath) {
|
|
2232
|
+
process.stdout.write(body);
|
|
2233
|
+
if (!body.endsWith("\n")) {
|
|
2234
|
+
process.stdout.write("\n");
|
|
2235
|
+
}
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
|
|
2239
|
+
await fs12.mkdir(path6.dirname(path6.resolve(resolved)), { recursive: true });
|
|
2240
|
+
await fs12.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
|
|
2241
|
+
process.stdout.write(`Wrote ${resolved}
|
|
2242
|
+
`);
|
|
2243
|
+
}
|
|
2244
|
+
async function runSessionsImport(file, opts = {}) {
|
|
2245
|
+
if (!file) {
|
|
2246
|
+
process.stderr.write(
|
|
2247
|
+
"Usage: hydra-acp sessions import <file>|- [--replace]\n"
|
|
2248
|
+
);
|
|
2249
|
+
process.exit(2);
|
|
2250
|
+
}
|
|
2251
|
+
let body;
|
|
2252
|
+
if (file === "-") {
|
|
2253
|
+
body = await readStdin();
|
|
2254
|
+
} else {
|
|
2255
|
+
body = await fs12.readFile(file, "utf8");
|
|
2256
|
+
}
|
|
2257
|
+
let bundle;
|
|
2258
|
+
try {
|
|
2259
|
+
bundle = JSON.parse(body);
|
|
2260
|
+
} catch (err) {
|
|
2261
|
+
process.stderr.write(`Failed to parse bundle: ${err.message}
|
|
2262
|
+
`);
|
|
2263
|
+
process.exit(1);
|
|
2264
|
+
}
|
|
2265
|
+
const config = await loadConfig();
|
|
2266
|
+
const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
|
|
2267
|
+
const response = await fetch(`${baseUrl}/v1/sessions/import`, {
|
|
2268
|
+
method: "POST",
|
|
2269
|
+
headers: {
|
|
2270
|
+
"Content-Type": "application/json",
|
|
2271
|
+
Authorization: `Bearer ${config.daemon.authToken}`
|
|
2272
|
+
},
|
|
2273
|
+
body: JSON.stringify({ bundle, replace: opts.replace === true })
|
|
2274
|
+
});
|
|
2275
|
+
if (response.status === 409) {
|
|
2276
|
+
const detail = await response.json().catch(() => ({}));
|
|
2277
|
+
process.stderr.write(
|
|
2278
|
+
`Bundle already imported as ${detail.existingSessionId ?? "unknown"}. Use --replace to overwrite.
|
|
2279
|
+
`
|
|
2280
|
+
);
|
|
2281
|
+
process.exit(1);
|
|
2282
|
+
}
|
|
2283
|
+
if (!response.ok) {
|
|
2284
|
+
const text = await response.text().catch(() => "");
|
|
2285
|
+
process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
|
|
2286
|
+
`);
|
|
2287
|
+
process.exit(1);
|
|
2288
|
+
}
|
|
2289
|
+
const result = await response.json();
|
|
2290
|
+
process.stdout.write(
|
|
2291
|
+
result.replaced ? `Replaced ${result.sessionId} (from ${result.importedFromSessionId})
|
|
2292
|
+
` : `Imported as ${result.sessionId} (from ${result.importedFromSessionId})
|
|
2293
|
+
`
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
async function readStdin() {
|
|
2297
|
+
const chunks = [];
|
|
2298
|
+
for await (const chunk of process.stdin) {
|
|
2299
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
2300
|
+
}
|
|
2301
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2302
|
+
}
|
|
2303
|
+
function deriveFilenameFrom(response, id) {
|
|
2304
|
+
const cd = response.headers.get("content-disposition");
|
|
2305
|
+
if (cd) {
|
|
2306
|
+
const match = cd.match(/filename="([^"]+)"/);
|
|
2307
|
+
if (match) {
|
|
2308
|
+
return match[1];
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2312
|
+
return `hydra-${id}-${stamp}.hydra`;
|
|
2313
|
+
}
|
|
1936
2314
|
function httpBase(host, port, tls) {
|
|
1937
2315
|
const protocol = tls ? "https" : "http";
|
|
1938
2316
|
return `${protocol}://${host}:${port}`;
|
|
@@ -1952,11 +2330,11 @@ function isResponse(msg) {
|
|
|
1952
2330
|
return !("method" in msg) && "id" in msg && msg.id !== void 0;
|
|
1953
2331
|
}
|
|
1954
2332
|
async function openWs(url, subprotocols) {
|
|
1955
|
-
return new Promise((
|
|
2333
|
+
return new Promise((resolve5, reject) => {
|
|
1956
2334
|
const ws = new WebSocket(url, subprotocols);
|
|
1957
2335
|
const onOpen = () => {
|
|
1958
2336
|
ws.off("error", onError);
|
|
1959
|
-
|
|
2337
|
+
resolve5(wsToMessageStream(ws));
|
|
1960
2338
|
};
|
|
1961
2339
|
const onError = (err) => {
|
|
1962
2340
|
ws.off("open", onOpen);
|
|
@@ -2027,8 +2405,8 @@ var init_resilient_ws = __esm({
|
|
|
2027
2405
|
throw new Error("resilient ws stream not connected");
|
|
2028
2406
|
}
|
|
2029
2407
|
const id = message.id;
|
|
2030
|
-
const promise = new Promise((
|
|
2031
|
-
this.pendingRequests.set(id, { resolve:
|
|
2408
|
+
const promise = new Promise((resolve5, reject) => {
|
|
2409
|
+
this.pendingRequests.set(id, { resolve: resolve5, reject });
|
|
2032
2410
|
});
|
|
2033
2411
|
try {
|
|
2034
2412
|
await this.current.send(message);
|
|
@@ -2056,8 +2434,8 @@ var init_resilient_ws = __esm({
|
|
|
2056
2434
|
this.bindStream(stream);
|
|
2057
2435
|
const wasFirst = this.firstConnect;
|
|
2058
2436
|
this.firstConnect = false;
|
|
2059
|
-
this.connectGate = new Promise((
|
|
2060
|
-
this.releaseConnectGate =
|
|
2437
|
+
this.connectGate = new Promise((resolve5) => {
|
|
2438
|
+
this.releaseConnectGate = resolve5;
|
|
2061
2439
|
});
|
|
2062
2440
|
try {
|
|
2063
2441
|
if (this.opts.onConnect) {
|
|
@@ -2180,64 +2558,6 @@ var init_resilient_ws = __esm({
|
|
|
2180
2558
|
}
|
|
2181
2559
|
});
|
|
2182
2560
|
|
|
2183
|
-
// src/tui/history.ts
|
|
2184
|
-
import { promises as fs10 } from "fs";
|
|
2185
|
-
import * as path4 from "path";
|
|
2186
|
-
async function loadHistory(file) {
|
|
2187
|
-
let text;
|
|
2188
|
-
try {
|
|
2189
|
-
text = await fs10.readFile(file, "utf8");
|
|
2190
|
-
} catch (err) {
|
|
2191
|
-
if (err.code === "ENOENT") {
|
|
2192
|
-
return [];
|
|
2193
|
-
}
|
|
2194
|
-
throw err;
|
|
2195
|
-
}
|
|
2196
|
-
return parseHistory(text);
|
|
2197
|
-
}
|
|
2198
|
-
function parseHistory(text) {
|
|
2199
|
-
const out = [];
|
|
2200
|
-
for (const rawLine of text.split("\n")) {
|
|
2201
|
-
if (rawLine.length === 0) {
|
|
2202
|
-
continue;
|
|
2203
|
-
}
|
|
2204
|
-
try {
|
|
2205
|
-
const decoded = JSON.parse(rawLine);
|
|
2206
|
-
if (typeof decoded === "string") {
|
|
2207
|
-
out.push(decoded);
|
|
2208
|
-
}
|
|
2209
|
-
} catch {
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
return out;
|
|
2213
|
-
}
|
|
2214
|
-
function appendEntry(history, entry) {
|
|
2215
|
-
const trimmed = entry.replace(/\n+$/, "");
|
|
2216
|
-
if (trimmed.length === 0) {
|
|
2217
|
-
return history;
|
|
2218
|
-
}
|
|
2219
|
-
if (history.length > 0 && history[history.length - 1] === trimmed) {
|
|
2220
|
-
return history;
|
|
2221
|
-
}
|
|
2222
|
-
const out = history.concat(trimmed);
|
|
2223
|
-
if (out.length > HISTORY_CAP) {
|
|
2224
|
-
return out.slice(out.length - HISTORY_CAP);
|
|
2225
|
-
}
|
|
2226
|
-
return out;
|
|
2227
|
-
}
|
|
2228
|
-
async function saveHistory(file, history) {
|
|
2229
|
-
await fs10.mkdir(path4.dirname(file), { recursive: true });
|
|
2230
|
-
const lines = history.map((entry) => JSON.stringify(entry));
|
|
2231
|
-
await fs10.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
|
|
2232
|
-
}
|
|
2233
|
-
var HISTORY_CAP;
|
|
2234
|
-
var init_history = __esm({
|
|
2235
|
-
"src/tui/history.ts"() {
|
|
2236
|
-
"use strict";
|
|
2237
|
-
HISTORY_CAP = 500;
|
|
2238
|
-
}
|
|
2239
|
-
});
|
|
2240
|
-
|
|
2241
2561
|
// src/tui/discovery.ts
|
|
2242
2562
|
async function listSessions(config, opts = {}, fetchImpl = fetch) {
|
|
2243
2563
|
const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
|
|
@@ -2266,6 +2586,8 @@ async function listSessions(config, opts = {}, fetchImpl = fetch) {
|
|
|
2266
2586
|
status: s.status ?? "live",
|
|
2267
2587
|
upstreamSessionId: s.upstreamSessionId,
|
|
2268
2588
|
agentId: s.agentId,
|
|
2589
|
+
currentModel: s.currentModel,
|
|
2590
|
+
currentUsage: s.currentUsage,
|
|
2269
2591
|
title: s.title
|
|
2270
2592
|
}));
|
|
2271
2593
|
}
|
|
@@ -2468,7 +2790,7 @@ async function pickSession(term, opts) {
|
|
|
2468
2790
|
};
|
|
2469
2791
|
renderFromScratch();
|
|
2470
2792
|
term.hideCursor();
|
|
2471
|
-
return await new Promise((
|
|
2793
|
+
return await new Promise((resolve5) => {
|
|
2472
2794
|
let resolved = false;
|
|
2473
2795
|
const onResize = () => {
|
|
2474
2796
|
if (resolved) {
|
|
@@ -2590,6 +2912,11 @@ async function pickSession(term, opts) {
|
|
|
2590
2912
|
}
|
|
2591
2913
|
clearTransient();
|
|
2592
2914
|
if (data?.isCharacter) {
|
|
2915
|
+
if (name === "r" || name === "R") {
|
|
2916
|
+
const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
|
|
2917
|
+
void refresh(currentId);
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2593
2920
|
if ((name === "k" || name === "K") && selectedIdx > 0) {
|
|
2594
2921
|
const session = visible[selectedIdx - 1];
|
|
2595
2922
|
if (!session) {
|
|
@@ -2650,12 +2977,12 @@ async function pickSession(term, opts) {
|
|
|
2650
2977
|
case "KP_ENTER": {
|
|
2651
2978
|
cleanup();
|
|
2652
2979
|
if (selectedIdx === 0) {
|
|
2653
|
-
|
|
2980
|
+
resolve5({ kind: "new" });
|
|
2654
2981
|
return;
|
|
2655
2982
|
}
|
|
2656
2983
|
const session = visible[selectedIdx - 1];
|
|
2657
2984
|
if (!session) {
|
|
2658
|
-
|
|
2985
|
+
resolve5({ kind: "abort" });
|
|
2659
2986
|
return;
|
|
2660
2987
|
}
|
|
2661
2988
|
const result = {
|
|
@@ -2665,13 +2992,13 @@ async function pickSession(term, opts) {
|
|
|
2665
2992
|
if (session.agentId !== void 0) {
|
|
2666
2993
|
result.agentId = session.agentId;
|
|
2667
2994
|
}
|
|
2668
|
-
|
|
2995
|
+
resolve5(result);
|
|
2669
2996
|
return;
|
|
2670
2997
|
}
|
|
2671
2998
|
case "ESCAPE":
|
|
2672
2999
|
case "CTRL_C":
|
|
2673
3000
|
cleanup();
|
|
2674
|
-
|
|
3001
|
+
resolve5({ kind: "abort" });
|
|
2675
3002
|
return;
|
|
2676
3003
|
}
|
|
2677
3004
|
};
|
|
@@ -2703,6 +3030,7 @@ var init_picker = __esm({
|
|
|
2703
3030
|
});
|
|
2704
3031
|
|
|
2705
3032
|
// src/tui/screen.ts
|
|
3033
|
+
import os3 from "os";
|
|
2706
3034
|
import stringWidth from "string-width";
|
|
2707
3035
|
import wrapAnsi from "wrap-ansi";
|
|
2708
3036
|
function formattedLineSig(zone, width, line) {
|
|
@@ -2853,6 +3181,12 @@ function wrap(text, width) {
|
|
|
2853
3181
|
if (text.length === 0) {
|
|
2854
3182
|
return [""];
|
|
2855
3183
|
}
|
|
3184
|
+
if (!NON_ASCII.test(text)) {
|
|
3185
|
+
return wrapAscii(text, width);
|
|
3186
|
+
}
|
|
3187
|
+
return wrapVisible(text, width);
|
|
3188
|
+
}
|
|
3189
|
+
function wrapAscii(text, width) {
|
|
2856
3190
|
const out = [];
|
|
2857
3191
|
let remaining = text;
|
|
2858
3192
|
while (remaining.length > width) {
|
|
@@ -2875,14 +3209,91 @@ function wrap(text, width) {
|
|
|
2875
3209
|
out.push(remaining);
|
|
2876
3210
|
return out;
|
|
2877
3211
|
}
|
|
3212
|
+
function wrapVisible(text, width) {
|
|
3213
|
+
const out = [];
|
|
3214
|
+
const graphemes = [];
|
|
3215
|
+
for (const { segment } of SEGMENTER.segment(text)) {
|
|
3216
|
+
graphemes.push({ seg: segment, w: stringWidth(segment) });
|
|
3217
|
+
}
|
|
3218
|
+
let i = 0;
|
|
3219
|
+
while (i < graphemes.length) {
|
|
3220
|
+
let chunk = "";
|
|
3221
|
+
let chunkW = 0;
|
|
3222
|
+
let lastSpaceI = -1;
|
|
3223
|
+
let chunkAtLastSpace = "";
|
|
3224
|
+
while (i < graphemes.length) {
|
|
3225
|
+
const g = graphemes[i];
|
|
3226
|
+
if (chunkW + g.w > width) {
|
|
3227
|
+
break;
|
|
3228
|
+
}
|
|
3229
|
+
if (g.seg === " ") {
|
|
3230
|
+
lastSpaceI = i;
|
|
3231
|
+
chunkAtLastSpace = chunk;
|
|
3232
|
+
}
|
|
3233
|
+
chunk += g.seg;
|
|
3234
|
+
chunkW += g.w;
|
|
3235
|
+
i += 1;
|
|
3236
|
+
}
|
|
3237
|
+
if (i >= graphemes.length) {
|
|
3238
|
+
out.push(chunk);
|
|
3239
|
+
break;
|
|
3240
|
+
}
|
|
3241
|
+
if (lastSpaceI >= 0) {
|
|
3242
|
+
out.push(chunkAtLastSpace);
|
|
3243
|
+
i = lastSpaceI + 1;
|
|
3244
|
+
} else if (chunk.length === 0) {
|
|
3245
|
+
out.push(graphemes[i].seg);
|
|
3246
|
+
i += 1;
|
|
3247
|
+
} else {
|
|
3248
|
+
out.push(chunk);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
return out;
|
|
3252
|
+
}
|
|
3253
|
+
function shortenHomePath(p) {
|
|
3254
|
+
const home = os3.homedir();
|
|
3255
|
+
if (!home) {
|
|
3256
|
+
return p;
|
|
3257
|
+
}
|
|
3258
|
+
if (p === home) {
|
|
3259
|
+
return "~";
|
|
3260
|
+
}
|
|
3261
|
+
if (p.startsWith(home + "/")) {
|
|
3262
|
+
return "~" + p.slice(home.length);
|
|
3263
|
+
}
|
|
3264
|
+
return p;
|
|
3265
|
+
}
|
|
2878
3266
|
function truncate(text, max) {
|
|
2879
|
-
if (
|
|
3267
|
+
if (max <= 0) {
|
|
3268
|
+
return "";
|
|
3269
|
+
}
|
|
3270
|
+
if (text.length <= max && !NON_ASCII.test(text)) {
|
|
3271
|
+
return text;
|
|
3272
|
+
}
|
|
3273
|
+
const visible = stringWidth(text);
|
|
3274
|
+
if (visible <= max) {
|
|
2880
3275
|
return text;
|
|
2881
3276
|
}
|
|
2882
3277
|
if (max <= 1) {
|
|
2883
|
-
return text
|
|
3278
|
+
return takeByWidth(text, max);
|
|
3279
|
+
}
|
|
3280
|
+
return takeByWidth(text, max - 1) + "\u2026";
|
|
3281
|
+
}
|
|
3282
|
+
function takeByWidth(text, budget) {
|
|
3283
|
+
if (budget <= 0) {
|
|
3284
|
+
return "";
|
|
3285
|
+
}
|
|
3286
|
+
let out = "";
|
|
3287
|
+
let used = 0;
|
|
3288
|
+
for (const { segment } of SEGMENTER.segment(text)) {
|
|
3289
|
+
const w = stringWidth(segment);
|
|
3290
|
+
if (used + w > budget) {
|
|
3291
|
+
break;
|
|
3292
|
+
}
|
|
3293
|
+
out += segment;
|
|
3294
|
+
used += w;
|
|
2884
3295
|
}
|
|
2885
|
-
return
|
|
3296
|
+
return out;
|
|
2886
3297
|
}
|
|
2887
3298
|
function firstLine2(text) {
|
|
2888
3299
|
const idx = text.indexOf("\n");
|
|
@@ -2930,11 +3341,6 @@ function formatTokens(n) {
|
|
|
2930
3341
|
}
|
|
2931
3342
|
return `${n}`;
|
|
2932
3343
|
}
|
|
2933
|
-
function formatCost(amount, currency) {
|
|
2934
|
-
const sign = currency === "USD" || currency === void 0 ? "$" : "";
|
|
2935
|
-
const decimals = amount >= 1 ? 2 : 4;
|
|
2936
|
-
return `${sign}${amount.toFixed(decimals)}${currency && currency !== "USD" ? ` ${currency}` : ""}`;
|
|
2937
|
-
}
|
|
2938
3344
|
function mapKeyName(name) {
|
|
2939
3345
|
switch (name) {
|
|
2940
3346
|
case "ENTER":
|
|
@@ -2997,10 +3403,11 @@ function mapKeyName(name) {
|
|
|
2997
3403
|
return null;
|
|
2998
3404
|
}
|
|
2999
3405
|
}
|
|
3000
|
-
var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, shortId;
|
|
3406
|
+
var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, shortId;
|
|
3001
3407
|
var init_screen = __esm({
|
|
3002
3408
|
"src/tui/screen.ts"() {
|
|
3003
3409
|
"use strict";
|
|
3410
|
+
init_agent_display();
|
|
3004
3411
|
init_session();
|
|
3005
3412
|
HEADER_ROWS = 2;
|
|
3006
3413
|
BANNER_ROWS = 1;
|
|
@@ -3100,6 +3507,11 @@ var init_screen = __esm({
|
|
|
3100
3507
|
}
|
|
3101
3508
|
this.started = true;
|
|
3102
3509
|
this.term.fullscreen(true);
|
|
3510
|
+
this.lastFrameRows.clear();
|
|
3511
|
+
this.lastFrameW = 0;
|
|
3512
|
+
this.lastFrameH = 0;
|
|
3513
|
+
this.lastWindowTitle = null;
|
|
3514
|
+
process.stdout.write("\x1B[?7l");
|
|
3103
3515
|
this.term.grabInput({ mouse: "button" });
|
|
3104
3516
|
this.term.hideCursor(false);
|
|
3105
3517
|
this.term.on("key", this.keyHandler);
|
|
@@ -3119,6 +3531,7 @@ var init_screen = __esm({
|
|
|
3119
3531
|
this.term.off("resize", this.resizeHandler);
|
|
3120
3532
|
this.term.grabInput(false);
|
|
3121
3533
|
this.term.hideCursor(false);
|
|
3534
|
+
process.stdout.write("\x1B[?7h");
|
|
3122
3535
|
this.term.fullscreen(false);
|
|
3123
3536
|
this.term("\n");
|
|
3124
3537
|
}
|
|
@@ -3430,6 +3843,22 @@ var init_screen = __esm({
|
|
|
3430
3843
|
redraw() {
|
|
3431
3844
|
this.repaint();
|
|
3432
3845
|
}
|
|
3846
|
+
// Forced clean-slate repaint. Drops the per-row signature cache, the
|
|
3847
|
+
// window-title cache, the wrap cache, and clears the terminal before
|
|
3848
|
+
// painting. Wired to ^L so the user can recover when something has
|
|
3849
|
+
// corrupted the visible state and the per-row sig check otherwise
|
|
3850
|
+
// short-circuits the re-emit.
|
|
3851
|
+
fullRedraw() {
|
|
3852
|
+
this.lastFrameRows.clear();
|
|
3853
|
+
this.lastFrameW = 0;
|
|
3854
|
+
this.lastFrameH = 0;
|
|
3855
|
+
this.lastWindowTitle = null;
|
|
3856
|
+
this.wrapCache.clear();
|
|
3857
|
+
this.wrapCacheWidth = 0;
|
|
3858
|
+
process.stdout.write("\x1B[?7l");
|
|
3859
|
+
this.term.clear();
|
|
3860
|
+
this.repaint();
|
|
3861
|
+
}
|
|
3433
3862
|
// While paused, append* methods buffer state but don't repaint. Calls are
|
|
3434
3863
|
// counter-based so they nest safely. Resume triggers exactly one repaint
|
|
3435
3864
|
// if any was requested while paused.
|
|
@@ -3656,22 +4085,23 @@ var init_screen = __esm({
|
|
|
3656
4085
|
const usage = formatUsage(this.header.usage);
|
|
3657
4086
|
const sid = shortId(this.header.sessionId);
|
|
3658
4087
|
const title = this.header.title?.trim();
|
|
3659
|
-
const
|
|
4088
|
+
const agentCell = formatAgentWithModel(this.header.agent, this.header.model);
|
|
4089
|
+
const cwdDisplay = shortenHomePath(this.header.cwd);
|
|
4090
|
+
const sig = `hdr|${w}|${agentCell}|${cwdDisplay}|${sid}|${title ?? ""}|${usage ?? ""}`;
|
|
3660
4091
|
this.paintRow(1, sig, () => {
|
|
3661
|
-
const fixed = "hydra \xB7 ".length +
|
|
4092
|
+
const fixed = "hydra \xB7 ".length + agentCell.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
|
|
3662
4093
|
const variableRoom = Math.max(8, w - fixed);
|
|
3663
4094
|
let cwdRoom;
|
|
3664
4095
|
let titleRoom;
|
|
3665
4096
|
if (title) {
|
|
3666
|
-
const
|
|
3667
|
-
|
|
3668
|
-
titleRoom = Math.
|
|
3669
|
-
cwdRoom = Math.max(8, variableRoom - titleRoom);
|
|
4097
|
+
const titleMin = Math.min(title.length, 8);
|
|
4098
|
+
cwdRoom = Math.min(cwdDisplay.length, Math.max(8, variableRoom - titleMin));
|
|
4099
|
+
titleRoom = Math.max(0, variableRoom - cwdRoom);
|
|
3670
4100
|
} else {
|
|
3671
4101
|
titleRoom = 0;
|
|
3672
4102
|
cwdRoom = variableRoom;
|
|
3673
4103
|
}
|
|
3674
|
-
this.term.bold("hydra")(" \xB7 ").cyan.noFormat(
|
|
4104
|
+
this.term.bold("hydra")(" \xB7 ").cyan.noFormat(agentCell)(" \xB7 ").dim.noFormat(truncate(cwdDisplay, cwdRoom))(" \xB7 ").yellow(sid);
|
|
3675
4105
|
if (title) {
|
|
3676
4106
|
this.term(" \xB7 ").bold.noFormat(truncate(title, titleRoom));
|
|
3677
4107
|
}
|
|
@@ -4083,6 +4513,8 @@ var init_screen = __esm({
|
|
|
4083
4513
|
}
|
|
4084
4514
|
}
|
|
4085
4515
|
};
|
|
4516
|
+
NON_ASCII = /[^\x20-\x7e]/;
|
|
4517
|
+
SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
4086
4518
|
shortId = stripHydraSessionPrefix;
|
|
4087
4519
|
}
|
|
4088
4520
|
});
|
|
@@ -4457,6 +4889,10 @@ var init_input = __esm({
|
|
|
4457
4889
|
});
|
|
4458
4890
|
|
|
4459
4891
|
// src/tui/render-update.ts
|
|
4892
|
+
import stripAnsi from "strip-ansi";
|
|
4893
|
+
function sanitizeWireText(text) {
|
|
4894
|
+
return stripAnsi(text).replace(STRIP_CONTROLS, "");
|
|
4895
|
+
}
|
|
4460
4896
|
function mapUpdate(update) {
|
|
4461
4897
|
if (!update || typeof update !== "object") {
|
|
4462
4898
|
return null;
|
|
@@ -4499,11 +4935,30 @@ function mapUpdate(update) {
|
|
|
4499
4935
|
}
|
|
4500
4936
|
}
|
|
4501
4937
|
function mapSessionInfo(u) {
|
|
4502
|
-
const
|
|
4503
|
-
|
|
4938
|
+
const rawTitle = readString(u, "title");
|
|
4939
|
+
const title = rawTitle !== void 0 ? sanitizeWireText(rawTitle) : void 0;
|
|
4940
|
+
const meta = u._meta;
|
|
4941
|
+
let agentId;
|
|
4942
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
4943
|
+
const ns = meta["hydra-acp"];
|
|
4944
|
+
if (ns && typeof ns === "object" && !Array.isArray(ns)) {
|
|
4945
|
+
const candidate = ns.agentId;
|
|
4946
|
+
if (typeof candidate === "string") {
|
|
4947
|
+
agentId = candidate;
|
|
4948
|
+
}
|
|
4949
|
+
}
|
|
4950
|
+
}
|
|
4951
|
+
if (title === void 0 && agentId === void 0) {
|
|
4504
4952
|
return null;
|
|
4505
4953
|
}
|
|
4506
|
-
|
|
4954
|
+
const event = { kind: "session-info" };
|
|
4955
|
+
if (title !== void 0) {
|
|
4956
|
+
event.title = title;
|
|
4957
|
+
}
|
|
4958
|
+
if (agentId !== void 0) {
|
|
4959
|
+
event.agentId = agentId;
|
|
4960
|
+
}
|
|
4961
|
+
return event;
|
|
4507
4962
|
}
|
|
4508
4963
|
function mapAvailableCommands(u) {
|
|
4509
4964
|
const list = u.availableCommands ?? u.commands;
|
|
@@ -4519,10 +4974,10 @@ function mapAvailableCommands(u) {
|
|
|
4519
4974
|
if (typeof c.name !== "string" || c.name.length === 0) {
|
|
4520
4975
|
continue;
|
|
4521
4976
|
}
|
|
4522
|
-
const
|
|
4523
|
-
const cmd = { name };
|
|
4977
|
+
const rawName = c.name.startsWith("/") ? c.name : `/${c.name}`;
|
|
4978
|
+
const cmd = { name: sanitizeWireText(rawName) };
|
|
4524
4979
|
if (typeof c.description === "string") {
|
|
4525
|
-
cmd.description = c.description;
|
|
4980
|
+
cmd.description = sanitizeWireText(c.description);
|
|
4526
4981
|
}
|
|
4527
4982
|
out.push(cmd);
|
|
4528
4983
|
}
|
|
@@ -4555,7 +5010,7 @@ function mapAgentText(u) {
|
|
|
4555
5010
|
return { kind: "agent-text", text };
|
|
4556
5011
|
}
|
|
4557
5012
|
function mapAgentThought(u) {
|
|
4558
|
-
const text = typeof u.text === "string" ? u.text : extractContentText(u.content);
|
|
5013
|
+
const text = typeof u.text === "string" ? sanitizeWireText(u.text) : extractContentText(u.content);
|
|
4559
5014
|
if (text === null) {
|
|
4560
5015
|
return null;
|
|
4561
5016
|
}
|
|
@@ -4587,7 +5042,8 @@ function mapToolCall(u) {
|
|
|
4587
5042
|
if (!toolCallId) {
|
|
4588
5043
|
return null;
|
|
4589
5044
|
}
|
|
4590
|
-
const
|
|
5045
|
+
const rawTitle = readString(u, "title") ?? readString(u, "name") ?? readString(u, "label") ?? "tool call";
|
|
5046
|
+
const title = sanitizeWireText(rawTitle);
|
|
4591
5047
|
const status = readString(u, "status");
|
|
4592
5048
|
const rawKind = readString(u, "kind");
|
|
4593
5049
|
const event = { kind: "tool-call", toolCallId, title };
|
|
@@ -4604,7 +5060,8 @@ function mapToolCallUpdate(u) {
|
|
|
4604
5060
|
if (!toolCallId) {
|
|
4605
5061
|
return null;
|
|
4606
5062
|
}
|
|
4607
|
-
const
|
|
5063
|
+
const rawTitle = readString(u, "title");
|
|
5064
|
+
const title = rawTitle !== void 0 ? sanitizeWireText(rawTitle) : void 0;
|
|
4608
5065
|
const status = readString(u, "status");
|
|
4609
5066
|
const meaningful = title !== void 0 || status === "completed" || status === "failed" || status === "rejected" || status === "cancelled";
|
|
4610
5067
|
if (!meaningful) {
|
|
@@ -4630,7 +5087,7 @@ function mapPlan(u) {
|
|
|
4630
5087
|
continue;
|
|
4631
5088
|
}
|
|
4632
5089
|
const e = raw;
|
|
4633
|
-
const content = typeof e.content === "string" ? e.content : void 0;
|
|
5090
|
+
const content = typeof e.content === "string" ? sanitizeWireText(e.content) : void 0;
|
|
4634
5091
|
if (!content) {
|
|
4635
5092
|
continue;
|
|
4636
5093
|
}
|
|
@@ -4650,14 +5107,14 @@ function mapMode(u) {
|
|
|
4650
5107
|
if (!mode) {
|
|
4651
5108
|
return null;
|
|
4652
5109
|
}
|
|
4653
|
-
return { kind: "mode-changed", mode };
|
|
5110
|
+
return { kind: "mode-changed", mode: sanitizeWireText(mode) };
|
|
4654
5111
|
}
|
|
4655
5112
|
function mapModel(u) {
|
|
4656
5113
|
const model = readString(u, "currentModel") ?? readString(u, "model");
|
|
4657
5114
|
if (!model) {
|
|
4658
5115
|
return null;
|
|
4659
5116
|
}
|
|
4660
|
-
return { kind: "model-changed", model };
|
|
5117
|
+
return { kind: "model-changed", model: sanitizeWireText(model) };
|
|
4661
5118
|
}
|
|
4662
5119
|
function mapTurnComplete(u) {
|
|
4663
5120
|
const stopReason = readString(u, "stopReason");
|
|
@@ -4665,17 +5122,17 @@ function mapTurnComplete(u) {
|
|
|
4665
5122
|
}
|
|
4666
5123
|
function extractContentText(content) {
|
|
4667
5124
|
if (typeof content === "string") {
|
|
4668
|
-
return content;
|
|
5125
|
+
return sanitizeWireText(content);
|
|
4669
5126
|
}
|
|
4670
5127
|
if (!content || typeof content !== "object") {
|
|
4671
5128
|
return null;
|
|
4672
5129
|
}
|
|
4673
5130
|
const c = content;
|
|
4674
5131
|
if (c.type === "text" && typeof c.text === "string") {
|
|
4675
|
-
return c.text;
|
|
5132
|
+
return sanitizeWireText(c.text);
|
|
4676
5133
|
}
|
|
4677
5134
|
if (typeof c.text === "string") {
|
|
4678
|
-
return c.text;
|
|
5135
|
+
return sanitizeWireText(c.text);
|
|
4679
5136
|
}
|
|
4680
5137
|
return null;
|
|
4681
5138
|
}
|
|
@@ -4699,9 +5156,11 @@ function readString(u, key) {
|
|
|
4699
5156
|
const v = u[key];
|
|
4700
5157
|
return typeof v === "string" ? v : void 0;
|
|
4701
5158
|
}
|
|
5159
|
+
var STRIP_CONTROLS;
|
|
4702
5160
|
var init_render_update = __esm({
|
|
4703
5161
|
"src/tui/render-update.ts"() {
|
|
4704
5162
|
"use strict";
|
|
5163
|
+
STRIP_CONTROLS = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
4705
5164
|
}
|
|
4706
5165
|
});
|
|
4707
5166
|
|
|
@@ -4907,8 +5366,9 @@ function formatToolLine(state) {
|
|
|
4907
5366
|
title = `${initial} \xB7 ${latest}`;
|
|
4908
5367
|
}
|
|
4909
5368
|
return {
|
|
4910
|
-
prefix:
|
|
4911
|
-
|
|
5369
|
+
prefix: ` ${toolStatusIcon(state.status)} `,
|
|
5370
|
+
prefixStyle: toolIconStyle(state.status),
|
|
5371
|
+
body: title,
|
|
4912
5372
|
bodyStyle: toolStatusStyle(state.status)
|
|
4913
5373
|
};
|
|
4914
5374
|
}
|
|
@@ -4933,6 +5393,22 @@ function toolStatusIcon(status) {
|
|
|
4933
5393
|
return "\u25D0";
|
|
4934
5394
|
}
|
|
4935
5395
|
}
|
|
5396
|
+
function toolIconStyle(status) {
|
|
5397
|
+
switch (status) {
|
|
5398
|
+
case "completed":
|
|
5399
|
+
case "succeeded":
|
|
5400
|
+
case "ok":
|
|
5401
|
+
return "tool-status-ok";
|
|
5402
|
+
case "failed":
|
|
5403
|
+
case "error":
|
|
5404
|
+
case "rejected":
|
|
5405
|
+
return "tool-status-fail";
|
|
5406
|
+
case "cancelled":
|
|
5407
|
+
return "tool-status-cancelled";
|
|
5408
|
+
default:
|
|
5409
|
+
return "tool-status-running";
|
|
5410
|
+
}
|
|
5411
|
+
}
|
|
4936
5412
|
function formatPlan(event) {
|
|
4937
5413
|
if (event.entries.length === 0) {
|
|
4938
5414
|
return [
|
|
@@ -5019,12 +5495,18 @@ async function runTuiApp(opts) {
|
|
|
5019
5495
|
const config = await ensureConfig();
|
|
5020
5496
|
await ensureDaemonReachable(config);
|
|
5021
5497
|
const term = termkit.terminal;
|
|
5498
|
+
const exitHint = {};
|
|
5022
5499
|
let nextOpts = opts;
|
|
5023
5500
|
while (nextOpts !== null) {
|
|
5024
|
-
nextOpts = await runSession(term, config, nextOpts);
|
|
5501
|
+
nextOpts = await runSession(term, config, nextOpts, exitHint);
|
|
5502
|
+
}
|
|
5503
|
+
if (exitHint.sessionId) {
|
|
5504
|
+
const short = stripHydraSessionPrefix(exitHint.sessionId);
|
|
5505
|
+
process.stdout.write(`To resume: hydra-acp tui --resume ${short}
|
|
5506
|
+
`);
|
|
5025
5507
|
}
|
|
5026
5508
|
}
|
|
5027
|
-
async function runSession(term, config, opts) {
|
|
5509
|
+
async function runSession(term, config, opts, exitHint) {
|
|
5028
5510
|
const ctx = await resolveSession(term, config, opts);
|
|
5029
5511
|
if (!ctx) {
|
|
5030
5512
|
term.grabInput(false);
|
|
@@ -5132,10 +5614,10 @@ async function runSession(term, config, opts) {
|
|
|
5132
5614
|
if (pendingPermission.toolCallId && toolCallId && pendingPermission.toolCallId !== toolCallId) {
|
|
5133
5615
|
return;
|
|
5134
5616
|
}
|
|
5135
|
-
const
|
|
5617
|
+
const resolve5 = pendingPermission.resolve;
|
|
5136
5618
|
pendingPermission = null;
|
|
5137
5619
|
screen.setPermissionPrompt(null);
|
|
5138
|
-
|
|
5620
|
+
resolve5(result ?? { outcome: { outcome: "cancelled" } });
|
|
5139
5621
|
};
|
|
5140
5622
|
const maybeDismissPermissionByToolUpdate = (update) => {
|
|
5141
5623
|
if (!pendingPermission?.toolCallId) {
|
|
@@ -5168,20 +5650,26 @@ async function runSession(term, config, opts) {
|
|
|
5168
5650
|
if (!pendingPermission) {
|
|
5169
5651
|
return;
|
|
5170
5652
|
}
|
|
5171
|
-
const { options, resolve:
|
|
5653
|
+
const { options, resolve: resolve5 } = pendingPermission;
|
|
5172
5654
|
pendingPermission = null;
|
|
5173
5655
|
screen.setPermissionPrompt(null);
|
|
5174
5656
|
if (optionId === null) {
|
|
5175
|
-
|
|
5657
|
+
resolve5({ outcome: { outcome: "cancelled" } });
|
|
5176
5658
|
return;
|
|
5177
5659
|
}
|
|
5178
|
-
|
|
5660
|
+
resolve5({ outcome: { outcome: "selected", optionId } });
|
|
5179
5661
|
void options;
|
|
5180
5662
|
};
|
|
5181
5663
|
conn.onRequest("session/request_permission", async (params) => {
|
|
5182
5664
|
const p = params ?? {};
|
|
5183
|
-
const
|
|
5184
|
-
const
|
|
5665
|
+
const rawOptions = Array.isArray(p.options) ? p.options : [];
|
|
5666
|
+
const options = rawOptions.map((o) => ({
|
|
5667
|
+
optionId: o.optionId,
|
|
5668
|
+
name: sanitizeWireText(o.name ?? ""),
|
|
5669
|
+
...o.kind !== void 0 ? { kind: o.kind } : {}
|
|
5670
|
+
}));
|
|
5671
|
+
const rawTitle = p.toolCall?.title ?? p.toolCall?.name ?? "tool";
|
|
5672
|
+
const title = sanitizeWireText(rawTitle);
|
|
5185
5673
|
const toolCallId = p.toolCall?.toolCallId;
|
|
5186
5674
|
if (options.length === 0) {
|
|
5187
5675
|
screen.appendLines([
|
|
@@ -5193,12 +5681,12 @@ async function runSession(term, config, opts) {
|
|
|
5193
5681
|
]);
|
|
5194
5682
|
return { outcome: { outcome: "cancelled" } };
|
|
5195
5683
|
}
|
|
5196
|
-
return new Promise((
|
|
5684
|
+
return new Promise((resolve5) => {
|
|
5197
5685
|
pendingPermission = {
|
|
5198
5686
|
title,
|
|
5199
5687
|
options,
|
|
5200
5688
|
selectedIndex: 0,
|
|
5201
|
-
resolve:
|
|
5689
|
+
resolve: resolve5,
|
|
5202
5690
|
toolCallId
|
|
5203
5691
|
};
|
|
5204
5692
|
refreshPermissionPrompt();
|
|
@@ -5236,6 +5724,7 @@ async function runSession(term, config, opts) {
|
|
|
5236
5724
|
...opts.name ? { _meta: { [HYDRA_META_KEY]: { name: opts.name } } } : {}
|
|
5237
5725
|
});
|
|
5238
5726
|
resolvedSessionId = created.sessionId;
|
|
5727
|
+
exitHint.sessionId = resolvedSessionId;
|
|
5239
5728
|
const hydraMeta = extractHydraMeta(created._meta ?? void 0);
|
|
5240
5729
|
upstreamSessionId = hydraMeta.upstreamSessionId;
|
|
5241
5730
|
if (hydraMeta.agentId) {
|
|
@@ -5262,6 +5751,7 @@ async function runSession(term, config, opts) {
|
|
|
5262
5751
|
clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
|
|
5263
5752
|
});
|
|
5264
5753
|
resolvedSessionId = attached.sessionId;
|
|
5754
|
+
exitHint.sessionId = resolvedSessionId;
|
|
5265
5755
|
const hydraMeta = extractHydraMeta(attached._meta ?? void 0);
|
|
5266
5756
|
upstreamSessionId = hydraMeta.upstreamSessionId;
|
|
5267
5757
|
if (hydraMeta.agentId) {
|
|
@@ -5449,17 +5939,15 @@ async function runSession(term, config, opts) {
|
|
|
5449
5939
|
agent: headerName,
|
|
5450
5940
|
cwd: resolvedCwd,
|
|
5451
5941
|
sessionId: resolvedSessionId,
|
|
5452
|
-
title: resolvedTitle
|
|
5942
|
+
title: resolvedTitle,
|
|
5943
|
+
model: initialModel
|
|
5453
5944
|
});
|
|
5454
5945
|
if (initialMode) {
|
|
5455
5946
|
screen.appendLines(formatEvent({ kind: "mode-changed", mode: initialMode }));
|
|
5456
5947
|
}
|
|
5457
|
-
if (initialModel) {
|
|
5458
|
-
screen.appendLines(formatEvent({ kind: "model-changed", model: initialModel }));
|
|
5459
|
-
}
|
|
5460
5948
|
let finishSession = null;
|
|
5461
|
-
const sessionDone = new Promise((
|
|
5462
|
-
finishSession =
|
|
5949
|
+
const sessionDone = new Promise((resolve5) => {
|
|
5950
|
+
finishSession = resolve5;
|
|
5463
5951
|
});
|
|
5464
5952
|
const cancelRemoteTurn = () => {
|
|
5465
5953
|
conn.notify("session/cancel", { sessionId: resolvedSessionId }).catch(() => void 0);
|
|
@@ -5631,7 +6119,7 @@ async function runSession(term, config, opts) {
|
|
|
5631
6119
|
screen.setBanner({});
|
|
5632
6120
|
return;
|
|
5633
6121
|
case "redraw":
|
|
5634
|
-
screen.
|
|
6122
|
+
screen.fullRedraw();
|
|
5635
6123
|
return;
|
|
5636
6124
|
case "switch-session":
|
|
5637
6125
|
void switchSession();
|
|
@@ -5957,6 +6445,10 @@ async function runSession(term, config, opts) {
|
|
|
5957
6445
|
if (event.title !== void 0) {
|
|
5958
6446
|
screen.setHeader({ title: event.title });
|
|
5959
6447
|
}
|
|
6448
|
+
if (event.agentId !== void 0 && event.agentId !== resolvedAgentId) {
|
|
6449
|
+
resolvedAgentId = event.agentId;
|
|
6450
|
+
screen.setHeader({ agent: event.agentId });
|
|
6451
|
+
}
|
|
5960
6452
|
return;
|
|
5961
6453
|
}
|
|
5962
6454
|
if (event.kind === "usage-update") {
|
|
@@ -6028,6 +6520,9 @@ async function runSession(term, config, opts) {
|
|
|
6028
6520
|
renderToolsBlock();
|
|
6029
6521
|
return;
|
|
6030
6522
|
}
|
|
6523
|
+
if (event.kind === "model-changed") {
|
|
6524
|
+
screen.setHeader({ model: event.model });
|
|
6525
|
+
}
|
|
6031
6526
|
const formatted = formatEvent(event);
|
|
6032
6527
|
if (formatted.length > 0) {
|
|
6033
6528
|
screen.appendLines(formatted);
|
|
@@ -6077,10 +6572,10 @@ async function runSession(term, config, opts) {
|
|
|
6077
6572
|
}
|
|
6078
6573
|
const resetInFlightUiState = () => {
|
|
6079
6574
|
if (pendingPermission) {
|
|
6080
|
-
const
|
|
6575
|
+
const resolve5 = pendingPermission.resolve;
|
|
6081
6576
|
pendingPermission = null;
|
|
6082
6577
|
screen.setPermissionPrompt(null);
|
|
6083
|
-
|
|
6578
|
+
resolve5({ outcome: { outcome: "cancelled" } });
|
|
6084
6579
|
}
|
|
6085
6580
|
closeAgentText();
|
|
6086
6581
|
if (toolsBlockStartedAt !== null) {
|
|
@@ -6268,6 +6763,7 @@ var init_app = __esm({
|
|
|
6268
6763
|
init_resilient_ws();
|
|
6269
6764
|
init_config();
|
|
6270
6765
|
init_daemon_bootstrap();
|
|
6766
|
+
init_session();
|
|
6271
6767
|
init_paths();
|
|
6272
6768
|
init_history();
|
|
6273
6769
|
init_discovery();
|
|
@@ -6293,6 +6789,11 @@ var init_tui = __esm({
|
|
|
6293
6789
|
}
|
|
6294
6790
|
});
|
|
6295
6791
|
|
|
6792
|
+
// src/cli.ts
|
|
6793
|
+
import { readFileSync } from "fs";
|
|
6794
|
+
import { fileURLToPath } from "url";
|
|
6795
|
+
import { dirname as dirname4, resolve as resolve4 } from "path";
|
|
6796
|
+
|
|
6296
6797
|
// src/cli/parse-args.ts
|
|
6297
6798
|
function parseArgs(argv) {
|
|
6298
6799
|
const positional = [];
|
|
@@ -6362,8 +6863,7 @@ async function runInit(flags) {
|
|
|
6362
6863
|
existing = void 0;
|
|
6363
6864
|
}
|
|
6364
6865
|
if (!existing) {
|
|
6365
|
-
const config =
|
|
6366
|
-
await writeConfig(config);
|
|
6866
|
+
const config = await writeMinimalInitConfig();
|
|
6367
6867
|
process.stdout.write(
|
|
6368
6868
|
`Initialized ${paths.config()}
|
|
6369
6869
|
Auth token: ${config.daemon.authToken}
|
|
@@ -6372,11 +6872,14 @@ Auth token: ${config.daemon.authToken}
|
|
|
6372
6872
|
return;
|
|
6373
6873
|
}
|
|
6374
6874
|
if (flagBool(flags, "rotate-token")) {
|
|
6375
|
-
|
|
6376
|
-
await
|
|
6875
|
+
const newToken = generateAuthToken();
|
|
6876
|
+
await updateConfigField((raw) => {
|
|
6877
|
+
const daemon = raw.daemon ??= {};
|
|
6878
|
+
daemon.authToken = newToken;
|
|
6879
|
+
});
|
|
6377
6880
|
process.stdout.write(
|
|
6378
6881
|
`Rotated token in ${paths.config()}
|
|
6379
|
-
New token: ${
|
|
6882
|
+
New token: ${newToken}
|
|
6380
6883
|
`
|
|
6381
6884
|
);
|
|
6382
6885
|
return;
|
|
@@ -6389,13 +6892,13 @@ New token: ${existing.daemon.authToken}
|
|
|
6389
6892
|
// src/cli/commands/daemon.ts
|
|
6390
6893
|
init_paths();
|
|
6391
6894
|
init_config();
|
|
6392
|
-
import * as
|
|
6895
|
+
import * as fsp5 from "fs/promises";
|
|
6393
6896
|
import { setTimeout as sleep2 } from "timers/promises";
|
|
6394
6897
|
|
|
6395
6898
|
// src/daemon/server.ts
|
|
6396
6899
|
init_config();
|
|
6397
|
-
import * as
|
|
6398
|
-
import * as
|
|
6900
|
+
import * as fs10 from "fs";
|
|
6901
|
+
import * as fsp3 from "fs/promises";
|
|
6399
6902
|
import Fastify from "fastify";
|
|
6400
6903
|
import websocketPlugin from "@fastify/websocket";
|
|
6401
6904
|
import pino from "pino";
|
|
@@ -6403,8 +6906,214 @@ import createPinoRoll from "pino-roll";
|
|
|
6403
6906
|
|
|
6404
6907
|
// src/core/registry.ts
|
|
6405
6908
|
init_paths();
|
|
6406
|
-
import * as
|
|
6909
|
+
import * as fs4 from "fs/promises";
|
|
6407
6910
|
import { z as z2 } from "zod";
|
|
6911
|
+
|
|
6912
|
+
// src/core/binary-install.ts
|
|
6913
|
+
init_paths();
|
|
6914
|
+
import * as fs3 from "fs";
|
|
6915
|
+
import * as fsp from "fs/promises";
|
|
6916
|
+
import * as path2 from "path";
|
|
6917
|
+
import { spawn } from "child_process";
|
|
6918
|
+
import { Readable } from "stream";
|
|
6919
|
+
function currentPlatformKey() {
|
|
6920
|
+
const osPart = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : void 0;
|
|
6921
|
+
const archPart = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : void 0;
|
|
6922
|
+
if (!osPart || !archPart) {
|
|
6923
|
+
return void 0;
|
|
6924
|
+
}
|
|
6925
|
+
return `${osPart}-${archPart}`;
|
|
6926
|
+
}
|
|
6927
|
+
function pickBinaryTarget(distribution, platformKey = currentPlatformKey()) {
|
|
6928
|
+
if (!platformKey) {
|
|
6929
|
+
return void 0;
|
|
6930
|
+
}
|
|
6931
|
+
return distribution[platformKey];
|
|
6932
|
+
}
|
|
6933
|
+
var logSink = (msg) => {
|
|
6934
|
+
process.stderr.write(msg + "\n");
|
|
6935
|
+
};
|
|
6936
|
+
function setBinaryInstallLogger(log) {
|
|
6937
|
+
logSink = log ?? ((msg) => process.stderr.write(msg + "\n"));
|
|
6938
|
+
}
|
|
6939
|
+
async function ensureBinary(args) {
|
|
6940
|
+
if (!args.target.archive) {
|
|
6941
|
+
throw new Error(
|
|
6942
|
+
`Agent ${args.agentId} has no archive URL for ${currentPlatformKey() ?? "this platform"}`
|
|
6943
|
+
);
|
|
6944
|
+
}
|
|
6945
|
+
if (!args.target.cmd) {
|
|
6946
|
+
throw new Error(`Agent ${args.agentId} has no cmd in its binary target`);
|
|
6947
|
+
}
|
|
6948
|
+
const platformKey = currentPlatformKey();
|
|
6949
|
+
if (!platformKey) {
|
|
6950
|
+
throw new Error(
|
|
6951
|
+
`Agent ${args.agentId}: cannot determine platform key for ${process.platform}/${process.arch}`
|
|
6952
|
+
);
|
|
6953
|
+
}
|
|
6954
|
+
const installDir = paths.agentInstallDir(
|
|
6955
|
+
args.agentId,
|
|
6956
|
+
platformKey,
|
|
6957
|
+
args.version
|
|
6958
|
+
);
|
|
6959
|
+
const cmdPath = path2.resolve(installDir, args.target.cmd);
|
|
6960
|
+
if (await fileExists(cmdPath)) {
|
|
6961
|
+
return cmdPath;
|
|
6962
|
+
}
|
|
6963
|
+
await downloadAndExtract({
|
|
6964
|
+
agentId: args.agentId,
|
|
6965
|
+
archiveUrl: args.target.archive,
|
|
6966
|
+
installDir
|
|
6967
|
+
});
|
|
6968
|
+
if (!await fileExists(cmdPath)) {
|
|
6969
|
+
throw new Error(
|
|
6970
|
+
`Agent ${args.agentId}: extracted archive did not contain ${args.target.cmd} (looked in ${installDir})`
|
|
6971
|
+
);
|
|
6972
|
+
}
|
|
6973
|
+
if (process.platform !== "win32") {
|
|
6974
|
+
await fsp.chmod(cmdPath, 493).catch(() => void 0);
|
|
6975
|
+
}
|
|
6976
|
+
return cmdPath;
|
|
6977
|
+
}
|
|
6978
|
+
async function downloadAndExtract(args) {
|
|
6979
|
+
await fsp.mkdir(path2.dirname(args.installDir), { recursive: true });
|
|
6980
|
+
const tempDir = await fsp.mkdtemp(`${args.installDir}.partial-`);
|
|
6981
|
+
try {
|
|
6982
|
+
logSink(`hydra-acp: downloading ${args.agentId} from ${args.archiveUrl}`);
|
|
6983
|
+
const archivePath = await downloadTo({
|
|
6984
|
+
url: args.archiveUrl,
|
|
6985
|
+
dir: tempDir,
|
|
6986
|
+
agentId: args.agentId
|
|
6987
|
+
});
|
|
6988
|
+
logSink(`hydra-acp: extracting ${args.agentId}`);
|
|
6989
|
+
await extract(archivePath, tempDir);
|
|
6990
|
+
await fsp.unlink(archivePath).catch(() => void 0);
|
|
6991
|
+
try {
|
|
6992
|
+
await fsp.rename(tempDir, args.installDir);
|
|
6993
|
+
} catch (err) {
|
|
6994
|
+
const e = err;
|
|
6995
|
+
if ((e.code === "EEXIST" || e.code === "ENOTEMPTY") && await fileExists(args.installDir)) {
|
|
6996
|
+
await fsp.rm(tempDir, { recursive: true, force: true }).catch(
|
|
6997
|
+
() => void 0
|
|
6998
|
+
);
|
|
6999
|
+
return;
|
|
7000
|
+
}
|
|
7001
|
+
throw err;
|
|
7002
|
+
}
|
|
7003
|
+
logSink(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
|
|
7004
|
+
} catch (err) {
|
|
7005
|
+
await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
|
|
7006
|
+
throw err;
|
|
7007
|
+
}
|
|
7008
|
+
}
|
|
7009
|
+
async function downloadTo(args) {
|
|
7010
|
+
const filename = inferArchiveName(args.url);
|
|
7011
|
+
const dest = path2.join(args.dir, filename);
|
|
7012
|
+
const response = await fetch(args.url, { redirect: "follow" });
|
|
7013
|
+
if (!response.ok || !response.body) {
|
|
7014
|
+
throw new Error(
|
|
7015
|
+
`Failed to download ${args.url}: HTTP ${response.status} ${response.statusText}`
|
|
7016
|
+
);
|
|
7017
|
+
}
|
|
7018
|
+
const total = Number(response.headers.get("content-length") ?? "0");
|
|
7019
|
+
const out = fs3.createWriteStream(dest);
|
|
7020
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
7021
|
+
let received = 0;
|
|
7022
|
+
let lastEmit = Date.now();
|
|
7023
|
+
const EMIT_INTERVAL_MS = 2e3;
|
|
7024
|
+
nodeStream.on("data", (chunk) => {
|
|
7025
|
+
received += chunk.length;
|
|
7026
|
+
const now = Date.now();
|
|
7027
|
+
if (now - lastEmit < EMIT_INTERVAL_MS) {
|
|
7028
|
+
return;
|
|
7029
|
+
}
|
|
7030
|
+
lastEmit = now;
|
|
7031
|
+
logSink(formatProgress(args.agentId, received, total));
|
|
7032
|
+
});
|
|
7033
|
+
await new Promise((resolve5, reject) => {
|
|
7034
|
+
nodeStream.on("error", reject);
|
|
7035
|
+
out.on("error", reject);
|
|
7036
|
+
out.on("finish", () => resolve5());
|
|
7037
|
+
nodeStream.pipe(out);
|
|
7038
|
+
});
|
|
7039
|
+
logSink(formatProgress(
|
|
7040
|
+
args.agentId,
|
|
7041
|
+
received,
|
|
7042
|
+
total,
|
|
7043
|
+
/* done */
|
|
7044
|
+
true
|
|
7045
|
+
));
|
|
7046
|
+
return dest;
|
|
7047
|
+
}
|
|
7048
|
+
function formatProgress(agentId, received, total, done = false) {
|
|
7049
|
+
const rxMb = (received / 1e6).toFixed(1);
|
|
7050
|
+
if (total > 0) {
|
|
7051
|
+
const totalMb = (total / 1e6).toFixed(1);
|
|
7052
|
+
const pct = Math.min(100, Math.floor(received / total * 100));
|
|
7053
|
+
const tag2 = done ? "downloaded" : "downloading";
|
|
7054
|
+
return `hydra-acp: ${tag2} ${agentId} ${rxMb}/${totalMb} MB (${pct}%)`;
|
|
7055
|
+
}
|
|
7056
|
+
const tag = done ? "downloaded" : "downloading";
|
|
7057
|
+
return `hydra-acp: ${tag} ${agentId} ${rxMb} MB`;
|
|
7058
|
+
}
|
|
7059
|
+
function inferArchiveName(url) {
|
|
7060
|
+
const u = new URL(url);
|
|
7061
|
+
const base = path2.posix.basename(u.pathname);
|
|
7062
|
+
return base || "archive";
|
|
7063
|
+
}
|
|
7064
|
+
async function extract(archivePath, dest) {
|
|
7065
|
+
const lower = archivePath.toLowerCase();
|
|
7066
|
+
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".tar")) {
|
|
7067
|
+
await run("tar", ["-xf", archivePath, "-C", dest]);
|
|
7068
|
+
return;
|
|
7069
|
+
}
|
|
7070
|
+
if (lower.endsWith(".zip")) {
|
|
7071
|
+
if (await hasCommand("unzip")) {
|
|
7072
|
+
await run("unzip", ["-q", archivePath, "-d", dest]);
|
|
7073
|
+
return;
|
|
7074
|
+
}
|
|
7075
|
+
await run("tar", ["-xf", archivePath, "-C", dest]);
|
|
7076
|
+
return;
|
|
7077
|
+
}
|
|
7078
|
+
throw new Error(`Unsupported archive format: ${archivePath}`);
|
|
7079
|
+
}
|
|
7080
|
+
function run(cmd, args) {
|
|
7081
|
+
return new Promise((resolve5, reject) => {
|
|
7082
|
+
const child = spawn(cmd, args, {
|
|
7083
|
+
stdio: ["ignore", "ignore", "inherit"]
|
|
7084
|
+
});
|
|
7085
|
+
child.on("error", reject);
|
|
7086
|
+
child.on("exit", (code, signal) => {
|
|
7087
|
+
if (code === 0) {
|
|
7088
|
+
resolve5();
|
|
7089
|
+
return;
|
|
7090
|
+
}
|
|
7091
|
+
reject(
|
|
7092
|
+
new Error(
|
|
7093
|
+
`${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
|
|
7094
|
+
)
|
|
7095
|
+
);
|
|
7096
|
+
});
|
|
7097
|
+
});
|
|
7098
|
+
}
|
|
7099
|
+
async function hasCommand(name) {
|
|
7100
|
+
return new Promise((resolve5) => {
|
|
7101
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
7102
|
+
const child = spawn(finder, [name], { stdio: "ignore" });
|
|
7103
|
+
child.on("error", () => resolve5(false));
|
|
7104
|
+
child.on("exit", (code) => resolve5(code === 0));
|
|
7105
|
+
});
|
|
7106
|
+
}
|
|
7107
|
+
async function fileExists(p) {
|
|
7108
|
+
try {
|
|
7109
|
+
await fsp.access(p);
|
|
7110
|
+
return true;
|
|
7111
|
+
} catch {
|
|
7112
|
+
return false;
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
|
|
7116
|
+
// src/core/registry.ts
|
|
6408
7117
|
var NpxDistribution = z2.object({
|
|
6409
7118
|
package: z2.string(),
|
|
6410
7119
|
args: z2.array(z2.string()).optional(),
|
|
@@ -6412,7 +7121,9 @@ var NpxDistribution = z2.object({
|
|
|
6412
7121
|
});
|
|
6413
7122
|
var BinaryTarget = z2.object({
|
|
6414
7123
|
archive: z2.string().url().optional(),
|
|
6415
|
-
cmd: z2.string().optional()
|
|
7124
|
+
cmd: z2.string().optional(),
|
|
7125
|
+
args: z2.array(z2.string()).optional(),
|
|
7126
|
+
env: z2.record(z2.string()).optional()
|
|
6416
7127
|
});
|
|
6417
7128
|
var BinaryDistribution = z2.object({
|
|
6418
7129
|
"darwin-aarch64": BinaryTarget.optional(),
|
|
@@ -6501,34 +7212,59 @@ var Registry = class {
|
|
|
6501
7212
|
if (!response.ok) {
|
|
6502
7213
|
throw new Error(`Registry fetch failed: HTTP ${response.status}`);
|
|
6503
7214
|
}
|
|
6504
|
-
const
|
|
6505
|
-
const data = RegistryDocument.parse(
|
|
6506
|
-
return { fetchedAt: Date.now(), data };
|
|
7215
|
+
const raw = await response.json();
|
|
7216
|
+
const data = RegistryDocument.parse(raw);
|
|
7217
|
+
return { fetchedAt: Date.now(), raw, data };
|
|
6507
7218
|
}
|
|
6508
7219
|
async readDiskCache() {
|
|
7220
|
+
let text;
|
|
6509
7221
|
try {
|
|
6510
|
-
|
|
6511
|
-
const parsed = JSON.parse(raw);
|
|
6512
|
-
if (typeof parsed.fetchedAt === "number" && parsed.data && Array.isArray(parsed.data.agents)) {
|
|
6513
|
-
return parsed;
|
|
6514
|
-
}
|
|
7222
|
+
text = await fs4.readFile(paths.registryCache(), "utf8");
|
|
6515
7223
|
} catch (err) {
|
|
6516
7224
|
const e = err;
|
|
6517
|
-
if (e.code
|
|
6518
|
-
|
|
7225
|
+
if (e.code === "ENOENT") {
|
|
7226
|
+
return void 0;
|
|
6519
7227
|
}
|
|
7228
|
+
throw err;
|
|
7229
|
+
}
|
|
7230
|
+
try {
|
|
7231
|
+
const parsed = JSON.parse(text);
|
|
7232
|
+
if (typeof parsed.fetchedAt !== "number" || parsed.data === void 0) {
|
|
7233
|
+
return void 0;
|
|
7234
|
+
}
|
|
7235
|
+
const data = RegistryDocument.parse(parsed.data);
|
|
7236
|
+
return { fetchedAt: parsed.fetchedAt, raw: parsed.data, data };
|
|
7237
|
+
} catch {
|
|
7238
|
+
return void 0;
|
|
6520
7239
|
}
|
|
6521
|
-
return void 0;
|
|
6522
7240
|
}
|
|
7241
|
+
// Atomic write: dump to a sibling temp path, then rename onto the
|
|
7242
|
+
// target. POSIX rename is atomic within a filesystem, so readers
|
|
7243
|
+
// either see the old file or the fully-written new file — never a
|
|
7244
|
+
// truncated middle. This also makes simultaneous writers safe
|
|
7245
|
+
// without a lock file: the loser of the rename race just gets its
|
|
7246
|
+
// version replaced by the winner's.
|
|
6523
7247
|
async writeDiskCache(cache) {
|
|
6524
|
-
await
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
7248
|
+
await fs4.mkdir(paths.home(), { recursive: true });
|
|
7249
|
+
const final = paths.registryCache();
|
|
7250
|
+
const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
|
|
7251
|
+
const body = JSON.stringify(
|
|
7252
|
+
{ fetchedAt: cache.fetchedAt, data: cache.raw },
|
|
7253
|
+
null,
|
|
7254
|
+
2
|
|
7255
|
+
) + "\n";
|
|
7256
|
+
try {
|
|
7257
|
+
await fs4.writeFile(tmp, body, "utf8");
|
|
7258
|
+
await fs4.rename(tmp, final);
|
|
7259
|
+
} catch (err) {
|
|
7260
|
+
await fs4.unlink(tmp).catch(() => void 0);
|
|
7261
|
+
throw err;
|
|
7262
|
+
}
|
|
6530
7263
|
}
|
|
6531
7264
|
};
|
|
7265
|
+
function randSuffix() {
|
|
7266
|
+
return Math.random().toString(36).slice(2, 10);
|
|
7267
|
+
}
|
|
6532
7268
|
function npxPackageBasename(agent) {
|
|
6533
7269
|
const pkg = agent.distribution.npx?.package;
|
|
6534
7270
|
if (!pkg) {
|
|
@@ -6539,7 +7275,7 @@ function npxPackageBasename(agent) {
|
|
|
6539
7275
|
const atIdx = afterSlash.lastIndexOf("@");
|
|
6540
7276
|
return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
|
|
6541
7277
|
}
|
|
6542
|
-
function planSpawn(agent, extraArgs = []) {
|
|
7278
|
+
async function planSpawn(agent, extraArgs = []) {
|
|
6543
7279
|
if (agent.distribution.npx) {
|
|
6544
7280
|
const npx = agent.distribution.npx;
|
|
6545
7281
|
const args = ["-y", npx.package, ...npx.args ?? [], ...extraArgs];
|
|
@@ -6550,9 +7286,22 @@ function planSpawn(agent, extraArgs = []) {
|
|
|
6550
7286
|
};
|
|
6551
7287
|
}
|
|
6552
7288
|
if (agent.distribution.binary) {
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
7289
|
+
const target = pickBinaryTarget(agent.distribution.binary);
|
|
7290
|
+
if (!target) {
|
|
7291
|
+
throw new Error(
|
|
7292
|
+
`Agent ${agent.id} has no binary distribution for ${currentPlatformKey() ?? "this platform"}.`
|
|
7293
|
+
);
|
|
7294
|
+
}
|
|
7295
|
+
const cmdPath = await ensureBinary({
|
|
7296
|
+
agentId: agent.id,
|
|
7297
|
+
version: agent.version ?? "current",
|
|
7298
|
+
target
|
|
7299
|
+
});
|
|
7300
|
+
return {
|
|
7301
|
+
command: cmdPath,
|
|
7302
|
+
args: [...target.args ?? [], ...extraArgs],
|
|
7303
|
+
env: target.env ?? {}
|
|
7304
|
+
};
|
|
6556
7305
|
}
|
|
6557
7306
|
if (agent.distribution.uvx) {
|
|
6558
7307
|
const uvx = agent.distribution.uvx;
|
|
@@ -6567,10 +7316,11 @@ function planSpawn(agent, extraArgs = []) {
|
|
|
6567
7316
|
}
|
|
6568
7317
|
|
|
6569
7318
|
// src/core/session-manager.ts
|
|
6570
|
-
import * as
|
|
7319
|
+
import * as fs8 from "fs/promises";
|
|
7320
|
+
import { customAlphabet as customAlphabet3 } from "nanoid";
|
|
6571
7321
|
|
|
6572
7322
|
// src/core/agent-instance.ts
|
|
6573
|
-
import { spawn } from "child_process";
|
|
7323
|
+
import { spawn as spawn2 } from "child_process";
|
|
6574
7324
|
|
|
6575
7325
|
// src/acp/framing.ts
|
|
6576
7326
|
init_types();
|
|
@@ -6626,13 +7376,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
|
|
|
6626
7376
|
throw new Error("stream is closed");
|
|
6627
7377
|
}
|
|
6628
7378
|
const line = JSON.stringify(message) + "\n";
|
|
6629
|
-
await new Promise((
|
|
7379
|
+
await new Promise((resolve5, reject) => {
|
|
6630
7380
|
stdin.write(line, (err) => {
|
|
6631
7381
|
if (err) {
|
|
6632
7382
|
reject(err);
|
|
6633
7383
|
return;
|
|
6634
7384
|
}
|
|
6635
|
-
|
|
7385
|
+
resolve5();
|
|
6636
7386
|
});
|
|
6637
7387
|
});
|
|
6638
7388
|
},
|
|
@@ -6684,7 +7434,7 @@ var AgentInstance = class _AgentInstance {
|
|
|
6684
7434
|
...opts.plan.env,
|
|
6685
7435
|
...opts.extraEnv ?? {}
|
|
6686
7436
|
};
|
|
6687
|
-
const child =
|
|
7437
|
+
const child = spawn2(opts.plan.command, opts.plan.args, {
|
|
6688
7438
|
cwd: opts.cwd,
|
|
6689
7439
|
env,
|
|
6690
7440
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -6711,17 +7461,43 @@ init_session();
|
|
|
6711
7461
|
|
|
6712
7462
|
// src/core/session-store.ts
|
|
6713
7463
|
init_paths();
|
|
6714
|
-
import * as
|
|
6715
|
-
import * as
|
|
7464
|
+
import * as fs5 from "fs/promises";
|
|
7465
|
+
import * as path3 from "path";
|
|
7466
|
+
import { customAlphabet as customAlphabet2 } from "nanoid";
|
|
6716
7467
|
import { z as z4 } from "zod";
|
|
7468
|
+
var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
7469
|
+
var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
|
|
7470
|
+
var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
|
|
7471
|
+
function generateLineageId() {
|
|
7472
|
+
return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
|
|
7473
|
+
}
|
|
6717
7474
|
var PersistedAgentCommand = z4.object({
|
|
6718
7475
|
name: z4.string(),
|
|
6719
7476
|
description: z4.string().optional()
|
|
6720
7477
|
});
|
|
7478
|
+
var PersistedUsage = z4.object({
|
|
7479
|
+
used: z4.number().optional(),
|
|
7480
|
+
size: z4.number().optional(),
|
|
7481
|
+
costAmount: z4.number().optional(),
|
|
7482
|
+
costCurrency: z4.string().optional()
|
|
7483
|
+
});
|
|
6721
7484
|
var SessionRecord = z4.object({
|
|
6722
7485
|
version: z4.literal(1),
|
|
6723
7486
|
sessionId: z4.string(),
|
|
7487
|
+
// Optional for back-compat with records written before this field
|
|
7488
|
+
// existed; mergeForPersistence generates one on next write so any
|
|
7489
|
+
// touched session converges to having a lineageId. A record that
|
|
7490
|
+
// never gets written again (truly cold and untouched) just won't
|
|
7491
|
+
// participate in lineage-based dedup, which is correct — it was
|
|
7492
|
+
// never exported, so no incoming bundle can claim its lineage.
|
|
7493
|
+
lineageId: z4.string().optional(),
|
|
6724
7494
|
upstreamSessionId: z4.string(),
|
|
7495
|
+
// When non-empty, marks a session that was created by import and is
|
|
7496
|
+
// waiting for its first attach to bootstrap a fresh upstream agent
|
|
7497
|
+
// and replay the imported history as a takeover transcript. The
|
|
7498
|
+
// origin's local id at export time, kept for debuggability and as a
|
|
7499
|
+
// breadcrumb in `sessions list` (informational, not used for routing).
|
|
7500
|
+
importedFromSessionId: z4.string().optional(),
|
|
6725
7501
|
agentId: z4.string(),
|
|
6726
7502
|
cwd: z4.string(),
|
|
6727
7503
|
title: z4.string().optional(),
|
|
@@ -6732,6 +7508,7 @@ var SessionRecord = z4.object({
|
|
|
6732
7508
|
// replay of a snapshot-shaped notification.
|
|
6733
7509
|
currentModel: z4.string().optional(),
|
|
6734
7510
|
currentMode: z4.string().optional(),
|
|
7511
|
+
currentUsage: PersistedUsage.optional(),
|
|
6735
7512
|
agentCommands: z4.array(PersistedAgentCommand).optional(),
|
|
6736
7513
|
createdAt: z4.string(),
|
|
6737
7514
|
updatedAt: z4.string()
|
|
@@ -6745,9 +7522,9 @@ function assertSafeId(id) {
|
|
|
6745
7522
|
var SessionStore = class {
|
|
6746
7523
|
async write(record) {
|
|
6747
7524
|
assertSafeId(record.sessionId);
|
|
6748
|
-
await
|
|
7525
|
+
await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
|
|
6749
7526
|
const full = { version: 1, ...record };
|
|
6750
|
-
await
|
|
7527
|
+
await fs5.writeFile(
|
|
6751
7528
|
paths.sessionFile(record.sessionId),
|
|
6752
7529
|
JSON.stringify(full, null, 2) + "\n",
|
|
6753
7530
|
{ encoding: "utf8", mode: 384 }
|
|
@@ -6759,7 +7536,7 @@ var SessionStore = class {
|
|
|
6759
7536
|
}
|
|
6760
7537
|
let raw;
|
|
6761
7538
|
try {
|
|
6762
|
-
raw = await
|
|
7539
|
+
raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
|
|
6763
7540
|
} catch (err) {
|
|
6764
7541
|
const e = err;
|
|
6765
7542
|
if (e.code === "ENOENT") {
|
|
@@ -6778,7 +7555,7 @@ var SessionStore = class {
|
|
|
6778
7555
|
return;
|
|
6779
7556
|
}
|
|
6780
7557
|
try {
|
|
6781
|
-
await
|
|
7558
|
+
await fs5.unlink(paths.sessionFile(sessionId));
|
|
6782
7559
|
} catch (err) {
|
|
6783
7560
|
const e = err;
|
|
6784
7561
|
if (e.code !== "ENOENT") {
|
|
@@ -6786,7 +7563,7 @@ var SessionStore = class {
|
|
|
6786
7563
|
}
|
|
6787
7564
|
}
|
|
6788
7565
|
try {
|
|
6789
|
-
await
|
|
7566
|
+
await fs5.rmdir(paths.sessionDir(sessionId));
|
|
6790
7567
|
} catch (err) {
|
|
6791
7568
|
const e = err;
|
|
6792
7569
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -6794,10 +7571,29 @@ var SessionStore = class {
|
|
|
6794
7571
|
}
|
|
6795
7572
|
}
|
|
6796
7573
|
}
|
|
7574
|
+
// Find a persisted session by lineageId. Used by SessionManager.import
|
|
7575
|
+
// to detect bundles that have already been imported (lineageId match)
|
|
7576
|
+
// so we can either error out or, with replace:true, overwrite.
|
|
7577
|
+
// Returns undefined if no record has that lineageId. Records that
|
|
7578
|
+
// pre-date the lineageId field simply don't match — which is
|
|
7579
|
+
// correct: they were never exported, so no incoming bundle can
|
|
7580
|
+
// legitimately claim their lineage.
|
|
7581
|
+
async findByLineageId(lineageId) {
|
|
7582
|
+
if (lineageId.length === 0) {
|
|
7583
|
+
return void 0;
|
|
7584
|
+
}
|
|
7585
|
+
const all = await this.list().catch(() => []);
|
|
7586
|
+
for (const record of all) {
|
|
7587
|
+
if (record.lineageId === lineageId) {
|
|
7588
|
+
return record;
|
|
7589
|
+
}
|
|
7590
|
+
}
|
|
7591
|
+
return void 0;
|
|
7592
|
+
}
|
|
6797
7593
|
async list() {
|
|
6798
7594
|
let entries;
|
|
6799
7595
|
try {
|
|
6800
|
-
entries = await
|
|
7596
|
+
entries = await fs5.readdir(paths.sessionsDir());
|
|
6801
7597
|
} catch (err) {
|
|
6802
7598
|
const e = err;
|
|
6803
7599
|
if (e.code === "ENOENT") {
|
|
@@ -6819,13 +7615,16 @@ function recordFromMemorySession(args) {
|
|
|
6819
7615
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6820
7616
|
return {
|
|
6821
7617
|
sessionId: args.sessionId,
|
|
7618
|
+
lineageId: args.lineageId,
|
|
6822
7619
|
upstreamSessionId: args.upstreamSessionId,
|
|
7620
|
+
importedFromSessionId: args.importedFromSessionId,
|
|
6823
7621
|
agentId: args.agentId,
|
|
6824
7622
|
cwd: args.cwd,
|
|
6825
7623
|
title: args.title,
|
|
6826
7624
|
agentArgs: args.agentArgs,
|
|
6827
7625
|
currentModel: args.currentModel,
|
|
6828
7626
|
currentMode: args.currentMode,
|
|
7627
|
+
currentUsage: args.currentUsage,
|
|
6829
7628
|
agentCommands: args.agentCommands,
|
|
6830
7629
|
createdAt: args.createdAt ?? now,
|
|
6831
7630
|
updatedAt: args.updatedAt ?? now
|
|
@@ -6834,7 +7633,7 @@ function recordFromMemorySession(args) {
|
|
|
6834
7633
|
|
|
6835
7634
|
// src/core/history-store.ts
|
|
6836
7635
|
init_paths();
|
|
6837
|
-
import * as
|
|
7636
|
+
import * as fs6 from "fs/promises";
|
|
6838
7637
|
var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
|
|
6839
7638
|
var MAX_ENTRIES = 1e3;
|
|
6840
7639
|
var HistoryStore = class {
|
|
@@ -6847,9 +7646,9 @@ var HistoryStore = class {
|
|
|
6847
7646
|
return;
|
|
6848
7647
|
}
|
|
6849
7648
|
return this.enqueue(sessionId, async () => {
|
|
6850
|
-
await
|
|
7649
|
+
await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
6851
7650
|
const line = JSON.stringify(entry) + "\n";
|
|
6852
|
-
await
|
|
7651
|
+
await fs6.appendFile(paths.historyFile(sessionId), line, {
|
|
6853
7652
|
encoding: "utf8",
|
|
6854
7653
|
mode: 384
|
|
6855
7654
|
});
|
|
@@ -6860,9 +7659,39 @@ var HistoryStore = class {
|
|
|
6860
7659
|
return;
|
|
6861
7660
|
}
|
|
6862
7661
|
return this.enqueue(sessionId, async () => {
|
|
6863
|
-
await
|
|
7662
|
+
await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
|
|
6864
7663
|
const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
6865
|
-
await
|
|
7664
|
+
await fs6.writeFile(paths.historyFile(sessionId), body, {
|
|
7665
|
+
encoding: "utf8",
|
|
7666
|
+
mode: 384
|
|
7667
|
+
});
|
|
7668
|
+
});
|
|
7669
|
+
}
|
|
7670
|
+
// Trim the on-disk history file to the most recent maxEntries lines.
|
|
7671
|
+
// Runs through the same per-session write queue as append/rewrite so
|
|
7672
|
+
// it's safe to invoke alongside ongoing writes; a no-op if the file is
|
|
7673
|
+
// already at or below the cap.
|
|
7674
|
+
async compact(sessionId, maxEntries) {
|
|
7675
|
+
if (!SESSION_ID_PATTERN2.test(sessionId)) {
|
|
7676
|
+
return;
|
|
7677
|
+
}
|
|
7678
|
+
return this.enqueue(sessionId, async () => {
|
|
7679
|
+
let raw;
|
|
7680
|
+
try {
|
|
7681
|
+
raw = await fs6.readFile(paths.historyFile(sessionId), "utf8");
|
|
7682
|
+
} catch (err) {
|
|
7683
|
+
const e = err;
|
|
7684
|
+
if (e.code === "ENOENT") {
|
|
7685
|
+
return;
|
|
7686
|
+
}
|
|
7687
|
+
throw err;
|
|
7688
|
+
}
|
|
7689
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
7690
|
+
if (lines.length <= maxEntries) {
|
|
7691
|
+
return;
|
|
7692
|
+
}
|
|
7693
|
+
const trimmed = lines.slice(-maxEntries);
|
|
7694
|
+
await fs6.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
|
|
6866
7695
|
encoding: "utf8",
|
|
6867
7696
|
mode: 384
|
|
6868
7697
|
});
|
|
@@ -6878,7 +7707,7 @@ var HistoryStore = class {
|
|
|
6878
7707
|
}
|
|
6879
7708
|
let raw;
|
|
6880
7709
|
try {
|
|
6881
|
-
raw = await
|
|
7710
|
+
raw = await fs6.readFile(paths.historyFile(sessionId), "utf8");
|
|
6882
7711
|
} catch (err) {
|
|
6883
7712
|
const e = err;
|
|
6884
7713
|
if (e.code === "ENOENT") {
|
|
@@ -6924,7 +7753,7 @@ var HistoryStore = class {
|
|
|
6924
7753
|
}
|
|
6925
7754
|
return this.enqueue(sessionId, async () => {
|
|
6926
7755
|
try {
|
|
6927
|
-
await
|
|
7756
|
+
await fs6.unlink(paths.historyFile(sessionId));
|
|
6928
7757
|
} catch (err) {
|
|
6929
7758
|
const e = err;
|
|
6930
7759
|
if (e.code !== "ENOENT") {
|
|
@@ -6932,7 +7761,7 @@ var HistoryStore = class {
|
|
|
6932
7761
|
}
|
|
6933
7762
|
}
|
|
6934
7763
|
try {
|
|
6935
|
-
await
|
|
7764
|
+
await fs6.rmdir(paths.sessionDir(sessionId));
|
|
6936
7765
|
} catch (err) {
|
|
6937
7766
|
const e = err;
|
|
6938
7767
|
if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
|
|
@@ -6957,7 +7786,10 @@ var HistoryStore = class {
|
|
|
6957
7786
|
|
|
6958
7787
|
// src/core/session-manager.ts
|
|
6959
7788
|
init_paths();
|
|
7789
|
+
init_history();
|
|
6960
7790
|
init_types();
|
|
7791
|
+
var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
7792
|
+
var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
|
|
6961
7793
|
var SessionManager = class {
|
|
6962
7794
|
constructor(registry, spawner, store, options = {}) {
|
|
6963
7795
|
this.registry = registry;
|
|
@@ -6965,6 +7797,7 @@ var SessionManager = class {
|
|
|
6965
7797
|
this.store = store ?? new SessionStore();
|
|
6966
7798
|
this.histories = new HistoryStore();
|
|
6967
7799
|
this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
7800
|
+
this.defaultModels = options.defaultModels ?? {};
|
|
6968
7801
|
}
|
|
6969
7802
|
registry;
|
|
6970
7803
|
sessions = /* @__PURE__ */ new Map();
|
|
@@ -6973,6 +7806,7 @@ var SessionManager = class {
|
|
|
6973
7806
|
store;
|
|
6974
7807
|
histories;
|
|
6975
7808
|
idleTimeoutMs;
|
|
7809
|
+
defaultModels;
|
|
6976
7810
|
// Serialize meta.json read-modify-write operations per session id so
|
|
6977
7811
|
// concurrent snapshot updates (e.g. an agent emitting model + mode
|
|
6978
7812
|
// back-to-back) don't lose writes via interleaved reads.
|
|
@@ -6994,7 +7828,8 @@ var SessionManager = class {
|
|
|
6994
7828
|
agentArgs: params.agentArgs,
|
|
6995
7829
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
6996
7830
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
6997
|
-
historyStore: this.histories
|
|
7831
|
+
historyStore: this.histories,
|
|
7832
|
+
currentModel: fresh.initialModel
|
|
6998
7833
|
});
|
|
6999
7834
|
await this.attachManagerHooks(session);
|
|
7000
7835
|
return session;
|
|
@@ -7036,7 +7871,10 @@ var SessionManager = class {
|
|
|
7036
7871
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
7037
7872
|
throw err;
|
|
7038
7873
|
}
|
|
7039
|
-
|
|
7874
|
+
if (params.upstreamSessionId === "") {
|
|
7875
|
+
return this.doResurrectFromImport(params);
|
|
7876
|
+
}
|
|
7877
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
7040
7878
|
const agent = this.spawner({
|
|
7041
7879
|
agentId: params.agentId,
|
|
7042
7880
|
cwd: params.cwd,
|
|
@@ -7049,11 +7887,14 @@ var SessionManager = class {
|
|
|
7049
7887
|
});
|
|
7050
7888
|
let loadResult;
|
|
7051
7889
|
try {
|
|
7052
|
-
loadResult = await agent.connection.request(
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7890
|
+
loadResult = await agent.connection.request(
|
|
7891
|
+
"session/load",
|
|
7892
|
+
{
|
|
7893
|
+
sessionId: params.upstreamSessionId,
|
|
7894
|
+
cwd: params.cwd,
|
|
7895
|
+
mcpServers: []
|
|
7896
|
+
}
|
|
7897
|
+
);
|
|
7057
7898
|
} catch (err) {
|
|
7058
7899
|
await agent.kill().catch(() => void 0);
|
|
7059
7900
|
throw new Error(
|
|
@@ -7072,17 +7913,65 @@ var SessionManager = class {
|
|
|
7072
7913
|
idleTimeoutMs: this.idleTimeoutMs,
|
|
7073
7914
|
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
7074
7915
|
historyStore: this.histories,
|
|
7075
|
-
|
|
7076
|
-
|
|
7916
|
+
// Prefer what we previously stored from a current_model_update; if
|
|
7917
|
+
// we never captured one (e.g. old opencode sessions on disk before
|
|
7918
|
+
// this fix), fall back to the model the agent ships in its
|
|
7919
|
+
// session/load response body.
|
|
7920
|
+
currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
|
|
7921
|
+
currentMode: params.currentMode,
|
|
7922
|
+
currentUsage: params.currentUsage,
|
|
7923
|
+
agentCommands: params.agentCommands,
|
|
7924
|
+
// Only gate the first-prompt title heuristic when we actually have
|
|
7925
|
+
// a title to preserve. A title-less session (lost to a write race
|
|
7926
|
+
// or never seeded) should re-derive from the next prompt rather
|
|
7927
|
+
// than stay stuck.
|
|
7928
|
+
firstPromptSeeded: !!params.title,
|
|
7929
|
+
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
7930
|
+
});
|
|
7931
|
+
await this.attachManagerHooks(session);
|
|
7932
|
+
return session;
|
|
7933
|
+
}
|
|
7934
|
+
// First-attach path for a session that was created via import(). The
|
|
7935
|
+
// on-disk meta.json carries upstreamSessionId="" as the import
|
|
7936
|
+
// marker; bootstrap a fresh agent (gets a real upstream id) and kick
|
|
7937
|
+
// off seedFromImport so the agent absorbs the historical transcript.
|
|
7938
|
+
// attachManagerHooks rewrites meta.json with the new upstreamSessionId,
|
|
7939
|
+
// so subsequent resurrects of this session use the normal session/load
|
|
7940
|
+
// path.
|
|
7941
|
+
async doResurrectFromImport(params) {
|
|
7942
|
+
const fresh = await this.bootstrapAgent({
|
|
7943
|
+
agentId: params.agentId,
|
|
7944
|
+
cwd: params.cwd,
|
|
7945
|
+
agentArgs: params.agentArgs,
|
|
7946
|
+
mcpServers: []
|
|
7947
|
+
});
|
|
7948
|
+
const session = new Session({
|
|
7949
|
+
sessionId: params.hydraSessionId,
|
|
7950
|
+
cwd: params.cwd,
|
|
7951
|
+
agentId: params.agentId,
|
|
7952
|
+
agent: fresh.agent,
|
|
7953
|
+
upstreamSessionId: fresh.upstreamSessionId,
|
|
7954
|
+
agentMeta: fresh.agentMeta,
|
|
7955
|
+
title: params.title,
|
|
7956
|
+
agentArgs: params.agentArgs,
|
|
7957
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
7958
|
+
spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
|
|
7959
|
+
historyStore: this.histories,
|
|
7960
|
+
// Prefer the stored value (set by a previous current_model_update);
|
|
7961
|
+
// fall back to whatever the agent ships in its session/new response.
|
|
7962
|
+
currentModel: params.currentModel ?? fresh.initialModel,
|
|
7077
7963
|
currentMode: params.currentMode,
|
|
7964
|
+
currentUsage: params.currentUsage,
|
|
7078
7965
|
agentCommands: params.agentCommands,
|
|
7079
|
-
firstPromptSeeded:
|
|
7966
|
+
firstPromptSeeded: !!params.title,
|
|
7967
|
+
createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
|
|
7080
7968
|
});
|
|
7081
7969
|
await this.attachManagerHooks(session);
|
|
7970
|
+
void session.seedFromImport().catch(() => void 0);
|
|
7082
7971
|
return session;
|
|
7083
7972
|
}
|
|
7084
7973
|
// Bootstrap a fresh agent process: registry resolve → spawn → initialize
|
|
7085
|
-
// → session/new. Shared by create() and the /hydra
|
|
7974
|
+
// → session/new. Shared by create() and the /hydra agent path so both
|
|
7086
7975
|
// go through the same env / capabilities / error-handling.
|
|
7087
7976
|
async bootstrapAgent(params) {
|
|
7088
7977
|
const agentDef = await this.registry.getAgent(params.agentId);
|
|
@@ -7093,7 +7982,7 @@ var SessionManager = class {
|
|
|
7093
7982
|
err.code = JsonRpcErrorCodes.AgentNotInstalled;
|
|
7094
7983
|
throw err;
|
|
7095
7984
|
}
|
|
7096
|
-
const plan = planSpawn(agentDef, params.agentArgs ?? []);
|
|
7985
|
+
const plan = await planSpawn(agentDef, params.agentArgs ?? []);
|
|
7097
7986
|
const agent = this.spawner({
|
|
7098
7987
|
agentId: params.agentId,
|
|
7099
7988
|
cwd: params.cwd,
|
|
@@ -7105,14 +7994,36 @@ var SessionManager = class {
|
|
|
7105
7994
|
clientCapabilities: {},
|
|
7106
7995
|
clientInfo: { name: "hydra", version: "0.1.0" }
|
|
7107
7996
|
});
|
|
7108
|
-
const newResult = await agent.connection.request(
|
|
7109
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7997
|
+
const newResult = await agent.connection.request(
|
|
7998
|
+
"session/new",
|
|
7999
|
+
{
|
|
8000
|
+
cwd: params.cwd,
|
|
8001
|
+
mcpServers: params.mcpServers ?? []
|
|
8002
|
+
}
|
|
8003
|
+
);
|
|
8004
|
+
const sessionIdRaw = newResult.sessionId;
|
|
8005
|
+
if (typeof sessionIdRaw !== "string") {
|
|
8006
|
+
throw new Error(
|
|
8007
|
+
`agent ${params.agentId} returned a non-string sessionId from session/new`
|
|
8008
|
+
);
|
|
8009
|
+
}
|
|
8010
|
+
let initialModel = extractInitialModel(newResult);
|
|
8011
|
+
const desired = this.defaultModels[params.agentId];
|
|
8012
|
+
if (desired && desired !== initialModel) {
|
|
8013
|
+
try {
|
|
8014
|
+
await agent.connection.request("session/set_model", {
|
|
8015
|
+
sessionId: sessionIdRaw,
|
|
8016
|
+
modelId: desired
|
|
8017
|
+
});
|
|
8018
|
+
initialModel = desired;
|
|
8019
|
+
} catch {
|
|
8020
|
+
}
|
|
8021
|
+
}
|
|
7112
8022
|
return {
|
|
7113
8023
|
agent,
|
|
7114
|
-
upstreamSessionId:
|
|
7115
|
-
agentMeta: newResult._meta
|
|
8024
|
+
upstreamSessionId: sessionIdRaw,
|
|
8025
|
+
agentMeta: newResult._meta,
|
|
8026
|
+
initialModel
|
|
7116
8027
|
};
|
|
7117
8028
|
} catch (err) {
|
|
7118
8029
|
await agent.kill().catch(() => void 0);
|
|
@@ -7123,7 +8034,7 @@ var SessionManager = class {
|
|
|
7123
8034
|
// bookkeeping. Called from both create() and resurrect() so the same
|
|
7124
8035
|
// session record + lifecycle handlers are wired regardless of origin.
|
|
7125
8036
|
// Returns once the initial disk record is written — callers should
|
|
7126
|
-
// await so a subsequent /hydra
|
|
8037
|
+
// await so a subsequent /hydra agent's persistAgentChange (which
|
|
7127
8038
|
// does read-then-write) finds the file in place.
|
|
7128
8039
|
async attachManagerHooks(session) {
|
|
7129
8040
|
session.onClose(({ deleteRecord }) => {
|
|
@@ -7151,6 +8062,11 @@ var SessionManager = class {
|
|
|
7151
8062
|
() => void 0
|
|
7152
8063
|
);
|
|
7153
8064
|
});
|
|
8065
|
+
session.onUsageChange((usage) => {
|
|
8066
|
+
void this.persistSnapshot(session.sessionId, {
|
|
8067
|
+
currentUsage: usageSnapshotToPersisted(usage)
|
|
8068
|
+
}).catch(() => void 0);
|
|
8069
|
+
});
|
|
7154
8070
|
session.onAgentCommandsChange((commands) => {
|
|
7155
8071
|
void this.persistSnapshot(session.sessionId, {
|
|
7156
8072
|
agentCommands: commands.map((c) => ({
|
|
@@ -7160,28 +8076,20 @@ var SessionManager = class {
|
|
|
7160
8076
|
}).catch(() => void 0);
|
|
7161
8077
|
});
|
|
7162
8078
|
this.sessions.set(session.sessionId, session);
|
|
7163
|
-
await this.
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
cwd: session.cwd,
|
|
7169
|
-
title: session.title,
|
|
7170
|
-
agentArgs: session.agentArgs,
|
|
7171
|
-
currentModel: session.currentModel,
|
|
7172
|
-
currentMode: session.currentMode
|
|
7173
|
-
})
|
|
7174
|
-
).catch(() => void 0);
|
|
8079
|
+
await this.enqueueMetaWrite(session.sessionId, async () => {
|
|
8080
|
+
const existing = await this.store.read(session.sessionId);
|
|
8081
|
+
const merged = mergeForPersistence(session, existing);
|
|
8082
|
+
await this.store.write(merged);
|
|
8083
|
+
}).catch(() => void 0);
|
|
7175
8084
|
}
|
|
7176
8085
|
// Resolve a session's recorded history without forcing a resurrect.
|
|
7177
|
-
//
|
|
7178
|
-
//
|
|
7179
|
-
//
|
|
7180
|
-
//
|
|
8086
|
+
// Always loads from disk — that's the source of truth whether the
|
|
8087
|
+
// session is hot or cold. Returns undefined if the session id is
|
|
8088
|
+
// unknown to both the live map and disk store, so the caller can
|
|
8089
|
+
// distinguish "no history yet" (empty array) from "404".
|
|
7181
8090
|
async getHistory(sessionId) {
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
return live.getHistorySnapshot();
|
|
8091
|
+
if (this.sessions.has(sessionId)) {
|
|
8092
|
+
return this.histories.load(sessionId).catch(() => []);
|
|
7185
8093
|
}
|
|
7186
8094
|
const record = await this.store.read(sessionId);
|
|
7187
8095
|
if (!record) {
|
|
@@ -7194,20 +8102,42 @@ var SessionManager = class {
|
|
|
7194
8102
|
if (!record) {
|
|
7195
8103
|
return void 0;
|
|
7196
8104
|
}
|
|
7197
|
-
|
|
8105
|
+
let title = record.title;
|
|
8106
|
+
if (!title) {
|
|
8107
|
+
title = await this.deriveTitleFromHistory(sessionId);
|
|
8108
|
+
}
|
|
7198
8109
|
return {
|
|
7199
8110
|
hydraSessionId: record.sessionId,
|
|
7200
8111
|
upstreamSessionId: record.upstreamSessionId,
|
|
7201
8112
|
agentId: record.agentId,
|
|
7202
8113
|
cwd: record.cwd,
|
|
7203
|
-
title
|
|
8114
|
+
title,
|
|
7204
8115
|
agentArgs: record.agentArgs,
|
|
7205
|
-
seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
|
|
7206
8116
|
currentModel: record.currentModel,
|
|
7207
8117
|
currentMode: record.currentMode,
|
|
7208
|
-
|
|
8118
|
+
currentUsage: persistedUsageToSnapshot(record.currentUsage),
|
|
8119
|
+
agentCommands: record.agentCommands,
|
|
8120
|
+
createdAt: record.createdAt
|
|
7209
8121
|
};
|
|
7210
8122
|
}
|
|
8123
|
+
// Best-effort: peek at the persisted history's first prompt and use
|
|
8124
|
+
// its first line (capped to 200 chars) as a session title. Returns
|
|
8125
|
+
// undefined if no usable prompt is found or any I/O fails.
|
|
8126
|
+
async deriveTitleFromHistory(sessionId) {
|
|
8127
|
+
const history = await this.histories.load(sessionId).catch(() => []);
|
|
8128
|
+
for (const entry of history) {
|
|
8129
|
+
const params = entry.params;
|
|
8130
|
+
if (params?.update?.sessionUpdate !== "prompt_received") {
|
|
8131
|
+
continue;
|
|
8132
|
+
}
|
|
8133
|
+
const text = extractPromptText(params.update.prompt);
|
|
8134
|
+
const line = firstLine(text, 200);
|
|
8135
|
+
if (line) {
|
|
8136
|
+
return line;
|
|
8137
|
+
}
|
|
8138
|
+
}
|
|
8139
|
+
return void 0;
|
|
8140
|
+
}
|
|
7211
8141
|
get(sessionId) {
|
|
7212
8142
|
return this.sessions.get(sessionId);
|
|
7213
8143
|
}
|
|
@@ -7254,6 +8184,8 @@ var SessionManager = class {
|
|
|
7254
8184
|
cwd: session.cwd,
|
|
7255
8185
|
title: session.title,
|
|
7256
8186
|
agentId: session.agentId,
|
|
8187
|
+
currentModel: session.currentModel,
|
|
8188
|
+
currentUsage: session.currentUsage,
|
|
7257
8189
|
updatedAt: used,
|
|
7258
8190
|
attachedClients: session.attachedCount,
|
|
7259
8191
|
status: "live"
|
|
@@ -7274,6 +8206,8 @@ var SessionManager = class {
|
|
|
7274
8206
|
cwd: r.cwd,
|
|
7275
8207
|
title: r.title,
|
|
7276
8208
|
agentId: r.agentId,
|
|
8209
|
+
currentModel: r.currentModel,
|
|
8210
|
+
currentUsage: r.currentUsage,
|
|
7277
8211
|
updatedAt: used,
|
|
7278
8212
|
attachedClients: 0,
|
|
7279
8213
|
status: "cold"
|
|
@@ -7282,6 +8216,112 @@ var SessionManager = class {
|
|
|
7282
8216
|
entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
|
|
7283
8217
|
return entries;
|
|
7284
8218
|
}
|
|
8219
|
+
// Build an export bundle for a session, reading meta + history from
|
|
8220
|
+
// disk. Backfills lineageId if the on-disk record pre-dates that
|
|
8221
|
+
// field. Returns undefined if the session doesn't exist. Callers
|
|
8222
|
+
// populate the bundle's exportedFrom metadata themselves.
|
|
8223
|
+
async exportBundle(sessionId) {
|
|
8224
|
+
const record = await this.store.read(sessionId);
|
|
8225
|
+
if (!record) {
|
|
8226
|
+
return void 0;
|
|
8227
|
+
}
|
|
8228
|
+
let withLineage;
|
|
8229
|
+
if (record.lineageId) {
|
|
8230
|
+
withLineage = record;
|
|
8231
|
+
} else {
|
|
8232
|
+
const lineageId = generateLineageId();
|
|
8233
|
+
const backfilled = { ...record, lineageId };
|
|
8234
|
+
await this.enqueueMetaWrite(sessionId, async () => {
|
|
8235
|
+
const latest = await this.store.read(sessionId);
|
|
8236
|
+
if (!latest) {
|
|
8237
|
+
return;
|
|
8238
|
+
}
|
|
8239
|
+
if (latest.lineageId) {
|
|
8240
|
+
return;
|
|
8241
|
+
}
|
|
8242
|
+
await this.store.write({ ...latest, lineageId });
|
|
8243
|
+
}).catch(() => void 0);
|
|
8244
|
+
withLineage = backfilled;
|
|
8245
|
+
}
|
|
8246
|
+
const history = await this.histories.load(sessionId).catch(() => []);
|
|
8247
|
+
const promptHistory = await loadPromptHistorySafely(sessionId);
|
|
8248
|
+
return { record: withLineage, history, promptHistory };
|
|
8249
|
+
}
|
|
8250
|
+
// Create a local session from an imported bundle. Without `replace`,
|
|
8251
|
+
// a bundle with a lineageId we already have on disk throws
|
|
8252
|
+
// BundleAlreadyImported citing the existing local id. With
|
|
8253
|
+
// `replace: true`, the existing record is overwritten in-place (its
|
|
8254
|
+
// local sessionId is preserved so bookmarks/Slack thread links still
|
|
8255
|
+
// resolve), and any live in-memory session is closed so the next
|
|
8256
|
+
// attach triggers the import-reseed path.
|
|
8257
|
+
async importBundle(bundle, opts = {}) {
|
|
8258
|
+
const existing = await this.store.findByLineageId(bundle.session.lineageId);
|
|
8259
|
+
if (existing) {
|
|
8260
|
+
if (!opts.replace) {
|
|
8261
|
+
const err = new Error(
|
|
8262
|
+
`bundle already imported as ${existing.sessionId}`
|
|
8263
|
+
);
|
|
8264
|
+
err.code = JsonRpcErrorCodes.BundleAlreadyImported;
|
|
8265
|
+
err.existingSessionId = existing.sessionId;
|
|
8266
|
+
throw err;
|
|
8267
|
+
}
|
|
8268
|
+
const live = this.sessions.get(existing.sessionId);
|
|
8269
|
+
if (live) {
|
|
8270
|
+
await live.close({ deleteRecord: false }).catch(() => void 0);
|
|
8271
|
+
}
|
|
8272
|
+
await this.writeImportedRecord({
|
|
8273
|
+
sessionId: existing.sessionId,
|
|
8274
|
+
bundle,
|
|
8275
|
+
preservedCreatedAt: existing.createdAt
|
|
8276
|
+
});
|
|
8277
|
+
return {
|
|
8278
|
+
sessionId: existing.sessionId,
|
|
8279
|
+
importedFromSessionId: bundle.session.sessionId,
|
|
8280
|
+
replaced: true
|
|
8281
|
+
};
|
|
8282
|
+
}
|
|
8283
|
+
const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
|
|
8284
|
+
await this.writeImportedRecord({ sessionId: newId, bundle });
|
|
8285
|
+
return {
|
|
8286
|
+
sessionId: newId,
|
|
8287
|
+
importedFromSessionId: bundle.session.sessionId,
|
|
8288
|
+
replaced: false
|
|
8289
|
+
};
|
|
8290
|
+
}
|
|
8291
|
+
// Write the imported bundle's history.jsonl, prompt-history (if
|
|
8292
|
+
// present), and meta.json. upstreamSessionId is left empty as the
|
|
8293
|
+
// marker that the first attach should bootstrap a fresh agent and
|
|
8294
|
+
// run seedFromImport rather than calling session/load.
|
|
8295
|
+
async writeImportedRecord(args) {
|
|
8296
|
+
await this.histories.rewrite(
|
|
8297
|
+
args.sessionId,
|
|
8298
|
+
args.bundle.history
|
|
8299
|
+
);
|
|
8300
|
+
if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
|
|
8301
|
+
await saveHistory(
|
|
8302
|
+
paths.tuiHistoryFile(args.sessionId),
|
|
8303
|
+
args.bundle.promptHistory
|
|
8304
|
+
).catch(() => void 0);
|
|
8305
|
+
}
|
|
8306
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8307
|
+
await this.enqueueMetaWrite(args.sessionId, async () => {
|
|
8308
|
+
await this.store.write({
|
|
8309
|
+
sessionId: args.sessionId,
|
|
8310
|
+
lineageId: args.bundle.session.lineageId,
|
|
8311
|
+
upstreamSessionId: "",
|
|
8312
|
+
importedFromSessionId: args.bundle.session.sessionId,
|
|
8313
|
+
agentId: args.bundle.session.agentId,
|
|
8314
|
+
cwd: args.bundle.session.cwd,
|
|
8315
|
+
title: args.bundle.session.title,
|
|
8316
|
+
currentModel: args.bundle.session.currentModel,
|
|
8317
|
+
currentMode: args.bundle.session.currentMode,
|
|
8318
|
+
currentUsage: args.bundle.session.currentUsage,
|
|
8319
|
+
agentCommands: args.bundle.session.agentCommands,
|
|
8320
|
+
createdAt: args.preservedCreatedAt ?? now,
|
|
8321
|
+
updatedAt: now
|
|
8322
|
+
});
|
|
8323
|
+
});
|
|
8324
|
+
}
|
|
7285
8325
|
async deleteRecord(sessionId) {
|
|
7286
8326
|
const record = await this.store.read(sessionId);
|
|
7287
8327
|
if (!record) {
|
|
@@ -7311,7 +8351,7 @@ var SessionManager = class {
|
|
|
7311
8351
|
});
|
|
7312
8352
|
});
|
|
7313
8353
|
}
|
|
7314
|
-
// Persist an agent swap from /hydra
|
|
8354
|
+
// Persist an agent swap from /hydra agent. The on-disk record's
|
|
7315
8355
|
// agentId + upstreamSessionId both rotate so a daemon restart (and
|
|
7316
8356
|
// later resurrect) brings the session back up on the agent the user
|
|
7317
8357
|
// most recently switched to, not the one it was originally created on.
|
|
@@ -7343,6 +8383,7 @@ var SessionManager = class {
|
|
|
7343
8383
|
...record,
|
|
7344
8384
|
...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
|
|
7345
8385
|
...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
|
|
8386
|
+
...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
|
|
7346
8387
|
...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
|
|
7347
8388
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7348
8389
|
});
|
|
@@ -7367,10 +8408,124 @@ var SessionManager = class {
|
|
|
7367
8408
|
await Promise.allSettled(sessions.map((s) => s.close()));
|
|
7368
8409
|
this.sessions.clear();
|
|
7369
8410
|
}
|
|
8411
|
+
// Wait for every pending meta.json write to settle. Daemon shutdown
|
|
8412
|
+
// hooks call this so a SIGTERM doesn't kill the process mid-write
|
|
8413
|
+
// and lose a freshly-set title (or model/mode/commands).
|
|
8414
|
+
async flushMetaWrites() {
|
|
8415
|
+
const pending = [...this.metaWriteQueues.values()];
|
|
8416
|
+
if (pending.length === 0) {
|
|
8417
|
+
return;
|
|
8418
|
+
}
|
|
8419
|
+
await Promise.allSettled(pending);
|
|
8420
|
+
}
|
|
7370
8421
|
};
|
|
8422
|
+
function mergeForPersistence(session, existing) {
|
|
8423
|
+
const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
|
|
8424
|
+
if (c.description !== void 0) {
|
|
8425
|
+
return { name: c.name, description: c.description };
|
|
8426
|
+
}
|
|
8427
|
+
return { name: c.name };
|
|
8428
|
+
}) : void 0;
|
|
8429
|
+
const agentCommands = persistedCommands ?? existing?.agentCommands;
|
|
8430
|
+
return recordFromMemorySession({
|
|
8431
|
+
sessionId: session.sessionId,
|
|
8432
|
+
lineageId: existing?.lineageId ?? generateLineageId(),
|
|
8433
|
+
upstreamSessionId: session.upstreamSessionId,
|
|
8434
|
+
importedFromSessionId: existing?.importedFromSessionId,
|
|
8435
|
+
agentId: session.agentId,
|
|
8436
|
+
cwd: session.cwd,
|
|
8437
|
+
title: session.title,
|
|
8438
|
+
agentArgs: session.agentArgs,
|
|
8439
|
+
currentModel: session.currentModel ?? existing?.currentModel,
|
|
8440
|
+
currentMode: session.currentMode ?? existing?.currentMode,
|
|
8441
|
+
currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
|
|
8442
|
+
agentCommands,
|
|
8443
|
+
createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
|
|
8444
|
+
});
|
|
8445
|
+
}
|
|
8446
|
+
function usageSnapshotToPersisted(usage) {
|
|
8447
|
+
if (!usage) {
|
|
8448
|
+
return void 0;
|
|
8449
|
+
}
|
|
8450
|
+
const out = {};
|
|
8451
|
+
if (usage.used !== void 0) {
|
|
8452
|
+
out.used = usage.used;
|
|
8453
|
+
}
|
|
8454
|
+
if (usage.size !== void 0) {
|
|
8455
|
+
out.size = usage.size;
|
|
8456
|
+
}
|
|
8457
|
+
if (usage.costAmount !== void 0) {
|
|
8458
|
+
out.costAmount = usage.costAmount;
|
|
8459
|
+
}
|
|
8460
|
+
if (usage.costCurrency !== void 0) {
|
|
8461
|
+
out.costCurrency = usage.costCurrency;
|
|
8462
|
+
}
|
|
8463
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
8464
|
+
}
|
|
8465
|
+
function persistedUsageToSnapshot(usage) {
|
|
8466
|
+
return usage ? { ...usage } : void 0;
|
|
8467
|
+
}
|
|
8468
|
+
function extractInitialModel(result) {
|
|
8469
|
+
const direct = asString(result.currentModelId) ?? asString(result.currentModel) ?? asString(result.modelId) ?? asString(result.model);
|
|
8470
|
+
if (direct) {
|
|
8471
|
+
return direct;
|
|
8472
|
+
}
|
|
8473
|
+
const models = result.models;
|
|
8474
|
+
if (models && typeof models === "object" && !Array.isArray(models)) {
|
|
8475
|
+
const m = asString(models.currentModelId) ?? asString(models.currentModel);
|
|
8476
|
+
if (m) {
|
|
8477
|
+
return m;
|
|
8478
|
+
}
|
|
8479
|
+
}
|
|
8480
|
+
const meta = result._meta;
|
|
8481
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
8482
|
+
for (const [key, value] of Object.entries(
|
|
8483
|
+
meta
|
|
8484
|
+
)) {
|
|
8485
|
+
if (key === "hydra-acp") {
|
|
8486
|
+
continue;
|
|
8487
|
+
}
|
|
8488
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
8489
|
+
const m = asString(value.modelId) ?? asString(value.model) ?? asString(value.currentModelId);
|
|
8490
|
+
if (m) {
|
|
8491
|
+
return m;
|
|
8492
|
+
}
|
|
8493
|
+
}
|
|
8494
|
+
}
|
|
8495
|
+
}
|
|
8496
|
+
return void 0;
|
|
8497
|
+
}
|
|
8498
|
+
function asString(value) {
|
|
8499
|
+
if (typeof value !== "string") {
|
|
8500
|
+
return void 0;
|
|
8501
|
+
}
|
|
8502
|
+
const trimmed = value.trim();
|
|
8503
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
8504
|
+
}
|
|
8505
|
+
async function loadPromptHistorySafely(sessionId) {
|
|
8506
|
+
try {
|
|
8507
|
+
const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
|
|
8508
|
+
const out = [];
|
|
8509
|
+
for (const line of raw.split("\n")) {
|
|
8510
|
+
if (line.length === 0) {
|
|
8511
|
+
continue;
|
|
8512
|
+
}
|
|
8513
|
+
try {
|
|
8514
|
+
const decoded = JSON.parse(line);
|
|
8515
|
+
if (typeof decoded === "string") {
|
|
8516
|
+
out.push(decoded);
|
|
8517
|
+
}
|
|
8518
|
+
} catch {
|
|
8519
|
+
}
|
|
8520
|
+
}
|
|
8521
|
+
return out;
|
|
8522
|
+
} catch {
|
|
8523
|
+
return [];
|
|
8524
|
+
}
|
|
8525
|
+
}
|
|
7371
8526
|
async function historyMtimeIso(sessionId) {
|
|
7372
8527
|
try {
|
|
7373
|
-
const st = await
|
|
8528
|
+
const st = await fs8.stat(paths.historyFile(sessionId));
|
|
7374
8529
|
return new Date(st.mtimeMs).toISOString();
|
|
7375
8530
|
} catch {
|
|
7376
8531
|
return void 0;
|
|
@@ -7379,10 +8534,10 @@ async function historyMtimeIso(sessionId) {
|
|
|
7379
8534
|
|
|
7380
8535
|
// src/core/extensions.ts
|
|
7381
8536
|
init_paths();
|
|
7382
|
-
import { spawn as
|
|
7383
|
-
import * as
|
|
7384
|
-
import * as
|
|
7385
|
-
import * as
|
|
8537
|
+
import { spawn as spawn3 } from "child_process";
|
|
8538
|
+
import * as fs9 from "fs";
|
|
8539
|
+
import * as fsp2 from "fs/promises";
|
|
8540
|
+
import * as path5 from "path";
|
|
7386
8541
|
var RESTART_BASE_MS = 1e3;
|
|
7387
8542
|
var RESTART_CAP_MS = 6e4;
|
|
7388
8543
|
var STOP_GRACE_MS = 3e3;
|
|
@@ -7403,7 +8558,7 @@ var ExtensionManager = class {
|
|
|
7403
8558
|
if (!this.context) {
|
|
7404
8559
|
throw new Error("ExtensionManager: setContext must be called before start");
|
|
7405
8560
|
}
|
|
7406
|
-
await
|
|
8561
|
+
await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
|
|
7407
8562
|
await this.reapOrphans();
|
|
7408
8563
|
for (const entry of this.entries.values()) {
|
|
7409
8564
|
if (!entry.config.enabled) {
|
|
@@ -7429,9 +8584,9 @@ var ExtensionManager = class {
|
|
|
7429
8584
|
} catch {
|
|
7430
8585
|
}
|
|
7431
8586
|
tasks.push(
|
|
7432
|
-
new Promise((
|
|
8587
|
+
new Promise((resolve5) => {
|
|
7433
8588
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
7434
|
-
|
|
8589
|
+
resolve5();
|
|
7435
8590
|
return;
|
|
7436
8591
|
}
|
|
7437
8592
|
const timer = setTimeout(() => {
|
|
@@ -7439,11 +8594,11 @@ var ExtensionManager = class {
|
|
|
7439
8594
|
child.kill("SIGKILL");
|
|
7440
8595
|
} catch {
|
|
7441
8596
|
}
|
|
7442
|
-
|
|
8597
|
+
resolve5();
|
|
7443
8598
|
}, STOP_GRACE_MS);
|
|
7444
8599
|
child.on("exit", () => {
|
|
7445
8600
|
clearTimeout(timer);
|
|
7446
|
-
|
|
8601
|
+
resolve5();
|
|
7447
8602
|
});
|
|
7448
8603
|
})
|
|
7449
8604
|
);
|
|
@@ -7551,8 +8706,8 @@ var ExtensionManager = class {
|
|
|
7551
8706
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
7552
8707
|
return;
|
|
7553
8708
|
}
|
|
7554
|
-
const exited = new Promise((
|
|
7555
|
-
entry.exitWaiters.push(
|
|
8709
|
+
const exited = new Promise((resolve5) => {
|
|
8710
|
+
entry.exitWaiters.push(resolve5);
|
|
7556
8711
|
});
|
|
7557
8712
|
try {
|
|
7558
8713
|
child.kill("SIGTERM");
|
|
@@ -7612,7 +8767,7 @@ var ExtensionManager = class {
|
|
|
7612
8767
|
async reapOrphans() {
|
|
7613
8768
|
let entries;
|
|
7614
8769
|
try {
|
|
7615
|
-
entries = await
|
|
8770
|
+
entries = await fsp2.readdir(paths.extensionsDir());
|
|
7616
8771
|
} catch (err) {
|
|
7617
8772
|
const e = err;
|
|
7618
8773
|
if (e.code === "ENOENT") {
|
|
@@ -7624,10 +8779,10 @@ var ExtensionManager = class {
|
|
|
7624
8779
|
if (!entry.endsWith(".pid")) {
|
|
7625
8780
|
continue;
|
|
7626
8781
|
}
|
|
7627
|
-
const pidPath =
|
|
8782
|
+
const pidPath = path5.join(paths.extensionsDir(), entry);
|
|
7628
8783
|
let pid;
|
|
7629
8784
|
try {
|
|
7630
|
-
const raw = await
|
|
8785
|
+
const raw = await fsp2.readFile(pidPath, "utf8");
|
|
7631
8786
|
const parsed = Number.parseInt(raw.trim(), 10);
|
|
7632
8787
|
if (Number.isInteger(parsed) && parsed > 0) {
|
|
7633
8788
|
pid = parsed;
|
|
@@ -7650,7 +8805,7 @@ var ExtensionManager = class {
|
|
|
7650
8805
|
}
|
|
7651
8806
|
}
|
|
7652
8807
|
}
|
|
7653
|
-
await
|
|
8808
|
+
await fsp2.unlink(pidPath).catch(() => void 0);
|
|
7654
8809
|
}
|
|
7655
8810
|
}
|
|
7656
8811
|
spawn(entry, attempt) {
|
|
@@ -7663,7 +8818,7 @@ var ExtensionManager = class {
|
|
|
7663
8818
|
}
|
|
7664
8819
|
const ext = entry.config;
|
|
7665
8820
|
const command = ext.command.length > 0 ? ext.command : [ext.name];
|
|
7666
|
-
const logStream =
|
|
8821
|
+
const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
|
|
7667
8822
|
flags: "a"
|
|
7668
8823
|
});
|
|
7669
8824
|
logStream.write(
|
|
@@ -7691,7 +8846,7 @@ var ExtensionManager = class {
|
|
|
7691
8846
|
const args = [...baseArgs, ...ext.args];
|
|
7692
8847
|
let child;
|
|
7693
8848
|
try {
|
|
7694
|
-
child =
|
|
8849
|
+
child = spawn3(cmd, args, {
|
|
7695
8850
|
env,
|
|
7696
8851
|
stdio: ["ignore", "pipe", "pipe"],
|
|
7697
8852
|
detached: false
|
|
@@ -7713,7 +8868,7 @@ var ExtensionManager = class {
|
|
|
7713
8868
|
}
|
|
7714
8869
|
if (typeof child.pid === "number") {
|
|
7715
8870
|
try {
|
|
7716
|
-
|
|
8871
|
+
fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
|
|
7717
8872
|
`, {
|
|
7718
8873
|
encoding: "utf8",
|
|
7719
8874
|
mode: 384
|
|
@@ -7738,7 +8893,7 @@ var ExtensionManager = class {
|
|
|
7738
8893
|
});
|
|
7739
8894
|
child.on("exit", (code, signal) => {
|
|
7740
8895
|
try {
|
|
7741
|
-
|
|
8896
|
+
fs9.unlinkSync(paths.extensionPidFile(ext.name));
|
|
7742
8897
|
} catch {
|
|
7743
8898
|
}
|
|
7744
8899
|
logStream.write(
|
|
@@ -7749,8 +8904,8 @@ var ExtensionManager = class {
|
|
|
7749
8904
|
entry.pid = void 0;
|
|
7750
8905
|
entry.lastExitCode = typeof code === "number" ? code : void 0;
|
|
7751
8906
|
const waiters = entry.exitWaiters.splice(0);
|
|
7752
|
-
for (const
|
|
7753
|
-
|
|
8907
|
+
for (const resolve5 of waiters) {
|
|
8908
|
+
resolve5();
|
|
7754
8909
|
}
|
|
7755
8910
|
if (this.stopping || entry.manuallyStopped) {
|
|
7756
8911
|
try {
|
|
@@ -7851,6 +9006,78 @@ function constantTimeEqual(a, b) {
|
|
|
7851
9006
|
|
|
7852
9007
|
// src/daemon/routes/sessions.ts
|
|
7853
9008
|
init_config();
|
|
9009
|
+
import * as os2 from "os";
|
|
9010
|
+
|
|
9011
|
+
// src/core/bundle.ts
|
|
9012
|
+
import { z as z5 } from "zod";
|
|
9013
|
+
var HistoryEntrySchema = z5.object({
|
|
9014
|
+
method: z5.string(),
|
|
9015
|
+
params: z5.unknown(),
|
|
9016
|
+
recordedAt: z5.number()
|
|
9017
|
+
});
|
|
9018
|
+
var BundleSession = z5.object({
|
|
9019
|
+
// The exporter's local id. Regenerated fresh on import (sessionId is
|
|
9020
|
+
// the local namespace; lineageId is what survives across hops).
|
|
9021
|
+
sessionId: z5.string(),
|
|
9022
|
+
// Required on bundles — the export path backfills if the source
|
|
9023
|
+
// record was written before lineageId existed.
|
|
9024
|
+
lineageId: z5.string(),
|
|
9025
|
+
agentId: z5.string(),
|
|
9026
|
+
cwd: z5.string(),
|
|
9027
|
+
title: z5.string().optional(),
|
|
9028
|
+
currentModel: z5.string().optional(),
|
|
9029
|
+
currentMode: z5.string().optional(),
|
|
9030
|
+
currentUsage: PersistedUsage.optional(),
|
|
9031
|
+
agentCommands: z5.array(PersistedAgentCommand).optional(),
|
|
9032
|
+
createdAt: z5.string(),
|
|
9033
|
+
updatedAt: z5.string()
|
|
9034
|
+
});
|
|
9035
|
+
var Bundle = z5.object({
|
|
9036
|
+
version: z5.literal(1),
|
|
9037
|
+
exportedAt: z5.string(),
|
|
9038
|
+
exportedFrom: z5.object({
|
|
9039
|
+
hydraVersion: z5.string(),
|
|
9040
|
+
machine: z5.string()
|
|
9041
|
+
}),
|
|
9042
|
+
session: BundleSession,
|
|
9043
|
+
history: z5.array(HistoryEntrySchema),
|
|
9044
|
+
promptHistory: z5.array(z5.string()).optional()
|
|
9045
|
+
});
|
|
9046
|
+
function encodeBundle(params) {
|
|
9047
|
+
const bundle = {
|
|
9048
|
+
version: 1,
|
|
9049
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9050
|
+
exportedFrom: {
|
|
9051
|
+
hydraVersion: params.hydraVersion,
|
|
9052
|
+
machine: params.machine
|
|
9053
|
+
},
|
|
9054
|
+
session: {
|
|
9055
|
+
sessionId: params.record.sessionId,
|
|
9056
|
+
lineageId: params.record.lineageId,
|
|
9057
|
+
agentId: params.record.agentId,
|
|
9058
|
+
cwd: params.record.cwd,
|
|
9059
|
+
...params.record.title !== void 0 ? { title: params.record.title } : {},
|
|
9060
|
+
...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
|
|
9061
|
+
...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
|
|
9062
|
+
...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
|
|
9063
|
+
...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
|
|
9064
|
+
createdAt: params.record.createdAt,
|
|
9065
|
+
updatedAt: params.record.updatedAt
|
|
9066
|
+
},
|
|
9067
|
+
history: params.history
|
|
9068
|
+
};
|
|
9069
|
+
if (params.promptHistory !== void 0) {
|
|
9070
|
+
bundle.promptHistory = params.promptHistory;
|
|
9071
|
+
}
|
|
9072
|
+
return bundle;
|
|
9073
|
+
}
|
|
9074
|
+
function decodeBundle(raw) {
|
|
9075
|
+
return Bundle.parse(raw);
|
|
9076
|
+
}
|
|
9077
|
+
|
|
9078
|
+
// src/daemon/routes/sessions.ts
|
|
9079
|
+
init_types();
|
|
9080
|
+
var HYDRA_VERSION = "0.1.0";
|
|
7854
9081
|
function registerSessionRoutes(app, manager, defaults) {
|
|
7855
9082
|
app.get("/v1/sessions", async (request) => {
|
|
7856
9083
|
const query = request.query;
|
|
@@ -7908,6 +9135,61 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
7908
9135
|
}
|
|
7909
9136
|
reply.code(204).send();
|
|
7910
9137
|
});
|
|
9138
|
+
app.get("/v1/sessions/:id/export", async (request, reply) => {
|
|
9139
|
+
const raw = request.params.id;
|
|
9140
|
+
const id = await manager.resolveCanonicalId(raw) ?? raw;
|
|
9141
|
+
const exported = await manager.exportBundle(id);
|
|
9142
|
+
if (!exported) {
|
|
9143
|
+
reply.code(404).send({ error: "session not found" });
|
|
9144
|
+
return;
|
|
9145
|
+
}
|
|
9146
|
+
const bundle = encodeBundle({
|
|
9147
|
+
record: exported.record,
|
|
9148
|
+
history: exported.history,
|
|
9149
|
+
promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
|
|
9150
|
+
hydraVersion: HYDRA_VERSION,
|
|
9151
|
+
machine: os2.hostname()
|
|
9152
|
+
});
|
|
9153
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
9154
|
+
reply.header(
|
|
9155
|
+
"Content-Disposition",
|
|
9156
|
+
`attachment; filename="hydra-${id}-${stamp}.hydra"`
|
|
9157
|
+
);
|
|
9158
|
+
reply.code(200).send(bundle);
|
|
9159
|
+
});
|
|
9160
|
+
app.post("/v1/sessions/import", async (request, reply) => {
|
|
9161
|
+
const body = request.body ?? {};
|
|
9162
|
+
if (body.bundle === void 0) {
|
|
9163
|
+
reply.code(400).send({ error: "missing bundle" });
|
|
9164
|
+
return;
|
|
9165
|
+
}
|
|
9166
|
+
let bundle;
|
|
9167
|
+
try {
|
|
9168
|
+
bundle = decodeBundle(body.bundle);
|
|
9169
|
+
} catch (err) {
|
|
9170
|
+
reply.code(400).send({
|
|
9171
|
+
error: "invalid bundle",
|
|
9172
|
+
details: err.message
|
|
9173
|
+
});
|
|
9174
|
+
return;
|
|
9175
|
+
}
|
|
9176
|
+
try {
|
|
9177
|
+
const result = await manager.importBundle(bundle, {
|
|
9178
|
+
replace: body.replace === true
|
|
9179
|
+
});
|
|
9180
|
+
reply.code(201).send(result);
|
|
9181
|
+
} catch (err) {
|
|
9182
|
+
const e = err;
|
|
9183
|
+
if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
|
|
9184
|
+
reply.code(409).send({
|
|
9185
|
+
error: "bundle already imported",
|
|
9186
|
+
existingSessionId: e.existingSessionId
|
|
9187
|
+
});
|
|
9188
|
+
return;
|
|
9189
|
+
}
|
|
9190
|
+
reply.code(500).send({ error: e.message });
|
|
9191
|
+
}
|
|
9192
|
+
});
|
|
7911
9193
|
app.get("/v1/sessions/:id/history", async (request, reply) => {
|
|
7912
9194
|
const raw = request.params.id;
|
|
7913
9195
|
const query = request.query;
|
|
@@ -7916,16 +9198,22 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
7916
9198
|
const live = manager.get(id);
|
|
7917
9199
|
let snapshot;
|
|
7918
9200
|
let unsubscribe;
|
|
9201
|
+
let snapshotDone = false;
|
|
9202
|
+
const pending = [];
|
|
7919
9203
|
if (live) {
|
|
7920
|
-
snapshot = live.getHistorySnapshot();
|
|
7921
9204
|
if (follow) {
|
|
7922
9205
|
unsubscribe = live.onBroadcast((entry) => {
|
|
7923
9206
|
if (reply.raw.writableEnded) {
|
|
7924
9207
|
return;
|
|
7925
9208
|
}
|
|
7926
|
-
|
|
9209
|
+
if (snapshotDone) {
|
|
9210
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
9211
|
+
} else {
|
|
9212
|
+
pending.push(entry);
|
|
9213
|
+
}
|
|
7927
9214
|
});
|
|
7928
9215
|
}
|
|
9216
|
+
snapshot = await live.getHistorySnapshot();
|
|
7929
9217
|
} else {
|
|
7930
9218
|
const cold = await manager.getHistory(id);
|
|
7931
9219
|
if (cold === void 0) {
|
|
@@ -7937,9 +9225,23 @@ function registerSessionRoutes(app, manager, defaults) {
|
|
|
7937
9225
|
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
|
7938
9226
|
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
7939
9227
|
reply.raw.statusCode = 200;
|
|
9228
|
+
const snapshotKeys = /* @__PURE__ */ new Set();
|
|
7940
9229
|
for (const entry of snapshot ?? []) {
|
|
7941
9230
|
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
9231
|
+
const e = entry;
|
|
9232
|
+
if (typeof e.recordedAt === "number") {
|
|
9233
|
+
snapshotKeys.add(String(e.recordedAt));
|
|
9234
|
+
}
|
|
9235
|
+
}
|
|
9236
|
+
for (const entry of pending) {
|
|
9237
|
+
const e = entry;
|
|
9238
|
+
const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
|
|
9239
|
+
if (key && snapshotKeys.has(key)) {
|
|
9240
|
+
continue;
|
|
9241
|
+
}
|
|
9242
|
+
reply.raw.write(JSON.stringify(entry) + "\n");
|
|
7942
9243
|
}
|
|
9244
|
+
snapshotDone = true;
|
|
7943
9245
|
if (!unsubscribe) {
|
|
7944
9246
|
reply.raw.end();
|
|
7945
9247
|
return reply;
|
|
@@ -8114,7 +9416,7 @@ init_connection();
|
|
|
8114
9416
|
init_ws_stream();
|
|
8115
9417
|
init_types();
|
|
8116
9418
|
import { nanoid as nanoid2 } from "nanoid";
|
|
8117
|
-
var
|
|
9419
|
+
var HYDRA_VERSION2 = "0.1.0";
|
|
8118
9420
|
var HYDRA_PROTOCOL_VERSION = 1;
|
|
8119
9421
|
function registerAcpWsEndpoint(app, deps) {
|
|
8120
9422
|
app.get("/acp", { websocket: true }, (socket, request) => {
|
|
@@ -8160,7 +9462,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
8160
9462
|
agentArgs: hydraMeta.agentArgs
|
|
8161
9463
|
});
|
|
8162
9464
|
const client = bindClientToSession(connection, session, state);
|
|
8163
|
-
session.attach(client, "full");
|
|
9465
|
+
await session.attach(client, "full");
|
|
8164
9466
|
state.attached.set(session.sessionId, {
|
|
8165
9467
|
sessionId: session.sessionId,
|
|
8166
9468
|
clientId: client.clientId
|
|
@@ -8179,14 +9481,22 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
8179
9481
|
const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
|
|
8180
9482
|
let session = deps.manager.get(lookupId);
|
|
8181
9483
|
if (!session) {
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
9484
|
+
const fromDisk = await deps.manager.loadFromDisk(lookupId);
|
|
9485
|
+
let resurrectParams = fromDisk;
|
|
9486
|
+
if (hydraHints) {
|
|
9487
|
+
resurrectParams = {
|
|
9488
|
+
hydraSessionId: params.sessionId,
|
|
9489
|
+
upstreamSessionId: hydraHints.upstreamSessionId,
|
|
9490
|
+
agentId: hydraHints.agentId,
|
|
9491
|
+
cwd: hydraHints.cwd,
|
|
9492
|
+
title: hydraHints.title ?? fromDisk?.title,
|
|
9493
|
+
agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
|
|
9494
|
+
currentModel: fromDisk?.currentModel,
|
|
9495
|
+
currentMode: fromDisk?.currentMode,
|
|
9496
|
+
agentCommands: fromDisk?.agentCommands,
|
|
9497
|
+
createdAt: fromDisk?.createdAt
|
|
9498
|
+
};
|
|
9499
|
+
}
|
|
8190
9500
|
if (!resurrectParams) {
|
|
8191
9501
|
const err = new Error(
|
|
8192
9502
|
`session ${params.sessionId} not found and no resume hints provided`
|
|
@@ -8202,13 +9512,13 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
8202
9512
|
state,
|
|
8203
9513
|
params.clientInfo
|
|
8204
9514
|
);
|
|
8205
|
-
const replay = session.attach(client, params.historyPolicy);
|
|
9515
|
+
const replay = await session.attach(client, params.historyPolicy);
|
|
8206
9516
|
state.attached.set(session.sessionId, {
|
|
8207
9517
|
sessionId: session.sessionId,
|
|
8208
9518
|
clientId: client.clientId
|
|
8209
9519
|
});
|
|
8210
9520
|
app.log.info(
|
|
8211
|
-
`session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
|
|
9521
|
+
`session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
|
|
8212
9522
|
);
|
|
8213
9523
|
for (const note of replay) {
|
|
8214
9524
|
await connection.notify(note.method, note.params);
|
|
@@ -8304,7 +9614,7 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
8304
9614
|
session = await deps.manager.resurrect(fromDisk);
|
|
8305
9615
|
}
|
|
8306
9616
|
const client = bindClientToSession(connection, session, state);
|
|
8307
|
-
const replay = session.attach(client, "pending_only");
|
|
9617
|
+
const replay = await session.attach(client, "pending_only");
|
|
8308
9618
|
state.attached.set(session.sessionId, {
|
|
8309
9619
|
sessionId: session.sessionId,
|
|
8310
9620
|
clientId: client.clientId
|
|
@@ -8370,7 +9680,7 @@ function buildResponseMeta(session) {
|
|
|
8370
9680
|
function buildInitializeResult() {
|
|
8371
9681
|
return {
|
|
8372
9682
|
protocolVersion: HYDRA_PROTOCOL_VERSION,
|
|
8373
|
-
agentInfo: { name: "hydra", version:
|
|
9683
|
+
agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
|
|
8374
9684
|
agentCapabilities: {
|
|
8375
9685
|
// hydra is a transparent proxy: prompt blocks and MCP server configs are
|
|
8376
9686
|
// forwarded to the underlying agent unchanged. We claim the union of
|
|
@@ -8409,14 +9719,14 @@ function bindClientToSession(connection, session, state, clientInfo) {
|
|
|
8409
9719
|
}
|
|
8410
9720
|
|
|
8411
9721
|
// src/daemon/server.ts
|
|
8412
|
-
var
|
|
9722
|
+
var HYDRA_VERSION3 = "0.1.0";
|
|
8413
9723
|
async function startDaemon(config) {
|
|
8414
9724
|
ensureLoopbackOrTls(config);
|
|
8415
9725
|
const httpsOptions = config.daemon.tls ? {
|
|
8416
|
-
key: await
|
|
8417
|
-
cert: await
|
|
9726
|
+
key: await fsp3.readFile(config.daemon.tls.key),
|
|
9727
|
+
cert: await fsp3.readFile(config.daemon.tls.cert)
|
|
8418
9728
|
} : void 0;
|
|
8419
|
-
await
|
|
9729
|
+
await fsp3.mkdir(paths.home(), { recursive: true });
|
|
8420
9730
|
const { stream: logStream, fileStream } = await buildLogStream(
|
|
8421
9731
|
config.daemon.logLevel
|
|
8422
9732
|
);
|
|
@@ -8428,6 +9738,9 @@ async function startDaemon(config) {
|
|
|
8428
9738
|
https: httpsOptions ?? null
|
|
8429
9739
|
});
|
|
8430
9740
|
await app.register(websocketPlugin);
|
|
9741
|
+
setBinaryInstallLogger((msg) => {
|
|
9742
|
+
app.log.info(msg);
|
|
9743
|
+
});
|
|
8431
9744
|
const auth = bearerAuth({ config });
|
|
8432
9745
|
app.addHook("onRequest", async (request, reply) => {
|
|
8433
9746
|
if (request.routeOptions.config?.skipAuth) {
|
|
@@ -8440,10 +9753,11 @@ async function startDaemon(config) {
|
|
|
8440
9753
|
});
|
|
8441
9754
|
const registry = new Registry(config);
|
|
8442
9755
|
const manager = new SessionManager(registry, void 0, void 0, {
|
|
8443
|
-
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
|
|
9756
|
+
idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
|
|
9757
|
+
defaultModels: config.defaultModels
|
|
8444
9758
|
});
|
|
8445
9759
|
const extensions = new ExtensionManager(extensionList(config));
|
|
8446
|
-
registerHealthRoutes(app,
|
|
9760
|
+
registerHealthRoutes(app, HYDRA_VERSION3);
|
|
8447
9761
|
registerSessionRoutes(app, manager, {
|
|
8448
9762
|
agentId: config.defaultAgent,
|
|
8449
9763
|
cwd: config.defaultCwd
|
|
@@ -8462,8 +9776,8 @@ async function startDaemon(config) {
|
|
|
8462
9776
|
await app.listen({ host: config.daemon.host, port: config.daemon.port });
|
|
8463
9777
|
const address = app.server.address();
|
|
8464
9778
|
const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
|
|
8465
|
-
await
|
|
8466
|
-
await
|
|
9779
|
+
await fsp3.mkdir(paths.home(), { recursive: true });
|
|
9780
|
+
await fsp3.writeFile(
|
|
8467
9781
|
paths.pidFile(),
|
|
8468
9782
|
JSON.stringify({
|
|
8469
9783
|
pid: process.pid,
|
|
@@ -8487,9 +9801,11 @@ async function startDaemon(config) {
|
|
|
8487
9801
|
const shutdown = async () => {
|
|
8488
9802
|
await extensions.stop();
|
|
8489
9803
|
await manager.closeAll();
|
|
9804
|
+
await manager.flushMetaWrites();
|
|
9805
|
+
setBinaryInstallLogger(null);
|
|
8490
9806
|
await app.close();
|
|
8491
9807
|
try {
|
|
8492
|
-
|
|
9808
|
+
fs10.unlinkSync(paths.pidFile());
|
|
8493
9809
|
} catch {
|
|
8494
9810
|
}
|
|
8495
9811
|
try {
|
|
@@ -8528,13 +9844,13 @@ function ensureLoopbackOrTls(config) {
|
|
|
8528
9844
|
init_daemon_bootstrap();
|
|
8529
9845
|
|
|
8530
9846
|
// src/cli/commands/log-tail.ts
|
|
8531
|
-
import * as
|
|
8532
|
-
import * as
|
|
9847
|
+
import * as fs11 from "fs";
|
|
9848
|
+
import * as fsp4 from "fs/promises";
|
|
8533
9849
|
async function runLogTail(logPath, argv, notFoundMessage) {
|
|
8534
9850
|
const opts = parseLogTailFlags(argv);
|
|
8535
9851
|
let stat3;
|
|
8536
9852
|
try {
|
|
8537
|
-
stat3 = await
|
|
9853
|
+
stat3 = await fsp4.stat(logPath);
|
|
8538
9854
|
} catch (err) {
|
|
8539
9855
|
const e = err;
|
|
8540
9856
|
if (e.code === "ENOENT") {
|
|
@@ -8552,7 +9868,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
|
|
|
8552
9868
|
process.stdout.write(`-- following ${logPath} --
|
|
8553
9869
|
`);
|
|
8554
9870
|
let pending = false;
|
|
8555
|
-
const watcher =
|
|
9871
|
+
const watcher = fs11.watch(logPath, () => {
|
|
8556
9872
|
if (pending) {
|
|
8557
9873
|
return;
|
|
8558
9874
|
}
|
|
@@ -8560,14 +9876,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
|
|
|
8560
9876
|
setImmediate(async () => {
|
|
8561
9877
|
pending = false;
|
|
8562
9878
|
try {
|
|
8563
|
-
const s = await
|
|
9879
|
+
const s = await fsp4.stat(logPath);
|
|
8564
9880
|
if (s.size <= position) {
|
|
8565
9881
|
if (s.size < position) {
|
|
8566
9882
|
position = s.size;
|
|
8567
9883
|
}
|
|
8568
9884
|
return;
|
|
8569
9885
|
}
|
|
8570
|
-
const fd = await
|
|
9886
|
+
const fd = await fsp4.open(logPath, "r");
|
|
8571
9887
|
try {
|
|
8572
9888
|
const buf = Buffer.alloc(s.size - position);
|
|
8573
9889
|
await fd.read(buf, 0, buf.length, position);
|
|
@@ -8580,10 +9896,10 @@ async function runLogTail(logPath, argv, notFoundMessage) {
|
|
|
8580
9896
|
}
|
|
8581
9897
|
});
|
|
8582
9898
|
});
|
|
8583
|
-
await new Promise((
|
|
9899
|
+
await new Promise((resolve5) => {
|
|
8584
9900
|
const finish = () => {
|
|
8585
9901
|
watcher.close();
|
|
8586
|
-
|
|
9902
|
+
resolve5();
|
|
8587
9903
|
};
|
|
8588
9904
|
process.once("SIGINT", finish);
|
|
8589
9905
|
process.once("SIGTERM", finish);
|
|
@@ -8594,7 +9910,7 @@ async function printTail(logPath, fileSize, lines) {
|
|
|
8594
9910
|
return fileSize;
|
|
8595
9911
|
}
|
|
8596
9912
|
const CHUNK = 64 * 1024;
|
|
8597
|
-
const fd = await
|
|
9913
|
+
const fd = await fsp4.open(logPath, "r");
|
|
8598
9914
|
try {
|
|
8599
9915
|
let position = fileSize;
|
|
8600
9916
|
let collected = "";
|
|
@@ -8651,20 +9967,37 @@ function parseLogTailFlags(argv) {
|
|
|
8651
9967
|
}
|
|
8652
9968
|
|
|
8653
9969
|
// src/cli/commands/daemon.ts
|
|
8654
|
-
async function runDaemonStart() {
|
|
9970
|
+
async function runDaemonStart(flags = {}) {
|
|
8655
9971
|
const config = await ensureConfig();
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
9972
|
+
if (await pingHealth(config)) {
|
|
9973
|
+
const info2 = await readPidFile();
|
|
9974
|
+
process.stdout.write(
|
|
9975
|
+
`Daemon already running${info2 ? ` (pid ${info2.pid})` : ""}. Run \`hydra-acp daemon restart\` to restart it.
|
|
9976
|
+
`
|
|
9977
|
+
);
|
|
9978
|
+
return;
|
|
9979
|
+
}
|
|
9980
|
+
if (flagBool(flags, "foreground")) {
|
|
9981
|
+
const handle = await startDaemon(config);
|
|
9982
|
+
process.stdout.write(
|
|
9983
|
+
`hydra-acp daemon listening on ${config.daemon.host}:${config.daemon.port}
|
|
8659
9984
|
`
|
|
9985
|
+
);
|
|
9986
|
+
const shutdown = async () => {
|
|
9987
|
+
process.stdout.write("Shutting down...\n");
|
|
9988
|
+
await handle.shutdown();
|
|
9989
|
+
process.exit(0);
|
|
9990
|
+
};
|
|
9991
|
+
process.on("SIGINT", () => void shutdown());
|
|
9992
|
+
process.on("SIGTERM", () => void shutdown());
|
|
9993
|
+
return;
|
|
9994
|
+
}
|
|
9995
|
+
spawnDaemonDetached();
|
|
9996
|
+
await waitForDaemonReady(config);
|
|
9997
|
+
const info = await readPidFile();
|
|
9998
|
+
process.stdout.write(
|
|
9999
|
+
`Daemon started on ${config.daemon.host}:${config.daemon.port}` + (info ? ` pid=${info.pid}` : "") + "\n"
|
|
8660
10000
|
);
|
|
8661
|
-
const shutdown = async () => {
|
|
8662
|
-
process.stdout.write("Shutting down...\n");
|
|
8663
|
-
await handle.shutdown();
|
|
8664
|
-
process.exit(0);
|
|
8665
|
-
};
|
|
8666
|
-
process.on("SIGINT", () => void shutdown());
|
|
8667
|
-
process.on("SIGTERM", () => void shutdown());
|
|
8668
10001
|
}
|
|
8669
10002
|
async function runDaemonStop() {
|
|
8670
10003
|
const info = await readPidFile();
|
|
@@ -8746,7 +10079,7 @@ async function runDaemonStatus() {
|
|
|
8746
10079
|
}
|
|
8747
10080
|
async function readPidFile() {
|
|
8748
10081
|
try {
|
|
8749
|
-
const raw = await
|
|
10082
|
+
const raw = await fsp5.readFile(paths.pidFile(), "utf8");
|
|
8750
10083
|
return JSON.parse(raw);
|
|
8751
10084
|
} catch (err) {
|
|
8752
10085
|
const e = err;
|
|
@@ -8771,7 +10104,7 @@ init_sessions();
|
|
|
8771
10104
|
// src/cli/commands/extensions.ts
|
|
8772
10105
|
init_config();
|
|
8773
10106
|
init_paths();
|
|
8774
|
-
import * as
|
|
10107
|
+
import * as fsp6 from "fs/promises";
|
|
8775
10108
|
init_sessions();
|
|
8776
10109
|
async function runExtensionsList() {
|
|
8777
10110
|
const config = await loadConfig();
|
|
@@ -8912,11 +10245,7 @@ async function runExtensionsAdd(name, argv) {
|
|
|
8912
10245
|
`Daemon refused to register ${name} (HTTP ${r.status}${detail}). Restart the daemon to apply.
|
|
8913
10246
|
`
|
|
8914
10247
|
);
|
|
8915
|
-
} catch
|
|
8916
|
-
process.stderr.write(
|
|
8917
|
-
`Daemon not reachable (${err.message}). Config saved; the new extension will start on next daemon launch.
|
|
8918
|
-
`
|
|
8919
|
-
);
|
|
10248
|
+
} catch {
|
|
8920
10249
|
}
|
|
8921
10250
|
}
|
|
8922
10251
|
async function runExtensionsRemove(name) {
|
|
@@ -8971,11 +10300,11 @@ async function runExtensionsRemove(name) {
|
|
|
8971
10300
|
}
|
|
8972
10301
|
}
|
|
8973
10302
|
async function readRawConfig() {
|
|
8974
|
-
const raw = await
|
|
10303
|
+
const raw = await fsp6.readFile(paths.config(), "utf8");
|
|
8975
10304
|
return JSON.parse(raw);
|
|
8976
10305
|
}
|
|
8977
10306
|
async function writeRawConfig(raw) {
|
|
8978
|
-
await
|
|
10307
|
+
await fsp6.writeFile(
|
|
8979
10308
|
paths.config(),
|
|
8980
10309
|
JSON.stringify(raw, null, 2) + "\n",
|
|
8981
10310
|
{ encoding: "utf8", mode: 384 }
|
|
@@ -9655,14 +10984,16 @@ async function main() {
|
|
|
9655
10984
|
process.exit(2);
|
|
9656
10985
|
return;
|
|
9657
10986
|
}
|
|
9658
|
-
const
|
|
10987
|
+
const launchResume = flags2.resume;
|
|
10988
|
+
const sessionId2 = typeof launchResume === "string" ? launchResume : resolveOption(flags2, "session-id");
|
|
9659
10989
|
const name2 = resolveOption(flags2, "name");
|
|
9660
10990
|
await runShim({ sessionId: sessionId2, agentId, agentArgs, name: name2 });
|
|
9661
10991
|
return;
|
|
9662
10992
|
}
|
|
9663
10993
|
const { positional, flags } = parseArgs(argv);
|
|
9664
10994
|
if (flags.version === true || positional[0] === "--version") {
|
|
9665
|
-
process.stdout.write(
|
|
10995
|
+
process.stdout.write(`hydra-acp ${readVersion()}
|
|
10996
|
+
`);
|
|
9666
10997
|
return;
|
|
9667
10998
|
}
|
|
9668
10999
|
if (flags.help === true) {
|
|
@@ -9670,7 +11001,8 @@ async function main() {
|
|
|
9670
11001
|
return;
|
|
9671
11002
|
}
|
|
9672
11003
|
const subcommand = positional[0];
|
|
9673
|
-
const
|
|
11004
|
+
const resumeFlag = flags.resume;
|
|
11005
|
+
const sessionId = typeof resumeFlag === "string" ? resumeFlag : resolveOption(flags, "session-id");
|
|
9674
11006
|
const name = resolveOption(flags, "name");
|
|
9675
11007
|
const agentIdFromFlag = resolveOption(flags, "agent-id");
|
|
9676
11008
|
if (!subcommand) {
|
|
@@ -9697,7 +11029,7 @@ async function main() {
|
|
|
9697
11029
|
const tail = argv.slice(daemonIdx + 1);
|
|
9698
11030
|
const sub = tail[0];
|
|
9699
11031
|
if (sub === "start" || sub === void 0) {
|
|
9700
|
-
await runDaemonStart();
|
|
11032
|
+
await runDaemonStart(flags);
|
|
9701
11033
|
return;
|
|
9702
11034
|
}
|
|
9703
11035
|
if (sub === "stop") {
|
|
@@ -9735,6 +11067,17 @@ async function main() {
|
|
|
9735
11067
|
await runSessionsRm(positional[2]);
|
|
9736
11068
|
return;
|
|
9737
11069
|
}
|
|
11070
|
+
if (sub === "export") {
|
|
11071
|
+
const out = resolveOption(flags, "out");
|
|
11072
|
+
await runSessionsExport(positional[2], out);
|
|
11073
|
+
return;
|
|
11074
|
+
}
|
|
11075
|
+
if (sub === "import") {
|
|
11076
|
+
await runSessionsImport(positional[2], {
|
|
11077
|
+
replace: flags.replace === true
|
|
11078
|
+
});
|
|
11079
|
+
return;
|
|
11080
|
+
}
|
|
9738
11081
|
process.stderr.write(`Unknown sessions subcommand: ${sub}
|
|
9739
11082
|
`);
|
|
9740
11083
|
process.exit(2);
|
|
@@ -9828,6 +11171,17 @@ async function dispatchTui(flags, base) {
|
|
|
9828
11171
|
}
|
|
9829
11172
|
await runTui(tuiOpts);
|
|
9830
11173
|
}
|
|
11174
|
+
function readVersion() {
|
|
11175
|
+
try {
|
|
11176
|
+
const here = dirname4(fileURLToPath(import.meta.url));
|
|
11177
|
+
const pkg = JSON.parse(
|
|
11178
|
+
readFileSync(resolve4(here, "../package.json"), "utf8")
|
|
11179
|
+
);
|
|
11180
|
+
return pkg.version ?? "unknown";
|
|
11181
|
+
} catch {
|
|
11182
|
+
return "unknown";
|
|
11183
|
+
}
|
|
11184
|
+
}
|
|
9831
11185
|
function printHelp() {
|
|
9832
11186
|
process.stdout.write(
|
|
9833
11187
|
[
|
|
@@ -9841,13 +11195,18 @@ function printHelp() {
|
|
|
9841
11195
|
" Shim mode, force daemon to spawn <agent-id>",
|
|
9842
11196
|
" from the registry. Args after <agent-id>",
|
|
9843
11197
|
" are forwarded to the agent's command.",
|
|
9844
|
-
" hydra-acp --
|
|
11198
|
+
" hydra-acp --resume <id> Attach to an existing session (TUI when in a terminal, shim otherwise)",
|
|
9845
11199
|
" hydra-acp init [--rotate-token] Initialize ~/.hydra-acp/config.json",
|
|
9846
|
-
" hydra-acp daemon start
|
|
11200
|
+
" hydra-acp daemon start [--foreground] Start daemon (detached by default; --foreground to attach)",
|
|
11201
|
+
" hydra-acp daemon stop|restart|status",
|
|
9847
11202
|
" hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
|
|
9848
11203
|
" hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
|
|
9849
11204
|
" hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
|
|
9850
11205
|
" hydra-acp sessions rm <id> Remove a session entirely (live or cold)",
|
|
11206
|
+
" hydra-acp sessions export <id> [--out <file>|.]",
|
|
11207
|
+
" Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
|
|
11208
|
+
" hydra-acp sessions import <file>|- [--replace]",
|
|
11209
|
+
" Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live)",
|
|
9851
11210
|
" hydra-acp extensions list List configured extensions and live state",
|
|
9852
11211
|
" hydra-acp extensions add <name> [opts] Add an extension to config",
|
|
9853
11212
|
" hydra-acp extensions remove <name> Remove an extension from config",
|
|
@@ -9855,15 +11214,16 @@ function printHelp() {
|
|
|
9855
11214
|
" hydra-acp extensions logs <name> [-f] [-n N]Tail or follow an extension's log",
|
|
9856
11215
|
" hydra-acp agents [list] List agents in the cached registry",
|
|
9857
11216
|
" hydra-acp agents refresh Force a registry re-fetch",
|
|
9858
|
-
" hydra-acp tui flags: [--
|
|
9859
|
-
"
|
|
11217
|
+
" hydra-acp tui flags: [--resume [<id>]] [--new] [--agent-id <id>] [--cwd <path>] [--name <label>]",
|
|
11218
|
+
" --resume <id> attaches to a specific session; bare --resume picks the most-recent",
|
|
11219
|
+
" in cwd. Smart default (no flags): picks if any live sessions exist, else new.",
|
|
9860
11220
|
" hydra-acp --version Print version",
|
|
9861
11221
|
" hydra-acp --help Show this help",
|
|
9862
11222
|
"",
|
|
9863
11223
|
"Config knob flags accept env-var equivalents (flag wins):",
|
|
9864
|
-
" --agent-id
|
|
9865
|
-
" --session-id
|
|
9866
|
-
" --name
|
|
11224
|
+
" --agent-id HYDRA_ACP_AGENT_ID",
|
|
11225
|
+
" --resume / --session-id HYDRA_ACP_SESSION_ID",
|
|
11226
|
+
" --name HYDRA_ACP_NAME",
|
|
9867
11227
|
""
|
|
9868
11228
|
].join("\n")
|
|
9869
11229
|
);
|