hoomanjs 1.35.3 → 1.36.0

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.
Files changed (82) hide show
  1. package/dist/acp/acp-agent.d.ts +40 -66
  2. package/dist/acp/acp-agent.js +786 -398
  3. package/dist/acp/acp-agent.js.map +1 -1
  4. package/dist/acp/approvals.d.ts +2 -2
  5. package/dist/acp/approvals.js +66 -58
  6. package/dist/acp/approvals.js.map +1 -1
  7. package/dist/acp/commands.d.ts +20 -0
  8. package/dist/acp/commands.js +66 -0
  9. package/dist/acp/commands.js.map +1 -0
  10. package/dist/acp/index.d.ts +1 -1
  11. package/dist/acp/index.js +1 -1
  12. package/dist/acp/index.js.map +1 -1
  13. package/dist/acp/mcp-servers.js +14 -8
  14. package/dist/acp/mcp-servers.js.map +1 -1
  15. package/dist/acp/prompt-invoke.js +103 -49
  16. package/dist/acp/prompt-invoke.js.map +1 -1
  17. package/dist/acp/session-config.d.ts +18 -0
  18. package/dist/acp/session-config.js +52 -0
  19. package/dist/acp/session-config.js.map +1 -0
  20. package/dist/acp/sessions/replay.d.ts +2 -2
  21. package/dist/acp/sessions/replay.js +28 -44
  22. package/dist/acp/sessions/replay.js.map +1 -1
  23. package/dist/acp/sessions/store.d.ts +4 -0
  24. package/dist/acp/sessions/store.js +5 -1
  25. package/dist/acp/sessions/store.js.map +1 -1
  26. package/dist/acp/utils/tool-kind.js +2 -2
  27. package/dist/acp/utils/tool-kind.js.map +1 -1
  28. package/dist/acp/utils/tool-result-content.d.ts +7 -0
  29. package/dist/acp/utils/tool-result-content.js +18 -0
  30. package/dist/acp/utils/tool-result-content.js.map +1 -1
  31. package/dist/chat/components/markdown/CodeBlock.js.map +1 -1
  32. package/dist/cli.js +1 -1
  33. package/dist/cli.js.map +1 -1
  34. package/dist/configure/app.js +6 -2
  35. package/dist/configure/app.js.map +1 -1
  36. package/dist/core/agent/index.js +17 -4
  37. package/dist/core/agent/index.js.map +1 -1
  38. package/dist/core/approvals/allowlist.d.ts +52 -0
  39. package/dist/core/approvals/allowlist.js +265 -0
  40. package/dist/core/approvals/allowlist.js.map +1 -0
  41. package/dist/core/approvals/bash-arity.d.ts +10 -0
  42. package/dist/core/approvals/bash-arity.js +178 -0
  43. package/dist/core/approvals/bash-arity.js.map +1 -0
  44. package/dist/core/approvals/intervention.d.ts +1 -2
  45. package/dist/core/approvals/intervention.js +5 -20
  46. package/dist/core/approvals/intervention.js.map +1 -1
  47. package/dist/core/approvals/wildcard.d.ts +14 -0
  48. package/dist/core/approvals/wildcard.js +27 -0
  49. package/dist/core/approvals/wildcard.js.map +1 -0
  50. package/dist/core/config.js.map +1 -1
  51. package/dist/core/state/file-tool-display.d.ts +6 -0
  52. package/dist/core/state/file-tool-display.js.map +1 -1
  53. package/dist/core/state/todos.d.ts +11 -0
  54. package/dist/core/state/todos.js +2 -0
  55. package/dist/core/state/todos.js.map +1 -1
  56. package/dist/core/state/tool-approvals.d.ts +6 -7
  57. package/dist/core/state/tool-approvals.js +7 -50
  58. package/dist/core/state/tool-approvals.js.map +1 -1
  59. package/dist/core/tools/filesystem.js +83 -27
  60. package/dist/core/tools/filesystem.js.map +1 -1
  61. package/dist/core/tools/shell.js +39 -0
  62. package/dist/core/tools/shell.js.map +1 -1
  63. package/dist/core/tools/terminal-backend.d.ts +41 -0
  64. package/dist/core/tools/terminal-backend.js +9 -0
  65. package/dist/core/tools/terminal-backend.js.map +1 -0
  66. package/dist/core/tools/text-fs-backend.d.ts +25 -0
  67. package/dist/core/tools/text-fs-backend.js +9 -0
  68. package/dist/core/tools/text-fs-backend.js.map +1 -0
  69. package/dist/core/tools/todo.d.ts +1 -0
  70. package/dist/core/tools/todo.js +2 -1
  71. package/dist/core/tools/todo.js.map +1 -1
  72. package/dist/core/utils/attachments.js.map +1 -1
  73. package/dist/core/utils/paths.d.ts +1 -0
  74. package/dist/core/utils/paths.js +1 -0
  75. package/dist/core/utils/paths.js.map +1 -1
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.js +2 -1
  78. package/dist/index.js.map +1 -1
  79. package/package.json +2 -2
  80. package/dist/acp/sessions/options.d.ts +0 -10
  81. package/dist/acp/sessions/options.js +0 -89
  82. package/dist/acp/sessions/options.js.map +0 -1
@@ -2,38 +2,86 @@ import path from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { Readable, Writable } from "node:stream";
4
4
  import { stdin, stdout } from "node:process";
5
- import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
5
+ import { agent as acpAgent, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
6
6
  import { isModelStreamEvent, Message, } from "@strands-agents/sdk";
7
7
  import { bootstrap } from "../core/index.js";
8
+ import { applySessionMode } from "../core/agent/sync-tool-registry-mode.js";
9
+ import { MODE_DEFINITIONS } from "../core/modes/definitions.js";
10
+ import { DEFAULT_SESSION_MODE, isKnownSessionMode, } from "../core/modes/schema.js";
11
+ import { getModeState, setSessionMode } from "../core/state/session-mode.js";
12
+ import { isYoloEnabled, setYoloEnabled } from "../core/state/yolo.js";
13
+ import { getAgentConversationManager, getAgentSessionManager, } from "../core/agent/index.js";
14
+ import { readBundledPrompt } from "../core/prompts/bundled.js";
15
+ import { formatModeNames } from "../core/modes/definitions.js";
16
+ import { modelProviders } from "../core/models/index.js";
17
+ import { buildSessionConfigOptions, currentModelName, CONFIG_ID_MODE, CONFIG_ID_MODEL, } from "./session-config.js";
18
+ import { ACP_SLASH_COMMANDS, parseAcpSlashCommand, parseYoloToggle, } from "./commands.js";
19
+ import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL, } from "../core/state/tool-approvals.js";
8
20
  import { runWithCwd } from "../core/utils/cwd-context.js";
21
+ import { runWithAgentMemoryScope } from "../core/memory/index.js";
22
+ import { consumeExitRequest, EXIT_REQUESTED_CODE, } from "../core/state/exit-request.js";
23
+ import { createSessionConfig, } from "../core/session-config.js";
9
24
  import { acpSessionsRootPath } from "./utils/paths.js";
10
25
  import { inferToolKind } from "./utils/tool-kind.js";
11
- import { toolResultToAcpContent } from "./utils/tool-result-content.js";
26
+ import { fileToolDiffContent, toolResultToAcpContent, } from "./utils/tool-result-content.js";
27
+ import { toolCallLocationsFromInput } from "./utils/tool-locations.js";
28
+ import { takeFileToolDisplay } from "../core/state/file-tool-display.js";
29
+ import { getTodoViewState } from "../core/state/todos.js";
30
+ import { UPDATE_TODOS_TOOL_NAME } from "../core/tools/todo.js";
31
+ import { setTextFsBackend } from "../core/tools/text-fs-backend.js";
32
+ import { setTerminalBackend, } from "../core/tools/terminal-backend.js";
12
33
  import { createAcpToolApprovalIntervention } from "./approvals.js";
13
- import { replayConversationHistory } from "./sessions/replay.js";
14
- import { applySessionConfigOption, buildSessionConfigOptions, HOOMAN_SESSION_MODE_CONFIG_ID, HOOMAN_YOLO_CONFIG_ID, } from "./sessions/options.js";
15
- import { applySessionMode } from "../core/agent/sync-tool-registry-mode.js";
16
- import { copyAgentAppState } from "../core/state/agent-app-state.js";
17
- import { getModeState } from "../core/state/session-mode.js";
18
- import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL, } from "../core/state/tool-approvals.js";
19
- import { isYoloEnabled } from "../core/state/yolo.js";
20
34
  import { extractAcpClientUserId } from "./meta/user-id.js";
