hoomanjs 1.40.2 → 1.41.1

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 (160) hide show
  1. package/README.md +27 -655
  2. package/dist/acp/acp-agent.d.ts +16 -3
  3. package/dist/acp/acp-agent.js +369 -172
  4. package/dist/acp/acp-agent.js.map +1 -1
  5. package/dist/acp/commands.d.ts +6 -3
  6. package/dist/acp/commands.js +6 -24
  7. package/dist/acp/commands.js.map +1 -1
  8. package/dist/acp/meta/vscode.d.ts +10 -0
  9. package/dist/acp/meta/vscode.js +16 -0
  10. package/dist/acp/meta/vscode.js.map +1 -0
  11. package/dist/acp/questions.d.ts +18 -0
  12. package/dist/acp/questions.js +80 -0
  13. package/dist/acp/questions.js.map +1 -0
  14. package/dist/acp/session-config.d.ts +3 -1
  15. package/dist/acp/session-config.js +20 -8
  16. package/dist/acp/session-config.js.map +1 -1
  17. package/dist/acp/sessions/replay.js +117 -1
  18. package/dist/acp/sessions/replay.js.map +1 -1
  19. package/dist/acp/sessions/store.d.ts +55 -16
  20. package/dist/acp/sessions/store.js +174 -71
  21. package/dist/acp/sessions/store.js.map +1 -1
  22. package/dist/acp/utils/tool-kind.js +1 -0
  23. package/dist/acp/utils/tool-kind.js.map +1 -1
  24. package/dist/acp/utils/tool-result-content.d.ts +1 -0
  25. package/dist/acp/utils/tool-result-content.js +28 -1
  26. package/dist/acp/utils/tool-result-content.js.map +1 -1
  27. package/dist/chat/app.d.ts +3 -1
  28. package/dist/chat/app.js +84 -43
  29. package/dist/chat/app.js.map +1 -1
  30. package/dist/chat/components/BottomChrome.d.ts +12 -3
  31. package/dist/chat/components/BottomChrome.js +2 -2
  32. package/dist/chat/components/BottomChrome.js.map +1 -1
  33. package/dist/chat/components/ChromePicker.d.ts +5 -1
  34. package/dist/chat/components/ChromePicker.js +6 -2
  35. package/dist/chat/components/ChromePicker.js.map +1 -1
  36. package/dist/chat/components/QuestionPrompt.d.ts +8 -0
  37. package/dist/chat/components/QuestionPrompt.js +41 -0
  38. package/dist/chat/components/QuestionPrompt.js.map +1 -0
  39. package/dist/chat/components/StatusBar.d.ts +8 -3
  40. package/dist/chat/components/StatusBar.js +55 -26
  41. package/dist/chat/components/StatusBar.js.map +1 -1
  42. package/dist/chat/index.d.ts +2 -0
  43. package/dist/chat/index.js +1 -1
  44. package/dist/chat/index.js.map +1 -1
  45. package/dist/chat/questions.d.ts +24 -0
  46. package/dist/chat/questions.js +71 -0
  47. package/dist/chat/questions.js.map +1 -0
  48. package/dist/cli.js +11 -0
  49. package/dist/cli.js.map +1 -1
  50. package/dist/core/agent/index.d.ts +3 -0
  51. package/dist/core/agent/index.js +11 -11
  52. package/dist/core/agent/index.js.map +1 -1
  53. package/dist/core/agent/prompt-cache-plugin.d.ts +1 -1
  54. package/dist/core/agent/prompt-cache-plugin.js +1 -1
  55. package/dist/core/agent/session-title-plugin.d.ts +27 -0
  56. package/dist/core/agent/session-title-plugin.js +77 -0
  57. package/dist/core/agent/session-title-plugin.js.map +1 -0
  58. package/dist/core/config.d.ts +33 -1
  59. package/dist/core/config.js +2 -0
  60. package/dist/core/config.js.map +1 -1
  61. package/dist/core/index.d.ts +12 -0
  62. package/dist/core/index.js +7 -2
  63. package/dist/core/index.js.map +1 -1
  64. package/dist/core/mcp/index.d.ts +3 -3
  65. package/dist/core/mcp/index.js +2 -2
  66. package/dist/core/mcp/index.js.map +1 -1
  67. package/dist/core/mcp/manager.d.ts +28 -0
  68. package/dist/core/mcp/manager.js +171 -2
  69. package/dist/core/mcp/manager.js.map +1 -1
  70. package/dist/core/models/anthropic.d.ts +4 -3
  71. package/dist/core/models/anthropic.js.map +1 -1
  72. package/dist/core/models/azure.js +5 -1
  73. package/dist/core/models/azure.js.map +1 -1
  74. package/dist/core/models/groq.js +5 -1
  75. package/dist/core/models/groq.js.map +1 -1
  76. package/dist/core/models/minimax.d.ts +2 -1
  77. package/dist/core/models/minimax.js +49 -7
  78. package/dist/core/models/minimax.js.map +1 -1
  79. package/dist/core/models/moonshot.js +5 -1
  80. package/dist/core/models/moonshot.js.map +1 -1
  81. package/dist/core/models/openai.js +6 -1
  82. package/dist/core/models/openai.js.map +1 -1
  83. package/dist/core/models/openrouter.js +5 -1
  84. package/dist/core/models/openrouter.js.map +1 -1
  85. package/dist/core/models/types.d.ts +54 -6
  86. package/dist/core/models/types.js +18 -2
  87. package/dist/core/models/types.js.map +1 -1
  88. package/dist/core/models/usage.d.ts +10 -0
  89. package/dist/core/models/usage.js +45 -0
  90. package/dist/core/models/usage.js.map +1 -0
  91. package/dist/core/models/xai.js +5 -1
  92. package/dist/core/models/xai.js.map +1 -1
  93. package/dist/core/modes/definitions.js +2 -0
  94. package/dist/core/modes/definitions.js.map +1 -1
  95. package/dist/core/prompts/environment.d.ts +10 -1
  96. package/dist/core/prompts/environment.js +15 -1
  97. package/dist/core/prompts/environment.js.map +1 -1
  98. package/dist/core/prompts/static/ask-user.md +7 -0
  99. package/dist/core/prompts/static/environment.md +2 -1
  100. package/dist/core/prompts/static/filesystem.md +1 -1
  101. package/dist/core/prompts/static/web-search.md +1 -1
  102. package/dist/core/prompts/system.d.ts +6 -0
  103. package/dist/core/prompts/system.js +12 -1
  104. package/dist/core/prompts/system.js.map +1 -1
  105. package/dist/core/sessions/generate-title.d.ts +10 -0
  106. package/dist/core/sessions/generate-title.js +113 -0
  107. package/dist/core/sessions/generate-title.js.map +1 -0
  108. package/dist/core/sessions/list-cli-sessions.js +13 -1
  109. package/dist/core/sessions/list-cli-sessions.js.map +1 -1
  110. package/dist/core/skills/built-in/hooman-config/providers.md +17 -0
  111. package/dist/core/skills/built-in/hooman-mcp/SKILL.md +20 -0
  112. package/dist/core/skills/built-in/hooman-skills/SKILL.md +1 -1
  113. package/dist/core/state/session-title.d.ts +17 -0
  114. package/dist/core/state/session-title.js +15 -0
  115. package/dist/core/state/session-title.js.map +1 -0
  116. package/dist/core/state/tool-approvals.js +3 -0
  117. package/dist/core/state/tool-approvals.js.map +1 -1
  118. package/dist/core/tools/ask-user.d.ts +46 -0
  119. package/dist/core/tools/ask-user.js +66 -0
  120. package/dist/core/tools/ask-user.js.map +1 -0
  121. package/dist/core/tools/filesystem.d.ts +26 -1
  122. package/dist/core/tools/filesystem.js +44 -23
  123. package/dist/core/tools/filesystem.js.map +1 -1
  124. package/dist/core/tools/index.d.ts +1 -0
  125. package/dist/core/tools/index.js +1 -0
  126. package/dist/core/tools/index.js.map +1 -1
  127. package/dist/core/tools/shell.d.ts +41 -0
  128. package/dist/core/tools/shell.js +8 -1
  129. package/dist/core/tools/shell.js.map +1 -1
  130. package/dist/core/tools/time.js +1 -1
  131. package/dist/core/tools/time.js.map +1 -1
  132. package/dist/core/utils/billing.d.ts +49 -0
  133. package/dist/core/utils/billing.js +295 -0
  134. package/dist/core/utils/billing.js.map +1 -0
  135. package/dist/core/utils/edit-replace.d.ts +6 -0
  136. package/dist/core/utils/edit-replace.js +88 -6
  137. package/dist/core/utils/edit-replace.js.map +1 -1
  138. package/dist/core/utils/paths.d.ts +1 -0
  139. package/dist/core/utils/paths.js +1 -0
  140. package/dist/core/utils/paths.js.map +1 -1
  141. package/dist/core/{models → utils}/reasoning-effort.d.ts +9 -0
  142. package/dist/core/utils/reasoning-effort.js.map +1 -0
  143. package/dist/daemon/questions.d.ts +18 -0
  144. package/dist/daemon/questions.js +44 -0
  145. package/dist/daemon/questions.js.map +1 -0
  146. package/dist/exec/questions.d.ts +9 -0
  147. package/dist/exec/questions.js +50 -0
  148. package/dist/exec/questions.js.map +1 -0
  149. package/dist/index.d.ts +7 -6
  150. package/dist/index.js +6 -5
  151. package/dist/index.js.map +1 -1
  152. package/package.json +3 -2
  153. package/dist/core/models/reasoning-effort.js.map +0 -1
  154. package/dist/core/tools/terminal-backend.d.ts +0 -41
  155. package/dist/core/tools/terminal-backend.js +0 -9
  156. package/dist/core/tools/terminal-backend.js.map +0 -1
  157. package/dist/core/tools/text-fs-backend.d.ts +0 -25
  158. package/dist/core/tools/text-fs-backend.js +0 -9
  159. package/dist/core/tools/text-fs-backend.js.map +0 -1
  160. /package/dist/core/{models → utils}/reasoning-effort.js +0 -0
