@hydra-acp/cli 0.1.2 → 0.1.3

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/index.d.ts CHANGED
@@ -92,10 +92,13 @@ declare const HydraConfig: z.ZodObject<{
92
92
  }>>>;
93
93
  tui: z.ZodDefault<z.ZodObject<{
94
94
  repaintThrottleMs: z.ZodDefault<z.ZodNumber>;
95
+ maxScrollbackLines: z.ZodDefault<z.ZodNumber>;
95
96
  }, "strip", z.ZodTypeAny, {
96
97
  repaintThrottleMs: number;
98
+ maxScrollbackLines: number;
97
99
  }, {
98
100
  repaintThrottleMs?: number | undefined;
101
+ maxScrollbackLines?: number | undefined;
99
102
  }>>;
100
103
  }, "strip", z.ZodTypeAny, {
101
104
  daemon: {
@@ -117,6 +120,7 @@ declare const HydraConfig: z.ZodObject<{
117
120
  }>;
118
121
  tui: {
119
122
  repaintThrottleMs: number;
123
+ maxScrollbackLines: number;
120
124
  };
121
125
  registry: {
122
126
  url: string;
@@ -145,6 +149,7 @@ declare const HydraConfig: z.ZodObject<{
145
149
  }> | undefined;
146
150
  tui?: {
147
151
  repaintThrottleMs?: number | undefined;
152
+ maxScrollbackLines?: number | undefined;
148
153
  } | undefined;
149
154
  registry?: {
150
155
  url?: string | undefined;
@@ -1250,9 +1255,12 @@ interface SessionInit {
1250
1255
  currentModel?: string;
1251
1256
  currentMode?: string;
1252
1257
  agentCommands?: AdvertisedCommand[];
1258
+ firstPromptSeeded?: boolean;
1253
1259
  }
1254
1260
  interface CloseOptions {
1255
1261
  deleteRecord?: boolean;
1262
+ regenTitle?: boolean;
1263
+ regenTitleTimeoutMs?: number;
1256
1264
  }
1257
1265
  declare class Session {
1258
1266
  readonly sessionId: string;
@@ -1294,6 +1302,7 @@ declare class Session {
1294
1302
  upstreamSessionId: string;
1295
1303
  }) => void): void;
1296
1304
  get attachedCount(): number;
1305
+ get turnStartedAt(): number | undefined;
1297
1306
  getHistorySnapshot(): CachedNotification[];
1298
1307
  onBroadcast(handler: (entry: CachedNotification) => void): () => void;
1299
1308
  attach(client: AttachedClient, historyPolicy: HistoryPolicy): CachedNotification[];
@@ -1447,6 +1456,7 @@ declare class SessionManager {
1447
1456
  cwd?: string;
1448
1457
  }): Promise<SessionListEntry[]>;
1449
1458
  deleteRecord(sessionId: string): Promise<boolean>;
1459
+ hasRecord(sessionId: string): Promise<boolean>;
1450
1460
  private persistTitle;
1451
1461
  private persistAgentChange;
1452
1462
  private persistSnapshot;
@@ -1530,7 +1540,8 @@ declare const paths: {
1530
1540
  extensionsDir: () => string;
1531
1541
  extensionLogFile: (name: string) => string;
1532
1542
  extensionPidFile: (name: string) => string;
1533
- tuiHistoryFile: () => string;
1543
+ tuiHistoryFile: (id: string) => string;
1544
+ tuiLogFile: () => string;
1534
1545
  };
1535
1546
 
1536
1547
  export { type AgentCapabilities, AgentInstance, HistoryPolicy, HydraConfig, type InitializeResult, JsonRpcConnection, type MessageStream, Registry, Session, SessionAttachParams, type SessionCapabilities, SessionDetachParams, SessionListEntry, SessionListParams, SessionListResult, SessionManager, defaultConfig, ensureConfig, generateAuthToken, loadConfig, ndjsonStreamFromStdio, paths, planSpawn, startDaemon, writeConfig, wsToMessageStream };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/daemon/server.ts
2
- import * as fs6 from "fs";
2
+ import * as fs7 from "fs";
3
3
  import * as fsp2 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
@@ -20,6 +20,11 @@ function hydraHome() {
20
20
  if (override && override.length > 0) {
21
21
  return path.resolve(override);
22
22
  }
23
+ if (process.env.VITEST) {
24
+ throw new Error(
25
+ "HYDRA_ACP_HOME is unset under VITEST; vitest.setup.ts must run first"
26
+ );
27
+ }
23
28
  return path.join(os.homedir(), ".hydra-acp");
24
29
  }
25
30
  var paths = {
@@ -41,7 +46,8 @@ var paths = {
41
46
  extensionsDir: () => path.join(hydraHome(), "extensions"),
42
47
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
43
48
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
44
- tuiHistoryFile: () => path.join(hydraHome(), "tui-history")
49
+ tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
50
+ tuiLogFile: () => path.join(hydraHome(), "tui.log")
45
51
  };
46
52
 
47
53
  // src/core/config.ts
@@ -69,7 +75,11 @@ var TuiConfig = z.object({
69
75
  // /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
70
76
  // CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
71
77
  // or 0 to disable throttling entirely.
72
- repaintThrottleMs: z.number().int().nonnegative().default(1e3)
78
+ repaintThrottleMs: z.number().int().nonnegative().default(1e3),
79
+ // Cap on logical lines retained in the in-memory scrollback render
80
+ // buffer. Oldest lines are dropped on overflow. The on-disk session
81
+ // history is unaffected; this only bounds the TUI's local view buffer.
82
+ maxScrollbackLines: z.number().int().positive().default(1e4)
73
83
  });
74
84
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
75
85
  var ExtensionBody = z.object({
@@ -94,7 +104,7 @@ var HydraConfig = z.object({
94
104
  // recency and truncated to this count. `--all` overrides in the CLI.
95
105
  sessionListColdLimit: z.number().int().nonnegative().default(20),
96
106
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
97
- tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
107
+ tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
98
108
  });
99
109
  function extensionList(config) {
100
110
  return Object.entries(config.extensions).map(([name, body]) => ({
@@ -337,6 +347,9 @@ function planSpawn(agent, extraArgs = []) {
337
347
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
338
348
  }
339
349
 
350
+ // src/core/session-manager.ts
351
+ import * as fs5 from "fs/promises";
352
+
340
353
  // src/core/agent-instance.ts
341
354
  import { spawn } from "child_process";
342
355
 
@@ -421,6 +434,9 @@ function extractHydraMeta(meta) {
421
434
  if (typeof obj.currentMode === "string") {
422
435
  out.currentMode = obj.currentMode;
423
436
  }
437
+ if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
438
+ out.turnStartedAt = obj.turnStartedAt;
439
+ }
424
440
  if (Array.isArray(obj.availableCommands)) {
425
441
  const cmds = [];
426
442
  for (const raw of obj.availableCommands) {
@@ -877,6 +893,9 @@ var Session = class {
877
893
  }
878
894
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
879
895
  this.spawnReplacementAgent = init.spawnReplacementAgent;
896
+ if (init.firstPromptSeeded) {
897
+ this.firstPromptSeeded = true;
898
+ }
880
899
  this.historyStore = init.historyStore;
881
900
  if (init.seedHistory && init.seedHistory.length > 0) {
882
901
  this.history = [...init.seedHistory];
@@ -941,6 +960,29 @@ var Session = class {
941
960
  get attachedCount() {
942
961
  return this.clients.size;
943
962
  }
963
+ // Wall-clock when the in-flight agent turn began, or undefined when
964
+ // idle. Derived from history: the most recent prompt_received without
965
+ // a later turn_complete is the outstanding turn, and its recordedAt
966
+ // is when the prompt was first broadcast. Used by buildResponseMeta
967
+ // so a fresh client reattaching mid-turn boots up with the busy
968
+ // banner showing real elapsed time.
969
+ get turnStartedAt() {
970
+ for (let i = this.history.length - 1; i >= 0; i--) {
971
+ const entry = this.history[i];
972
+ if (!entry) {
973
+ continue;
974
+ }
975
+ const params = entry.params;
976
+ const kind = params?.update?.sessionUpdate;
977
+ if (kind === "turn_complete") {
978
+ return void 0;
979
+ }
980
+ if (kind === "prompt_received") {
981
+ return entry.recordedAt;
982
+ }
983
+ }
984
+ return void 0;
985
+ }
944
986
  // Snapshot of the current in-memory replay history. Used by the
945
987
  // HTTP history endpoint to deliver the "what's accumulated so far"
946
988
  // prefix before optionally tailing with onBroadcast. Returns a copy
@@ -1018,13 +1060,19 @@ var Session = class {
1018
1060
  this.broadcastPromptReceived(client, params);
1019
1061
  this.maybeSeedTitleFromPrompt(params);
1020
1062
  return this.enqueuePrompt(async () => {
1021
- const response = await this.agent.connection.request(
1022
- "session/prompt",
1023
- {
1024
- ...params,
1025
- sessionId: this.upstreamSessionId
1026
- }
1027
- );
1063
+ let response;
1064
+ try {
1065
+ response = await this.agent.connection.request(
1066
+ "session/prompt",
1067
+ {
1068
+ ...params,
1069
+ sessionId: this.upstreamSessionId
1070
+ }
1071
+ );
1072
+ } catch (err) {
1073
+ this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
1074
+ throw err;
1075
+ }
1028
1076
  this.broadcastTurnComplete(client.clientId, response);
1029
1077
  return response;
1030
1078
  });
@@ -1112,6 +1160,13 @@ var Session = class {
1112
1160
  return;
1113
1161
  }
1114
1162
  this.cancelIdleTimer();
1163
+ if (opts.regenTitle && this.firstPromptSeeded) {
1164
+ const timeoutMs = opts.regenTitleTimeoutMs ?? 5e3;
1165
+ await Promise.race([
1166
+ this.runTitleRegen().catch(() => void 0),
1167
+ new Promise((r) => setTimeout(r, timeoutMs).unref?.())
1168
+ ]);
1169
+ }
1115
1170
  await this.agent.kill().catch(() => void 0);
1116
1171
  this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
1117
1172
  }
@@ -1160,7 +1215,7 @@ var Session = class {
1160
1215
  }
1161
1216
  const promptParams = params ?? {};
1162
1217
  const text = extractPromptText(promptParams.prompt);
1163
- const seed = firstLine(text, 80);
1218
+ const seed = firstLine(text, 200);
1164
1219
  if (!seed) {
1165
1220
  return;
1166
1221
  }
@@ -1539,7 +1594,8 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1539
1594
  }
1540
1595
  this.idleTimer = setTimeout(() => {
1541
1596
  this.idleTimer = void 0;
1542
- void this.close({ deleteRecord: false }).catch(() => void 0);
1597
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1598
+ void this.close(opts).catch(() => void 0);
1543
1599
  }, this.idleTimeoutMs);
1544
1600
  if (typeof this.idleTimer.unref === "function") {
1545
1601
  this.idleTimer.unref();
@@ -2139,7 +2195,8 @@ var SessionManager = class {
2139
2195
  seedHistory: params.seedHistory,
2140
2196
  currentModel: params.currentModel,
2141
2197
  currentMode: params.currentMode,
2142
- agentCommands: params.agentCommands
2198
+ agentCommands: params.agentCommands,
2199
+ firstPromptSeeded: true
2143
2200
  });
2144
2201
  await this.attachManagerHooks(session);
2145
2202
  return session;
@@ -2310,13 +2367,14 @@ var SessionManager = class {
2310
2367
  continue;
2311
2368
  }
2312
2369
  liveIds.add(session.sessionId);
2370
+ const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
2313
2371
  entries.push({
2314
2372
  sessionId: session.sessionId,
2315
2373
  upstreamSessionId: session.upstreamSessionId,
2316
2374
  cwd: session.cwd,
2317
2375
  title: session.title,
2318
2376
  agentId: session.agentId,
2319
- updatedAt: new Date(session.updatedAt).toISOString(),
2377
+ updatedAt: used,
2320
2378
  attachedClients: session.attachedCount,
2321
2379
  status: "live"
2322
2380
  });
@@ -2329,13 +2387,14 @@ var SessionManager = class {
2329
2387
  if (filter.cwd && r.cwd !== filter.cwd) {
2330
2388
  continue;
2331
2389
  }
2390
+ const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
2332
2391
  entries.push({
2333
2392
  sessionId: r.sessionId,
2334
2393
  upstreamSessionId: r.upstreamSessionId,
2335
2394
  cwd: r.cwd,
2336
2395
  title: r.title,
2337
2396
  agentId: r.agentId,
2338
- updatedAt: r.updatedAt,
2397
+ updatedAt: used,
2339
2398
  attachedClients: 0,
2340
2399
  status: "cold"
2341
2400
  });
@@ -2351,6 +2410,10 @@ var SessionManager = class {
2351
2410
  await this.store.delete(sessionId).catch(() => void 0);
2352
2411
  return true;
2353
2412
  }
2413
+ async hasRecord(sessionId) {
2414
+ const record = await this.store.read(sessionId).catch(() => void 0);
2415
+ return record !== void 0;
2416
+ }
2354
2417
  // Persist a title update from Session.setTitle. The on-disk record
2355
2418
  // was written at create time; updating it here keeps the session
2356
2419
  // record's title in sync with what was broadcast to clients so a
@@ -2425,10 +2488,18 @@ var SessionManager = class {
2425
2488
  this.sessions.clear();
2426
2489
  }
2427
2490
  };
2491
+ async function historyMtimeIso(sessionId) {
2492
+ try {
2493
+ const st = await fs5.stat(paths.historyFile(sessionId));
2494
+ return new Date(st.mtimeMs).toISOString();
2495
+ } catch {
2496
+ return void 0;
2497
+ }
2498
+ }
2428
2499
 
2429
2500
  // src/core/extensions.ts
2430
2501
  import { spawn as spawn2 } from "child_process";
2431
- import * as fs5 from "fs";
2502
+ import * as fs6 from "fs";
2432
2503
  import * as fsp from "fs/promises";
2433
2504
  import * as path3 from "path";
2434
2505
  var RESTART_BASE_MS = 1e3;
@@ -2711,7 +2782,7 @@ var ExtensionManager = class {
2711
2782
  }
2712
2783
  const ext = entry.config;
2713
2784
  const command = ext.command.length > 0 ? ext.command : [ext.name];
2714
- const logStream = fs5.createWriteStream(paths.extensionLogFile(ext.name), {
2785
+ const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
2715
2786
  flags: "a"
2716
2787
  });
2717
2788
  logStream.write(
@@ -2761,7 +2832,7 @@ var ExtensionManager = class {
2761
2832
  }
2762
2833
  if (typeof child.pid === "number") {
2763
2834
  try {
2764
- fs5.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2835
+ fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2765
2836
  `, {
2766
2837
  encoding: "utf8",
2767
2838
  mode: 384
@@ -2786,7 +2857,7 @@ var ExtensionManager = class {
2786
2857
  });
2787
2858
  child.on("exit", (code, signal) => {
2788
2859
  try {
2789
- fs5.unlinkSync(paths.extensionPidFile(ext.name));
2860
+ fs6.unlinkSync(paths.extensionPidFile(ext.name));
2790
2861
  } catch {
2791
2862
  }
2792
2863
  logStream.write(
@@ -2920,6 +2991,22 @@ function registerSessionRoutes(app, manager, defaults) {
2920
2991
  reply.code(500).send({ error: err.message });
2921
2992
  }
2922
2993
  });
2994
+ app.post("/v1/sessions/:id/kill", async (request, reply) => {
2995
+ const raw = request.params.id;
2996
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
2997
+ const session = manager.get(id);
2998
+ if (session) {
2999
+ await session.close({ deleteRecord: false });
3000
+ reply.code(204).send();
3001
+ return;
3002
+ }
3003
+ const exists = await manager.hasRecord(id);
3004
+ if (!exists) {
3005
+ reply.code(404).send({ error: "session not found" });
3006
+ return;
3007
+ }
3008
+ reply.code(204).send();
3009
+ });
2923
3010
  app.delete("/v1/sessions/:id", async (request, reply) => {
2924
3011
  const raw = request.params.id;
2925
3012
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -3127,6 +3214,16 @@ function parseRegisterBody(body) {
3127
3214
  };
3128
3215
  }
3129
3216
 
3217
+ // src/daemon/routes/config.ts
3218
+ function registerConfigRoutes(app, defaults) {
3219
+ app.get("/v1/config", async () => {
3220
+ return {
3221
+ defaultAgent: defaults.defaultAgent,
3222
+ defaultCwd: defaults.defaultCwd
3223
+ };
3224
+ });
3225
+ }
3226
+
3130
3227
  // src/daemon/acp-ws.ts
3131
3228
  import { nanoid as nanoid2 } from "nanoid";
3132
3229
 
@@ -3450,6 +3547,9 @@ function buildResponseMeta(session) {
3450
3547
  if (commands.length > 0) {
3451
3548
  ours.availableCommands = commands;
3452
3549
  }
3550
+ if (session.turnStartedAt !== void 0) {
3551
+ ours.turnStartedAt = session.turnStartedAt;
3552
+ }
3453
3553
  return mergeMeta(session.agentMeta, ours);
3454
3554
  }
3455
3555
  function buildInitializeResult() {
@@ -3535,6 +3635,10 @@ async function startDaemon(config) {
3535
3635
  });
3536
3636
  registerAgentRoutes(app, registry);
3537
3637
  registerExtensionRoutes(app, extensions);
3638
+ registerConfigRoutes(app, {
3639
+ defaultAgent: config.defaultAgent,
3640
+ defaultCwd: config.defaultCwd
3641
+ });
3538
3642
  registerAcpWsEndpoint(app, {
3539
3643
  config,
3540
3644
  manager,
@@ -3570,7 +3674,7 @@ async function startDaemon(config) {
3570
3674
  await manager.closeAll();
3571
3675
  await app.close();
3572
3676
  try {
3573
- fs6.unlinkSync(paths.pidFile());
3677
+ fs7.unlinkSync(paths.pidFile());
3574
3678
  } catch {
3575
3679
  }
3576
3680
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydra-acp/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Multi-client ACP session daemon: spawn agents, attach over WSS, multiplex sessions across editors.",
5
5
  "license": "MIT",
6
6
  "type": "module",