21
35
  import { deriveSessionTitleFromEcho } from "./sessions/title.js";
22
36
  import { acpPromptEchoText, acpPromptToInvokeArgs } from "./prompt-invoke.js";
23
- import { createSessionConfig, } from "../core/session-config.js";
24
37
  import { normalizeAcpSessionMcpServers } from "./mcp-servers.js";
25
- import { consumeExitRequest, EXIT_REQUESTED_CODE, } from "../core/state/exit-request.js";
26
- import { flushAgentMemory, runWithAgentMemoryScope, } from "../core/memory/index.js";
27
- import { listStoredSessionIds, loadSessionMessages, patchSessionMeta, readSessionMeta, saveSessionMessages, toSessionInfo, writeSessionMeta, } from "./sessions/store.js";
28
- const DEFAULT_MODE_ID = "agent";
38
+ import { replayConversationHistory } from "./sessions/replay.js";
39
+ import { deleteStoredSession, listStoredSessionIds, loadSessionMessages, patchSessionMeta, readSessionMeta, saveSessionMessages, toSessionInfo, writeSessionMeta, } from "./sessions/store.js";
40
+ /** Max sessions returned per `session/list` page. */
29
41
  const LIST_PAGE_SIZE = 40;
30
42
  /** Fallback when a session record is missing (never mutate). */
31
43
  const EMPTY_STREAMED_TOOL_CALL_IDS = new Set();
44
+ /** Prompt injected by the `/init` slash command (generates AGENTS.md). */
45
+ const INIT_AGENTS_PROMPT = readBundledPrompt("static", "init.md");
46
+ async function readAgentIdentity() {
47
+ const packageUrl = new URL("../../package.json", import.meta.url);
48
+ const pkg = JSON.parse(await readFile(packageUrl, "utf8"));
49
+ const binName = pkg.bin && typeof pkg.bin === "object"
50
+ ? Object.keys(pkg.bin)[0]
51
+ : undefined;
52
+ return {
53
+ name: binName ?? pkg.name ?? "hooman",
54
+ version: pkg.version ?? "0.0.0",
55
+ };
56
+ }
57
+ /** Clamp the client's requested protocol version to what we implement. */
32
58
  function negotiateProtocolVersion(clientVersion) {
33
59
  if (!Number.isFinite(clientVersion) || clientVersion < 1) {
34
60
  return PROTOCOL_VERSION;
35
61
  }
36
- return clientVersion > PROTOCOL_VERSION ? PROTOCOL_VERSION : clientVersion;
62
+ return Math.min(clientVersion, PROTOCOL_VERSION);
63
+ }
64
+ /** Opaque `session/list` cursor: base64-encoded `{ offset }`. */
65
+ function encodeCursor(offset) {
66
+ return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64");
67
+ }
68
+ function decodeCursor(cursor) {
69
+ try {
70
+ const raw = Buffer.from(cursor, "base64").toString("utf8");
71
+ const parsed = JSON.parse(raw);
72
+ if (typeof parsed.offset === "number" &&
73
+ Number.isInteger(parsed.offset) &&
74
+ parsed.offset >= 0) {
75
+ return parsed.offset;
76
+ }
77
+ }
78
+ catch {
79
+ /* fall through to the error below */
80
+ }
81
+ throw RequestError.invalidParams({
82
+ cursor,
83
+ message: "Invalid pagination cursor",
84
+ });
37
85
  }
38
86
  function assertAbsolutePath(value, field) {
39
87
  const trimmed = value?.trim() ?? "";
@@ -50,6 +98,7 @@ function assertAbsolutePath(value, field) {
50
98
  });
51
99
  }
52
100
  }