@@ -3,20 +3,22 @@ import { readFile } from "node:fs/promises";
3
3
  import { Readable, Writable } from "node:stream";
4
4
  import { stdin, stdout } from "node:process";
5
5
  import { agent as acpAgent, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
6
- import { isModelStreamEvent, Message, } from "@strands-agents/sdk";
6
+ import { isModelStreamEvent, } from "@strands-agents/sdk";
7
7
  import { bootstrap } from "../core/index.js";
8
8
  import { applySessionMode } from "../core/agent/sync-tool-registry-mode.js";
9
9
  import { MODE_DEFINITIONS } from "../core/modes/definitions.js";
10
10
  import { DEFAULT_SESSION_MODE, isKnownSessionMode, } from "../core/modes/schema.js";
11
11
  import { getModeState, setSessionMode } from "../core/state/session-mode.js";
12
12
  import { isYoloEnabled, setYoloEnabled } from "../core/state/yolo.js";
13
+ import { ChatTurnSteeringController, createChatTurnSteeringIntervention, } from "../core/agent/turn-steering.js";
13
14
  import { getAgentConversationManager, getAgentSessionManager, } from "../core/agent/index.js";
14
15
  import { readBundledPrompt } from "../core/prompts/bundled.js";
15
- import { formatModeNames } from "../core/modes/definitions.js";
16
16
  import { modelProviders } from "../core/models/index.js";
17
- import { buildSessionConfigOptions, currentModelName, CONFIG_ID_EFFORT, CONFIG_ID_MODE, CONFIG_ID_MODEL, } from "./session-config.js";
18
- import { activeProviderName, currentReasoningEffort, parseReasoningEffortArg, REASONING_EFFORT_LEVELS, REASONING_EFFORT_OFF, withReasoningEffort, } from "../core/models/reasoning-effort.js";
19
- import { ACP_SLASH_COMMANDS, parseAcpSlashCommand, parseYoloToggle, } from "./commands.js";
17
+ import { toAdditiveUsage } from "../core/models/usage.js";
18
+ import { computeUsageCostUsd, contextTokensFromUsage, resolveLlmBilling, } from "../core/utils/billing.js";
19
+ import { buildSessionConfigOptions, currentModelName, CONFIG_ID_EFFORT, CONFIG_ID_MODE, CONFIG_ID_MODEL, MODE_VALUE_YOLO, } from "./session-config.js";
20
+ import { activeProviderName, parseReasoningEffortArg, withReasoningEffort, } from "../core/utils/reasoning-effort.js";
21
+ import { ACP_SLASH_COMMANDS, parseAcpSlashCommand, } from "./commands.js";
20
22
  import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL, } from "../core/state/tool-approvals.js";
21
23
  import { runWithCwd } from "../core/utils/cwd-context.js";
22
24
  import { runWithAgentMemoryScope } from "../core/memory/index.js";
@@ -28,15 +30,20 @@ import { toolCallLocationsFromInput } from "./utils/tool-locations.js";
28
30
  import { takeFileToolDisplay } from "../core/state/file-tool-display.js";
29
31
  import { getTodoViewState } from "../core/state/todos.js";
30
32
  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";
33
+ import { setAskUserBackend } from "../core/tools/ask-user.js";
34
+ import { setTextFsBackend } from "../core/tools/filesystem.js";
35
+ import { setTerminalBackend, } from "../core/tools/shell.js";
33
36
  import { createAcpToolApprovalIntervention } from "./approvals.js";
37
+ import { createAcpAskUserBackend } from "./questions.js";
34
38
  import { extractAcpClientUserId } from "./meta/user-id.js";
39
+ import { extractAcpVscodeFlag } from "./meta/vscode.js";
35
40
  import { deriveSessionTitleFromEcho } from "./sessions/title.js";
36
41
  import { acpPromptEchoText, acpPromptToInvokeArgs } from "./prompt-invoke.js";
37
42
  import { normalizeAcpSessionMcpServers } from "./mcp-servers.js";
38
43
  import { replayConversationHistory } from "./sessions/replay.js";
39
- import { deleteStoredSession, listStoredSessionIds, loadSessionMessages, patchSessionMeta, readSessionMeta, saveSessionMessages, toSessionInfo, writeSessionMeta, } from "./sessions/store.js";
44
+ import { compactSessionIndex, deleteSessionEntry, migrateLegacySessionStore, patchSessionEntry, readSessionEntry, readSessionIndex, touchSessionEntry, writeSessionEntry, } from "./sessions/store.js";
45
+ import { FlatFileStorage } from "../core/sessions/flat-file-storage.js";
46
+ import { sessionsPath } from "../core/utils/paths.js";
40
47
  /** Max sessions returned per `session/list` page. */
