hoomanjs 1.40.2 → 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 (135) 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 -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 +65 -17
  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 +9 -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 -5
  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 -0
  81. package/dist/core/modes/definitions.js.map +1 -1
  82. package/dist/core/prompts/static/ask-user.md +7 -0
  83. package/dist/core/prompts/static/filesystem.md +1 -1
  84. package/dist/core/prompts/system.js +5 -0
  85. package/dist/core/prompts/system.js.map +1 -1
  86. package/dist/core/sessions/generate-title.d.ts +10 -0
  87. package/dist/core/sessions/generate-title.js +113 -0
  88. package/dist/core/sessions/generate-title.js.map +1 -0
  89. package/dist/core/sessions/list-cli-sessions.js +13 -1
  90. package/dist/core/sessions/list-cli-sessions.js.map +1 -1
  91. package/dist/core/skills/built-in/hooman-config/providers.md +17 -0
  92. package/dist/core/skills/built-in/hooman-skills/SKILL.md +1 -1
  93. package/dist/core/state/session-title.d.ts +17 -0
  94. package/dist/core/state/session-title.js +15 -0
  95. package/dist/core/state/session-title.js.map +1 -0
  96. package/dist/core/state/tool-approvals.js +3 -0
  97. package/dist/core/state/tool-approvals.js.map +1 -1
  98. package/dist/core/tools/ask-user.d.ts +38 -0
  99. package/dist/core/tools/ask-user.js +62 -0
  100. package/dist/core/tools/ask-user.js.map +1 -0
  101. package/dist/core/tools/filesystem.d.ts +26 -1
  102. package/dist/core/tools/filesystem.js +44 -23
  103. package/dist/core/tools/filesystem.js.map +1 -1
  104. package/dist/core/tools/index.d.ts +1 -0
  105. package/dist/core/tools/index.js +1 -0
  106. package/dist/core/tools/index.js.map +1 -1
  107. package/dist/core/tools/shell.d.ts +41 -0
  108. package/dist/core/tools/shell.js +8 -1
  109. package/dist/core/tools/shell.js.map +1 -1
  110. package/dist/core/utils/billing.d.ts +49 -0
  111. package/dist/core/utils/billing.js +295 -0
  112. package/dist/core/utils/billing.js.map +1 -0
  113. package/dist/core/utils/edit-replace.d.ts +6 -0
  114. package/dist/core/utils/edit-replace.js +88 -6
  115. package/dist/core/utils/edit-replace.js.map +1 -1
  116. package/dist/core/utils/paths.d.ts +1 -0
  117. package/dist/core/utils/paths.js +1 -0
  118. package/dist/core/utils/paths.js.map +1 -1
  119. package/dist/core/{models → utils}/reasoning-effort.d.ts +9 -0
  120. package/dist/core/utils/reasoning-effort.js.map +1 -0
  121. package/dist/exec/questions.d.ts +9 -0
  122. package/dist/exec/questions.js +50 -0
  123. package/dist/exec/questions.js.map +1 -0
  124. package/dist/index.d.ts +5 -4
  125. package/dist/index.js +5 -4
  126. package/dist/index.js.map +1 -1
  127. package/package.json +2 -2
  128. package/dist/core/models/reasoning-effort.js.map +0 -1
  129. package/dist/core/tools/terminal-backend.d.ts +0 -41
  130. package/dist/core/tools/terminal-backend.js +0 -9
  131. package/dist/core/tools/terminal-backend.js.map +0 -1
  132. package/dist/core/tools/text-fs-backend.d.ts +0 -25
  133. package/dist/core/tools/text-fs-backend.js +0 -9
  134. package/dist/core/tools/text-fs-backend.js.map +0 -1
  135. /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,36 @@ function decodeCursor(cursor) {
83
90
  message: "Invalid pagination cursor",
84
91
  });