101
+ /** Best-effort parse of a streamed tool-input JSON fragment. */
53
102
  function parseStreamingToolJson(buffer) {
54
103
  try {
55
104
  return JSON.parse(buffer);
@@ -58,6 +107,7 @@ function parseStreamingToolJson(buffer) {
58
107
  return { _partialJson: buffer };
59
108
  }
60
109
  }
110
+ /** Whether a thrown error represents cooperative cancellation, not a failure. */
61
111
  function isCancellationError(err, cancelSignals) {
62
112
  if (cancelSignals.some((s) => s.aborted)) {
63
113
  return true;
@@ -68,6 +118,7 @@ function isCancellationError(err, cancelSignals) {
68
118
  const e = err;
69
119
  return e.name === "AbortError" || e.name === "CancelledError";
70
120
  }
121
+ /** Map a Strands stop reason onto the ACP {@link StopReason} vocabulary. */
71
122
  function toAcpStopReason(reason) {
72
123
  switch (reason) {
73
124
  case "cancelled":
@@ -88,149 +139,114 @@ function toAcpStopReason(reason) {
88
139
  function serializeAgentMessages(agent) {
89
140
  return agent.messages.map((m) => m.toJSON());
90
141
  }
91
- async function readPackageDetails() {
92
- const packageUrl = new URL("../../package.json", import.meta.url);
93
- const pkg = JSON.parse(await readFile(packageUrl, "utf8"));
94
- const commandName = typeof pkg.bin === "string"
95
- ? pkg.name
96
- : pkg.bin && typeof pkg.bin === "object"
97
- ? Object.keys(pkg.bin)[0]
98
- : undefined;
142
+ /** Normalize a persisted/requested mode to a known id, else the default. */
143
+ function resolveSessionMode(mode) {
144
+ return mode && isKnownSessionMode(mode) ? mode : DEFAULT_SESSION_MODE;
145
+ }
146
+ /** The ACP `SessionModeState` advertised for a session's current mode. */
147
+ function buildSessionModeState(currentModeId) {
99
148
  return {
100
- name: commandName ?? pkg.name ?? "hooman",
101
- version: pkg.version ?? "0.0.0",
149
+ currentModeId,
150
+ availableModes: MODE_DEFINITIONS.map((mode) => ({
151
+ id: mode.id,
152
+ name: mode.name,
153
+ description: mode.description,
154
+ })),
102
155
  };
103
156
  }
104
- export class AcpAgent {
105
- #connection;
157
+ /**
158
+ * Hooman's ACP agent, built on the app-style SDK API.
159
+ *
160
+ * Implements initialization, session setup (`session/new`, `session/load`),
161
+ * session discovery (`session/list`, `session/delete`), and the full prompt
162
+ * turn (`session/prompt` streaming + `session/cancel`).
163
+ */
164
+ export class HoomanAcpAgent {
165
+ #identity;
166
+ #acpRoot = acpSessionsRootPath();
106
167
  #sessions = new Map();
107
- #acpRoot;
108
- #version = null;
109
- constructor(connection) {
110
- this.#connection = connection;
111
- this.#acpRoot = acpSessionsRootPath();
112
- queueMicrotask(() => {
113
- if (connection.signal.aborted) {
114
- void this.#disposeAllSessions();
115
- return;
116
- }
117
- connection.signal.addEventListener("abort", () => {
118
- void this.#disposeAllSessions();
119
- }, { once: true });
120
- });
168
+ /** Connection-scoped outbound context, captured in {@link onConnect}. */
169
+ #client = null;
170
+ #connectionSignal = null;
171
+ /** Client filesystem capabilities from `initialize` (gates `fs/*` calls). */
172
+ #clientFsCaps = null;
173
+ /** Whether the client supports `terminal/*` methods (gates the shell tool). */
174
+ #clientTerminalCap = false;
175
+ constructor(identity) {
176
+ this.#identity = identity;
121
177
  }
122
- async initialize(params) {
123
- const { name, version } = await readPackageDetails();
124
- this.#version ??= version;
178
+ /** Bind connection-scoped lifecycle: capture the client + dispose on close. */
179
+ onConnect(connection) {
180
+ this.#client = connection.client;
181
+ this.#connectionSignal = connection.signal;
182
+ if (connection.signal.aborted) {
183
+ void this.#disposeAll();
184
+ return;
185
+ }
186
+ connection.signal.addEventListener("abort", () => {
187
+ void this.#disposeAll();
188
+ }, { once: true });
189
+ }
190
+ initialize(params) {
191
+ this.#clientFsCaps = params.clientCapabilities?.fs ?? null;
192
+ this.#clientTerminalCap = params.clientCapabilities?.terminal === true;
125
193
  return {
126
194
  protocolVersion: negotiateProtocolVersion(params.protocolVersion),
127
195
  agentInfo: {
128
- name,
129
- title: name,
130
- version: this.#version,
196
+ name: this.#identity.name,
197
+ title: this.#identity.name,
198
+ version: this.#identity.version,
131
199
  },
132
200
  authMethods: [],
133
201
  agentCapabilities: {
134
202
  loadSession: true,
135
- mcpCapabilities: {
136
- http: true,
137
- sse: true,
138
- },
139
203
  promptCapabilities: {
140
- embeddedContext: true,
141
204
  image: true,
142
205
  audio: true,
206
+ embeddedContext: true,
207
+ },
208
+ mcpCapabilities: {
209
+ http: true,
210
+ sse: true,
143
211
  },
144
212
  sessionCapabilities: {
145
213
  list: {},
214
+ delete: {},
146
215
  },
147
216
  },
148
217
  };
149
218
  }
150
- async authenticate(_params) {
151
- return {};
152
- }
153
- async setSessionMode(params) {
154
- const rec = this.#sessions.get(params.sessionId);
155
- if (!rec) {
156
- throw RequestError.invalidParams({ sessionId: params.sessionId });
157
- }
158
- if (!rec.availableModeIds.has(params.modeId)) {
159
- throw RequestError.invalidParams({ modeId: params.modeId });
160
- }
161
- rec.currentModeId = params.modeId;
162
- return {};
163
- }
164
- async setSessionConfigOption(params) {
165
- const rec = this.#sessions.get(params.sessionId);
166
- if (!rec) {
167
- throw RequestError.invalidParams({ sessionId: params.sessionId });
168
- }
169
- applySessionConfigOption(rec.config, params, rec.agent);
170
- if (params.configId === HOOMAN_YOLO_CONFIG_ID) {
171
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
172
- yolo: isYoloEnabled(rec.agent),
173
- });
174
- }
175
- else if (params.configId === HOOMAN_SESSION_MODE_CONFIG_ID) {
176
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
177
- sessionMode: getModeState(rec.agent).mode,
178
- });
179
- }
180
- else {
181
- rec.agentStale = true;
182
- }
183
- return {
184
- configOptions: buildSessionConfigOptions(rec.config, rec.agent),
185
- };
186
- }
187
- async extMethod(method, _params) {
188
- throw RequestError.methodNotFound(method);
189
- }
190
- async extNotification(_method, _params) {
191
- return;
192
- }
193
219
  async listSessions(params) {
194
- void params.additionalDirectories;
195
220
  const ids = await listStoredSessionIds(this.#acpRoot);
196
- const rows = [];
221
+ const sessions = [];
197
222
  for (const id of ids) {
198
223
  const info = await toSessionInfo(this.#acpRoot, id);
199
- if (info) {
200
- rows.push({ id, info });
201
- }
202
- }
203
- let filtered = rows;
204
- if (params.cwd) {
205
- filtered = rows.filter((r) => r.info.cwd === params.cwd);
206
- }
207
- filtered.sort((a, b) => String(b.info.updatedAt ?? "").localeCompare(String(a.info.updatedAt ?? "")));
208
- let offset = 0;
209
- if (params.cursor) {
210
- try {
211
- const raw = Buffer.from(params.cursor, "base64").toString("utf8");
212
- const o = JSON.parse(raw);
213
- offset = Number.isFinite(o.offset) ? Math.max(0, o.offset) : 0;
214
- }
215
- catch {
216
- offset = 0;
224
+ if (info && (!params.cwd || info.cwd === params.cwd)) {
225
+ sessions.push(info);
217
226
  }
218
227
  }
219
- const slice = filtered
220
- .slice(offset, offset + LIST_PAGE_SIZE)
221
- .map((r) => r.info);
228
+ sessions.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
229
+ const offset = params.cursor ? decodeCursor(params.cursor) : 0;
230
+ const page = sessions.slice(offset, offset + LIST_PAGE_SIZE);
222
231
  const nextOffset = offset + LIST_PAGE_SIZE;
223
- const nextCursor = nextOffset < filtered.length
224
- ? Buffer.from(JSON.stringify({ offset: nextOffset }), "utf8").toString("base64")
225
- : null;
226
- return { sessions: slice, nextCursor };
232
+ const nextCursor = nextOffset < sessions.length ? encodeCursor(nextOffset) : null;
233
+ return { sessions: page, nextCursor };
234
+ }
235
+ async deleteSession(params) {
236
+ const record = this.#sessions.get(params.sessionId);
237
+ if (record) {
238
+ this.#sessions.delete(params.sessionId);
239
+ record.turnAbort?.abort();
240
+ await record.mcpDisconnect();
241
+ }
242
+ await deleteStoredSession(this.#acpRoot, params.sessionId);
227
243
  }
228
244
  async newSession(params) {
229
245
  assertAbsolutePath(params.cwd, "cwd");
230
246
  const sessionId = crypto.randomUUID();
231
247
  const clientUserId = extractAcpClientUserId(params._meta) ?? null;
232
- const bootstrapUserId = clientUserId ?? sessionId;
233
248
  const mcpServers = normalizeAcpSessionMcpServers(params.mcpServers);
249
+ const mode = DEFAULT_SESSION_MODE;
234
250
  const now = new Date().toISOString();
235
251
  const meta = {
236
252
  cwd: params.cwd,
@@ -239,59 +255,19 @@ export class AcpAgent {
239
255
  title: null,
240
256
  userId: clientUserId,
241
257
  mcpServers,
258
+ sessionMode: mode,
242
259
  };
243
260
  await writeSessionMeta(this.#acpRoot, sessionId, meta);
244
- const { config: bootConfig, agent, mcp: { manager }, } = await bootstrap("acp", {
245
- userId: bootstrapUserId,
246
- sessionId,
247
- createInterventions: () => [
248
- createAcpToolApprovalIntervention(this.#connection, sessionId, () => this.#sessions.get(sessionId)?.streamedToolCallIds ??
249
- EMPTY_STREAMED_TOOL_CALL_IDS),
250
- ],
251
- acp: {
252
- mcpServers,
253
- },
254
- }, false, createSessionConfig());
255
- const config = bootConfig;
256
- const availableModeIds = new Set([DEFAULT_MODE_ID]);
257
- this.#sessions.set(sessionId, {
258
- cwd: params.cwd,
259
- agent,
260
- config,
261
- currentModeId: DEFAULT_MODE_ID,
262
- availableModeIds,
263
- mcpDisconnect: async () => {
264
- try {
265
- await manager.disconnect();
266
- }
267
- catch {
268
- /* ignore */
269
- }
270
- },
271
- agentStale: false,
272
- turnAbort: null,
273
- promptExclusive: Promise.resolve(),
274
- streamedToolCallIds: new Set(),
275
- streamingToolInputJson: new Map(),
276
- lastStreamToolUseId: null,
277
- });
261
+ const record = await this.#bootstrapSession(sessionId, params.cwd, clientUserId ?? sessionId, mcpServers, mode);
262
+ this.#sessions.set(sessionId, record);
263
+ await this.#advertiseCommands(this.#requireClient(), sessionId);
278
264
  return {
279
265
  sessionId,
280
- modes: {
281
- currentModeId: DEFAULT_MODE_ID,
282
- availableModes: [
283
- {
284
- id: DEFAULT_MODE_ID,
285
- name: "Default",
286
- description: "Standard Hooman behaviour for this session.",
287
- },
288
- ],
289
- },
290
- models: null,
291
- configOptions: buildSessionConfigOptions(config, agent),
266
+ modes: buildSessionModeState(mode),
267
+ configOptions: buildSessionConfigOptions(record.config, mode),
292
268
  };
293
269
  }
294
- async loadSession(params) {
270
+ async loadSession(params, client) {
295
271
  if (this.#sessions.has(params.sessionId)) {
296
272
  throw RequestError.invalidParams({
297
273
  sessionId: params.sessionId,
@@ -304,141 +280,328 @@ export class AcpAgent {
304
280
  }
305
281
  assertAbsolutePath(params.cwd, "cwd");
306
282
  const fromRequest = extractAcpClientUserId(params._meta);
307
- const storedUserId = existing.userId ?? null;
308
- const clientUserId = fromRequest !== undefined ? fromRequest : storedUserId;
309
- const bootstrapUserId = clientUserId ?? params.sessionId;
283
+ const clientUserId = fromRequest !== undefined ? fromRequest : (existing.userId ?? null);
310
284
  const mcpServers = params.mcpServers.length > 0
311
285
  ? normalizeAcpSessionMcpServers(params.mcpServers)
312
286
  : (existing.mcpServers ?? []);
313
- const { config: bootConfig, agent, mcp: { manager }, } = await bootstrap("acp", {
314
- userId: bootstrapUserId,
315
- sessionId: params.sessionId,
316
- yolo: existing.yolo === true,
317
- mode: existing.sessionMode ?? "agent",
318
- createInterventions: () => [
319
- createAcpToolApprovalIntervention(this.#connection, params.sessionId, () => this.#sessions.get(params.sessionId)?.streamedToolCallIds ??
320
- EMPTY_STREAMED_TOOL_CALL_IDS),
321
- ],
322
- acp: {
323
- mcpServers,
324
- },
325
- }, false, createSessionConfig());
326
- const config = bootConfig;
287
+ const mode = resolveSessionMode(existing.sessionMode);
288
+ const record = await this.#bootstrapSession(params.sessionId, params.cwd, clientUserId ?? params.sessionId, mcpServers, mode, existing.model);
327
289
  const saved = await loadSessionMessages(this.#acpRoot, params.sessionId);
328
- agent.messages.length = 0;
290
+ record.agent.messages.length = 0;
329
291
  for (const md of saved) {
330
- agent.messages.push(Message.fromJSON(md));
292
+ record.agent.messages.push(Message.fromJSON(md));
331
293
  }
332
- await replayConversationHistory(this.#connection, params.sessionId, agent.messages);
333
- const availableModeIds = new Set([DEFAULT_MODE_ID]);
334
- this.#sessions.set(params.sessionId, {
335
- cwd: params.cwd,
336
- agent,
337
- config,
338
- currentModeId: DEFAULT_MODE_ID,
339
- availableModeIds,
340
- mcpDisconnect: async () => {
341
- try {
342
- await manager.disconnect();
343
- }
344
- catch {
345
- /* ignore */
346
- }
347
- },
348
- agentStale: false,
349
- turnAbort: null,
350
- promptExclusive: Promise.resolve(),
351
- streamedToolCallIds: new Set(),
352
- streamingToolInputJson: new Map(),
353
- lastStreamToolUseId: null,
354
- });
294
+ await replayConversationHistory(client, params.sessionId, record.agent.messages);
295
+ this.#sessions.set(params.sessionId, record);
355
296
  await patchSessionMeta(this.#acpRoot, params.sessionId, {
356
297
  cwd: params.cwd,
357
- updatedAt: new Date().toISOString(),
358
298
  ...(fromRequest !== undefined ? { userId: fromRequest || null } : {}),
359
299
  mcpServers,
300
+ sessionMode: mode,
360
301
  });
302
+ await this.#advertiseCommands(client, params.sessionId);
303
+ // Surface any restored plan so a resumed session shows its todo list.
304
+ if (getTodoViewState(record.agent).total > 0) {
305
+ await this.#sendPlanUpdate(client, params.sessionId, record);
306
+ }
361
307
  return {
362
- modes: {
363
- currentModeId: DEFAULT_MODE_ID,
364
- availableModes: [
365
- {
366
- id: DEFAULT_MODE_ID,
367
- name: "Default",
368
- description: "Standard Hooman behaviour for this session.",
369
- },
370
- ],
371
- },
372
- models: null,
373
- configOptions: buildSessionConfigOptions(config, agent),
308
+ modes: buildSessionModeState(mode),
309
+ configOptions: buildSessionConfigOptions(record.config, mode),
374
310
  };
375
311
  }
376
- async cancel(params) {
312
+ /** Advertise the available slash commands for a freshly set-up session. */
313
+ #advertiseCommands(client, sessionId) {
314
+ return this.#sendUpdate(client, sessionId, {
315
+ sessionUpdate: "available_commands_update",
316
+ availableCommands: [...ACP_SLASH_COMMANDS],
317
+ });
318
+ }
319
+ /**
320
+ * Handle `session/set_mode`: switch the active mode for a running session,
321
+ * re-applying the tool surface and persisting the choice.
322
+ */
323
+ async setSessionMode(params) {
377
324
  const rec = this.#sessions.get(params.sessionId);
378
325
  if (!rec) {
379
- return;
326
+ throw RequestError.invalidParams({ sessionId: params.sessionId });
380
327
  }
381
- rec.turnAbort?.abort();
382
- try {
383
- rec.agent.cancel();
328
+ if (!isKnownSessionMode(params.modeId)) {
329
+ throw RequestError.invalidParams({
330
+ modeId: params.modeId,
331
+ message: `Unknown mode "${params.modeId}"`,
332
+ });
384
333
  }
385
- catch {
386
- /* ignore */
334
+ if (this.#transitionMode(rec, params.modeId)) {
335
+ await patchSessionMeta(this.#acpRoot, params.sessionId, {
336
+ sessionMode: params.modeId,
337
+ });
387
338
  }
339
+ return {};
388
340
  }
389
- async #rebuildSessionAgent(sessionId) {
390
- const rec = this.#sessions.get(sessionId);
341
+ /**
342
+ * Handle `session/set_config_option`: apply a model or mode selection and
343
+ * return the complete, up-to-date configuration state (as the spec requires).
344
+ */
345
+ async setSessionConfigOption(params) {
346
+ const rec = this.#sessions.get(params.sessionId);
391
347
  if (!rec) {
348
+ throw RequestError.invalidParams({ sessionId: params.sessionId });
349
+ }
350
+ if (typeof params.value !== "string") {
351
+ throw RequestError.invalidParams({
352
+ configId: params.configId,
353
+ message: `Config option "${params.configId}" expects a string value`,
354
+ });
355
+ }
356
+ const value = params.value;
357
+ if (params.configId === CONFIG_ID_MODE) {
358
+ if (!isKnownSessionMode(value)) {
359
+ throw RequestError.invalidParams({
360
+ value,
361
+ message: `Unknown mode "${value}"`,
362
+ });
363
+ }
364
+ if (this.#transitionMode(rec, value)) {
365
+ await patchSessionMeta(this.#acpRoot, params.sessionId, {
366
+ sessionMode: value,
367
+ });
368
+ }
369
+ }
370
+ else if (params.configId === CONFIG_ID_MODEL) {
371
+ await this.#applyModelChange(rec, value);
372
+ await patchSessionMeta(this.#acpRoot, params.sessionId, { model: value });
373
+ }
374
+ else {
375
+ throw RequestError.invalidParams({
376
+ configId: params.configId,
377
+ message: `Unknown config option "${params.configId}"`,
378
+ });
379
+ }
380
+ return {
381
+ configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
382
+ };
383
+ }
384
+ /** Whether a prompt turn is currently streaming for this session. */
385
+ #isTurnActive(rec) {
386
+ return rec.turnAbort !== null;
387
+ }
388
+ /**
389
+ * Switch the session mode. Records it immediately (so state reads are
390
+ * correct), but defers the tool-surface rebuild to the next turn boundary
391
+ * when a turn is streaming, avoiding a mid-turn tool-surface swap. Returns
392
+ * whether the mode actually changed.
393
+ */
394
+ #transitionMode(rec, mode) {
395
+ if (resolveSessionMode(getModeState(rec.agent).mode) === mode) {
396
+ return false;
397
+ }
398
+ setSessionMode(rec.agent, mode);
399
+ if (this.#isTurnActive(rec)) {
400
+ rec.pendingModeReapply = true;
401
+ }
402
+ else {
403
+ applySessionMode(rec.agent);
404
+ }
405
+ return true;
406
+ }
407
+ /**
408
+ * Select a new model for the session. Flips the in-memory (ephemeral) config
409
+ * default immediately, then rebuilds the live model — deferring the rebuild
410
+ * to the next turn boundary when a turn is streaming.
411
+ */
412
+ async #applyModelChange(rec, modelName) {
413
+ const match = rec.config.llms.find((entry) => entry.name === modelName);
414
+ if (!match) {
415
+ throw RequestError.invalidParams({
416
+ value: modelName,
417
+ message: `Unknown model "${modelName}"`,
418
+ });
419
+ }
420
+ if (match.name === currentModelName(rec.config)) {
392
421
  return;
393
422
  }
394
- const meta = await readSessionMeta(this.#acpRoot, sessionId);
395
- if (!meta) {
423
+ const previous = currentModelName(rec.config);
424
+ rec.config.update({
425
+ llms: rec.config.llms.map((entry) => ({
426
+ ...entry,
427
+ default: entry.name === match.name,
428
+ })),
429
+ });
430
+ if (this.#isTurnActive(rec)) {
431
+ rec.pendingModelRebuild = true;
396
432
  return;
397
433
  }
398
- const bootstrapUserId = meta.userId ?? sessionId;
399
- const mcpServers = meta.mcpServers ?? [];
400
- const messageSnapshot = serializeAgentMessages(rec.agent);
401
- const { config: bootConfig, agent, mcp: { manager }, } = await bootstrap("acp", {
402
- userId: bootstrapUserId,
403
- sessionId,
404
- yolo: isYoloEnabled(rec.agent),
405
- mode: getModeState(rec.agent).mode,
406
- createInterventions: () => [
407
- createAcpToolApprovalIntervention(this.#connection, sessionId, () => this.#sessions.get(sessionId)?.streamedToolCallIds ??
408
- EMPTY_STREAMED_TOOL_CALL_IDS),
409
- ],
410
- acp: {
411
- mcpServers,
412
- },
413
- }, false, rec.config);
414
- const config = bootConfig;
415
- agent.messages.length = 0;
416
- for (const md of messageSnapshot) {
417
- agent.messages.push(Message.fromJSON(md));
418
- }
419
- copyAgentAppState(rec.agent, agent);
420
- applySessionMode(agent);
421
- const oldAgent = rec.agent;
422
- const oldMcpDisconnect = rec.mcpDisconnect;
423
- rec.agent = agent;
424
- rec.config = config;
425
- rec.mcpDisconnect = async () => {
426
- try {
427
- await manager.disconnect();
434
+ await this.#rebuildModel(rec, previous);
435
+ }
436
+ /** Rebuild the Strands model from current config, rolling back on failure. */
437
+ async #rebuildModel(rec, previous) {
438
+ try {
439
+ const resolved = rec.config.llm;
440
+ const provider = await modelProviders[resolved.provider]();
441
+ rec.agent.model = provider.create(resolved.providerOptions, resolved.llmOptions);
442
+ }
443
+ catch (error) {
444
+ if (previous) {
445
+ rec.config.update({
446
+ llms: rec.config.llms.map((entry) => ({
447
+ ...entry,
448
+ default: entry.name === previous,
449
+ })),
450
+ });
428
451
  }
429
- catch {
430
- /* ignore */
452
+ throw RequestError.internalError({
453
+ message: error instanceof Error ? error.message : String(error),
454
+ });
455
+ }
456
+ }
457
+ /**
458
+ * Apply settings changes deferred during a turn (mode tool-surface + model
459
+ * rebuild). Runs at the turn boundary before the next turn may start.
460
+ */
461
+ async #flushPendingSettings(rec) {
462
+ if (rec.pendingModeReapply) {
463
+ rec.pendingModeReapply = false;
464
+ applySessionMode(rec.agent);
465
+ }
466
+ if (rec.pendingModelRebuild) {
467
+ rec.pendingModelRebuild = false;
468
+ await this.#rebuildModel(rec).catch(() => undefined);
469
+ }
470
+ }
471
+ /**
472
+ * Execute an inline (non-model) slash command and return the text reply to
473
+ * stream back as an `agent_message_chunk`. `/init` is handled by the caller.
474
+ */
475
+ #runControlCommand(client, sessionId, rec, command) {
476
+ switch (command.name) {
477
+ case "mode":
478
+ return this.#commandSetMode(client, sessionId, rec, command.args);
479
+ case "model":
480
+ return this.#commandSetModel(client, sessionId, rec, command.args);
481
+ case "yolo":
482
+ return this.#commandSetYolo(sessionId, rec, command.args);
483
+ case "compact":
484
+ return this.#commandCompact(rec);
485
+ default:
486
+ return Promise.resolve(`Unknown command "/${command.name}".`);
487
+ }
488
+ }
489
+ async #commandSetMode(client, sessionId, rec, args) {
490
+ const current = resolveSessionMode(getModeState(rec.agent).mode);
491
+ const arg = args.trim().toLowerCase();
492
+ if (!arg) {
493
+ return `Usage: /mode <${formatModeNames()}>. Current mode: "${current}".`;
494
+ }
495
+ if (!isKnownSessionMode(arg)) {
496
+ return `Unknown mode "${arg}". Use ${formatModeNames()}.`;
497
+ }
498
+ if (!this.#transitionMode(rec, arg)) {
499
+ return `Already in "${arg}" mode.`;
500
+ }
501
+ await this.#syncCurrentMode(client, sessionId, rec);
502
+ return `Switched session mode to "${arg}".`;
503
+ }
504
+ async #commandSetModel(client, sessionId, rec, args) {
505
+ const arg = args.trim();
506
+ if (!arg) {
507
+ return this.#listModelsText(rec);
508
+ }
509
+ if (!rec.config.llms.some((entry) => entry.name === arg)) {
510
+ return `Unknown model "${arg}".\n\n${this.#listModelsText(rec)}`;
511
+ }
512
+ if (arg === currentModelName(rec.config)) {
513
+ return `Already using model "${arg}".`;
514
+ }
515
+ try {
516
+ await this.#applyModelChange(rec, arg);
517
+ }
518
+ catch (error) {
519
+ const message = error instanceof Error ? error.message : String(error);
520
+ return `Could not switch to model "${arg}": ${message}`;
521
+ }
522
+ await patchSessionMeta(this.#acpRoot, sessionId, { model: arg });
523
+ await this.#sendUpdate(client, sessionId, {
524
+ sessionUpdate: "config_option_update",
525
+ configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
526
+ });
527
+ return `Switched model to "${arg}".`;
528
+ }
529
+ #listModelsText(rec) {
530
+ const current = currentModelName(rec.config);
531
+ const lines = rec.config.llms.map((entry) => {
532
+ const marker = entry.name === current ? "*" : "-";
533
+ return `${marker} ${entry.name} (${entry.provider}/${entry.options.model})`;
534
+ });
535
+ return [
536
+ `Current model: ${current ?? "(none)"}`,
537
+ "Available models:",
538
+ ...lines,
539
+ 'Use "/model <name>" to switch.',
540
+ ].join("\n");
541
+ }
542
+ async #commandSetYolo(sessionId, rec, args) {
543
+ const arg = args.trim();
544
+ if (!arg) {
545
+ return `Usage: /yolo <on|off>. Auto-approve is currently ${isYoloEnabled(rec.agent) ? "on" : "off"}.`;
546
+ }
547
+ const enabled = parseYoloToggle(arg);
548
+ if (enabled === undefined) {
549
+ return `Unknown value "${arg}". Use on or off.`;
550
+ }
551
+ if (isYoloEnabled(rec.agent) === enabled) {
552
+ return `Auto-approve is already ${enabled ? "on" : "off"}.`;
553
+ }
554
+ setYoloEnabled(rec.agent, enabled);
555
+ await patchSessionMeta(this.#acpRoot, sessionId, { yolo: enabled });
556
+ return `Auto-approve tools ${enabled ? "enabled" : "disabled"}.`;
557
+ }
558
+ async #commandCompact(rec) {
559
+ const conversationManager = getAgentConversationManager(rec.agent);
560
+ if (!conversationManager) {
561
+ return "This session has no conversation manager to compact.";
562
+ }
563
+ const before = rec.agent.messages.length;
564
+ try {
565
+ const reduced = await conversationManager.reduce({
566
+ agent: rec.agent,
567
+ model: rec.agent.model,
568
+ });
569
+ if (!reduced) {
570
+ return "Conversation history is already too short to compact.";
431
571
  }
432
- };
433
- rec.agentStale = false;
434
- await flushAgentMemory(oldAgent).catch(() => { });
435
- await oldMcpDisconnect();
572
+ const after = rec.agent.messages.length;
573
+ await getAgentSessionManager(rec.agent)?.saveSnapshot({
574
+ target: rec.agent,
575
+ isLatest: true,
576
+ });
577
+ return `Compacted conversation history (${before} → ${after} messages).`;
578
+ }
579
+ catch (error) {
580
+ return error instanceof Error ? error.message : String(error);
581
+ }
436
582
  }
583
+ /** Cancel an in-flight prompt turn. Silent no-op for unknown sessions. */
584
+ async cancel(params) {
585
+ const rec = this.#sessions.get(params.sessionId);
586
+ if (!rec) {
587
+ return;
588
+ }
589
+ rec.turnAbort?.abort();
590
+ try {
591
+ rec.agent.cancel();
592
+ }
593
+ catch {
594
+ /* ignore */
595
+ }
596
+ }
597
+ /** Run one prompt turn: stream model output, tool calls, and a stop reason. */
437
598
  async prompt(params) {
438
599
  const rec = this.#sessions.get(params.sessionId);
439
600
  if (!rec) {
440
601
  throw RequestError.invalidParams({ sessionId: params.sessionId });
441
602
  }
603
+ const client = this.#requireClient();
604
+ // Serialize concurrent prompt turns for the same session.
442
605
  const prevExclusive = rec.promptExclusive;
443
606
  let releaseExclusive;
444
607
  const exclusiveGate = new Promise((resolve) => {
@@ -446,9 +609,6 @@ export class AcpAgent {
446
609
  });
447
610
  rec.promptExclusive = prevExclusive.then(() => exclusiveGate);
448
611
  await prevExclusive;
449
- if (rec.agentStale) {
450
- await this.#rebuildSessionAgent(params.sessionId);
451
- }
452
612
  const turnAbort = new AbortController();
453
613
  rec.turnAbort = turnAbort;
454
614
  rec.streamedToolCallIds.clear();
@@ -458,38 +618,48 @@ export class AcpAgent {
458
618
  try {
459
619
  const echo = acpPromptEchoText(params.prompt);
460
620
  if (echo.length > 0) {
461
- await this.#connection.sessionUpdate({
462
- sessionId: params.sessionId,
463
- update: {
464
- sessionUpdate: "user_message_chunk",
465
- content: { type: "text", text: echo },
466
- },
621
+ await this.#sendUpdate(client, params.sessionId, {
622
+ sessionUpdate: "user_message_chunk",
623
+ content: { type: "text", text: echo },
467
624
  });
468
- const meta = await readSessionMeta(this.#acpRoot, params.sessionId);
469
- const needsTitle = meta &&
470
- (meta.title === undefined ||
471
- meta.title === null ||
472
- String(meta.title).trim() === "");
473
- if (needsTitle) {
474
- const title = deriveSessionTitleFromEcho(echo);
475
- if (title) {
476
- await patchSessionMeta(this.#acpRoot, params.sessionId, { title });
477
- await this.#connection.sessionUpdate({
478
- sessionId: params.sessionId,
479
- update: {
480
- sessionUpdate: "session_info_update",
481
- title,
482
- updatedAt: new Date().toISOString(),
483
- },
484
- });
485
- }
625
+ await this.#maybeDeriveTitle(client, params.sessionId, echo);
626
+ }
627
+ // Slash commands arrive as prompt text. Control commands run inline
628
+ // (no model turn); `/init` rewrites the prompt and runs normally.
629
+ const command = parseAcpSlashCommand(echo);
630
+ if (command && command.name !== "init") {
631
+ const reply = await this.#runControlCommand(client, params.sessionId, rec, command);
632
+ if (reply) {
633
+ await this.#sendUpdate(client, params.sessionId, {
634
+ sessionUpdate: "agent_message_chunk",
635
+ content: { type: "text", text: reply },
636
+ });
486
637
  }
638
+ return { stopReason: "end_turn" };
639
+ }
640
+ const invokeArgs = command?.name === "init"
641
+ ? acpPromptToInvokeArgs([
642
+ {
643
+ type: "text",
644
+ text: command.args
645
+ ? `${INIT_AGENTS_PROMPT}\n\n${command.args}`
646
+ : INIT_AGENTS_PROMPT,
647
+ },
648
+ ])
649
+ : acpPromptToInvokeArgs(params.prompt);
650
+ const signals = [turnAbort.signal];
651
+ if (this.#connectionSignal) {
652
+ signals.push(this.#connectionSignal);
653
+ }
654
+ const cancelSignal = AbortSignal.any(signals);
655
+ // A new turn resets todos (BeforeInvocationEvent). Clear any stale plan
656
+ // the client is still showing from the previous turn.
657
+ if (getTodoViewState(rec.agent).total > 0) {
658
+ await this.#sendUpdate(client, params.sessionId, {
659
+ sessionUpdate: "plan",
660
+ entries: [],
661
+ });
487
662
  }
488
- const invokeArgs = acpPromptToInvokeArgs(params.prompt);
489
- const cancelSignal = AbortSignal.any([
490
- this.#connection.signal,
491
- turnAbort.signal,
492
- ]);
493
663
  try {
494
664
  await runWithAgentMemoryScope(rec.agent, async () => {
495
665
  await runWithCwd(rec.cwd, async () => {
@@ -498,32 +668,28 @@ export class AcpAgent {
498
668
  while (!iter.done) {
499
669
  const ev = iter.value;
500
670
  if (ev.type === "modelStreamUpdateEvent") {
501
- await this.#dispatchModelStreamUpdate(params.sessionId, ev, rec);
671
+ await this.#dispatchModelStreamUpdate(client, params.sessionId, ev, rec);
502
672
  }
503
673
  else if (ev.type === "afterToolCallEvent") {
504
- await this.#connection.sessionUpdate({
505
- sessionId: params.sessionId,
506
- update: {
507
- sessionUpdate: "tool_call_update",
508
- toolCallId: ev.toolUse.toolUseId,
509
- status: ev.result.status === "success" ? "completed" : "failed",
510
- rawOutput: ev.result.toJSON(),
511
- content: toolResultToAcpContent(ev.result),
512
- },
674
+ const display = takeFileToolDisplay(rec.agent.appState, ev.toolUse.toolUseId);
675
+ const diff = fileToolDiffContent(display);
676
+ const locations = toolCallLocationsFromInput(ev.toolUse.name, ev.toolUse.input);
677
+ await this.#sendUpdate(client, params.sessionId, {
678
+ sessionUpdate: "tool_call_update",
679
+ toolCallId: ev.toolUse.toolUseId,
680
+ status: ev.result.status === "success" ? "completed" : "failed",
681
+ rawOutput: ev.result.toJSON(),
682
+ content: diff ?? toolResultToAcpContent(ev.result),
683
+ ...(locations ? { locations } : {}),
513
684
  });
685
+ if (ev.toolUse.name === UPDATE_TODOS_TOOL_NAME &&
686
+ ev.result.status === "success") {
687
+ await this.#sendPlanUpdate(client, params.sessionId, rec);
688
+ }
514
689
  if (ev.result.status === "success" &&
515
690
  (ev.toolUse.name === ENTER_PLAN_MODE_TOOL ||
516
691
  ev.toolUse.name === EXIT_PLAN_MODE_TOOL)) {
517
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
518
- sessionMode: getModeState(rec.agent).mode,
519
- });
520
- await this.#connection.sessionUpdate({
521
- sessionId: params.sessionId,
522
- update: {
523
- sessionUpdate: "config_option_update",
524
- configOptions: buildSessionConfigOptions(rec.config, rec.agent),
525
- },
526
- });
692
+ await this.#syncCurrentMode(client, params.sessionId, rec);
527
693
  }
528
694
  }
529
695
  else if (ev.type === "agentResultEvent") {
@@ -535,32 +701,31 @@ export class AcpAgent {
535
701
  });
536
702
  }
537
703
  catch (err) {
538
- const cancelSignals = [
539
- cancelSignal,
540
- turnAbort.signal,
541
- this.#connection.signal,
542
- ];
704
+ const cancelSignals = [cancelSignal, turnAbort.signal];
543
705
  if (isCancellationError(err, cancelSignals)) {
544
706
  stopReason = "cancelled";
545
707
  }
546
708
  else {
547
709
  const message = err instanceof Error ? err.message : String(err);
548
- await this.#connection.sessionUpdate({
549
- sessionId: params.sessionId,
550
- update: {
551
- sessionUpdate: "agent_message_chunk",
552
- content: {
553
- type: "text",
554
- text: `\n[error] ${message}\n`,
555
- },
556
- },
710
+ await this.#sendUpdate(client, params.sessionId, {
711
+ sessionUpdate: "agent_message_chunk",
712
+ content: { type: "text", text: `\n[error] ${message}\n` },
557
713
  });
558
714
  stopReason = "refusal";
559
715
  }
560
716
  }
717
+ // If cancellation was requested, report `cancelled` even when the stream
718
+ // happened to finish cleanly right as the signal fired (spec requires the
719
+ // prompt to resolve with the cancelled stop reason, not `end_turn`).
720
+ if (turnAbort.signal.aborted) {
721
+ stopReason = "cancelled";
722
+ }
561
723
  }
562
724
  finally {
563
725
  rec.turnAbort = null;
726
+ // Apply mode/model changes requested mid-turn before releasing the gate
727
+ // so the next turn sees the settled state.
728
+ await this.#flushPendingSettings(rec);
564
729
  releaseExclusive();
565
730
  try {
566
731
  await saveSessionMessages(this.#acpRoot, params.sessionId, serializeAgentMessages(rec.agent));
@@ -570,12 +735,69 @@ export class AcpAgent {
570
735
  }
571
736
  }
572
737
  if (consumeExitRequest(rec.agent)) {
573
- await this.#disposeAllSessions();
738
+ await this.#disposeAll();
574
739
  setTimeout(() => process.exit(EXIT_REQUESTED_CODE), 25);
575
740
  }
576
741
  return { stopReason };
577
742
  }
578
- async #dispatchModelStreamUpdate(sessionId, ev, rec) {
743
+ /**
744
+ * Surface the agent's todo list as an ACP `plan` update. Sends the complete
745
+ * list, as the client replaces the plan wholesale on each update. The
746
+ * in-progress entry uses its present-tense `activeForm` for a nicer live view.
747
+ */
748
+ #sendPlanUpdate(client, sessionId, rec) {
749
+ const { todos } = getTodoViewState(rec.agent);
750
+ return this.#sendUpdate(client, sessionId, {
751
+ sessionUpdate: "plan",
752
+ entries: todos.map((todo) => ({
753
+ content: todo.status === "in_progress" && todo.activeForm
754
+ ? todo.activeForm
755
+ : todo.content,
756
+ priority: todo.priority,
757
+ status: todo.status,
758
+ })),
759
+ });
760
+ }
761
+ /**
762
+ * Reflect an agent-driven mode change (e.g. `enter_plan_mode` /
763
+ * `exit_plan_mode`) to the client via `current_mode_update` and persist it.
764
+ */
765
+ async #syncCurrentMode(client, sessionId, rec) {
766
+ const modeId = resolveSessionMode(getModeState(rec.agent).mode);
767
+ await patchSessionMeta(this.#acpRoot, sessionId, { sessionMode: modeId });
768
+ await this.#sendUpdate(client, sessionId, {
769
+ sessionUpdate: "current_mode_update",
770
+ currentModeId: modeId,
771
+ });
772
+ // Config Options supersede modes; mirror the change for config-aware clients.
773
+ await this.#sendUpdate(client, sessionId, {
774
+ sessionUpdate: "config_option_update",
775
+ configOptions: buildSessionConfigOptions(rec.config, modeId),
776
+ });
777
+ }
778
+ /** Derive + persist a title from the first meaningful prompt echo. */
779
+ async #maybeDeriveTitle(client, sessionId, echo) {
780
+ const meta = await readSessionMeta(this.#acpRoot, sessionId);
781
+ const needsTitle = meta &&
782
+ (meta.title === undefined ||
783
+ meta.title === null ||
784
+ String(meta.title).trim() === "");
785
+ if (!needsTitle) {
786
+ return;
787
+ }
788
+ const title = deriveSessionTitleFromEcho(echo);
789
+ if (!title) {
790
+ return;
791
+ }
792
+ await patchSessionMeta(this.#acpRoot, sessionId, { title });
793
+ await this.#sendUpdate(client, sessionId, {
794
+ sessionUpdate: "session_info_update",
795
+ title,
796
+ updatedAt: new Date().toISOString(),
797
+ });
798
+ }
799
+ /** Translate a Strands model-stream event into ACP `session/update`s. */
800
+ async #dispatchModelStreamUpdate(client, sessionId, ev, rec) {
579
801
  const inner = ev.event;