41
48
  const LIST_PAGE_SIZE = 40;
42
49
  /** Fallback when a session record is missing (never mutate). */
@@ -83,6 +90,34 @@ function decodeCursor(cursor) {
83
90
  message: "Invalid pagination cursor",
84
91
  });
85
92
  }
93
+ /** Zeroed `Usage` for a fresh session's per-turn token meter. */
94
+ function createEmptyUsage() {
95
+ return { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
96
+ }
97
+ /**
98
+ * Build the per-turn token meter from a single request's usage, mirroring the
99
+ * CLI TUI (`src/chat/app.tsx`): report just the latest request's tokens rather
100
+ * than a session running total — the context gauge already reflects overall
101
+ * window consumption. `source` must already be in the additive shape (see
102
+ * `toAdditiveUsage`), where cache reads are not part of `inputTokens`.
103
+ */
104
+ function lastTurnUsage(source) {
105
+ const inputTokens = source.inputTokens ?? 0;
106
+ const outputTokens = source.outputTokens ?? 0;
107
+ const cacheRead = source.cacheReadInputTokens ?? 0;
108
+ const cacheWrite = source.cacheWriteInputTokens ?? 0;
109
+ return {
110
+ inputTokens,
111
+ outputTokens,
112
+ totalTokens: inputTokens + outputTokens + cacheRead + cacheWrite,
113
+ ...(source.cacheReadInputTokens !== undefined && {
114
+ cacheReadInputTokens: cacheRead,
115
+ }),
116
+ ...(source.cacheWriteInputTokens !== undefined && {
117
+ cacheWriteInputTokens: cacheWrite,
118
+ }),
119
+ };
120
+ }
86
121
  function assertAbsolutePath(value, field) {
87
122
  const trimmed = value?.trim() ?? "";
88
123
  if (!trimmed) {
@@ -122,23 +157,26 @@ function isCancellationError(err, cancelSignals) {
122
157
  function toAcpStopReason(reason) {
123
158
  switch (reason) {
124
159
  case "cancelled":
160
+ case "interrupt":
125
161
  return "cancelled";
126
162
  case "maxTokens":
127
163
  case "modelContextWindowExceeded":
164
+ case "limitOutputTokens":
165
+ case "limitTotalTokens":
128
166
  return "max_tokens";
167
+ case "limitTurns":
168
+ return "max_turn_requests";
129
169
  case "contentFiltered":
130
170
  case "guardrailIntervened":
131
171
  return "refusal";
132
172
  case "endTurn":
133
173
  case "toolUse":
134
174
  case "stopSequence":
175
+ case "pauseTurn":
135
176
  default:
136
177
  return "end_turn";
137
178
  }
138
179
  }
139
- function serializeAgentMessages(agent) {
140
- return agent.messages.map((m) => m.toJSON());
141
- }
142
180
  /** Normalize a persisted/requested mode to a known id, else the default. */
143
181
  function resolveSessionMode(mode) {
144
182
  return mode && isKnownSessionMode(mode) ? mode : DEFAULT_SESSION_MODE;
@@ -164,6 +202,10 @@ function buildSessionModeState(currentModeId) {
164
202
  export class HoomanAcpAgent {
165
203
  #identity;
166
204
  #acpRoot = acpSessionsRootPath();
205
+ /** Strands snapshot storage root (`<project>/sessions`); message source of truth. */
206
+ #snapshotsRoot = sessionsPath();
207
+ /** Resolves once the legacy store migration + index compaction have run. */
208
+ #storeReady;
167
209
  #sessions = new Map();
168
210
  /** Connection-scoped outbound context, captured in {@link onConnect}. */
169
211
  #client = null;
@@ -174,6 +216,9 @@ export class HoomanAcpAgent {
174
216
  #clientTerminalCap = false;
175
217
  constructor(identity) {
176
218
  this.#identity = identity;
219
+ this.#storeReady = migrateLegacySessionStore(this.#acpRoot, this.#snapshotsRoot)
220
+ .then(() => compactSessionIndex(this.#acpRoot))
221
+ .catch(() => undefined);
177
222
  }
178
223
  /** Bind connection-scoped lifecycle: capture the client + dispose on close. */
179
224
  onConnect(connection) {
@@ -202,7 +247,6 @@ export class HoomanAcpAgent {
202
247
  loadSession: true,
203
248
  promptCapabilities: {
204
249
  image: true,
205
- audio: true,
206
250
  embeddedContext: true,
207
251
  },
208
252
  mcpCapabilities: {
@@ -212,17 +256,24 @@ export class HoomanAcpAgent {
212
256
  sessionCapabilities: {
213
257
  list: {},
214
258
  delete: {},
259
+ resume: {},
260
+ close: {},
215
261
  },
216
262
  },
217
263
  };
218
264
  }
219
265
  async listSessions(params) {
220
- const ids = await listStoredSessionIds(this.#acpRoot);
266
+ await this.#storeReady;
267
+ const entries = await readSessionIndex(this.#acpRoot);
221
268
  const sessions = [];
222
- for (const id of ids) {
223
- const info = await toSessionInfo(this.#acpRoot, id);
224
- if (info && (!params.cwd || info.cwd === params.cwd)) {
225
- sessions.push(info);
269
+ for (const entry of entries.values()) {
270
+ if (!params.cwd || entry.cwd === params.cwd) {
271
+ sessions.push({
272
+ sessionId: entry.sessionId,
273
+ cwd: entry.cwd,
274
+ title: entry.title ?? null,
275
+ updatedAt: entry.updatedAt,
276
+ });
226
277
  }
227
278
  }
228
279
  sessions.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
@@ -233,70 +284,135 @@ export class HoomanAcpAgent {
233
284
  return { sessions: page, nextCursor };
234
285
  }
235
286
  async deleteSession(params) {
287
+ await this.#storeReady;
236
288
  const record = this.#sessions.get(params.sessionId);
237
289
  if (record) {
238
290
  this.#sessions.delete(params.sessionId);
239
291
  record.turnAbort?.abort();
240
292
  await record.mcpDisconnect();
241
293
  }
242
- await deleteStoredSession(this.#acpRoot, params.sessionId);
294
+ await deleteSessionEntry(this.#acpRoot, params.sessionId);
295
+ // Also drop the Strands snapshot (the conversation history itself).
296
+ await new FlatFileStorage(this.#snapshotsRoot).deleteSession({
297
+ sessionId: params.sessionId,
298
+ });
243
299
  }
244
300
  async newSession(params) {
301
+ await this.#storeReady;
245
302
  assertAbsolutePath(params.cwd, "cwd");
246
303
  const sessionId = crypto.randomUUID();
247
304
  const clientUserId = extractAcpClientUserId(params._meta) ?? null;
248
305
  const mcpServers = normalizeAcpSessionMcpServers(params.mcpServers);
306
+ const vscode = extractAcpVscodeFlag(params._meta);
249
307
  const mode = DEFAULT_SESSION_MODE;
250
308
  const now = new Date().toISOString();
251
- const meta = {
309
+ const entry = {
310
+ sessionId,
252
311
  cwd: params.cwd,
253
312
  createdAt: now,
254
313
  updatedAt: now,
255
314
  title: null,
256
315
  userId: clientUserId,
257
316
  mcpServers,
317
+ ...(vscode ? { vscode } : {}),
258
318
  sessionMode: mode,
259
319
  };
260
- await writeSessionMeta(this.#acpRoot, sessionId, meta);
261
- const record = await this.#bootstrapSession(sessionId, params.cwd, clientUserId ?? sessionId, mcpServers, mode);
320
+ await writeSessionEntry(this.#acpRoot, entry);
321
+ const record = await this.#bootstrapSession(sessionId, params.cwd, clientUserId ?? sessionId, mcpServers, mode, undefined, vscode);
262
322
  this.#sessions.set(sessionId, record);
263
323
  await this.#advertiseCommands(this.#requireClient(), sessionId);
264
324
  return {
265
325
  sessionId,
266
326
  modes: buildSessionModeState(mode),
267
- configOptions: buildSessionConfigOptions(record.config, mode),
327
+ configOptions: buildSessionConfigOptions(record.config, mode, isYoloEnabled(record.agent)),
268
328
  };
269
329
  }
270
330
  async loadSession(params, client) {
331
+ const { record, mode } = await this.#reactivateSession({
332
+ sessionId: params.sessionId,
333
+ cwd: params.cwd,
334
+ mcpServers: params.mcpServers,
335
+ meta: params._meta,
336
+ }, client, { replayHistory: true });
337
+ return {
338
+ modes: buildSessionModeState(mode),
339
+ configOptions: buildSessionConfigOptions(record.config, mode, isYoloEnabled(record.agent)),
340
+ };
341
+ }
342
+ /**
343
+ * Handle `session/resume`: reactivate a persisted session's in-memory state
344
+ * (including its conversation history, so the turn continues correctly)
345
+ * without replaying that history to the client — the spec reserves the full
346
+ * transcript replay for `session/load`.
347
+ */
348
+ async resumeSession(params, client) {
349
+ const { record, mode } = await this.#reactivateSession({
350
+ sessionId: params.sessionId,
351
+ cwd: params.cwd,
352
+ mcpServers: params.mcpServers ?? [],
353
+ meta: params._meta,
354
+ }, client, { replayHistory: false });
355
+ return {
356
+ modes: buildSessionModeState(mode),
357
+ configOptions: buildSessionConfigOptions(record.config, mode, isYoloEnabled(record.agent)),
358
+ };
359
+ }
360
+ /**
361
+ * Handle `session/close`: cancel any in-flight turn and free the in-memory
362
+ * session state, without deleting the persisted session from disk (unlike
363
+ * `session/delete`).
364
+ */
365
+ async closeSession(params) {
366
+ const record = this.#sessions.get(params.sessionId);
367
+ if (record) {
368
+ this.#sessions.delete(params.sessionId);
369
+ record.turnAbort?.abort();
370
+ await record.mcpDisconnect();
371
+ }
372
+ return {};
373
+ }
374
+ /** Shared setup for `session/load` and `session/resume`. */
375
+ async #reactivateSession(params, client, options) {
271
376
  if (this.#sessions.has(params.sessionId)) {
272
377
  throw RequestError.invalidParams({
273
378
  sessionId: params.sessionId,
274
379
  message: "Session is already active in this agent process.",
275
380
  });
276
381
  }
277
- const existing = await readSessionMeta(this.#acpRoot, params.sessionId);
382
+ await this.#storeReady;
383
+ const existing = await readSessionEntry(this.#acpRoot, params.sessionId);
278
384
  if (!existing) {
279
385
  throw RequestError.resourceNotFound(`session:${params.sessionId}`);
280
386
  }
281
387
  assertAbsolutePath(params.cwd, "cwd");
282
- const fromRequest = extractAcpClientUserId(params._meta);
388
+ const fromRequest = extractAcpClientUserId(params.meta);
283
389
  const clientUserId = fromRequest !== undefined ? fromRequest : (existing.userId ?? null);
284
390
  const mcpServers = params.mcpServers.length > 0
285
391
  ? normalizeAcpSessionMcpServers(params.mcpServers)
286
392
  : (existing.mcpServers ?? []);
393
+ const vscode = extractAcpVscodeFlag(params.meta) || existing.vscode === true;
287
394
  const mode = resolveSessionMode(existing.sessionMode);
288
- const record = await this.#bootstrapSession(params.sessionId, params.cwd, clientUserId ?? params.sessionId, mcpServers, mode, existing.model);
289
- const saved = await loadSessionMessages(this.#acpRoot, params.sessionId);
290
- record.agent.messages.length = 0;
291
- for (const md of saved) {
292
- record.agent.messages.push(Message.fromJSON(md));
395
+ // Bootstrapping restores the Strands snapshot (messages + appState) during
396
+ // `agent.initialize()` the snapshot is the conversation source of truth.
397
+ const record = await this.#bootstrapSession(params.sessionId, params.cwd, clientUserId ?? params.sessionId, mcpServers, mode, existing.model, vscode);
398
+ // The snapshot restore replaces appState wholesale, so re-apply the
399
+ // connection-scoped values that must win over persisted state: the
400
+ // client's user id, and the index-persisted yolo/mode settings.
401
+ record.agent.appState.set("userId", clientUserId ?? params.sessionId);
402
+ setYoloEnabled(record.agent, existing.yolo === true);
403
+ if (resolveSessionMode(getModeState(record.agent).mode) !== mode) {
404
+ setSessionMode(record.agent, mode);
405
+ applySessionMode(record.agent);
406
+ }
407
+ if (options.replayHistory) {
408
+ await replayConversationHistory(client, params.sessionId, record.agent.messages);
293
409
  }
294
- await replayConversationHistory(client, params.sessionId, record.agent.messages);
295
410
  this.#sessions.set(params.sessionId, record);
296
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
411
+ await patchSessionEntry(this.#acpRoot, params.sessionId, {
297
412
  cwd: params.cwd,
298
413
  ...(fromRequest !== undefined ? { userId: fromRequest || null } : {}),
299
414
  mcpServers,
415
+ ...(vscode ? { vscode } : {}),
300
416
  sessionMode: mode,
301
417
  });
302
418
  await this.#advertiseCommands(client, params.sessionId);
@@ -304,10 +420,7 @@ export class HoomanAcpAgent {
304
420
  if (getTodoViewState(record.agent).total > 0) {
305
421
  await this.#sendPlanUpdate(client, params.sessionId, record);
306
422
  }
307
- return {
308
- modes: buildSessionModeState(mode),
309
- configOptions: buildSessionConfigOptions(record.config, mode),
310
- };
423
+ return { record, mode };
311
424
  }
312
425
  /** Advertise the available slash commands for a freshly set-up session. */
313
426
  #advertiseCommands(client, sessionId) {
@@ -320,7 +433,7 @@ export class HoomanAcpAgent {
320
433
  * Handle `session/set_mode`: switch the active mode for a running session,
321
434
  * re-applying the tool surface and persisting the choice.
322
435
  */
323
- async setSessionMode(params) {
436
+ async setSessionMode(params, client) {
324
437
  const rec = this.#sessions.get(params.sessionId);
325
438
  if (!rec) {
326
439
  throw RequestError.invalidParams({ sessionId: params.sessionId });
@@ -332,9 +445,9 @@ export class HoomanAcpAgent {
332
445
  });
333
446
  }
334
447
  if (this.#transitionMode(rec, params.modeId)) {
335
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
336
- sessionMode: params.modeId,
337
- });
448
+ // Keep the (superseding) config-options `mode` value in sync too, since
449
+ // Clients may read either surface (see Session Config Options spec).
450
+ await this.#syncCurrentMode(client, params.sessionId, rec);
338
451
  }
339
452
  return {};
340
453
  }
@@ -342,7 +455,7 @@ export class HoomanAcpAgent {
342
455
  * Handle `session/set_config_option`: apply a model or mode selection and
343
456
  * return the complete, up-to-date configuration state (as the spec requires).
344
457
  */
345
- async setSessionConfigOption(params) {
458
+ async setSessionConfigOption(params, client) {
346
459
  const rec = this.#sessions.get(params.sessionId);
347
460
  if (!rec) {
348
461
  throw RequestError.invalidParams({ sessionId: params.sessionId });
@@ -355,21 +468,55 @@ export class HoomanAcpAgent {
355
468
  }
356
469
  const value = params.value;
357
470
  if (params.configId === CONFIG_ID_MODE) {
358
- if (!isKnownSessionMode(value)) {
359
- throw RequestError.invalidParams({
360
- value,
361
- message: `Unknown mode "${value}"`,
362
- });
471
+ if (value === MODE_VALUE_YOLO) {
472
+ // Yolo is agent mode with auto-approval on, not a distinct SessionMode.
473
+ if (this.#transitionMode(rec, "agent")) {
474
+ // Keep the legacy `modes` surface in sync too (see Session Config
475
+ // Options spec: "Agents SHOULD keep both in sync").
476
+ await this.#syncCurrentMode(client, params.sessionId, rec);
477
+ }
478
+ if (!isYoloEnabled(rec.agent)) {
479
+ setYoloEnabled(rec.agent, true);
480
+ await patchSessionEntry(this.#acpRoot, params.sessionId, {
481
+ yolo: true,
482
+ });
483
+ }
363
484
  }
364
- if (this.#transitionMode(rec, value)) {
365
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
366
- sessionMode: value,
367
- });
485
+ else {
486
+ if (!isKnownSessionMode(value)) {
487
+ throw RequestError.invalidParams({
488
+ value,
489
+ message: `Unknown mode "${value}"`,
490
+ });
491
+ }
492
+ if (this.#transitionMode(rec, value)) {
493
+ // Keep the legacy `modes` surface in sync too (see Session Config
494
+ // Options spec: "Agents SHOULD keep both in sync").
495
+ await this.#syncCurrentMode(client, params.sessionId, rec);
496
+ }
497
+ // Selecting a plain mode always turns yolo back off.
498
+ if (isYoloEnabled(rec.agent)) {
499
+ setYoloEnabled(rec.agent, false);
500
+ await patchSessionEntry(this.#acpRoot, params.sessionId, {
501
+ yolo: false,
502
+ });
503
+ }
368
504
  }
369
505
  }
370
506
  else if (params.configId === CONFIG_ID_MODEL) {
371
507
  await this.#applyModelChange(rec, value);
372
- await patchSessionMeta(this.#acpRoot, params.sessionId, { model: value });
508
+ await patchSessionEntry(this.#acpRoot, params.sessionId, {
509
+ model: value,
510
+ });
511
+ // The conversation (and thus the context tokens in use) carries over to
512
+ // the new model, so rescale the client's context gauge against the new
513
+ // window immediately instead of leaving the old one up until the next
514
+ // turn ends. Skipped mid-turn: the rebuild (and billing re-resolution)
515
+ // is deferred to the turn boundary, and the running turn's own
516
+ // `usage_update` is still correctly priced against the old model.
517
+ if (!this.#isTurnActive(rec)) {
518
+ await this.#sendUsageUpdate(this.#requireClient(), params.sessionId, rec, undefined);
519
+ }
373
520
  }
374
521
  else if (params.configId === CONFIG_ID_EFFORT) {
375
522
  const parsed = parseReasoningEffortArg(value);
@@ -388,7 +535,7 @@ export class HoomanAcpAgent {
388
535
  });
389
536
  }
390
537
  return {
391
- configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
538
+ configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode), isYoloEnabled(rec.agent)),
392
539
  };
393
540
  }
394
541
  /** Whether a prompt turn is currently streaming for this session. */
@@ -522,6 +669,7 @@ export class HoomanAcpAgent {
522
669
  const resolved = rec.config.llm;
523
670
  const provider = await modelProviders[resolved.provider]();
524
671
  rec.agent.model = provider.create(resolved.providerOptions, resolved.llmOptions);
672
+ rec.billing = await resolveLlmBilling(resolved.billing, resolved.llmOptions.model).catch(() => null);
525
673
  }
526
674
  catch (error) {
527
675
  if (previous) {
@@ -541,7 +689,7 @@ export class HoomanAcpAgent {
541
689
  * Apply settings changes deferred during a turn (mode tool-surface + model
542
690
  * rebuild). Runs at the turn boundary before the next turn may start.
543
691
  */
544
- async #flushPendingSettings(rec) {
692
+ async #flushPendingSettings(rec, client, sessionId) {
545
693
  if (rec.pendingModeReapply) {
546
694
  rec.pendingModeReapply = false;
547
695
  applySessionMode(rec.agent);
@@ -560,6 +708,11 @@ export class HoomanAcpAgent {
560
708
  if (persistEffort) {
561
709
  this.#persistProviderEffort(rec, persistEffort.provider, persistEffort.effort);
562
710
  }
711
+ // The rebuild re-resolved billing; rescale the client's context gauge
712
+ // against the new model's window (the turn's earlier `usage_update`
713
+ // carried the previous model's size). Best-effort: the turn result
714
+ // must not fail over a lost notification.
715
+ await this.#sendUsageUpdate(client, sessionId, rec, undefined).catch(() => undefined);
563
716
  }
564
717
  }
565
718
  }
@@ -567,117 +720,14 @@ export class HoomanAcpAgent {
567
720
  * Execute an inline (non-model) slash command and return the text reply to
568
721
  * stream back as an `agent_message_chunk`. `/init` is handled by the caller.
569
722
  */
570
- #runControlCommand(client, sessionId, rec, command) {
723
+ #runControlCommand(rec, command) {
571
724
  switch (command.name) {
572
- case "mode":
573
- return this.#commandSetMode(client, sessionId, rec, command.args);
574
- case "model":
575
- return this.#commandSetModel(client, sessionId, rec, command.args);
576
- case "effort":
577
- return this.#commandSetEffort(client, sessionId, rec, command.args);
578
- case "yolo":
579
- return this.#commandSetYolo(sessionId, rec, command.args);
580
725
  case "compact":
581
726
  return this.#commandCompact(rec);
582
727
  default:
583
728
  return Promise.resolve(`Unknown command "/${command.name}".`);
584
729
  }
585
730
  }
586
- async #commandSetMode(client, sessionId, rec, args) {
587
- const current = resolveSessionMode(getModeState(rec.agent).mode);
588
- const arg = args.trim().toLowerCase();
589
- if (!arg) {
590
- return `Usage: /mode <${formatModeNames()}>. Current mode: "${current}".`;
591
- }
592
- if (!isKnownSessionMode(arg)) {
593
- return `Unknown mode "${arg}". Use ${formatModeNames()}.`;
594
- }
595
- if (!this.#transitionMode(rec, arg)) {
596
- return `Already in "${arg}" mode.`;
597
- }
598
- await this.#syncCurrentMode(client, sessionId, rec);
599
- return `Switched session mode to "${arg}".`;
600
- }
601
- async #commandSetModel(client, sessionId, rec, args) {
602
- const arg = args.trim();
603
- if (!arg) {
604
- return this.#listModelsText(rec);
605
- }
606
- if (!rec.config.llms.some((entry) => entry.name === arg)) {
607
- return `Unknown model "${arg}".\n\n${this.#listModelsText(rec)}`;
608
- }
609
- if (arg === currentModelName(rec.config)) {
610
- return `Already using model "${arg}".`;
611
- }
612
- try {
613
- await this.#applyModelChange(rec, arg);
614
- }
615
- catch (error) {
616
- const message = error instanceof Error ? error.message : String(error);
617
- return `Could not switch to model "${arg}": ${message}`;
618
- }
619
- await patchSessionMeta(this.#acpRoot, sessionId, { model: arg });
620
- await this.#sendUpdate(client, sessionId, {
621
- sessionUpdate: "config_option_update",
622
- configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
623
- });
624
- return `Switched model to "${arg}".`;
625
- }
626
- #listModelsText(rec) {
627
- const current = currentModelName(rec.config);
628
- const lines = rec.config.llms.map((entry) => {
629
- const marker = entry.name === current ? "*" : "-";
630
- return `${marker} ${entry.name} (${entry.provider}/${entry.options.model})`;
631
- });
632
- return [
633
- `Current model: ${current ?? "(none)"}`,
634
- "Available models:",
635
- ...lines,
636
- 'Use "/model <name>" to switch.',
637
- ].join("\n");
638
- }
639
- async #commandSetEffort(client, sessionId, rec, args) {
640
- const current = currentReasoningEffort(rec.config);
641
- const arg = args.trim();
642
- if (!arg) {
643
- return `Current reasoning effort: "${current ?? REASONING_EFFORT_OFF}". Use ${REASONING_EFFORT_LEVELS.join(", ")} or ${REASONING_EFFORT_OFF}.`;
644
- }
645
- const parsed = parseReasoningEffortArg(arg);
646
- if (!parsed) {
647
- return `Unknown effort "${arg}". Use ${REASONING_EFFORT_LEVELS.join(", ")} or ${REASONING_EFFORT_OFF}.`;
648
- }
649
- if (parsed.value === current) {
650
- return `Reasoning effort is already "${current ?? REASONING_EFFORT_OFF}".`;
651
- }
652
- try {
653
- await this.#applyEffortChange(rec, parsed.value);
654
- }
655
- catch (error) {
656
- const message = error instanceof Error ? error.message : String(error);
657
- return `Could not set reasoning effort: ${message}`;
658
- }
659
- await this.#sendUpdate(client, sessionId, {
660
- sessionUpdate: "config_option_update",
661
- configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
662
- });
663
- return `Set reasoning effort to "${parsed.value ?? REASONING_EFFORT_OFF}".`;
664
- }
665
- async #commandSetYolo(sessionId, rec, args) {
666
- const arg = args.trim();
667
- if (!arg) {
668
- return `Usage: /yolo <on|off>. Auto-approve is currently ${isYoloEnabled(rec.agent) ? "on" : "off"}.`;
669
- }
670
- const enabled = parseYoloToggle(arg);
671
- if (enabled === undefined) {
672
- return `Unknown value "${arg}". Use on or off.`;
673
- }
674
- if (isYoloEnabled(rec.agent) === enabled) {
675
- return `Auto-approve is already ${enabled ? "on" : "off"}.`;
676
- }
677
- setYoloEnabled(rec.agent, enabled);
678
- await patchSessionMeta(this.#acpRoot, sessionId, { yolo: enabled });
679
- return `Auto-approve tools ${enabled ? "enabled" : "disabled"}.`;
680
- }
681
731
  async #commandCompact(rec) {
682
732
  const conversationManager = getAgentConversationManager(rec.agent);
683
733
  if (!conversationManager) {
@@ -717,6 +767,28 @@ export class HoomanAcpAgent {
717
767
  /* ignore */
718
768
  }
719
769
  }
770
+ /**
771
+ * Queue guidance for the currently active turn via {@link ChatTurnSteeringController}
772
+ * rather than starting a new turn. Echoes the steered text back to the
773
+ * client tagged with `_meta["hoomanjs/steered"]` so it can be shown inline
774
+ * in the transcript. Errors (no active turn) surface as a normal RPC error.
775
+ */
776
+ async #steerActiveTurn(client, rec, params) {
777
+ if (!this.#isTurnActive(rec)) {
778
+ throw RequestError.invalidParams({
779
+ message: "No active turn to steer.",
780
+ });
781
+ }
782
+ const text = acpPromptEchoText(params.prompt);
783
+ rec.steering.queue([{ text, attachments: [] }]);
784
+ await this.#sendUpdate(client, params.sessionId, {
785
+ sessionUpdate: "user_message_chunk",
786
+ content: { type: "text", text },
787
+ messageId: crypto.randomUUID(),
788
+ _meta: { "hoomanjs/steered": true },
789
+ });
790
+ return { stopReason: "end_turn" };
791
+ }
720
792
  /** Run one prompt turn: stream model output, tool calls, and a stop reason. */
721
793
  async prompt(params) {
722
794
  const rec = this.#sessions.get(params.sessionId);
@@ -724,6 +796,12 @@ export class HoomanAcpAgent {
724
796
  throw RequestError.invalidParams({ sessionId: params.sessionId });
725
797
  }
726
798
  const client = this.#requireClient();
799
+ // A client-side "steer" request injects guidance into the *currently
800
+ // running* turn (via the steering intervention) instead of queuing a
801
+ // brand new one. See `_meta["hoomanjs/steer"]` extensibility contract.
802
+ if (params._meta?.["hoomanjs/steer"] === true) {
803
+ return this.#steerActiveTurn(client, rec, params);
804
+ }
727
805
  // Serialize concurrent prompt turns for the same session.
728
806
  const prevExclusive = rec.promptExclusive;
729
807
  let releaseExclusive;
@@ -737,6 +815,7 @@ export class HoomanAcpAgent {
737
815
  rec.streamedToolCallIds.clear();
738
816
  rec.streamingToolInputJson.clear();
739
817
  rec.lastStreamToolUseId = null;
818
+ rec.currentAssistantMessageId = null;
740
819
  let stopReason = "end_turn";
741
820
  try {
742
821
  const echo = acpPromptEchoText(params.prompt);
@@ -744,6 +823,7 @@ export class HoomanAcpAgent {
744
823
  await this.#sendUpdate(client, params.sessionId, {
745
824
  sessionUpdate: "user_message_chunk",
746
825
  content: { type: "text", text: echo },
826
+ messageId: crypto.randomUUID(),
747
827
  });
748
828
  await this.#maybeDeriveTitle(client, params.sessionId, echo);
749
829
  }
@@ -751,11 +831,12 @@ export class HoomanAcpAgent {
751
831
  // (no model turn); `/init` rewrites the prompt and runs normally.
752
832
  const command = parseAcpSlashCommand(echo);
753
833
  if (command && command.name !== "init") {
754
- const reply = await this.#runControlCommand(client, params.sessionId, rec, command);
834
+ const reply = await this.#runControlCommand(rec, command);
755
835
  if (reply) {
756
836
  await this.#sendUpdate(client, params.sessionId, {
757
837
  sessionUpdate: "agent_message_chunk",
758
838
  content: { type: "text", text: reply },
839
+ messageId: crypto.randomUUID(),
759
840
  });
760
841
  }
761
842
  return { stopReason: "end_turn" };
@@ -826,6 +907,7 @@ export class HoomanAcpAgent {
826
907
  }
827
908
  else if (ev.type === "agentResultEvent") {
828
909
  stopReason = toAcpStopReason(ev.result.stopReason);
910
+ await this.#sendUsageUpdate(client, params.sessionId, rec, ev.result.contextSize);
829
911
  }
830
912
  iter = await stream.next();
831
913
  }
@@ -842,6 +924,7 @@ export class HoomanAcpAgent {
842
924
  await this.#sendUpdate(client, params.sessionId, {
843
925
  sessionUpdate: "agent_message_chunk",
844
926
  content: { type: "text", text: `\n[error] ${message}\n` },
927
+ messageId: this.#assistantMessageId(rec),
845
928
  });
846
929
  stopReason = "refusal";
847
930
  }
@@ -857,10 +940,13 @@ export class HoomanAcpAgent {
857
940
  rec.turnAbort = null;
858
941
  // Apply mode/model changes requested mid-turn before releasing the gate
859
942
  // so the next turn sees the settled state.
860
- await this.#flushPendingSettings(rec);
943
+ await this.#flushPendingSettings(rec, client, params.sessionId);
861
944
  releaseExclusive();
945
+ // Conversation history is persisted by the Strands session manager
946
+ // (snapshot save on AfterInvocation, which fires even on error/cancel);
947
+ // only the index's `updatedAt` needs bumping for `session/list` order.
862
948
  try {
863
- await saveSessionMessages(this.#acpRoot, params.sessionId, serializeAgentMessages(rec.agent));
949
+ await touchSessionEntry(this.#acpRoot, params.sessionId);
864
950
  }
865
951
  catch {
866
952
  /* ignore */
@@ -868,6 +954,46 @@ export class HoomanAcpAgent {
868
954
  }
869
955
  return { stopReason };
870
956
  }
957
+ /**
958
+ * Emit a `usage_update` at the end of a turn:
959
+ * - `used`: context tokens of the latest request (additive prompt total,
960
+ * so cache reads/writes count regardless of provider reporting style).
961
+ * - `size`: the context window resolved from the model's `billing` config /
962
+ * the models.dev catalog; `0` ("unknown") when unresolved, in which case
963
+ * clients should not render a used/size percentage.
964
+ * - `cost`: cumulative session USD, only while every priced request had
965
+ * resolved rates — otherwise omitted rather than reporting a lowball.
966
+ * - `_meta["hoomanjs/tokens"]`: the latest request's token totals
967
+ * (mirroring the CLI TUI's per-turn `in`/`cin`/`out` meter).
968
+ */
969
+ #sendUsageUpdate(client, sessionId, rec, used) {
970
+ const contextUsed = rec.lastContextTokens ?? used;
971
+ if (contextUsed === undefined) {
972
+ return Promise.resolve();
973
+ }
974
+ const tokens = rec.lastTurnUsage;
975
+ const includeCost = rec.billing?.costs !== undefined && !rec.costUnpriced;
976
+ return this.#sendUpdate(client, sessionId, {
977
+ sessionUpdate: "usage_update",
978
+ used: contextUsed,
979
+ size: rec.billing?.context ?? 0,
980
+ ...(includeCost && {
981
+ cost: { amount: rec.cumulativeCostUsd, currency: "USD" },
982
+ }),
983
+ _meta: {
984
+ "hoomanjs/tokens": {
985
+ input: tokens.inputTokens,
986
+ output: tokens.outputTokens,
987
+ ...(tokens.cacheReadInputTokens !== undefined && {
988
+ cacheRead: tokens.cacheReadInputTokens,
989
+ }),
990
+ ...(tokens.cacheWriteInputTokens !== undefined && {
991
+ cacheWrite: tokens.cacheWriteInputTokens,
992
+ }),
993
+ },
994
+ },
995
+ });
996
+ }
871
997
  /**
872
998
  * Surface the agent's todo list as an ACP `plan` update. Sends the complete
873
999
  * list, as the client replaces the plan wholesale on each update. The
@@ -892,7 +1018,7 @@ export class HoomanAcpAgent {
892
1018
  */
893
1019
  async #syncCurrentMode(client, sessionId, rec) {
894
1020
  const modeId = resolveSessionMode(getModeState(rec.agent).mode);
895
- await patchSessionMeta(this.#acpRoot, sessionId, { sessionMode: modeId });
1021
+ await patchSessionEntry(this.#acpRoot, sessionId, { sessionMode: modeId });
896
1022
  await this.#sendUpdate(client, sessionId, {
897
1023
  sessionUpdate: "current_mode_update",
898
1024
  currentModeId: modeId,
@@ -900,16 +1026,20 @@ export class HoomanAcpAgent {
900
1026
  // Config Options supersede modes; mirror the change for config-aware clients.
901
1027
  await this.#sendUpdate(client, sessionId, {
902
1028
  sessionUpdate: "config_option_update",
903
- configOptions: buildSessionConfigOptions(rec.config, modeId),
1029
+ configOptions: buildSessionConfigOptions(rec.config, modeId, isYoloEnabled(rec.agent)),
904
1030
  });
905
1031
  }
906
- /** Derive + persist a title from the first meaningful prompt echo. */
1032
+ /**
1033
+ * Derive + persist an instant placeholder title from the first meaningful
1034
+ * prompt echo. The session-title plugin upgrades it with an AI-generated
1035
+ * summary once the turn runs (see `onSessionTitle` in the bootstrap meta).
1036
+ */
907
1037
  async #maybeDeriveTitle(client, sessionId, echo) {
908
- const meta = await readSessionMeta(this.#acpRoot, sessionId);
909
- const needsTitle = meta &&
910
- (meta.title === undefined ||
911
- meta.title === null ||
912
- String(meta.title).trim() === "");
1038
+ const entry = await readSessionEntry(this.#acpRoot, sessionId);
1039
+ const needsTitle = entry &&
1040
+ (entry.title === undefined ||
1041
+ entry.title === null ||
1042
+ String(entry.title).trim() === "");
913
1043
  if (!needsTitle) {
914
1044
  return;
915
1045
  }
@@ -917,7 +1047,7 @@ export class HoomanAcpAgent {
917
1047
  if (!title) {
918
1048
  return;
919
1049
  }
920
- await patchSessionMeta(this.#acpRoot, sessionId, { title });
1050
+ await patchSessionEntry(this.#acpRoot, sessionId, { title });
921
1051
  await this.#sendUpdate(client, sessionId, {
922
1052
  sessionUpdate: "session_info_update",
923
1053
  title,
@@ -931,6 +1061,12 @@ export class HoomanAcpAgent {
931
1061
  return;
932
1062
  }
933
1063
  switch (inner.type) {
1064
+ case "modelMessageStartEvent": {
1065
+ // A new assistant message starts a fresh `messageId` shared by all of
1066
+ // its `agent_message_chunk`/`agent_thought_chunk` updates.
1067
+ rec.currentAssistantMessageId = crypto.randomUUID();
1068
+ return;
1069
+ }
934
1070
  case "modelContentBlockStartEvent": {
935
1071
  const start = inner.start;
936
1072
  if (start?.type === "toolUseStart") {
@@ -954,6 +1090,7 @@ export class HoomanAcpAgent {
954
1090
  await this.#sendUpdate(client, sessionId, {
955
1091
  sessionUpdate: "agent_message_chunk",
956
1092
  content: { type: "text", text: delta.text },
1093
+ messageId: this.#assistantMessageId(rec),
957
1094
  });
958
1095
  return;
959
1096
  }
@@ -962,6 +1099,7 @@ export class HoomanAcpAgent {
962
1099
  await this.#sendUpdate(client, sessionId, {
963
1100
  sessionUpdate: "agent_thought_chunk",
964
1101
  content: { type: "text", text: delta.text },
1102
+ messageId: this.#assistantMessageId(rec),
965
1103
  });
966
1104
  }
967
1105
  return;
@@ -990,11 +1128,34 @@ export class HoomanAcpAgent {
990
1128
  type: "text",
991
1129
  text: `[citations: ${n} reference(s)]\n`,
992
1130
  },
1131
+ messageId: this.#assistantMessageId(rec),
993
1132
  });
994
1133
  }
995
1134
  }
996
1135
  return;
997
1136
  }
1137
+ case "modelMetadataEvent": {
1138
+ if (inner.usage) {
1139
+ // Providers like OpenAI/Moonshot report input inclusive of cache
1140
+ // reads; normalize to the additive shape so `in`/`cin` don't
1141
+ // double-count in the client's meter.
1142
+ const additive = toAdditiveUsage(inner.usage, rec.agent.model);
1143
+ rec.lastTurnUsage = lastTurnUsage(additive);
1144
+ rec.lastContextTokens = contextTokensFromUsage(additive);
1145
+ // Session cost accrues per request at the rates of the model that
1146
+ // served it; once a request with usage runs unpriced, the total is
1147
+ // incomplete and cost reporting stops for the session.
1148
+ if ((additive.totalTokens ?? 0) > 0) {
1149
+ if (rec.billing?.costs) {
1150
+ rec.cumulativeCostUsd += computeUsageCostUsd(additive, rec.billing.costs);
1151
+ }
1152
+ else {
1153
+ rec.costUnpriced = true;
1154
+ }
1155
+ }
1156
+ }
1157
+ return;
1158
+ }
998
1159
  case "modelRedactionEvent": {
999
1160
  const replace = inner.outputRedaction?.replaceContent ??
1000
1161
  inner.inputRedaction?.replaceContent;
@@ -1002,6 +1163,7 @@ export class HoomanAcpAgent {
1002
1163
  await this.#sendUpdate(client, sessionId, {
1003
1164
  sessionUpdate: "agent_message_chunk",
1004
1165
  content: { type: "text", text: replace },
1166
+ messageId: this.#assistantMessageId(rec),
1005
1167
  });
1006
1168
  }
1007
1169
  return;
@@ -1010,6 +1172,13 @@ export class HoomanAcpAgent {
1010
1172
  return;
1011
1173
  }
1012
1174
  }
1175
+ /** Lazily assign a `messageId` for the in-flight assistant message. */
1176
+ #assistantMessageId(rec) {
1177
+ if (!rec.currentAssistantMessageId) {
1178
+ rec.currentAssistantMessageId = crypto.randomUUID();
1179
+ }
1180
+ return rec.currentAssistantMessageId;
1181
+ }
1013
1182
  #streamToolUseIdForInputDelta(rec) {
1014
1183
  if (rec.lastStreamToolUseId &&
1015
1184
  rec.streamedToolCallIds.has(rec.lastStreamToolUseId)) {
@@ -1029,7 +1198,7 @@ export class HoomanAcpAgent {
1029
1198
  }
1030
1199
  return this.#client;
1031
1200
  }
1032
- async #bootstrapSession(sessionId, cwd, userId, mcpServers, mode, preferredModel) {
1201
+ async #bootstrapSession(sessionId, cwd, userId, mcpServers, mode, preferredModel, vscode = false) {
1033
1202
  const client = this.#requireClient();
1034
1203
  const sessionConfig = createSessionConfig();
1035
1204
  if (preferredModel &&
@@ -1042,18 +1211,37 @@ export class HoomanAcpAgent {
1042
1211
  })),
1043
1212
  });
