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