580
802
  if (!isModelStreamEvent(inner)) {
581
803
  return;
@@ -587,16 +809,13 @@ export class AcpAgent {
587
809
  rec.streamedToolCallIds.add(start.toolUseId);
588
810
  rec.lastStreamToolUseId = start.toolUseId;
589
811
  rec.streamingToolInputJson.set(start.toolUseId, "");
590
- await this.#connection.sessionUpdate({
591
- sessionId,
592
- update: {
593
- sessionUpdate: "tool_call",
594
- toolCallId: start.toolUseId,
595
- title: start.name,
596
- kind: inferToolKind(start.name),
597
- status: "pending",
598
- rawInput: {},
599
- },
812
+ await this.#sendUpdate(client, sessionId, {
813
+ sessionUpdate: "tool_call",
814
+ toolCallId: start.toolUseId,
815
+ title: start.name,
816
+ kind: inferToolKind(start.name),
817
+ status: "pending",
818
+ rawInput: {},
600
819
  });
601
820
  }
602
821
  return;
@@ -604,23 +823,17 @@ export class AcpAgent {
604
823
  case "modelContentBlockDeltaEvent": {
605
824
  const { delta } = inner;
606
825
  if (delta.type === "textDelta" && delta.text) {
607
- await this.#connection.sessionUpdate({
608
- sessionId,
609
- update: {
610
- sessionUpdate: "agent_message_chunk",
611
- content: { type: "text", text: delta.text },
612
- },
826
+ await this.#sendUpdate(client, sessionId, {
827
+ sessionUpdate: "agent_message_chunk",
828
+ content: { type: "text", text: delta.text },
613
829
  });
614
830
  return;
615
831
  }