1044
1213
  }
1214
+ const steering = new ChatTurnSteeringController();
1045
1215
  const { config, agent, mcp: { manager }, } = await bootstrap("acp", {
1046
1216
  userId,
1047
1217
  sessionId,
1048
1218
  mode,
1219
+ interventions: [createChatTurnSteeringIntervention(steering)],
1049
1220
  createInterventions: () => [
1050
1221
  createAcpToolApprovalIntervention(client, sessionId, () => this.#sessions.get(sessionId)?.streamedToolCallIds ??
1051
1222
  EMPTY_STREAMED_TOOL_CALL_IDS),
1052
1223
  ],
1053
- acp: { mcpServers: mcpServers ?? [] },
1224
+ // The session-title plugin generated an AI title (upgrading the
1225
+ // echo-derived placeholder): persist it and notify the client.
1226
+ onSessionTitle: async (title) => {
1227
+ await patchSessionEntry(this.#acpRoot, sessionId, { title });
1228
+ await this.#sendUpdate(client, sessionId, {
1229
+ sessionUpdate: "session_info_update",
1230
+ title,
1231
+ updatedAt: new Date().toISOString(),
1232
+ });
1233
+ },
1234
+ acp: {
1235
+ mcpServers: mcpServers ?? [],
1236
+ vscode,
1237
+ cwd,
1238
+ },
1054
1239
  }, false, sessionConfig);