85
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
+ }
86
123
  function assertAbsolutePath(value, field) {
87
124
  const trimmed = value?.trim() ?? "";
88
125
  if (!trimmed) {
@@ -122,23 +159,26 @@ function isCancellationError(err, cancelSignals) {
122
159
  function toAcpStopReason(reason) {
123
160
  switch (reason) {
124
161
  case "cancelled":
162
+ case "interrupt":
125
163
  return "cancelled";
126
164
  case "maxTokens":
127
165
  case "modelContextWindowExceeded":
166
+ case "limitOutputTokens":
167
+ case "limitTotalTokens":
128
168
  return "max_tokens";
169
+ case "limitTurns":
170
+ return "max_turn_requests";
129
171
  case "contentFiltered":
130
172
  case "guardrailIntervened":
131
173
  return "refusal";
132
174
  case "endTurn":
133
175
  case "toolUse":
134
176
  case "stopSequence":
177
+ case "pauseTurn":
135
178
  default:
136
179
  return "end_turn";
137
180
  }
138
181
  }
139
- function serializeAgentMessages(agent) {
140
- return agent.messages.map((m) => m.toJSON());
141
- }
142
182
  /** Normalize a persisted/requested mode to a known id, else the default. */
143
183
  function resolveSessionMode(mode) {
144
184
  return mode && isKnownSessionMode(mode) ? mode : DEFAULT_SESSION_MODE;
@@ -164,6 +204,10 @@ function buildSessionModeState(currentModeId) {
164
204
  export class HoomanAcpAgent {
165
205
  #identity;
166
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;
167
211
  #sessions = new Map();
168
212
  /** Connection-scoped outbound context, captured in {@link onConnect}. */
169
213
  #client = null;
@@ -174,6 +218,9 @@ export class HoomanAcpAgent {
174
218
  #clientTerminalCap = false;
175
219
  constructor(identity) {
176
220
  this.#identity = identity;
221
+ this.#storeReady = migrateLegacySessionStore(this.#acpRoot, this.#snapshotsRoot)
222
+ .then(() => compactSessionIndex(this.#acpRoot))
223
+ .catch(() => undefined);
177
224
  }
178
225
  /** Bind connection-scoped lifecycle: capture the client + dispose on close. */
179
226
  onConnect(connection) {
@@ -202,7 +249,6 @@ export class HoomanAcpAgent {
202
249
  loadSession: true,
203
250
  promptCapabilities: {
204
251
  image: true,
205
- audio: true,
206
252
  embeddedContext: true,
207
253
  },
208
254
  mcpCapabilities: {
@@ -212,17 +258,24 @@ export class HoomanAcpAgent {
212
258
  sessionCapabilities: {
213
259
  list: {},
214
260
  delete: {},
261
+ resume: {},
262
+ close: {},
215
263
  },
216
264
  },
217
265
  };
218
266
  }
219
267
  async listSessions(params) {
220
- const ids = await listStoredSessionIds(this.#acpRoot);
268
+ await this.#storeReady;
269
+ const entries = await readSessionIndex(this.#acpRoot);
221
270
  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);
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
+ });
226
279
  }
227
280
  }
228
281
  sessions.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
@@ -233,70 +286,135 @@ export class HoomanAcpAgent {
233
286
  return { sessions: page, nextCursor };
234
287
  }
235
288
  async deleteSession(params) {
289
+ await this.#storeReady;
236
290
  const record = this.#sessions.get(params.sessionId);
237
291
  if (record) {
238
292
  this.#sessions.delete(params.sessionId);
239
293
  record.turnAbort?.abort();
240
294
  await record.mcpDisconnect();
241
295
  }
242
- 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
+ });
243
301
  }
244
302
  async newSession(params) {
303
+ await this.#storeReady;
245
304
  assertAbsolutePath(params.cwd, "cwd");
246
305
  const sessionId = crypto.randomUUID();
247
306
  const clientUserId = extractAcpClientUserId(params._meta) ?? null;
248
307
  const mcpServers = normalizeAcpSessionMcpServers(params.mcpServers);
308
+ const vscode = extractAcpVscodeFlag(params._meta);
249
309
  const mode = DEFAULT_SESSION_MODE;
250
310
  const now = new Date().toISOString();
251
- const meta = {
311
+ const entry = {
312
+ sessionId,
252
313
  cwd: params.cwd,
253
314
  createdAt: now,
254
315
  updatedAt: now,
255
316
  title: null,
256
317
  userId: clientUserId,
257
318
  mcpServers,
319
+ ...(vscode ? { vscode } : {}),
258
320
  sessionMode: mode,
259
321
  };
260
- await writeSessionMeta(this.#acpRoot, sessionId, meta);
261
- 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);
262
324
  this.#sessions.set(sessionId, record);
263
325
  await this.#advertiseCommands(this.#requireClient(), sessionId);
264
326
  return {
265
327
  sessionId,
266
328
  modes: buildSessionModeState(mode),
267
- configOptions: buildSessionConfigOptions(record.config, mode),
329
+ configOptions: buildSessionConfigOptions(record.config, mode, isYoloEnabled(record.agent)),
268
330
  };
269
331
  }
270
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) {
271
378
  if (this.#sessions.has(params.sessionId)) {
272
379
  throw RequestError.invalidParams({
273
380
  sessionId: params.sessionId,
274
381
  message: "Session is already active in this agent process.",
275
382
  });
276
383
  }
277
- const existing = await readSessionMeta(this.#acpRoot, params.sessionId);
384
+ await this.#storeReady;
385
+ const existing = await readSessionEntry(this.#acpRoot, params.sessionId);
278
386
  if (!existing) {
279
387
  throw RequestError.resourceNotFound(`session:${params.sessionId}`);
280
388
  }
281
389
  assertAbsolutePath(params.cwd, "cwd");
282
- const fromRequest = extractAcpClientUserId(params._meta);
390
+ const fromRequest = extractAcpClientUserId(params.meta);
283
391
  const clientUserId = fromRequest !== undefined ? fromRequest : (existing.userId ?? null);
284
392
  const mcpServers = params.mcpServers.length > 0
285
393
  ? normalizeAcpSessionMcpServers(params.mcpServers)
286
394
  : (existing.mcpServers ?? []);
395
+ const vscode = extractAcpVscodeFlag(params.meta) || existing.vscode === true;
287
396
  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));
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);
293
411
  }
294
- await replayConversationHistory(client, params.sessionId, record.agent.messages);
295
412
  this.#sessions.set(params.sessionId, record);
296
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
413
+ await patchSessionEntry(this.#acpRoot, params.sessionId, {
297
414
  cwd: params.cwd,
298
415
  ...(fromRequest !== undefined ? { userId: fromRequest || null } : {}),
299
416
  mcpServers,
417
+ ...(vscode ? { vscode } : {}),
300
418
  sessionMode: mode,
301
419
  });
302
420
  await this.#advertiseCommands(client, params.sessionId);
@@ -304,10 +422,7 @@ export class HoomanAcpAgent {
304
422
  if (getTodoViewState(record.agent).total > 0) {
305
423
  await this.#sendPlanUpdate(client, params.sessionId, record);
306
424
  }
307
- return {
308
- modes: buildSessionModeState(mode),
309
- configOptions: buildSessionConfigOptions(record.config, mode),
310
- };
425
+ return { record, mode };
311
426
  }
312
427
  /** Advertise the available slash commands for a freshly set-up session. */
313
428
  #advertiseCommands(client, sessionId) {
@@ -320,7 +435,7 @@ export class HoomanAcpAgent {
320
435
  * Handle `session/set_mode`: switch the active mode for a running session,
321
436
  * re-applying the tool surface and persisting the choice.
322
437
  */
323
- async setSessionMode(params) {
438
+ async setSessionMode(params, client) {
324
439
  const rec = this.#sessions.get(params.sessionId);
325
440
  if (!rec) {
326
441
  throw RequestError.invalidParams({ sessionId: params.sessionId });
@@ -332,9 +447,9 @@ export class HoomanAcpAgent {
332
447
  });
333
448
  }
334
449
  if (this.#transitionMode(rec, params.modeId)) {
335
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
336
- sessionMode: params.modeId,
337
- });
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);
338
453
  }
339
454
  return {};
340
455
  }
@@ -342,7 +457,7 @@ export class HoomanAcpAgent {
342
457
  * Handle `session/set_config_option`: apply a model or mode selection and
343
458
  * return the complete, up-to-date configuration state (as the spec requires).
344
459
  */
345
- async setSessionConfigOption(params) {
460
+ async setSessionConfigOption(params, client) {
346
461
  const rec = this.#sessions.get(params.sessionId);
347
462
  if (!rec) {
348
463
  throw RequestError.invalidParams({ sessionId: params.sessionId });
@@ -355,21 +470,55 @@ export class HoomanAcpAgent {
355
470
  }
356
471
  const value = params.value;
357
472
  if (params.configId === CONFIG_ID_MODE) {
358
- if (!isKnownSessionMode(value)) {
359
- throw RequestError.invalidParams({
360
- value,
361
- message: `Unknown mode "${value}"`,
362
- });
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
+ }
363
486
  }
364
- if (this.#transitionMode(rec, value)) {
365
- await patchSessionMeta(this.#acpRoot, params.sessionId, {
366
- sessionMode: value,
367
- });
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
+ }
368
506
  }
369
507
  }
370
508
  else if (params.configId === CONFIG_ID_MODEL) {
371
509
  await this.#applyModelChange(rec, value);
372
- 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
+ }
373
522
  }
374
523
  else if (params.configId === CONFIG_ID_EFFORT) {
375
524
  const parsed = parseReasoningEffortArg(value);
@@ -388,7 +537,7 @@ export class HoomanAcpAgent {
388
537
  });
389
538
  }
390
539
  return {
391
- configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
540
+ configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode), isYoloEnabled(rec.agent)),
392
541
  };