616
832
  if (delta.type === "reasoningContentDelta") {
617
833
  if (delta.text) {
618
- await this.#connection.sessionUpdate({
619
- sessionId,
620
- update: {
621
- sessionUpdate: "agent_thought_chunk",
622
- content: { type: "text", text: delta.text },
623
- },
834
+ await this.#sendUpdate(client, sessionId, {
835
+ sessionUpdate: "agent_thought_chunk",
836
+ content: { type: "text", text: delta.text },
624
837
  });
625
838
  }
626
839
  return;
@@ -633,27 +846,21 @@ export class AcpAgent {
633
846
  const prev = rec.streamingToolInputJson.get(toolUseId) ?? "";
634
847
  const next = prev + delta.input;
635
848
  rec.streamingToolInputJson.set(toolUseId, next);
636
- await this.#connection.sessionUpdate({
637
- sessionId,
638
- update: {
639
- sessionUpdate: "tool_call_update",
640
- toolCallId: toolUseId,
641
- rawInput: parseStreamingToolJson(next),
642
- },
849
+ await this.#sendUpdate(client, sessionId, {
850
+ sessionUpdate: "tool_call_update",
851
+ toolCallId: toolUseId,
852
+ rawInput: parseStreamingToolJson(next),
643
853
  });
644
854
  return;
