@hydra-acp/cli 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1256 -128
- package/dist/index.d.ts +56 -1
- package/dist/index.js +478 -32
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/daemon/server.ts
|
|
2
2
|
import * as fs14 from "fs";
|
|
3
|
-
import * as
|
|
3
|
+
import * as fsp5 from "fs/promises";
|
|
4
4
|
import Fastify from "fastify";
|
|
5
5
|
import websocketPlugin from "@fastify/websocket";
|
|
6
6
|
import pino from "pino";
|
|
@@ -186,7 +186,15 @@ var TuiConfig = z.object({
|
|
|
186
186
|
// on Windows Terminal, dock badge on KDE/Konsole, etc.) while a turn is
|
|
187
187
|
// running. Set false if your terminal renders this obnoxiously or you
|
|
188
188
|
// just don't want it.
|
|
189
|
-
progressIndicator: z.boolean().default(true)
|
|
189
|
+
progressIndicator: z.boolean().default(true),
|
|
190
|
+
// What the unmodified Enter key does in the prompt composer.
|
|
191
|
+
// "enqueue" (default) — Enter enqueues the prompt (sends immediately
|
|
192
|
+
// when idle, queues behind an in-flight turn); Shift+Enter amends
|
|
193
|
+
// the in-flight turn.
|
|
194
|
+
// "amend" — flips the two: Enter amends the in-flight turn,
|
|
195
|
+
// Shift+Enter enqueues. With no turn in flight either key just
|
|
196
|
+
// enqueues, since there's nothing to amend.
|
|
197
|
+
defaultEnterAction: z.enum(["enqueue", "amend"]).default("enqueue")
|
|
190
198
|
});
|
|
191
199
|
var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
|
|
192
200
|
var ExtensionBody = z.object({
|
|
@@ -230,7 +238,8 @@ var HydraConfig = z.object({
|
|
|
230
238
|
mouse: true,
|
|
231
239
|
logMaxBytes: 5 * 1024 * 1024,
|
|
232
240
|
cwdColumnMaxWidth: 24,
|
|
233
|
-
progressIndicator: true
|
|
241
|
+
progressIndicator: true,
|
|
242
|
+
defaultEnterAction: "enqueue"
|
|
234
243
|
})
|
|
235
244
|
});
|
|
236
245
|
function extensionList(config) {
|
|
@@ -693,10 +702,12 @@ var RegistryDocument = z2.object({
|
|
|
693
702
|
extensions: z2.array(z2.unknown()).optional()
|
|
694
703
|
});
|
|
695
704
|
var Registry = class {
|
|
696
|
-
constructor(config) {
|
|
705
|
+
constructor(config, options = {}) {
|
|
697
706
|
this.config = config;
|
|
707
|
+
this.options = options;
|
|
698
708
|
}
|
|
699
709
|
config;
|
|
710
|
+
options;
|
|
700
711
|
cache;
|
|
701
712
|
async load() {
|
|
702
713
|
if (this.cache && this.isFresh(this.cache.fetchedAt)) {
|
|
@@ -746,7 +757,12 @@ var Registry = class {
|
|
|
746
757
|
}
|
|
747
758
|
const raw = await response.json();
|
|
748
759
|
const data = RegistryDocument.parse(raw);
|
|
749
|
-
|
|
760
|
+
const cached = { fetchedAt: Date.now(), raw, data };
|
|
761
|
+
const hook = this.options.onFetched;
|
|
762
|
+
if (hook) {
|
|
763
|
+
void Promise.resolve().then(() => hook(data)).catch(() => void 0);
|
|
764
|
+
}
|
|
765
|
+
return cached;
|
|
750
766
|
}
|
|
751
767
|
async readDiskCache() {
|
|
752
768
|
let text;
|
|
@@ -808,6 +824,7 @@ function npxPackageBasename(agent) {
|
|
|
808
824
|
return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
|
|
809
825
|
}
|
|
810
826
|
async function planSpawn(agent, callerArgs = [], options = {}) {
|
|
827
|
+
const version = agent.version ?? "current";
|
|
811
828
|
if (agent.distribution.npx) {
|
|
812
829
|
const npx = agent.distribution.npx;
|
|
813
830
|
const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
|
|
@@ -815,13 +832,14 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
|
|
|
815
832
|
return {
|
|
816
833
|
command: "npx",
|
|
817
834
|
args: ["-y", npx.package, ...tail],
|
|
818
|
-
env: npx.env ?? {}
|
|
835
|
+
env: npx.env ?? {},
|
|
836
|
+
version
|
|
819
837
|
};
|
|
820
838
|
}
|
|
821
839
|
const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
|
|
822
840
|
const binPath = await ensureNpmPackage({
|
|
823
841
|
agentId: agent.id,
|
|
824
|
-
version
|
|
842
|
+
version,
|
|
825
843
|
packageSpec: npx.package,
|
|
826
844
|
bin,
|
|
827
845
|
registry: options.npmRegistry
|
|
@@ -829,7 +847,8 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
|
|
|
829
847
|
return {
|
|
830
848
|
command: binPath,
|
|
831
849
|
args: tail,
|
|
832
|
-
env: npx.env ?? {}
|
|
850
|
+
env: npx.env ?? {},
|
|
851
|
+
version
|
|
833
852
|
};
|
|
834
853
|
}
|
|
835
854
|
if (agent.distribution.binary) {
|
|
@@ -841,14 +860,15 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
|
|
|
841
860
|
}
|
|
842
861
|
const cmdPath = await ensureBinary({
|
|
843
862
|
agentId: agent.id,
|
|
844
|
-
version
|
|
863
|
+
version,
|
|
845
864
|
target
|
|
846
865
|
});
|
|
847
866
|
const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
|
|
848
867
|
return {
|
|
849
868
|
command: cmdPath,
|
|
850
869
|
args: tail,
|
|
851
|
-
env: target.env ?? {}
|
|
870
|
+
env: target.env ?? {},
|
|
871
|
+
version
|
|
852
872
|
};
|
|
853
873
|
}
|
|
854
874
|
if (agent.distribution.uvx) {
|
|
@@ -857,7 +877,8 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
|
|
|
857
877
|
return {
|
|
858
878
|
command: "uvx",
|
|
859
879
|
args: [uvx.package, ...tail],
|
|
860
|
-
env: uvx.env ?? {}
|
|
880
|
+
env: uvx.env ?? {},
|
|
881
|
+
version
|
|
861
882
|
};
|
|
862
883
|
}
|
|
863
884
|
throw new Error(`Agent ${agent.id} has no usable distribution method.`);
|
|
@@ -1012,6 +1033,18 @@ function extractHydraMeta(meta) {
|
|
|
1012
1033
|
if (typeof obj.promptQueueing === "boolean") {
|
|
1013
1034
|
out.promptQueueing = obj.promptQueueing;
|
|
1014
1035
|
}
|
|
1036
|
+
if (typeof obj.promptCancelling === "boolean") {
|
|
1037
|
+
out.promptCancelling = obj.promptCancelling;
|
|
1038
|
+
}
|
|
1039
|
+
if (typeof obj.promptUpdating === "boolean") {
|
|
1040
|
+
out.promptUpdating = obj.promptUpdating;
|
|
1041
|
+
}
|
|
1042
|
+
if (typeof obj.promptAmending === "boolean") {
|
|
1043
|
+
out.promptAmending = obj.promptAmending;
|
|
1044
|
+
}
|
|
1045
|
+
if (typeof obj.promptPipelining === "boolean") {
|
|
1046
|
+
out.promptPipelining = obj.promptPipelining;
|
|
1047
|
+
}
|
|
1015
1048
|
if (Array.isArray(obj.queue)) {
|
|
1016
1049
|
const entries = [];
|
|
1017
1050
|
for (const raw of obj.queue) {
|
|
@@ -1156,6 +1189,34 @@ var UpdatePromptResult = z3.object({
|
|
|
1156
1189
|
updated: z3.boolean(),
|
|
1157
1190
|
reason: z3.enum(["ok", "not_found", "already_running"])
|
|
1158
1191
|
});
|
|
1192
|
+
var AmendPromptParams = z3.object({
|
|
1193
|
+
sessionId: z3.string(),
|
|
1194
|
+
targetMessageId: z3.string(),
|
|
1195
|
+
prompt: z3.array(z3.unknown()),
|
|
1196
|
+
replaceQueue: z3.boolean().optional(),
|
|
1197
|
+
onTargetCompleted: z3.enum(["reject", "send_anyway"]).optional()
|
|
1198
|
+
});
|
|
1199
|
+
var AmendPromptResult = z3.object({
|
|
1200
|
+
amended: z3.boolean(),
|
|
1201
|
+
reason: z3.enum([
|
|
1202
|
+
"ok",
|
|
1203
|
+
"target_completed",
|
|
1204
|
+
"target_cancelled",
|
|
1205
|
+
"target_not_found"
|
|
1206
|
+
]),
|
|
1207
|
+
// Present when a prompt was sent or replaced: the amendment's id on
|
|
1208
|
+
// success, or the regular follow-up's id when onTargetCompleted is
|
|
1209
|
+
// "send_anyway" and the daemon forwarded the prompt anyway.
|
|
1210
|
+
messageId: z3.string().optional()
|
|
1211
|
+
});
|
|
1212
|
+
var PromptAmendedParams = z3.object({
|
|
1213
|
+
sessionId: z3.string(),
|
|
1214
|
+
cancelledMessageId: z3.string(),
|
|
1215
|
+
newMessageId: z3.string(),
|
|
1216
|
+
prompt: z3.array(z3.unknown()),
|
|
1217
|
+
originator: PromptOriginatorSchema,
|
|
1218
|
+
amendedAt: z3.number()
|
|
1219
|
+
});
|
|
1159
1220
|
var ProxyInitializeParams = z3.object({
|
|
1160
1221
|
protocolVersion: z3.number().optional(),
|
|
1161
1222
|
proxyInfo: z3.object({
|
|
@@ -1443,6 +1504,9 @@ var JsonRpcConnection = class _JsonRpcConnection {
|
|
|
1443
1504
|
var DEFAULT_STDERR_TAIL_BYTES = 4096;
|
|
1444
1505
|
var AgentInstance = class _AgentInstance {
|
|
1445
1506
|
agentId;
|
|
1507
|
+
// Version this process was spawned from — used by the registry-fetch
|
|
1508
|
+
// prune sweep to skip install dirs belonging to a live agent.
|
|
1509
|
+
version;
|
|
1446
1510
|
cwd;
|
|
1447
1511
|
connection;
|
|
1448
1512
|
child;
|
|
@@ -1454,6 +1518,7 @@ var AgentInstance = class _AgentInstance {
|
|
|
1454
1518
|
exitHandlers = [];
|
|
1455
1519
|
constructor(opts, child) {
|
|
1456
1520
|
this.agentId = opts.agentId;
|
|
1521
|
+
this.version = opts.plan.version;
|
|
1457
1522
|
this.cwd = opts.cwd;
|
|
1458
1523
|
this.child = child;
|
|
1459
1524
|
this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
|
|
@@ -1630,6 +1695,7 @@ function stripHydraSessionPrefix(id) {
|
|
|
1630
1695
|
return id.startsWith(HYDRA_SESSION_PREFIX) ? id.slice(HYDRA_SESSION_PREFIX.length) : id;
|
|
1631
1696
|
}
|
|
1632
1697
|
var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
|
|
1698
|
+
var RECENTLY_TERMINAL_LIMIT = 64;
|
|
1633
1699
|
var Session = class {
|
|
1634
1700
|
sessionId;
|
|
1635
1701
|
cwd;
|
|
@@ -1729,6 +1795,20 @@ var Session = class {
|
|
|
1729
1795
|
modelHandlers = [];
|
|
1730
1796
|
modeHandlers = [];
|
|
1731
1797
|
usageHandlers = [];
|
|
1798
|
+
// Set by amendPrompt at the start of a cancel-and-resubmit dance.
|
|
1799
|
+
// broadcastTurnComplete reads it to attach the _meta.amended marker
|
|
1800
|
+
// to the cancelled turn's turn_complete notification, and to fire the
|
|
1801
|
+
// dedicated prompt_amended notification. Cleared when the cancelled
|
|
1802
|
+
// turn's task completes (runQueueEntry) OR if the amendment is
|
|
1803
|
+
// cancelled mid-window via cancel_prompt(M2) before drainQueue picks
|
|
1804
|
+
// it up.
|
|
1805
|
+
amendInProgress;
|
|
1806
|
+
// LRU of recently-terminal messageIds → stopReason. Used by
|
|
1807
|
+
// amendPrompt to resolve targets that completed/cancelled before
|
|
1808
|
+
// the amend arrived. Capped at RECENTLY_TERMINAL_LIMIT entries;
|
|
1809
|
+
// older entries fall out and resolve to target_not_found, which is
|
|
1810
|
+
// the correct behavior.
|
|
1811
|
+
recentlyTerminal = /* @__PURE__ */ new Map();
|
|
1732
1812
|
constructor(init) {
|
|
1733
1813
|
this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
|
|
1734
1814
|
this.cwd = init.cwd;
|
|
@@ -2158,7 +2238,7 @@ var Session = class {
|
|
|
2158
2238
|
);
|
|
2159
2239
|
}
|
|
2160
2240
|
}
|
|
2161
|
-
broadcastTurnComplete(originatorClientId, response) {
|
|
2241
|
+
broadcastTurnComplete(originatorClientId, response, promptMessageId, wasAmend) {
|
|
2162
2242
|
const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
|
|
2163
2243
|
const update = {
|
|
2164
2244
|
sessionUpdate: "turn_complete",
|
|
@@ -2167,15 +2247,83 @@ var Session = class {
|
|
|
2167
2247
|
if (stopReason !== void 0) {
|
|
2168
2248
|
update.stopReason = stopReason;
|
|
2169
2249
|
}
|
|
2250
|
+
const amend = this.amendInProgress;
|
|
2251
|
+
if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
|
|
2252
|
+
update._meta = {
|
|
2253
|
+
"hydra-acp": {
|
|
2254
|
+
amended: {
|
|
2255
|
+
cancelledMessageId: amend.cancelledMessageId,
|
|
2256
|
+
newMessageId: amend.newMessageId
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2170
2261
|
this.promptStartedAt = void 0;
|
|
2262
|
+
if (promptMessageId !== void 0 && stopReason !== void 0) {
|
|
2263
|
+
this.recordTerminal(promptMessageId, stopReason);
|
|
2264
|
+
}
|
|
2171
2265
|
this.recordAndBroadcast(
|
|
2172
2266
|
"session/update",
|
|
2173
2267
|
{
|
|
2174
2268
|
sessionId: this.sessionId,
|
|
2175
2269
|
update
|
|
2176
2270
|
},
|
|
2177
|
-
originatorClientId
|
|
2271
|
+
wasAmend ? void 0 : originatorClientId
|
|
2178
2272
|
);
|
|
2273
|
+
if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
|
|
2274
|
+
this.broadcastPromptAmended(amend);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
// Record that a prompt's turn has ended, with its terminal stopReason.
|
|
2278
|
+
// Used by amendPrompt to resolve targetMessageIds that completed/cancelled
|
|
2279
|
+
// before the amend arrived. LRU-trimmed at RECENTLY_TERMINAL_LIMIT.
|
|
2280
|
+
recordTerminal(messageId, stopReason) {
|
|
2281
|
+
this.recentlyTerminal.set(messageId, {
|
|
2282
|
+
stopReason,
|
|
2283
|
+
terminatedAt: Date.now()
|
|
2284
|
+
});
|
|
2285
|
+
while (this.recentlyTerminal.size > RECENTLY_TERMINAL_LIMIT) {
|
|
2286
|
+
const oldest = this.recentlyTerminal.keys().next().value;
|
|
2287
|
+
if (oldest === void 0) {
|
|
2288
|
+
break;
|
|
2289
|
+
}
|
|
2290
|
+
this.recentlyTerminal.delete(oldest);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
// Fire hydra-acp/prompt_amended for the M1→M2 linkage. The amendment's
|
|
2294
|
+
// current content is read live from the queue entry so any update_prompt
|
|
2295
|
+
// calls during the amend window are reflected. Best-effort: if M2 has
|
|
2296
|
+
// already been cancelled out of the queue by the time we get here, we
|
|
2297
|
+
// skip — the amendInProgress clearing in cancelQueuedPrompt should have
|
|
2298
|
+
// prevented this code path from running in that case.
|
|
2299
|
+
broadcastPromptAmended(amend) {
|
|
2300
|
+
const entry = this.findUserEntry(amend.newMessageId);
|
|
2301
|
+
if (!entry) {
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
const params = {
|
|
2305
|
+
sessionId: this.sessionId,
|
|
2306
|
+
cancelledMessageId: amend.cancelledMessageId,
|
|
2307
|
+
newMessageId: amend.newMessageId,
|
|
2308
|
+
prompt: entry.prompt,
|
|
2309
|
+
originator: entry.originator,
|
|
2310
|
+
amendedAt: Date.now()
|
|
2311
|
+
};
|
|
2312
|
+
this.broadcastQueueNotification(
|
|
2313
|
+
"hydra-acp/prompt_amended",
|
|
2314
|
+
params
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
// Look up a user-prompt queue entry by messageId, searching both the
|
|
2318
|
+
// currentEntry slot and the waiting queue.
|
|
2319
|
+
findUserEntry(messageId) {
|
|
2320
|
+
if (this.currentEntry?.messageId === messageId && this.currentEntry.kind === "user") {
|
|
2321
|
+
return this.currentEntry;
|
|
2322
|
+
}
|
|
2323
|
+
const queued = this.promptQueue.find(
|
|
2324
|
+
(e) => e.messageId === messageId && e.kind === "user"
|
|
2325
|
+
);
|
|
2326
|
+
return queued?.kind === "user" ? queued : void 0;
|
|
2179
2327
|
}
|
|
2180
2328
|
// Total visible-or-running entries: the in-flight head (if any) plus
|
|
2181
2329
|
// the queue's user-visible waiting entries. Internal entries don't
|
|
@@ -2188,9 +2336,9 @@ var Session = class {
|
|
|
2188
2336
|
}
|
|
2189
2337
|
return count;
|
|
2190
2338
|
}
|
|
2191
|
-
broadcastQueueAdded(entry) {
|
|
2339
|
+
broadcastQueueAdded(entry, options) {
|
|
2192
2340
|
const depth = this.visibleQueueDepth();
|
|
2193
|
-
const position = Math.max(0, depth - 1);
|
|
2341
|
+
const position = options?.position ?? Math.max(0, depth - 1);
|
|
2194
2342
|
const params = {
|
|
2195
2343
|
sessionId: this.sessionId,
|
|
2196
2344
|
messageId: entry.messageId,
|
|
@@ -2200,6 +2348,11 @@ var Session = class {
|
|
|
2200
2348
|
queueDepth: depth,
|
|
2201
2349
|
enqueuedAt: entry.enqueuedAt
|
|
2202
2350
|
};
|
|
2351
|
+
if (options?.amending !== void 0) {
|
|
2352
|
+
params._meta = {
|
|
2353
|
+
"hydra-acp": { amending: options.amending }
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2203
2356
|
this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
|
|
2204
2357
|
}
|
|
2205
2358
|
broadcastQueueUpdated(messageId, prompt) {
|
|
@@ -2322,6 +2475,9 @@ var Session = class {
|
|
|
2322
2475
|
this.broadcastQueueRemoved(messageId, "cancelled");
|
|
2323
2476
|
this.persistRewrite();
|
|
2324
2477
|
}
|
|
2478
|
+
if (this.amendInProgress?.newMessageId === messageId) {
|
|
2479
|
+
this.amendInProgress = void 0;
|
|
2480
|
+
}
|
|
2325
2481
|
entry.resolve({ stopReason: "cancelled" });
|
|
2326
2482
|
return { cancelled: true, reason: "ok" };
|
|
2327
2483
|
}
|
|
@@ -2343,6 +2499,143 @@ var Session = class {
|
|
|
2343
2499
|
this.persistRewrite();
|
|
2344
2500
|
return { updated: true, reason: "ok" };
|
|
2345
2501
|
}
|
|
2502
|
+
// Amend the head prompt: cancel the in-flight turn and submit a
|
|
2503
|
+
// replacement that sits at the head of the queue. Resolves the
|
|
2504
|
+
// request immediately (the caller doesn't wait on cancel-settle).
|
|
2505
|
+
// Honours race outcomes — if the target finished or was cancelled
|
|
2506
|
+
// before this arrived, the request resolves with an outcome explaining
|
|
2507
|
+
// why and (depending on onTargetCompleted) optionally forwards as a
|
|
2508
|
+
// plain prompt. Queued targets are edited in place (same machinery
|
|
2509
|
+
// as updateQueuedPrompt).
|
|
2510
|
+
amendPrompt(clientId, params) {
|
|
2511
|
+
const client = this.clients.get(clientId);
|
|
2512
|
+
if (!client) {
|
|
2513
|
+
throw withCode(
|
|
2514
|
+
new Error("client not attached"),
|
|
2515
|
+
JsonRpcErrorCodes.SessionNotFound
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
const { targetMessageId, prompt, replaceQueue, onTargetCompleted } = params;
|
|
2519
|
+
if (this.currentEntry?.messageId === targetMessageId && this.currentEntry.kind === "user" && !this.currentEntry.cancelled && this.amendInProgress === void 0) {
|
|
2520
|
+
return this.amendOnHead(client, prompt, targetMessageId, replaceQueue);
|
|
2521
|
+
}
|
|
2522
|
+
const queuedEntry = this.promptQueue.find(
|
|
2523
|
+
(e) => e.messageId === targetMessageId && e.kind === "user"
|
|
2524
|
+
);
|
|
2525
|
+
if (queuedEntry && queuedEntry.kind === "user" && !queuedEntry.cancelled) {
|
|
2526
|
+
queuedEntry.prompt = prompt;
|
|
2527
|
+
this.broadcastQueueUpdated(targetMessageId, prompt);
|
|
2528
|
+
this.persistRewrite();
|
|
2529
|
+
return { amended: true, reason: "ok", messageId: targetMessageId };
|
|
2530
|
+
}
|
|
2531
|
+
const terminal = this.recentlyTerminal.get(targetMessageId);
|
|
2532
|
+
if (terminal) {
|
|
2533
|
+
if (terminal.stopReason === "cancelled") {
|
|
2534
|
+
return { amended: false, reason: "target_cancelled" };
|
|
2535
|
+
}
|
|
2536
|
+
if (onTargetCompleted === "send_anyway") {
|
|
2537
|
+
const newMessageId = this.enqueueAmendmentAsFollowUp(client, prompt);
|
|
2538
|
+
return {
|
|
2539
|
+
amended: false,
|
|
2540
|
+
reason: "target_completed",
|
|
2541
|
+
messageId: newMessageId
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
return { amended: false, reason: "target_completed" };
|
|
2545
|
+
}
|
|
2546
|
+
return { amended: false, reason: "target_not_found" };
|
|
2547
|
+
}
|
|
2548
|
+
// Head-of-queue amendment: splice M2 in front of any waiting entries,
|
|
2549
|
+
// broadcast the amend window's queue_added with the amending hint,
|
|
2550
|
+
// mark amendInProgress so the cancelled turn's broadcastTurnComplete
|
|
2551
|
+
// attaches the _meta marker and fires prompt_amended, then fire the
|
|
2552
|
+
// upstream session/cancel without awaiting it. drainQueue is already
|
|
2553
|
+
// running on the head; when its session/prompt returns, it advances
|
|
2554
|
+
// to M2 in the normal way.
|
|
2555
|
+
amendOnHead(client, prompt, targetMessageId, replaceQueue) {
|
|
2556
|
+
const newMessageId = generateMessageId();
|
|
2557
|
+
const originator = { clientId: client.clientId };
|
|
2558
|
+
if (client.clientInfo?.name) {
|
|
2559
|
+
originator.name = client.clientInfo.name;
|
|
2560
|
+
}
|
|
2561
|
+
if (client.clientInfo?.version) {
|
|
2562
|
+
originator.version = client.clientInfo.version;
|
|
2563
|
+
}
|
|
2564
|
+
if (replaceQueue) {
|
|
2565
|
+
const survivors = [];
|
|
2566
|
+
for (const entry2 of this.promptQueue) {
|
|
2567
|
+
if (entry2.kind === "user" && !entry2.cancelled) {
|
|
2568
|
+
entry2.cancelled = true;
|
|
2569
|
+
this.broadcastQueueRemoved(entry2.messageId, "cancelled");
|
|
2570
|
+
entry2.resolve({ stopReason: "cancelled" });
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
survivors.push(entry2);
|
|
2574
|
+
}
|
|
2575
|
+
this.promptQueue = survivors;
|
|
2576
|
+
}
|
|
2577
|
+
const entry = {
|
|
2578
|
+
kind: "user",
|
|
2579
|
+
messageId: newMessageId,
|
|
2580
|
+
originator,
|
|
2581
|
+
clientId: client.clientId,
|
|
2582
|
+
prompt,
|
|
2583
|
+
enqueuedAt: Date.now(),
|
|
2584
|
+
cancelled: false,
|
|
2585
|
+
wasAmend: true,
|
|
2586
|
+
// No-op resolve/reject: there's no client request awaiting M2's
|
|
2587
|
+
// session/prompt response. The amend_prompt request has already
|
|
2588
|
+
// returned by this point. drainQueue calls these unconditionally
|
|
2589
|
+
// when runQueueEntry settles; making them no-ops is safe.
|
|
2590
|
+
resolve: () => void 0,
|
|
2591
|
+
reject: () => void 0
|
|
2592
|
+
};
|
|
2593
|
+
this.promptQueue.unshift(entry);
|
|
2594
|
+
this.persistRewrite();
|
|
2595
|
+
this.broadcastQueueAdded(entry, {
|
|
2596
|
+
amending: targetMessageId,
|
|
2597
|
+
position: 1
|
|
2598
|
+
});
|
|
2599
|
+
this.amendInProgress = {
|
|
2600
|
+
cancelledMessageId: targetMessageId,
|
|
2601
|
+
newMessageId
|
|
2602
|
+
};
|
|
2603
|
+
void this.agent.connection.notify("session/cancel", { sessionId: this.upstreamSessionId }).catch(() => void 0);
|
|
2604
|
+
return {
|
|
2605
|
+
amended: true,
|
|
2606
|
+
reason: "ok",
|
|
2607
|
+
messageId: newMessageId
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
// Send the amendment as a plain follow-up prompt — used when the
|
|
2611
|
+
// target already completed and the caller opted in to send_anyway.
|
|
2612
|
+
// Returns the new prompt's messageId so the result can surface it.
|
|
2613
|
+
enqueueAmendmentAsFollowUp(client, prompt) {
|
|
2614
|
+
const messageId = generateMessageId();
|
|
2615
|
+
const originator = { clientId: client.clientId };
|
|
2616
|
+
if (client.clientInfo?.name) {
|
|
2617
|
+
originator.name = client.clientInfo.name;
|
|
2618
|
+
}
|
|
2619
|
+
if (client.clientInfo?.version) {
|
|
2620
|
+
originator.version = client.clientInfo.version;
|
|
2621
|
+
}
|
|
2622
|
+
const entry = {
|
|
2623
|
+
kind: "user",
|
|
2624
|
+
messageId,
|
|
2625
|
+
originator,
|
|
2626
|
+
clientId: client.clientId,
|
|
2627
|
+
prompt,
|
|
2628
|
+
enqueuedAt: Date.now(),
|
|
2629
|
+
cancelled: false,
|
|
2630
|
+
resolve: () => void 0,
|
|
2631
|
+
reject: () => void 0
|
|
2632
|
+
};
|
|
2633
|
+
this.promptQueue.push(entry);
|
|
2634
|
+
this.persistRewrite();
|
|
2635
|
+
this.broadcastQueueAdded(entry);
|
|
2636
|
+
void this.drainQueue();
|
|
2637
|
+
return messageId;
|
|
2638
|
+
}
|
|
2346
2639
|
async cancel(clientId) {
|
|
2347
2640
|
const client = this.clients.get(clientId);
|
|
2348
2641
|
if (!client) {
|
|
@@ -3201,6 +3494,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
3201
3494
|
try {
|
|
3202
3495
|
const result = await this.runQueueEntry(next);
|
|
3203
3496
|
next.resolve(result);
|
|
3497
|
+
await Promise.resolve();
|
|
3204
3498
|
} catch (err) {
|
|
3205
3499
|
next.reject(err);
|
|
3206
3500
|
} finally {
|
|
@@ -3237,12 +3531,33 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
|
|
|
3237
3531
|
}
|
|
3238
3532
|
);
|
|
3239
3533
|
} catch (err) {
|
|
3240
|
-
this.broadcastTurnComplete(
|
|
3534
|
+
this.broadcastTurnComplete(
|
|
3535
|
+
entry.clientId,
|
|
3536
|
+
{ stopReason: "error" },
|
|
3537
|
+
entry.messageId,
|
|
3538
|
+
entry.wasAmend
|
|
3539
|
+
);
|
|
3540
|
+
this.clearAmendIfMatches(entry.messageId);
|
|
3241
3541
|
throw err;
|
|
3242
3542
|
}
|
|
3243
|
-
this.broadcastTurnComplete(
|
|
3543
|
+
this.broadcastTurnComplete(
|
|
3544
|
+
entry.clientId,
|
|
3545
|
+
response,
|
|
3546
|
+
entry.messageId,
|
|
3547
|
+
entry.wasAmend
|
|
3548
|
+
);
|
|
3549
|
+
this.clearAmendIfMatches(entry.messageId);
|
|
3244
3550
|
return response;
|
|
3245
3551
|
}
|
|
3552
|
+
// Clear amendInProgress once the cancelled turn's task has fully
|
|
3553
|
+
// settled. broadcastTurnComplete needs the marker still set when it
|
|
3554
|
+
// fires, so the clear must happen *after*. Called from runQueueEntry's
|
|
3555
|
+
// settle path for both success and error.
|
|
3556
|
+
clearAmendIfMatches(messageId) {
|
|
3557
|
+
if (this.amendInProgress?.cancelledMessageId === messageId) {
|
|
3558
|
+
this.amendInProgress = void 0;
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3246
3561
|
};
|
|
3247
3562
|
function withCode(err, code) {
|
|
3248
3563
|
err.code = code;
|
|
@@ -4257,6 +4572,23 @@ var SessionManager = class {
|
|
|
4257
4572
|
get(sessionId) {
|
|
4258
4573
|
return this.sessions.get(sessionId);
|
|
4259
4574
|
}
|
|
4575
|
+
// Snapshot of which agent versions are currently in use by live
|
|
4576
|
+
// sessions, keyed by agentId. Read by the registry-fetch prune sweep
|
|
4577
|
+
// so it can skip install dirs that still back a running process.
|
|
4578
|
+
activeAgentVersions() {
|
|
4579
|
+
const out = /* @__PURE__ */ new Map();
|
|
4580
|
+
for (const session of this.sessions.values()) {
|
|
4581
|
+
const id = session.agent.agentId;
|
|
4582
|
+
const version = session.agent.version;
|
|
4583
|
+
let set = out.get(id);
|
|
4584
|
+
if (!set) {
|
|
4585
|
+
set = /* @__PURE__ */ new Set();
|
|
4586
|
+
out.set(id, set);
|
|
4587
|
+
}
|
|
4588
|
+
set.add(version);
|
|
4589
|
+
}
|
|
4590
|
+
return out;
|
|
4591
|
+
}
|
|
4260
4592
|
// Resolve a user-typed session id (which may have the hydra_session_
|
|
4261
4593
|
// prefix stripped — that's what `sessions list` and the picker show) to
|
|
4262
4594
|
// the canonical form that actually exists. Tries the input as-given
|
|
@@ -5230,9 +5562,85 @@ function withCode2(err, code) {
|
|
|
5230
5562
|
return err;
|
|
5231
5563
|
}
|
|
5232
5564
|
|
|
5565
|
+
// src/core/agent-prune.ts
|
|
5566
|
+
import * as fsp4 from "fs/promises";
|
|
5567
|
+
import * as path8 from "path";
|
|
5568
|
+
var logSink3 = (msg) => {
|
|
5569
|
+
process.stderr.write(msg + "\n");
|
|
5570
|
+
};
|
|
5571
|
+
function setAgentPruneLogger(log) {
|
|
5572
|
+
logSink3 = log ?? ((msg) => process.stderr.write(msg + "\n"));
|
|
5573
|
+
}
|
|
5574
|
+
async function pruneStaleAgentVersions(registry, sessionManager) {
|
|
5575
|
+
const platformKey = currentPlatformKey();
|
|
5576
|
+
if (!platformKey) {
|
|
5577
|
+
return;
|
|
5578
|
+
}
|
|
5579
|
+
const doc = await registry.load();
|
|
5580
|
+
const desiredByAgent = /* @__PURE__ */ new Map();
|
|
5581
|
+
for (const a of doc.agents) {
|
|
5582
|
+
desiredByAgent.set(a.id, a.version ?? "current");
|
|
5583
|
+
}
|
|
5584
|
+
const activeByAgent = sessionManager.activeAgentVersions();
|
|
5585
|
+
const platformDir = path8.join(paths.agentsDir(), platformKey);
|
|
5586
|
+
let agentEntries;
|
|
5587
|
+
try {
|
|
5588
|
+
agentEntries = await fsp4.readdir(platformDir, { withFileTypes: true });
|
|
5589
|
+
} catch (err) {
|
|
5590
|
+
const e = err;
|
|
5591
|
+
if (e.code === "ENOENT") {
|
|
5592
|
+
return;
|
|
5593
|
+
}
|
|
5594
|
+
logSink3(`hydra-acp: prune: failed to read ${platformDir}: ${e.message}`);
|
|
5595
|
+
return;
|
|
5596
|
+
}
|
|
5597
|
+
for (const agentEntry of agentEntries) {
|
|
5598
|
+
if (!agentEntry.isDirectory()) {
|
|
5599
|
+
continue;
|
|
5600
|
+
}
|
|
5601
|
+
const agentId = agentEntry.name;
|
|
5602
|
+
const desired = desiredByAgent.get(agentId);
|
|
5603
|
+
if (desired === void 0) {
|
|
5604
|
+
continue;
|
|
5605
|
+
}
|
|
5606
|
+
const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
|
|
5607
|
+
const agentDir = path8.join(platformDir, agentId);
|
|
5608
|
+
let versionEntries;
|
|
5609
|
+
try {
|
|
5610
|
+
versionEntries = await fsp4.readdir(agentDir, { withFileTypes: true });
|
|
5611
|
+
} catch (err) {
|
|
5612
|
+
logSink3(
|
|
5613
|
+
`hydra-acp: prune: failed to read ${agentDir}: ${err.message}`
|
|
5614
|
+
);
|
|
5615
|
+
continue;
|
|
5616
|
+
}
|
|
5617
|
+
for (const versionEntry of versionEntries) {
|
|
5618
|
+
if (!versionEntry.isDirectory()) {
|
|
5619
|
+
continue;
|
|
5620
|
+
}
|
|
5621
|
+
const version = versionEntry.name;
|
|
5622
|
+
if (version === desired) {
|
|
5623
|
+
continue;
|
|
5624
|
+
}
|
|
5625
|
+
if (activeVersions.has(version)) {
|
|
5626
|
+
continue;
|
|
5627
|
+
}
|
|
5628
|
+
const versionDir = path8.join(agentDir, version);
|
|
5629
|
+
try {
|
|
5630
|
+
await fsp4.rm(versionDir, { recursive: true, force: true });
|
|
5631
|
+
logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
|
|
5632
|
+
} catch (err) {
|
|
5633
|
+
logSink3(
|
|
5634
|
+
`hydra-acp: prune: failed to remove ${versionDir}: ${err.message}`
|
|
5635
|
+
);
|
|
5636
|
+
}
|
|
5637
|
+
}
|
|
5638
|
+
}
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5233
5641
|
// src/core/session-tokens.ts
|
|
5234
5642
|
import * as fs12 from "fs/promises";
|
|
5235
|
-
import * as
|
|
5643
|
+
import * as path9 from "path";
|
|
5236
5644
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
5237
5645
|
var TOKEN_PREFIX = "hydra_session_";
|
|
5238
5646
|
var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
|
|
@@ -5240,7 +5648,7 @@ var ID_LENGTH = 12;
|
|
|
5240
5648
|
var TOKEN_BYTES = 32;
|
|
5241
5649
|
var WRITE_DEBOUNCE_MS = 50;
|
|
5242
5650
|
function tokensFilePath() {
|
|
5243
|
-
return
|
|
5651
|
+
return path9.join(paths.home(), "session-tokens.json");
|
|
5244
5652
|
}
|
|
5245
5653
|
function sha256Hex(input) {
|
|
5246
5654
|
return createHash("sha256").update(input).digest("hex");
|
|
@@ -5883,7 +6291,16 @@ function mapModel(u) {
|
|
|
5883
6291
|
}
|
|
5884
6292
|
function mapTurnComplete(u) {
|
|
5885
6293
|
const stopReason = readString(u, "stopReason");
|
|
5886
|
-
|
|
6294
|
+
const meta = u._meta;
|
|
6295
|
+
const amended = meta?.["hydra-acp"]?.amended !== void 0 && meta["hydra-acp"].amended !== null;
|
|
6296
|
+
const out = { kind: "turn-complete" };
|
|
6297
|
+
if (stopReason !== void 0) {
|
|
6298
|
+
out.stopReason = stopReason;
|
|
6299
|
+
}
|
|
6300
|
+
if (amended) {
|
|
6301
|
+
out.amended = true;
|
|
6302
|
+
}
|
|
6303
|
+
return out;
|
|
5887
6304
|
}
|
|
5888
6305
|
function extractContentText(content) {
|
|
5889
6306
|
if (typeof content === "string") {
|
|
@@ -6536,12 +6953,12 @@ import { z as z6 } from "zod";
|
|
|
6536
6953
|
|
|
6537
6954
|
// src/core/password.ts
|
|
6538
6955
|
import * as fs13 from "fs/promises";
|
|
6539
|
-
import * as
|
|
6956
|
+
import * as path10 from "path";
|
|
6540
6957
|
import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
6541
6958
|
import { promisify } from "util";
|
|
6542
6959
|
var scryptAsync = promisify(scrypt);
|
|
6543
6960
|
function passwordHashPath() {
|
|
6544
|
-
return
|
|
6961
|
+
return path10.join(paths.home(), "password-hash");
|
|
6545
6962
|
}
|
|
6546
6963
|
var DEFAULT_N = 1 << 15;
|
|
6547
6964
|
var MAX_MEM = 128 * 1024 * 1024;
|
|
@@ -7002,6 +7419,22 @@ function registerAcpWsEndpoint(app, deps) {
|
|
|
7002
7419
|
}
|
|
7003
7420
|
return session.updateQueuedPrompt(params.messageId, params.prompt);
|
|
7004
7421
|
});
|
|
7422
|
+
connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
|
|
7423
|
+
const params = AmendPromptParams.parse(raw);
|
|
7424
|
+
const att = state.attached.get(params.sessionId);
|
|
7425
|
+
if (!att) {
|
|
7426
|
+
const err = new Error("not attached to session");
|
|
7427
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
7428
|
+
throw err;
|
|
7429
|
+
}
|
|
7430
|
+
const session = deps.manager.get(params.sessionId);
|
|
7431
|
+
if (!session) {
|
|
7432
|
+
const err = new Error(`session ${params.sessionId} not found`);
|
|
7433
|
+
err.code = JsonRpcErrorCodes.SessionNotFound;
|
|
7434
|
+
throw err;
|
|
7435
|
+
}
|
|
7436
|
+
return session.amendPrompt(att.clientId, params);
|
|
7437
|
+
});
|
|
7005
7438
|
connection.onRequest("session/load", async (raw) => {
|
|
7006
7439
|
const rawObj = raw ?? {};
|
|
7007
7440
|
const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
|
|
@@ -7154,10 +7587,17 @@ function buildInitializeResult() {
|
|
|
7154
7587
|
],
|
|
7155
7588
|
// Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
|
|
7156
7589
|
// ACP clients ignore the field; capability-aware clients learn here
|
|
7157
|
-
//
|
|
7158
|
-
//
|
|
7159
|
-
//
|
|
7160
|
-
|
|
7590
|
+
// which hydra-acp extensions the daemon supports so they can gate
|
|
7591
|
+
// UI surface accordingly. promptPipelining is false until the
|
|
7592
|
+
// streaming-input probe lands (Option A in the steering brief);
|
|
7593
|
+
// the others are unconditional method-availability flags.
|
|
7594
|
+
_meta: mergeMeta(void 0, {
|
|
7595
|
+
promptQueueing: true,
|
|
7596
|
+
promptCancelling: true,
|
|
7597
|
+
promptUpdating: true,
|
|
7598
|
+
promptAmending: true,
|
|
7599
|
+
promptPipelining: false
|
|
7600
|
+
})
|
|
7161
7601
|
};
|
|
7162
7602
|
}
|
|
7163
7603
|
function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
|
|
@@ -7174,10 +7614,10 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
|
|
|
7174
7614
|
async function startDaemon(config, serviceToken) {
|
|
7175
7615
|
ensureLoopbackOrTls(config);
|
|
7176
7616
|
const httpsOptions = config.daemon.tls ? {
|
|
7177
|
-
key: await
|
|
7178
|
-
cert: await
|
|
7617
|
+
key: await fsp5.readFile(config.daemon.tls.key),
|
|
7618
|
+
cert: await fsp5.readFile(config.daemon.tls.cert)
|
|
7179
7619
|
} : void 0;
|
|
7180
|
-
await
|
|
7620
|
+
await fsp5.mkdir(paths.home(), { recursive: true });
|
|
7181
7621
|
const { stream: logStream, fileStream } = await buildLogStream(
|
|
7182
7622
|
config.daemon.logLevel
|
|
7183
7623
|
);
|
|
@@ -7221,7 +7661,12 @@ async function startDaemon(config, serviceToken) {
|
|
|
7221
7661
|
5 * 60 * 1e3
|
|
7222
7662
|
);
|
|
7223
7663
|
sweepInterval.unref();
|
|
7224
|
-
const registry = new Registry(config
|
|
7664
|
+
const registry = new Registry(config, {
|
|
7665
|
+
onFetched: () => {
|
|
7666
|
+
void pruneStaleAgentVersions(registry, manager);
|
|
7667
|
+
}
|
|
7668
|
+
});
|
|
7669
|
+
setAgentPruneLogger((msg) => app.log.info(msg));
|
|
7225
7670
|
const agentLogger = {
|
|
7226
7671
|
info: (msg) => app.log.info(msg),
|
|
7227
7672
|
warn: (msg) => app.log.warn(msg)
|
|
@@ -7262,8 +7707,8 @@ async function startDaemon(config, serviceToken) {
|
|
|
7262
7707
|
await app.listen({ host: config.daemon.host, port: config.daemon.port });
|
|
7263
7708
|
const address = app.server.address();
|
|
7264
7709
|
const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
|
|
7265
|
-
await
|
|
7266
|
-
await
|
|
7710
|
+
await fsp5.mkdir(paths.home(), { recursive: true });
|
|
7711
|
+
await fsp5.writeFile(
|
|
7267
7712
|
paths.pidFile(),
|
|
7268
7713
|
JSON.stringify({
|
|
7269
7714
|
pid: process.pid,
|
|
@@ -7297,6 +7742,7 @@ async function startDaemon(config, serviceToken) {
|
|
|
7297
7742
|
await manager.flushMetaWrites();
|
|
7298
7743
|
setBinaryInstallLogger(null);
|
|
7299
7744
|
setNpmInstallLogger(null);
|
|
7745
|
+
setAgentPruneLogger(null);
|
|
7300
7746
|
await app.close();
|
|
7301
7747
|
try {
|
|
7302
7748
|
fs14.unlinkSync(paths.pidFile());
|