393
542
  }
394
543
  /** Whether a prompt turn is currently streaming for this session. */
@@ -522,6 +671,7 @@ export class HoomanAcpAgent {
522
671
  const resolved = rec.config.llm;
523
672
  const provider = await modelProviders[resolved.provider]();
524
673
  rec.agent.model = provider.create(resolved.providerOptions, resolved.llmOptions);
674
+ rec.billing = await resolveLlmBilling(resolved.billing, resolved.llmOptions.model).catch(() => null);
525
675
  }
526
676
  catch (error) {
527
677
  if (previous) {
@@ -541,7 +691,7 @@ export class HoomanAcpAgent {
541
691
  * Apply settings changes deferred during a turn (mode tool-surface + model
542
692
  * rebuild). Runs at the turn boundary before the next turn may start.
543
693
  */
544
- async #flushPendingSettings(rec) {
694
+ async #flushPendingSettings(rec, client, sessionId) {
545
695
  if (rec.pendingModeReapply) {
546
696
  rec.pendingModeReapply = false;
547
697
  applySessionMode(rec.agent);
@@ -560,6 +710,11 @@ export class HoomanAcpAgent {
560
710
  if (persistEffort) {
561
711
  this.#persistProviderEffort(rec, persistEffort.provider, persistEffort.effort);
562
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);
563
718
  }
564
719
  }
565
720
  }
@@ -567,117 +722,14 @@ export class HoomanAcpAgent {
567
722
  * Execute an inline (non-model) slash command and return the text reply to
568
723
  * stream back as an `agent_message_chunk`. `/init` is handled by the caller.
569
724
  */
570
- #runControlCommand(client, sessionId, rec, command) {
725
+ #runControlCommand(rec, command) {
571
726
  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
727
  case "compact":
581
728
  return this.#commandCompact(rec);
582
729
  default:
583
730
  return Promise.resolve(`Unknown command "/${command.name}".`);
584
731
  }
585
732
  }
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
733
  async #commandCompact(rec) {
682
734
  const conversationManager = getAgentConversationManager(rec.agent);
683
735
  if (!conversationManager) {
@@ -717,6 +769,28 @@ export class HoomanAcpAgent {
717
769
  /* ignore */
718
770
  }
719
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
+ }
720
794
  /** Run one prompt turn: stream model output, tool calls, and a stop reason. */
721
795
  async prompt(params) {
722
796
  const rec = this.#sessions.get(params.sessionId);
@@ -724,6 +798,12 @@ export class HoomanAcpAgent {
724
798
  throw RequestError.invalidParams({ sessionId: params.sessionId });
725
799
  }
726
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
+ }
727
807
  // Serialize concurrent prompt turns for the same session.
728
808
  const prevExclusive = rec.promptExclusive;
729
809
  let releaseExclusive;
@@ -737,6 +817,7 @@ export class HoomanAcpAgent {
737
817
  rec.streamedToolCallIds.clear();
738
818
  rec.streamingToolInputJson.clear();
739
819
  rec.lastStreamToolUseId = null;
820
+ rec.currentAssistantMessageId = null;
740
821
  let stopReason = "end_turn";
741
822
  try {
742
823
  const echo = acpPromptEchoText(params.prompt);
@@ -744,6 +825,7 @@ export class HoomanAcpAgent {
744
825
  await this.#sendUpdate(client, params.sessionId, {
745
826
  sessionUpdate: "user_message_chunk",
746
827
  content: { type: "text", text: echo },
828
+ messageId: crypto.randomUUID(),
747
829
  });
748
830
  await this.#maybeDeriveTitle(client, params.sessionId, echo);
749
831
  }
@@ -751,11 +833,12 @@ export class HoomanAcpAgent {
751
833
  // (no model turn); `/init` rewrites the prompt and runs normally.
752
834
  const command = parseAcpSlashCommand(echo);
753
835
  if (command && command.name !== "init") {
754
- const reply = await this.#runControlCommand(client, params.sessionId, rec, command);
836
+ const reply = await this.#runControlCommand(rec, command);
755
837
  if (reply) {
756
838
  await this.#sendUpdate(client, params.sessionId, {
757
839
  sessionUpdate: "agent_message_chunk",
758
840
  content: { type: "text", text: reply },
841
+ messageId: crypto.randomUUID(),
759
842
  });
760
843
  }
761
844
  return { stopReason: "end_turn" };
@@ -826,6 +909,7 @@ export class HoomanAcpAgent {
826
909
  }
827
910
  else if (ev.type === "agentResultEvent") {
828
911
  stopReason = toAcpStopReason(ev.result.stopReason);
912
+ await this.#sendUsageUpdate(client, params.sessionId, rec, ev.result.contextSize);
829
913
  }
830
914
  iter = await stream.next();
831
915
  }