645
855
  }
646
856
  if (delta.type === "citationsDelta") {
647
857
  const n = delta.citations?.length ?? 0;
648
858
  if (n > 0) {
649
- await this.#connection.sessionUpdate({
650
- sessionId,
651
- update: {
652
- sessionUpdate: "agent_thought_chunk",
653
- content: {
654
- type: "text",
655
- text: `[citations: ${n} reference(s)]\n`,
656
- },
859
+ await this.#sendUpdate(client, sessionId, {
860
+ sessionUpdate: "agent_thought_chunk",
861
+ content: {
862
+ type: "text",
863
+ text: `[citations: ${n} reference(s)]\n`,
657
864
  },
658
865
  });
659
866
  }
@@ -664,12 +871,9 @@ export class AcpAgent {
664
871
  const replace = inner.outputRedaction?.replaceContent ??
665
872
  inner.inputRedaction?.replaceContent;
666
873
  if (replace) {
667
- await this.#connection.sessionUpdate({
668
- sessionId,
669
- update: {
670
- sessionUpdate: "agent_message_chunk",
671
- content: { type: "text", text: replace },
672
- },
874
+ await this.#sendUpdate(client, sessionId, {
875
+ sessionUpdate: "agent_message_chunk",
876
+ content: { type: "text", text: replace },
673
877
  });
