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