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