674
878
  }
675
879
  return;
@@ -686,32 +890,216 @@ export class AcpAgent {
686
890
  const keys = [...rec.streamingToolInputJson.keys()];
687
891
  return keys.length === 1 ? keys[0] : undefined;
688
892
  }
689
- async #disposeAllSessions() {
690
- const ids = [...this.#sessions.keys()];
691
- for (const id of ids) {
692
- await this.#destroySession(id);
893
+ #sendUpdate(client, sessionId, update) {
894
+ return client.notify(methods.client.session.update, { sessionId, update });
895
+ }
896
+ #requireClient() {
897
+ if (!this.#client) {
898
+ throw RequestError.internalError({
899
+ message: "ACP client context is not available.",
900
+ });
693
901
  }
902
+ return this.#client;
694
903
  }
695
- async #destroySession(sessionId) {
696
- const rec = this.#sessions.get(sessionId);
697
- if (!rec) {
904
+ async #bootstrapSession(sessionId, cwd, userId, mcpServers, mode, preferredModel) {
905
+ const client = this.#requireClient();
906
+ const sessionConfig = createSessionConfig();
907
+ if (preferredModel &&
908
+ preferredModel !== currentModelName(sessionConfig) &&
909
+ sessionConfig.llms.some((entry) => entry.name === preferredModel)) {
910
+ sessionConfig.update({
911
+ llms: sessionConfig.llms.map((entry) => ({
912
+ ...entry,
913
+ default: entry.name === preferredModel,
914
+ })),
915
+ });
916
+ }
917
+ const { config, agent, mcp: { manager }, } = await bootstrap("acp", {
918
+ userId,
919
+ sessionId,
920
+ mode,
921
+ createInterventions: () => [
922
+ createAcpToolApprovalIntervention(client, sessionId, () => this.#sessions.get(sessionId)?.streamedToolCallIds ??
923
+ EMPTY_STREAMED_TOOL_CALL_IDS),
924
+ ],
925
+ acp: { mcpServers: mcpServers ?? [] },
926
+ }, false, sessionConfig);
927
+ this.#registerTextFsBackend(agent, client, sessionId);
928
+ this.#registerTerminalBackend(agent, client, sessionId);
929
+ return {
930
+ cwd,
931
+ agent,
932
+ config: config,
933
+ mcpDisconnect: () => manager.disconnect().catch(() => undefined),
934
+ turnAbort: null,
935
+ promptExclusive: Promise.resolve(),
936
+ streamedToolCallIds: new Set(),
937
+ streamingToolInputJson: new Map(),
938
+ lastStreamToolUseId: null,
939
+ pendingModeReapply: false,
940
+ pendingModelRebuild: false,
941
+ };
942
+ }
943
+ /**
944
+ * Route the built-in filesystem tools' text reads/writes through the client's
945
+ * `fs/*` methods when it advertised the capability during initialization.
946
+ */
947
+ #registerTextFsBackend(agent, client, sessionId) {
948
+ const caps = this.#clientFsCaps;
949
+ if (!caps || (!caps.readTextFile && !caps.writeTextFile)) {
698
950
  return;
699
951
  }