1055
1240
  this.#registerTextFsBackend(agent, client, sessionId);
1056
1241
  this.#registerTerminalBackend(agent, client, sessionId);
1242
+ setAskUserBackend(agent, createAcpAskUserBackend(client, sessionId));
1243
+ const activeLlm = config.llm;
1244
+ const billing = await resolveLlmBilling(activeLlm.billing, activeLlm.llmOptions.model).catch(() => null);
1057
1245
  return {
1058
1246
  cwd,
1059
1247
  agent,
@@ -1061,14 +1249,21 @@ export class HoomanAcpAgent {
1061
1249
  mcpDisconnect: () => manager.disconnect().catch(() => undefined),
1062
1250
  turnAbort: null,
1063
1251
  promptExclusive: Promise.resolve(),
1252
+ steering,
1064
1253
  streamedToolCallIds: new Set(),
1065
1254
  streamingToolInputJson: new Map(),
1066
1255
  lastStreamToolUseId: null,
1067
1256
  terminalByToolCall: new Map(),
1257
+ currentAssistantMessageId: null,
1068
1258
  pendingModeReapply: false,
1069
1259
  pendingModelRebuild: false,
1070
1260
  pendingPersistModel: null,
1071
1261
  pendingPersistEffort: null,
1262
+ lastTurnUsage: createEmptyUsage(),
1263
+ billing,
1264
+ cumulativeCostUsd: 0,
1265
+ costUnpriced: false,
1266
+ lastContextTokens: undefined,
1072
1267
  };
1073
1268
  }
1074
1269
  /**
@@ -1224,10 +1419,12 @@ export function createAcpApp(agent) {
1224
1419
  .onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
1225
1420
  .onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
1226
1421
  .onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params, ctx.client))
1422
+ .onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params, ctx.client))
1423
+ .onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
1227
1424
  .onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
1228
1425
  .onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
1229
- .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
1230
- .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
1426
+ .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params, ctx.client))
1427
+ .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params, ctx.client))
1231
1428
  .onRequest(methods.agent.session.prompt, (ctx) => agent.prompt(ctx.params))
1232
1429
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params));
1233
1430
  }