@hydra-acp/cli 0.1.30 → 0.1.32

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
@@ -1320,10 +1320,12 @@ declare const SessionAttachParams: z.ZodObject<{
1320
1320
  name: string;
1321
1321
  version?: string | undefined;
1322
1322
  }>>;
1323
+ readonly: z.ZodOptional<z.ZodBoolean>;
1323
1324
  _meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
1324
1325
  }, "strip", z.ZodTypeAny, {
1325
1326
  sessionId: string;
1326
1327
  historyPolicy: "full" | "pending_only" | "none" | "after_message";
1328
+ readonly?: boolean | undefined;
1327
1329
  clientInfo?: {
1328
1330
  name: string;
1329
1331
  version?: string | undefined;
@@ -1333,6 +1335,7 @@ declare const SessionAttachParams: z.ZodObject<{
1333
1335
  _meta?: Record<string, unknown> | undefined;
1334
1336
  }, {
1335
1337
  sessionId: string;
1338
+ readonly?: boolean | undefined;
1336
1339
  clientInfo?: {
1337
1340
  name: string;
1338
1341
  version?: string | undefined;
@@ -1779,6 +1782,7 @@ interface AttachedClient {
1779
1782
  name: string;
1780
1783
  version?: string;
1781
1784
  };
1785
+ readonly?: boolean;
1782
1786
  }
1783
1787
  type CachedNotification = HistoryEntry;
1784
1788
  interface SpawnReplacementAgentParams {
@@ -2400,6 +2404,7 @@ declare class SessionManager {
2400
2404
  private bootstrapAgent;
2401
2405
  private attachManagerHooks;
2402
2406
  getHistory(sessionId: string): Promise<HistoryEntry[] | undefined>;
2407
+ loadHistory(sessionId: string): Promise<HistoryEntry[]>;
2403
2408
  loadFromDisk(sessionId: string): Promise<ResurrectParams | undefined>;
2404
2409
  private deriveTitleFromHistory;
2405
2410
  get(sessionId: string): Session | undefined;
package/dist/index.js CHANGED
@@ -168,12 +168,12 @@ var TuiConfig = z.object({
168
168
  // buffer. Oldest lines are dropped on overflow. The on-disk session
169
169
  // history is unaffected; this only bounds the TUI's local view buffer.
170
170
  maxScrollbackLines: z.number().int().positive().default(1e4),
171
- // When true (default), the TUI captures mouse events so the wheel can
172
- // drive scrollback. The cost: terminals route clicks to the app, so
173
- // text selection requires shift+drag to bypass mouse reporting. Set
174
- // false to disable capture — wheel scrollback stops working, but
175
- // plain click-drag selects text via the terminal emulator.
176
- mouse: z.boolean().default(true),
171
+ // When true, the TUI captures mouse events so the wheel can drive
172
+ // scrollback. The cost: terminals route clicks to the app, so text
173
+ // selection requires shift+drag to bypass mouse reporting. Default
174
+ // false — wheel scrollback stops working, but plain click-drag
175
+ // selects text via the terminal emulator. Set true to opt back in.
176
+ mouse: z.boolean().default(false),
177
177
  // Size at which the TUI's session/update debug log (tui.log) rotates
178
178
  // to tui.log.0 and resets. Bounds on-disk use at ~2x this value.
179
179
  logMaxBytes: z.number().int().positive().default(5 * 1024 * 1024),
@@ -188,13 +188,13 @@ var TuiConfig = z.object({
188
188
  // just don't want it.
189
189
  progressIndicator: z.boolean().default(true),
190
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")
191
+ // "amend" (default) — Enter amends the in-flight turn; Shift+Enter
192
+ // enqueues. With no turn in flight either key just enqueues,
193
+ // since there's nothing to amend.
194
+ // "enqueue" — flips the two: Enter enqueues the prompt (sends
195
+ // immediately when idle, queues behind an in-flight turn);
196
+ // Shift+Enter amends the in-flight turn.
197
+ defaultEnterAction: z.enum(["enqueue", "amend"]).default("amend")
198
198
  });
199
199
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
200
200
  var ExtensionBody = z.object({
@@ -235,11 +235,11 @@ var HydraConfig = z.object({
235
235
  tui: TuiConfig.default({
236
236
  repaintThrottleMs: 1e3,
237
237
  maxScrollbackLines: 1e4,
238
- mouse: true,
238
+ mouse: false,
239
239
  logMaxBytes: 5 * 1024 * 1024,
240
240
  cwdColumnMaxWidth: 24,
241
241
  progressIndicator: true,
242
- defaultEnterAction: "enqueue"
242
+ defaultEnterAction: "amend"
243
243
  })
244
244
  });
245
245
  function extensionList(config) {
@@ -1075,6 +1075,12 @@ var SessionAttachParams = z3.object({
1075
1075
  name: z3.string(),
1076
1076
  version: z3.string().optional()
1077
1077
  }).optional(),
1078
+ // When true, the connection observes the session but cannot mutate
1079
+ // it: state-changing methods (session/prompt, session/cancel,
1080
+ // session/set_model, etc.) are rejected with -32011, and attaching
1081
+ // to a cold session does not resurrect or spawn an agent — just
1082
+ // streams history from disk. Used by the TUI's view-only mode.
1083
+ readonly: z3.boolean().optional(),
1078
1084
  _meta: z3.record(z3.unknown()).optional()
1079
1085
  });
1080
1086
  var HYDRA_META_KEY = "hydra-acp";
@@ -4893,6 +4899,13 @@ var SessionManager = class {
4893
4899
  }
4894
4900
  return this.histories.load(sessionId).catch(() => []);
4895
4901
  }
4902
+ // Read the on-disk history.jsonl for a session without constructing a
4903
+ // Session instance. Used by the daemon's read-only viewer attach path
4904
+ // (cli/src/daemon/acp-ws.ts) to stream replay events to a client for
4905
+ // a cold session without spawning an agent.
4906
+ async loadHistory(sessionId) {
4907
+ return this.histories.load(sessionId);
4908
+ }
4896
4909
  async loadFromDisk(sessionId) {
4897
4910
  const record = await this.store.read(sessionId);
4898
4911
  if (!record) {
@@ -6066,6 +6079,9 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6066
6079
  if (activeVersions.has(version)) {
6067
6080
  continue;
6068
6081
  }
6082
+ if (version.includes(".partial-")) {
6083
+ continue;
6084
+ }
6069
6085
  const versionDir = path8.join(agentDir, version);
6070
6086
  try {
6071
6087
  await fsp4.rm(versionDir, { recursive: true, force: true });
@@ -7714,6 +7730,16 @@ function registerAcpWsEndpoint(app, deps) {
7714
7730
  }
7715
7731
  state.attached.clear();
7716
7732
  });
7733
+ const denyIfReadonly = (sessionId, method) => {
7734
+ const att = state.attached.get(sessionId);
7735
+ if (att?.readonly) {
7736
+ const err = new Error(
7737
+ `${method} not permitted on a read-only attachment`
7738
+ );
7739
+ err.code = JsonRpcErrorCodes.PermissionDenied;
7740
+ throw err;
7741
+ }
7742
+ };
7717
7743
  connection.onRequest("initialize", async (raw) => {
7718
7744
  InitializeParams.parse(raw ?? {});
7719
7745
  return buildInitializeResult();
@@ -7740,7 +7766,8 @@ function registerAcpWsEndpoint(app, deps) {
7740
7766
  const { entries: replay } = await session.attach(client, "full");
7741
7767
  state.attached.set(session.sessionId, {
7742
7768
  sessionId: session.sessionId,
7743
- clientId: client.clientId
7769
+ clientId: client.clientId,
7770
+ readonly: false
7744
7771
  });
7745
7772
  setImmediate(() => {
7746
7773
  void (async () => {
@@ -7766,11 +7793,46 @@ function registerAcpWsEndpoint(app, deps) {
7766
7793
  connection.onRequest("session/attach", async (raw) => {
7767
7794
  const params = SessionAttachParams.parse(raw);
7768
7795
  const hydraHints = extractHydraMeta(params._meta).resume;
7796
+ const readonly = params.readonly === true;
7769
7797
  app.log.info(
7770
- `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints}`
7798
+ `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints} readonly=${readonly}`
7771
7799
  );
7772
7800
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
7773
7801
  let session = deps.manager.get(lookupId);
7802
+ if (!session && readonly) {
7803
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
7804
+ if (!fromDisk) {
7805
+ const err = new Error(
7806
+ `session ${params.sessionId} not found`
7807
+ );
7808
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7809
+ throw err;
7810
+ }
7811
+ const history = await deps.manager.loadHistory(lookupId);
7812
+ const viewerClientId = params.clientId ?? `cli_${nanoid2(8)}`;
7813
+ state.attached.set(fromDisk.hydraSessionId, {
7814
+ sessionId: fromDisk.hydraSessionId,
7815
+ clientId: viewerClientId,
7816
+ readonly: true
7817
+ });
7818
+ app.log.info(
7819
+ `session/attach OK (viewer) sessionId=${fromDisk.hydraSessionId} clientId=${viewerClientId} attachedCount=${state.attached.size} replayed=${history.length}`
7820
+ );
7821
+ for (const entry of history) {
7822
+ await connection.notify(entry.method, entry.params).catch(() => void 0);
7823
+ }
7824
+ return {
7825
+ sessionId: fromDisk.hydraSessionId,
7826
+ clientId: viewerClientId,
7827
+ connectedClients: [viewerClientId],
7828
+ // No Session.attach() ran, so no history policy was applied —
7829
+ // the viewer always gets full history. Report "full" so the
7830
+ // wire shape matches the normal attach response.
7831
+ historyPolicy: "full",
7832
+ replayed: history.length,
7833
+ _meta: buildViewerResponseMeta(fromDisk)
7834
+ };
7835
+ }
7774
7836
  if (!session) {
7775
7837
  const fromDisk = await deps.manager.loadFromDisk(lookupId);
7776
7838
  let resurrectParams = fromDisk;
@@ -7814,10 +7876,11 @@ function registerAcpWsEndpoint(app, deps) {
7814
7876
  );
7815
7877
  state.attached.set(session.sessionId, {
7816
7878
  sessionId: session.sessionId,
7817
- clientId: client.clientId
7879
+ clientId: client.clientId,
7880
+ readonly
7818
7881
  });
7819
7882
  app.log.info(
7820
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length}`
7883
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length} readonly=${readonly}`
7821
7884
  );
7822
7885
  for (const note of replay) {
7823
7886
  await connection.notify(note.method, note.params);
@@ -7861,6 +7924,7 @@ function registerAcpWsEndpoint(app, deps) {
7861
7924
  });
7862
7925
  connection.onRequest("session/prompt", async (raw) => {
7863
7926
  const params = SessionPromptParams.parse(raw);
7927
+ denyIfReadonly(params.sessionId, "session/prompt");
7864
7928
  const att = state.attached.get(params.sessionId);
7865
7929
  if (!att) {
7866
7930
  app.log.warn(
@@ -7909,6 +7973,12 @@ function registerAcpWsEndpoint(app, deps) {
7909
7973
  if (!att) {
7910
7974
  return;
7911
7975
  }
7976
+ if (att.readonly) {
7977
+ app.log.warn(
7978
+ `session/cancel dropped (readonly attachment) sessionId=${params.sessionId}`
7979
+ );
7980
+ return;
7981
+ }
7912
7982
  const session = deps.manager.get(params.sessionId);
7913
7983
  if (!session) {
7914
7984
  return;
@@ -7921,11 +7991,14 @@ function registerAcpWsEndpoint(app, deps) {
7921
7991
  };
7922
7992
  connection.onNotification("session/cancel", handleCancelParams);
7923
7993
  connection.onRequest("session/cancel", async (raw) => {
7994
+ const params = SessionCancelParams.parse(raw);
7995
+ denyIfReadonly(params.sessionId, "session/cancel");
7924
7996
  handleCancelParams(raw);
7925
7997
  return null;
7926
7998
  });
7927
7999
  connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
7928
8000
  const params = CancelPromptParams.parse(raw);
8001
+ denyIfReadonly(params.sessionId, "hydra-acp/cancel_prompt");
7929
8002
  const session = deps.manager.get(params.sessionId);
7930
8003
  if (!session) {
7931
8004
  const err = new Error(`session ${params.sessionId} not found`);
@@ -7936,6 +8009,7 @@ function registerAcpWsEndpoint(app, deps) {
7936
8009
  });
7937
8010
  connection.onRequest("hydra-acp/update_prompt", async (raw) => {
7938
8011
  const params = UpdatePromptParams.parse(raw);
8012
+ denyIfReadonly(params.sessionId, "hydra-acp/update_prompt");
7939
8013
  const session = deps.manager.get(params.sessionId);
7940
8014
  if (!session) {
7941
8015
  const err = new Error(`session ${params.sessionId} not found`);
@@ -7946,6 +8020,7 @@ function registerAcpWsEndpoint(app, deps) {
7946
8020
  });
7947
8021
  connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
7948
8022
  const params = AmendPromptParams.parse(raw);
8023
+ denyIfReadonly(params.sessionId, "hydra-acp/amend_prompt");
7949
8024
  const att = state.attached.get(params.sessionId);
7950
8025
  if (!att) {
7951
8026
  const err = new Error("not attached to session");
@@ -7985,7 +8060,8 @@ function registerAcpWsEndpoint(app, deps) {
7985
8060
  const { entries: replay } = await session.attach(client, "pending_only");
7986
8061
  state.attached.set(session.sessionId, {
7987
8062
  sessionId: session.sessionId,
7988
- clientId: client.clientId
8063
+ clientId: client.clientId,
8064
+ readonly: false
7989
8065
  });
7990
8066
  for (const note of replay) {
7991
8067
  await connection.notify(note.method, note.params);
@@ -8004,6 +8080,10 @@ function registerAcpWsEndpoint(app, deps) {
8004
8080
  };
8005
8081
  });
8006
8082
  connection.onRequest("session/set_model", async (rawParams) => {
8083
+ const sessionIdField = rawParams?.sessionId;
8084
+ if (typeof sessionIdField === "string") {
8085
+ denyIfReadonly(sessionIdField, "session/set_model");
8086
+ }
8007
8087
  const decision = decideSetModel(rawParams, deps.manager);
8008
8088
  if (decision.kind === "error") {
8009
8089
  app.log.warn(decision.logMessage);
@@ -8037,6 +8117,7 @@ function registerAcpWsEndpoint(app, deps) {
8037
8117
  err.code = JsonRpcErrorCodes.MethodNotFound;
8038
8118
  throw err;
8039
8119
  }
8120
+ denyIfReadonly(sessionId, method);
8040
8121
  const session = deps.manager.get(sessionId);
8041
8122
  if (!session) {
8042
8123
  const err = new Error(`session ${sessionId} not found`);
@@ -8175,6 +8256,38 @@ function decideSetModel(rawParams, manager) {
8175
8256
  logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
8176
8257
  };
8177
8258
  }
8259
+ function buildViewerResponseMeta(fromDisk) {
8260
+ const ours = {
8261
+ upstreamSessionId: fromDisk.upstreamSessionId,
8262
+ agentId: fromDisk.agentId,
8263
+ cwd: fromDisk.cwd
8264
+ };
8265
+ if (fromDisk.title !== void 0) {
8266
+ ours.name = fromDisk.title;
8267
+ }
8268
+ if (fromDisk.agentArgs && fromDisk.agentArgs.length > 0) {
8269
+ ours.agentArgs = fromDisk.agentArgs;
8270
+ }
8271
+ if (fromDisk.currentModel !== void 0) {
8272
+ ours.currentModel = fromDisk.currentModel;
8273
+ }
8274
+ if (fromDisk.currentMode !== void 0) {
8275
+ ours.currentMode = fromDisk.currentMode;
8276
+ }
8277
+ if (fromDisk.currentUsage !== void 0) {
8278
+ ours.currentUsage = fromDisk.currentUsage;
8279
+ }
8280
+ if (fromDisk.agentCommands && fromDisk.agentCommands.length > 0) {
8281
+ ours.availableCommands = fromDisk.agentCommands;
8282
+ }
8283
+ if (fromDisk.agentModes && fromDisk.agentModes.length > 0) {
8284
+ ours.availableModes = fromDisk.agentModes;
8285
+ }
8286
+ if (fromDisk.agentModels && fromDisk.agentModels.length > 0) {
8287
+ ours.availableModels = fromDisk.agentModels;
8288
+ }
8289
+ return { [HYDRA_META_KEY]: ours };
8290
+ }
8178
8291
  function buildResponseMeta(session) {
8179
8292
  const ours = {
8180
8293
  upstreamSessionId: session.upstreamSessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydra-acp/cli",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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",