@@ -842,6 +926,7 @@ export class HoomanAcpAgent {
842
926
  await this.#sendUpdate(client, params.sessionId, {
843
927
  sessionUpdate: "agent_message_chunk",
844
928
  content: { type: "text", text: `\n[error] ${message}\n` },
929
+ messageId: this.#assistantMessageId(rec),
845
930
  });
846
931
  stopReason = "refusal";
847
932
  }
@@ -857,10 +942,13 @@ export class HoomanAcpAgent {
857
942
  rec.turnAbort = null;
858
943
  // Apply mode/model changes requested mid-turn before releasing the gate
859
944
  // so the next turn sees the settled state.
860
- await this.#flushPendingSettings(rec);
945
+ await this.#flushPendingSettings(rec, client, params.sessionId);
861
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.
862
950
  try {
863
- await saveSessionMessages(this.#acpRoot, params.sessionId, serializeAgentMessages(rec.agent));
951
+ await touchSessionEntry(this.#acpRoot, params.sessionId);
864
952
  }
865
953
  catch {
866
954
  /* ignore */
@@ -868,6 +956,46 @@ export class HoomanAcpAgent {
868
956
  }
869
957
  return { stopReason };
870
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
+ }
871
999
  /**
872
1000
  * Surface the agent's todo list as an ACP `plan` update. Sends the complete
873
1001
  * list, as the client replaces the plan wholesale on each update. The
@@ -892,7 +1020,7 @@ export class HoomanAcpAgent {
892
1020
  */
893
1021
  async #syncCurrentMode(client, sessionId, rec) {
894
1022
  const modeId = resolveSessionMode(getModeState(rec.agent).mode);
895
- await patchSessionMeta(this.#acpRoot, sessionId, { sessionMode: modeId });
1023
+ await patchSessionEntry(this.#acpRoot, sessionId, { sessionMode: modeId });
896
1024
  await this.#sendUpdate(client, sessionId, {
897
1025
  sessionUpdate: "current_mode_update",
898
1026
  currentModeId: modeId,
@@ -900,16 +1028,20 @@ export class HoomanAcpAgent {
900
1028
  // Config Options supersede modes; mirror the change for config-aware clients.
901
1029
  await this.#sendUpdate(client, sessionId, {
902
1030
  sessionUpdate: "config_option_update",
903
- configOptions: buildSessionConfigOptions(rec.config, modeId),
1031
+ configOptions: buildSessionConfigOptions(rec.config, modeId, isYoloEnabled(rec.agent)),
904
1032
  });
905
1033
  }
906
- /** 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
+ */
907
1039
  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() === "");
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() === "");
913
1045
  if (!needsTitle) {
914
1046
  return;
915
1047
  }
@@ -917,7 +1049,7 @@ export class HoomanAcpAgent {
917
1049
  if (!title) {
918
1050
  return;
919
1051
  }
920
- await patchSessionMeta(this.#acpRoot, sessionId, { title });
1052
+ await patchSessionEntry(this.#acpRoot, sessionId, { title });
921
1053
  await this.#sendUpdate(client, sessionId, {
922
1054
  sessionUpdate: "session_info_update",
923
1055
  title,
@@ -931,6 +1063,12 @@ export class HoomanAcpAgent {
931
1063
  return;
932
1064
  }
933
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
+ }
934
1072
  case "modelContentBlockStartEvent": {
935
1073
  const start = inner.start;
936
1074
  if (start?.type === "toolUseStart") {
@@ -954,6 +1092,7 @@ export class HoomanAcpAgent {
954
1092
  await this.#sendUpdate(client, sessionId, {
955
1093
  sessionUpdate: "agent_message_chunk",
956
1094
  content: { type: "text", text: delta.text },
1095
+ messageId: this.#assistantMessageId(rec),
957
1096
  });
958
1097
  return;
959
1098
  }
@@ -962,6 +1101,7 @@ export class HoomanAcpAgent {
962
1101
  await this.#sendUpdate(client, sessionId, {
963
1102
  sessionUpdate: "agent_thought_chunk",
964
1103
  content: { type: "text", text: delta.text },
1104
+ messageId: this.#assistantMessageId(rec),
965
1105
  });
966
1106
  }
967
1107
  return;
@@ -990,11 +1130,34 @@ export class HoomanAcpAgent {
990
1130
  type: "text",
991
1131
  text: `[citations: ${n} reference(s)]\n`,
992
1132
  },
1133
+ messageId: this.#assistantMessageId(rec),
993
1134
  });