700
- rec.turnAbort?.abort();
952
+ setTextFsBackend(agent, {
953
+ canRead: caps.readTextFile === true,
954
+ canWrite: caps.writeTextFile === true,
955
+ readTextFile: async (filePath, options) => {
956
+ const response = await client.request(methods.client.fs.readTextFile, {
957
+ sessionId,
958
+ path: filePath,
959
+ ...(options?.line !== undefined ? { line: options.line } : {}),
960
+ ...(options?.limit !== undefined ? { limit: options.limit } : {}),
961
+ });
962
+ return response.content;
963
+ },
964
+ writeTextFile: async (filePath, content) => {
965
+ await client.request(methods.client.fs.writeTextFile, {
966
+ sessionId,
967
+ path: filePath,
968
+ content,
969
+ });
970
+ },
971
+ });
972
+ }
973
+ /**
974
+ * Route the built-in `shell` tool through the client's `terminal/*` methods
975
+ * when it advertised the `terminal` capability during initialization.
976
+ */
977
+ #registerTerminalBackend(agent, client, sessionId) {
978
+ if (!this.#clientTerminalCap) {
979
+ return;
980
+ }
981
+ setTerminalBackend(agent, {
982
+ run: (request) => this.#runClientTerminal(client, sessionId, request),
983
+ });
984
+ }
985
+ async #runClientTerminal(client, sessionId, request) {
986
+ const created = await client.request(methods.client.terminal.create, {
987
+ sessionId,
988
+ command: request.command,
989
+ args: request.args,
990
+ cwd: request.cwd,
991
+ ...(request.outputByteLimit !== undefined
992
+ ? { outputByteLimit: request.outputByteLimit }
993
+ : {}),
994
+ },
995
+ // Cascade `$/cancel_request` if the turn is cancelled before the terminal
996
+ // is created; once created we explicitly kill+release below instead.
997
+ { cancellationSignal: request.cancelSignal });
998
+ const terminalId = created.terminalId;
701
999
  try {
702
- rec.agent.cancel();
1000
+ // Embed the live terminal in the tool call so the client streams output.
1001
+ if (request.toolUseId) {
1002
+ await this.#sendUpdate(client, sessionId, {
1003
+ sessionUpdate: "tool_call_update",
1004
+ toolCallId: request.toolUseId,
1005
+ status: "in_progress",
1006
+ content: [{ type: "terminal", terminalId }],
1007
+ });
1008
+ }
1009
+ const { exit, timedOut } = await this.#awaitTerminalExit(client, sessionId, terminalId, request);
1010
+ const output = await client.request(methods.client.terminal.output, {
1011
+ sessionId,
1012
+ terminalId,
1013
+ });
1014
+ return {
1015
+ output: output.output,
1016
+ truncated: output.truncated,
1017
+ exitCode: exit?.exitCode ?? output.exitStatus?.exitCode ?? null,
1018
+ signal: exit?.signal ?? output.exitStatus?.signal ?? null,
1019
+ timedOut,
1020
+ };
703
1021
  }
704
- catch {
705
- /* ignore */
1022
+ finally {
1023
+ await client
1024
+ .request(methods.client.terminal.release, { sessionId, terminalId })
1025
+ .catch(() => undefined);
706
1026
  }
707
- await flushAgentMemory(rec.agent).catch(() => { });
708
- await rec.mcpDisconnect();
709
- this.#sessions.delete(sessionId);
710
1027
  }
1028
+ /**
1029
+ * Wait for the command to exit, killing it if the timeout elapses or the turn
1030
+ * is cancelled (spec: "Building a Timeout").
1031
+ */
1032
+ async #awaitTerminalExit(client, sessionId, terminalId, request) {
1033
+ const waitForExit = client.request(methods.client.terminal.waitForExit, {
1034
+ sessionId,
1035
+ terminalId,
1036
+ });
1037
+ // Both branches are handled so a late settle never becomes unhandled.
1038
+ const exitRace = waitForExit.then((value) => ({ kind: "exit", value }), () => ({ kind: "exit", value: null }));
1039
+ let timer;
1040
+ const timeoutRace = new Promise((resolve) => {
1041
+ timer = setTimeout(() => resolve({ kind: "timeout" }), request.timeoutMs);
1042
+ });
1043
+ const signal = request.cancelSignal;
1044
+ let onAbort;
1045
+ const cancelRace = new Promise((resolve) => {
1046
+ if (!signal) {
1047
+ return;
1048
+ }
1049
+ if (signal.aborted) {
1050
+ resolve({ kind: "cancel" });
1051
+ return;
1052
+ }
1053
+ onAbort = () => resolve({ kind: "cancel" });
1054
+ signal.addEventListener("abort", onAbort, { once: true });
1055
+ });
1056
+ try {
1057
+ const winner = await Promise.race([exitRace, timeoutRace, cancelRace]);
1058
+ if (winner.kind === "exit") {
1059
+ return { exit: winner.value, timedOut: false };
1060
+ }
1061
+ await client
1062
+ .request(methods.client.terminal.kill, { sessionId, terminalId })
1063
+ .catch(() => undefined);
1064
+ return { exit: null, timedOut: winner.kind === "timeout" };
1065
+ }
1066
+ finally {
1067
+ if (timer) {
1068
+ clearTimeout(timer);
1069
+ }
1070
+ if (signal && onAbort) {
1071
+ signal.removeEventListener("abort", onAbort);
1072
+ }
1073
+ }
1074
+ }
1075
+ async #disposeAll() {
1076
+ const records = [...this.#sessions.values()];
1077
+ this.#sessions.clear();
1078
+ for (const record of records) {
1079
+ record.turnAbort?.abort();
1080
+ await record.mcpDisconnect();
1081
+ }
1082
+ }
1083
+ }
1084
+ /** Build the ACP agent app and register the handlers implemented so far. */
1085
+ export function createAcpApp(agent) {
1086
+ return acpAgent({ name: "hooman" })
1087
+ .onConnect((connection) => agent.onConnect(connection))
1088
+ .onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
1089
+ .onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
1090
+ .onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params, ctx.client))
1091
+ .onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
1092
+ .onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
1093
+ .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
1094
+ .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
1095
+ .onRequest(methods.agent.session.prompt, (ctx) => agent.prompt(ctx.params))
1096
+ .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params));
711
1097
  }
1098
+ /** Run Hooman as an ACP agent over stdio. */
712
1099
  export async function runAcpStdio() {
1100
+ const identity = await readAgentIdentity();
713
1101
  const stream = ndJsonStream(Writable.toWeb(stdout), Readable.toWeb(stdin));
714
- const connection = new AgentSideConnection((conn) => new AcpAgent(conn), stream);
1102
+ const connection = createAcpApp(new HoomanAcpAgent(identity)).connect(stream);
715
1103
  await connection.closed;
716
1104
  }
717
1105
  //# sourceMappingURL=acp-agent.js.map