994
1135
  }
995
1136
  }
996
1137
  return;
997
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
+ }
998
1161
  case "modelRedactionEvent": {
999
1162
  const replace = inner.outputRedaction?.replaceContent ??
1000
1163
  inner.inputRedaction?.replaceContent;
@@ -1002,6 +1165,7 @@ export class HoomanAcpAgent {
1002
1165
  await this.#sendUpdate(client, sessionId, {
1003
1166
  sessionUpdate: "agent_message_chunk",
1004
1167
  content: { type: "text", text: replace },
1168
+ messageId: this.#assistantMessageId(rec),
1005
1169
  });
1006
1170
  }
1007
1171
  return;
@@ -1010,6 +1174,13 @@ export class HoomanAcpAgent {
1010
1174
  return;
1011
1175
  }
1012
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
+ }
1013
1184
  #streamToolUseIdForInputDelta(rec) {
1014
1185
  if (rec.lastStreamToolUseId &&
1015
1186
  rec.streamedToolCallIds.has(rec.lastStreamToolUseId)) {
@@ -1029,7 +1200,7 @@ export class HoomanAcpAgent {
1029
1200
  }
1030
1201
  return this.#client;
1031
1202
  }
1032
- async #bootstrapSession(sessionId, cwd, userId, mcpServers, mode, preferredModel) {
1203
+ async #bootstrapSession(sessionId, cwd, userId, mcpServers, mode, preferredModel, vscode = false) {
1033
1204
  const client = this.#requireClient();
1034
1205
  const sessionConfig = createSessionConfig();
1035
1206
  if (preferredModel &&
@@ -1042,18 +1213,37 @@ export class HoomanAcpAgent {
1042
1213
  })),
1043
1214
  });
1044
1215
  }
1216
+ const steering = new ChatTurnSteeringController();
1045
1217
  const { config, agent, mcp: { manager }, } = await bootstrap("acp", {
1046
1218
  userId,
1047
1219
  sessionId,
1048
1220
  mode,
1221
+ interventions: [createChatTurnSteeringIntervention(steering)],
1049
1222
  createInterventions: () => [
1050
1223
  createAcpToolApprovalIntervention(client, sessionId, () => this.#sessions.get(sessionId)?.streamedToolCallIds ??
1051
1224
  EMPTY_STREAMED_TOOL_CALL_IDS),
1052
1225
  ],
1053
- 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
+ },
1054
1241
  }, false, sessionConfig);
1055
1242
  this.#registerTextFsBackend(agent, client, sessionId);
1056
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);
1057
1247
  return {
1058
1248
  cwd,
1059
1249
  agent,
@@ -1061,14 +1251,21 @@ export class HoomanAcpAgent {
1061
1251
  mcpDisconnect: () => manager.disconnect().catch(() => undefined),
1062
1252
  turnAbort: null,
1063
1253
  promptExclusive: Promise.resolve(),
1254
+ steering,
1064
1255
  streamedToolCallIds: new Set(),
1065
1256
  streamingToolInputJson: new Map(),
1066
1257
  lastStreamToolUseId: null,
1067
1258
  terminalByToolCall: new Map(),
1259
+ currentAssistantMessageId: null,
1068
1260
  pendingModeReapply: false,
1069
1261
  pendingModelRebuild: false,
1070
1262
  pendingPersistModel: null,
1071
1263
  pendingPersistEffort: null,
1264
+ cumulativeUsage: createEmptyUsage(),
1265
+ billing,
1266
+ cumulativeCostUsd: 0,
1267
+ costUnpriced: false,
1268
+ lastContextTokens: undefined,
1072
1269
  };
1073
1270
  }
1074
1271
  /**
@@ -1224,10 +1421,12 @@ export function createAcpApp(agent) {
1224
1421
  .onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
1225
1422
  .onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
1226
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))
1227
1426
  .onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
1228
1427
  .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))
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))
1231
1430
  .onRequest(methods.agent.session.prompt, (ctx) => agent.prompt(ctx.params))
1232
1431
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params));
1233
1432
  }