sakuracode 0.1.0 → 0.2.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/README.md +19 -0
- package/README.zh-CN.md +19 -0
- package/dist/THIRD_PARTY_NOTICES.md +2 -0
- package/dist/code/src/acp/agent.js +70 -0
- package/dist/code/src/acp/auth.js +19 -0
- package/dist/code/src/acp/capabilities.js +22 -0
- package/dist/code/src/acp/connection.js +32 -0
- package/dist/code/src/acp/content.js +29 -0
- package/dist/code/src/acp/events-to-updates.js +37 -0
- package/dist/code/src/acp/log.js +22 -0
- package/dist/code/src/acp/serve.js +59 -0
- package/dist/code/src/acp/sessions.js +81 -0
- package/dist/code/src/acp/stop-reasons.js +31 -0
- package/dist/code/src/acp/types.js +22 -0
- package/dist/code/src/cli.js +8 -0
- package/dist/code/src/context-estimate.js +44 -0
- package/dist/code/src/invocation.js +2 -0
- package/dist/code/src/permissions.js +9 -0
- package/dist/code/src/reports.js +1 -1
- package/dist/code/src/session-controller.js +68 -5
- package/dist/code/src/slash-commands.js +1 -1
- package/dist/code/src/status-line.js +11 -2
- package/dist/code/src/tui/app.js +6 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -105,6 +105,7 @@ sakuracode exec --json "list the TODOs" | jq . # events as JSON lines
|
|
|
105
105
|
echo "explain this error" | sakuracode exec - # prompt from stdin
|
|
106
106
|
sakuracode sessions # list sessions
|
|
107
107
|
sakuracode providers # providers and whether their key is set (never the key)
|
|
108
|
+
sakuracode acp # serve the Agent Client Protocol on stdin/stdout (see below)
|
|
108
109
|
```
|
|
109
110
|
|
|
110
111
|
### Options
|
|
@@ -135,6 +136,7 @@ sakuracode providers # providers and whether their key is set (ne
|
|
|
135
136
|
|---|---|---|
|
|
136
137
|
| Enter | send | **steer**: delivered before the agent's next model request |
|
|
137
138
|
| Tab | complete a slash command | **queue**: handled when the agent would stop |
|
|
139
|
+
| Shift-Tab | switch the review mode: auto, strict, read-only (`full-access` only with `/permissions`) | applied when the run ends |
|
|
138
140
|
| Esc | close the popup | **cancel** the run |
|
|
139
141
|
| Ctrl-C | clear the prompt; twice on an empty prompt leaves | cancel the run |
|
|
140
142
|
| Ctrl-D | leave (empty prompt) | |
|
|
@@ -179,6 +181,23 @@ Messages typed during a run show above the prompt as "steering" or "queued" unti
|
|
|
179
181
|
|
|
180
182
|
None of them restricts the working directory: the review plugin runs with `directoryRules: false`, so a path outside the launch directory is not a reason to refuse. Writes, command expansions, credentials and catastrophic patterns are still checked by the hard rules and the reviewer model.
|
|
181
183
|
|
|
184
|
+
## Use with T3 Code and other ACP clients
|
|
185
|
+
|
|
186
|
+
`sakuracode acp` speaks the [Agent Client Protocol](https://agentclientprotocol.com) (ACP, version 1) on stdin and stdout, so an ACP client can run it as its coding agent. Stdout carries only protocol messages; logs go to stderr (`SAKURACODE_ACP_LOG=off|error|warn|info|debug`, default `warn`).
|
|
187
|
+
|
|
188
|
+
**T3 Code:** Settings, Providers, Add provider, Show more, **Local ACP command**. Fill in:
|
|
189
|
+
|
|
190
|
+
| Field | Value |
|
|
191
|
+
|---|---|
|
|
192
|
+
| Name | `Sakura Code` (any name) |
|
|
193
|
+
| Executable | `sakuracode` (a full path works too) |
|
|
194
|
+
| Arguments | one row, exactly `acp` |
|
|
195
|
+
| Environment | your key, for example `DEEPSEEK_API_KEY` or `OPENCODE_API_KEY` (a GUI app often does not see the variables of your shell) |
|
|
196
|
+
|
|
197
|
+
Then start a chat with that provider. To check it by hand, run `sakuracode acp` and paste `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}` followed by Enter: one JSON line comes back.
|
|
198
|
+
|
|
199
|
+
What works now: chat with streamed replies and reasoning, cancelling a running turn, several chats at once (one prompt at a time in each). New chats use the review mode `auto` (every call is reviewed); `--permissions strict` or `read-only` after `acp` changes it, and `full-access` only runs when you ask for it on that command line. Not yet: tool call cards, images, attached files, model and mode pickers, resuming a chat, MCP servers from the client. See `docs/acp-design.md`.
|
|
200
|
+
|
|
182
201
|
## Config
|
|
183
202
|
|
|
184
203
|
`~/.sakura/config.toml` (set `SAKURA_HOME` to use another directory; sessions are stored in `sessions/` beside it). Precedence: defaults < file < profile < environment (`SAKURA_PROVIDER`, `SAKURA_MODEL`) < flags.
|
package/README.zh-CN.md
CHANGED
|
@@ -105,6 +105,7 @@ sakuracode exec --json "列出 TODO" | jq . # 事件以 JSON 行输出
|
|
|
105
105
|
echo "解释这个报错" | sakuracode exec - # 从 stdin 读取提示
|
|
106
106
|
sakuracode sessions # 列出会话
|
|
107
107
|
sakuracode providers # 服务商以及它们的密钥是否已设置(不显示密钥)
|
|
108
|
+
sakuracode acp # 在标准输入输出上提供 Agent Client Protocol(见下文)
|
|
108
109
|
```
|
|
109
110
|
|
|
110
111
|
### 选项
|
|
@@ -135,6 +136,7 @@ sakuracode providers # 服务商以及它们的密钥是否已设
|
|
|
135
136
|
|---|---|---|
|
|
136
137
|
| Enter | 发送 | **引导**:在 Agent 的下一个模型请求之前送达 |
|
|
137
138
|
| Tab | 补全斜杠命令 | **排队**:Agent 要停下时处理 |
|
|
139
|
+
| Shift-Tab | 切换审查模式:auto、strict、read-only(`full-access` 只能用 `/permissions` 设置) | 运行结束后生效 |
|
|
138
140
|
| Esc | 关闭弹出列表 | **取消**这次运行 |
|
|
139
141
|
| Ctrl-C | 清空提示;在空提示上按两次退出 | 取消这次运行 |
|
|
140
142
|
| Ctrl-D | 退出(提示为空时) | |
|
|
@@ -179,6 +181,23 @@ sakuracode providers # 服务商以及它们的密钥是否已设
|
|
|
179
181
|
|
|
180
182
|
它们都不限制工作目录:审核插件以 `directoryRules: false` 运行,所以启动目录之外的路径本身不是拒绝的理由。写入、命令展开、凭据和灾难性的模式仍然由硬规则和审核模型检查。
|
|
181
183
|
|
|
184
|
+
## 在 T3 Code 和其他 ACP 客户端里使用
|
|
185
|
+
|
|
186
|
+
`sakuracode acp` 在标准输入和标准输出上说 [Agent Client Protocol](https://agentclientprotocol.com)(ACP,版本 1),所以 ACP 客户端可以把它当作自己的编程 Agent 来运行。标准输出只有协议消息;日志写到标准错误(`SAKURACODE_ACP_LOG=off|error|warn|info|debug`,默认 `warn`)。
|
|
187
|
+
|
|
188
|
+
**T3 Code:** Settings → Providers → Add provider → Show more → **Local ACP command**,填写:
|
|
189
|
+
|
|
190
|
+
| 字段 | 内容 |
|
|
191
|
+
|---|---|
|
|
192
|
+
| Name | `Sakura Code`(随意) |
|
|
193
|
+
| Executable | `sakuracode`(也可以写完整路径) |
|
|
194
|
+
| Arguments | 一行,就是 `acp` |
|
|
195
|
+
| Environment | 你的密钥,例如 `DEEPSEEK_API_KEY` 或 `OPENCODE_API_KEY`(图形界面应用通常看不到 shell 里的环境变量) |
|
|
196
|
+
|
|
197
|
+
然后用这个 provider 开一个对话。想手动验证:运行 `sakuracode acp`,粘贴 `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}` 再按回车,会返回一行 JSON。
|
|
198
|
+
|
|
199
|
+
现在可用:对话(回复和推理流式显示)、取消正在运行的一轮、同时开多个对话(每个对话一次只处理一条消息)。新对话使用审核模式 `auto`(每次调用都审核);在 `acp` 后加 `--permissions strict` 或 `read-only` 可以更改,`full-access` 只有在这条命令行上明确要求时才会生效。暂时没有:工具调用卡片、图片、附件、模型和模式选择器、恢复对话、客户端传来的 MCP 服务器。详见 `docs/acp-design.md`。
|
|
200
|
+
|
|
182
201
|
## 配置
|
|
183
202
|
|
|
184
203
|
`~/.sakura/config.toml`(设置 `SAKURA_HOME` 可以改用别的目录;会话保存在它旁边的 `sessions/` 里)。优先级:默认值 < 文件 < 方案 < 环境变量(`SAKURA_PROVIDER`、`SAKURA_MODEL`)< 命令行选项。
|
|
@@ -10,4 +10,6 @@ Sakura borrows from a few other projects. Where code or text was copied or adapt
|
|
|
10
10
|
|
|
11
11
|
Dependency: [`quickjs-emscripten`](https://github.com/justjake/quickjs-emscripten) (MIT) runs the code-mode scripts. It is installed from npm, not copied.
|
|
12
12
|
|
|
13
|
+
Dependency: [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) (Apache-2.0, Zed Industries) carries the Agent Client Protocol for `sakuracode acp`, and [`zod`](https://github.com/colinhacks/zod) (MIT) is its required peer dependency. Both are installed from npm, not copied.
|
|
14
|
+
|
|
13
15
|
Licence texts: `licenses/pi-mono-MIT.txt`, `licenses/gitleaks-MIT.txt`, `licenses/codex-Apache-2.0.txt`.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The ACP agent: routes the protocol methods to the session registry and the pure mappers. It knows nothing of JSON-RPC framing
|
|
2
|
+
// (that is `connection.ts`) and nothing of the model.
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { initializeResult, readClientFeatures } from "./capabilities.js";
|
|
5
|
+
import { promptText } from "./content.js";
|
|
6
|
+
import { createUpdateMapper } from "./events-to-updates.js";
|
|
7
|
+
import { outcomeOf } from "./stop-reasons.js";
|
|
8
|
+
import { AcpError, ErrorCode } from "./types.js";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
export function createAcpAgent(deps) {
|
|
11
|
+
const { sessions, log } = deps;
|
|
12
|
+
let features = readClientFeatures(undefined);
|
|
13
|
+
// every handler is wrapped once: a failure becomes an error answer with a code, never a crash, and never reaches another session
|
|
14
|
+
const guard = (method, handler) => {
|
|
15
|
+
return async (...args) => {
|
|
16
|
+
try {
|
|
17
|
+
return await handler(...args);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof AcpError)
|
|
21
|
+
throw error;
|
|
22
|
+
log.error(`${method} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
23
|
+
throw new AcpError(ErrorCode.internalError, "The agent hit an internal error. See its log on stderr.");
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
features: () => features,
|
|
29
|
+
initialize: guard("initialize", async (params) => {
|
|
30
|
+
features = readClientFeatures(params.clientCapabilities);
|
|
31
|
+
return initializeResult(params, deps.identity);
|
|
32
|
+
}),
|
|
33
|
+
// no authentication is needed yet (keys come from the environment), so there is nothing to do and nothing can fail
|
|
34
|
+
authenticate: guard("authenticate", async () => ({})),
|
|
35
|
+
newSession: guard("session/new", async (params) => {
|
|
36
|
+
if (typeof params.cwd !== "string" || !isAbsolute(params.cwd))
|
|
37
|
+
throw new AcpError(ErrorCode.invalidParams, "cwd must be an absolute path", { cwd: typeof params.cwd === "string" ? params.cwd : undefined });
|
|
38
|
+
if (Array.isArray(params.mcpServers) && params.mcpServers.length > 0)
|
|
39
|
+
log.debug("session/new: mcpServers are not used yet", { count: params.mcpServers.length });
|
|
40
|
+
return { sessionId: await sessions.create(params.cwd) };
|
|
41
|
+
}),
|
|
42
|
+
prompt: guard("session/prompt", async (params, channel, signal) => {
|
|
43
|
+
const text = promptText(params.prompt);
|
|
44
|
+
if (!sessions.has(params.sessionId))
|
|
45
|
+
throw new AcpError(ErrorCode.sessionNotFound, `Unknown session ${params.sessionId}`, { sessionId: params.sessionId });
|
|
46
|
+
if (text === "")
|
|
47
|
+
return { stopReason: "end_turn" };
|
|
48
|
+
const mapper = createUpdateMapper();
|
|
49
|
+
const cancel = () => sessions.cancel(params.sessionId);
|
|
50
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
51
|
+
let turn;
|
|
52
|
+
try {
|
|
53
|
+
turn = await sessions.run(params.sessionId, text, async (event) => {
|
|
54
|
+
for (const update of mapper.push(event))
|
|
55
|
+
await channel.update(params.sessionId, update);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
signal?.removeEventListener("abort", cancel);
|
|
60
|
+
}
|
|
61
|
+
const outcome = outcomeOf(turn.result, turn.cancelled);
|
|
62
|
+
if (outcome.notice)
|
|
63
|
+
await channel.update(params.sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: outcome.notice }, messageId: randomUUID() });
|
|
64
|
+
return outcome.result;
|
|
65
|
+
}),
|
|
66
|
+
cancel: guard("session/cancel", async (params) => {
|
|
67
|
+
sessions.cancel(params.sessionId);
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { chooseProvider, listProviders, ProviderSetupError } from "../providers.js";
|
|
2
|
+
import { AcpError, ErrorCode } from "./types.js";
|
|
3
|
+
/** The provider to use, or the `auth_required` error with an instruction when there is none or its key is missing. */
|
|
4
|
+
export function providerFor(settings, env) {
|
|
5
|
+
let name;
|
|
6
|
+
try {
|
|
7
|
+
name = chooseProvider(settings, env);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (!(error instanceof ProviderSetupError))
|
|
11
|
+
throw error;
|
|
12
|
+
throw new AcpError(ErrorCode.authRequired, `${error.message}. Set the key in the environment of this agent (in T3 Code: the environment overrides of the ACP command), then start a new chat.`);
|
|
13
|
+
}
|
|
14
|
+
const entry = listProviders(settings, env).find((item) => item.name === name);
|
|
15
|
+
if (entry && !entry.keyPresent) {
|
|
16
|
+
throw new AcpError(ErrorCode.authRequired, `No API key for provider "${name}". Set ${entry.definition.keyEnv} in the environment of this agent (in T3 Code: the environment overrides of the ACP command), then start a new chat.`, { provider: name, keyEnv: entry.definition.keyEnv });
|
|
17
|
+
}
|
|
18
|
+
return name;
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const flag = (value) => value === true;
|
|
2
|
+
const record = (value) => (typeof value === "object" && value !== null && !Array.isArray(value) ? value : {});
|
|
3
|
+
export function readClientFeatures(capabilities) {
|
|
4
|
+
const root = record(capabilities);
|
|
5
|
+
const fs = record(root.fs);
|
|
6
|
+
return Object.freeze({ readTextFile: flag(fs.readTextFile), writeTextFile: flag(fs.writeTextFile), terminal: flag(root.terminal) });
|
|
7
|
+
}
|
|
8
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
9
|
+
/** The `initialize` answer. The agent speaks ACP version 1 whatever the client asks for; the client then decides whether to continue. */
|
|
10
|
+
export function initializeResult(_request, identity) {
|
|
11
|
+
return {
|
|
12
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
13
|
+
agentCapabilities: {
|
|
14
|
+
loadSession: false,
|
|
15
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
|
16
|
+
mcpCapabilities: { http: false, sse: false },
|
|
17
|
+
},
|
|
18
|
+
// Keys come from the environment of the agent process; there is nothing to log in to yet.
|
|
19
|
+
authMethods: [],
|
|
20
|
+
agentInfo: identity,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The JSON-RPC link: the only file that imports the ACP SDK. It turns the SDK's handler calls into the plain calls of
|
|
2
|
+
// `AgentHandlers`, turns `AcpError` into JSON-RPC errors, and gives the agent a `ClientChannel` to send updates through. The SDK
|
|
3
|
+
// checks every message in both directions against the protocol schema.
|
|
4
|
+
import { agent, methods, ndJsonStream, RequestError } from "@agentclientprotocol/sdk";
|
|
5
|
+
import { AcpError } from "./types.js";
|
|
6
|
+
/** A handler's `AcpError` must reach the client as the JSON-RPC error it describes. */
|
|
7
|
+
async function answering(work) {
|
|
8
|
+
try {
|
|
9
|
+
return await work();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error instanceof AcpError)
|
|
13
|
+
throw new RequestError(error.code, error.message, error.data);
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function connect(handlers, streams, log) {
|
|
18
|
+
const app = agent({ name: "sakuracode" })
|
|
19
|
+
.onRequest(methods.agent.initialize, ({ params }) => answering(() => handlers.initialize(params)))
|
|
20
|
+
.onRequest(methods.agent.authenticate, ({ params }) => answering(() => handlers.authenticate(params)))
|
|
21
|
+
.onRequest(methods.agent.session.new, ({ params }) => answering(() => handlers.newSession(params)))
|
|
22
|
+
.onRequest(methods.agent.session.prompt, async ({ params, client, signal }) => {
|
|
23
|
+
const channel = {
|
|
24
|
+
update: (sessionId, update) => client.notify(methods.client.session.update, { sessionId, update }),
|
|
25
|
+
};
|
|
26
|
+
return answering(() => handlers.prompt(params, channel, signal));
|
|
27
|
+
})
|
|
28
|
+
.onNotification(methods.agent.session.cancel, ({ params }) => handlers.cancel(params));
|
|
29
|
+
const connection = app.connect(ndJsonStream(streams.output, streams.input));
|
|
30
|
+
void connection.closed.catch((error) => log.warn(`connection closed with an error: ${error instanceof Error ? error.message : String(error)}`));
|
|
31
|
+
return { closed: connection.closed.catch(() => undefined) };
|
|
32
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function fileLinkPath(uri) {
|
|
2
|
+
if (!uri.startsWith("file://"))
|
|
3
|
+
return undefined;
|
|
4
|
+
try {
|
|
5
|
+
return decodeURIComponent(new URL(uri).pathname);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Text blocks are joined with a blank line. A link to a file becomes an `@path` mention, which the agent reads like one typed
|
|
13
|
+
* in the terminal; any other link stays a Markdown link. Blocks the agent does not read yet (images, audio, embedded
|
|
14
|
+
* resources) leave a one-line note so the model knows something was there. An empty result means there is nothing to run.
|
|
15
|
+
*/
|
|
16
|
+
export function promptText(blocks) {
|
|
17
|
+
const parts = [];
|
|
18
|
+
for (const block of blocks) {
|
|
19
|
+
if (block.type === "text" && typeof block.text === "string")
|
|
20
|
+
parts.push(block.text);
|
|
21
|
+
else if (block.type === "resource_link" && typeof block.uri === "string") {
|
|
22
|
+
const path = fileLinkPath(block.uri);
|
|
23
|
+
parts.push(path ? `@${path}` : `[${typeof block.name === "string" ? block.name : block.uri}](${block.uri})`);
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
parts.push(`[a ${String(block.type)} attachment was sent but is not supported yet]`);
|
|
27
|
+
}
|
|
28
|
+
return parts.join("\n\n").trim();
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Framework events to ACP updates, as a pure state machine. It decides message ids: ACP groups chunks by `messageId`, and cannot
|
|
2
|
+
// take chunks back, so a reply that is sent again after a dropped connection gets a new id and the text already sent stays.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export function createUpdateMapper(newId = randomUUID) {
|
|
5
|
+
// one id for the text and one for the reasoning of the current reply; both start again after the reply ends or is voided
|
|
6
|
+
let messageId;
|
|
7
|
+
let thoughtId;
|
|
8
|
+
const startAgain = () => {
|
|
9
|
+
messageId = undefined;
|
|
10
|
+
thoughtId = undefined;
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
push(event) {
|
|
14
|
+
switch (event.type) {
|
|
15
|
+
case "message_update": {
|
|
16
|
+
const delta = event.delta;
|
|
17
|
+
if (delta.type !== "text" || delta.text === "")
|
|
18
|
+
return [];
|
|
19
|
+
messageId ??= newId();
|
|
20
|
+
return [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: delta.text }, messageId }];
|
|
21
|
+
}
|
|
22
|
+
case "reasoning_update": {
|
|
23
|
+
if (typeof event.delta !== "string" || event.delta === "")
|
|
24
|
+
return [];
|
|
25
|
+
thoughtId ??= newId();
|
|
26
|
+
return [{ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: event.delta }, messageId: thoughtId }];
|
|
27
|
+
}
|
|
28
|
+
case "message_reset":
|
|
29
|
+
case "message_end":
|
|
30
|
+
startAgain();
|
|
31
|
+
return [];
|
|
32
|
+
default:
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
2
|
+
/** `SAKURACODE_ACP_LOG` is off, error, warn (default), info or debug. */
|
|
3
|
+
export function logLevelFromEnv(value) {
|
|
4
|
+
const level = value?.trim().toLowerCase();
|
|
5
|
+
if (level === "off" || level === "error" || level === "warn" || level === "info" || level === "debug")
|
|
6
|
+
return level;
|
|
7
|
+
return "warn";
|
|
8
|
+
}
|
|
9
|
+
export function createLogger(write, level, module = "acp") {
|
|
10
|
+
const emit = (at, message, fields) => {
|
|
11
|
+
if (level === "off" || ORDER[at] > ORDER[level])
|
|
12
|
+
return;
|
|
13
|
+
write(`${JSON.stringify({ ts: new Date().toISOString(), level: at, module, message, ...fields })}\n`);
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
error: (message, fields) => emit("error", message, fields),
|
|
17
|
+
warn: (message, fields) => emit("warn", message, fields),
|
|
18
|
+
info: (message, fields) => emit("info", message, fields),
|
|
19
|
+
debug: (message, fields) => emit("debug", message, fields),
|
|
20
|
+
child: (name) => createLogger(write, level, name),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Starting the agent: wires the registry, the agent and the connection together and handles the process lifecycle.
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import { openAgentSession } from "../agent-session.js";
|
|
4
|
+
import { createAcpAgent } from "./agent.js";
|
|
5
|
+
import { connect } from "./connection.js";
|
|
6
|
+
import { createLogger, logLevelFromEnv } from "./log.js";
|
|
7
|
+
import { createSessions } from "./sessions.js";
|
|
8
|
+
/** Serves one client until it closes its end (or `stop` resolves), then cancels running turns and closes every session. Returns the exit code. */
|
|
9
|
+
export async function runAcp(options) {
|
|
10
|
+
const sessions = createSessions({
|
|
11
|
+
settings: options.settings,
|
|
12
|
+
store: options.store,
|
|
13
|
+
env: options.env,
|
|
14
|
+
open: options.open ?? openAgentSession,
|
|
15
|
+
permissions: options.permissions,
|
|
16
|
+
clients: options.clients,
|
|
17
|
+
log: options.log.child("sessions"),
|
|
18
|
+
});
|
|
19
|
+
const agent = createAcpAgent({ sessions, identity: { name: "sakuracode", title: "Sakura Code", version: options.version }, log: options.log.child("agent") });
|
|
20
|
+
const connection = connect(agent, options.streams, options.log.child("connection"));
|
|
21
|
+
options.log.info("serving", { version: options.version, permissions: options.permissions });
|
|
22
|
+
await Promise.race([connection.closed, options.stop ?? new Promise(() => undefined)]);
|
|
23
|
+
await sessions.closeAll();
|
|
24
|
+
options.log.info("stopped");
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The real process: stdin and stdout carry the protocol and nothing else. Anything the program or a library prints with
|
|
29
|
+
* `console` goes to stderr instead, so a stray line can never corrupt the stream. The first SIGINT or SIGTERM ends the run
|
|
30
|
+
* cleanly; a second one exits at once.
|
|
31
|
+
*/
|
|
32
|
+
export async function serveProcess(io, options) {
|
|
33
|
+
const writeLog = (line) => void io.stderr.write(line);
|
|
34
|
+
for (const name of ["log", "info", "debug", "warn", "error"])
|
|
35
|
+
console[name] = (...args) => void io.stderr.write(`${args.map(String).join(" ")}\n`);
|
|
36
|
+
const log = createLogger(writeLog, logLevelFromEnv(io.env.SAKURACODE_ACP_LOG));
|
|
37
|
+
let stop;
|
|
38
|
+
const stopped = new Promise((resolve) => (stop = resolve));
|
|
39
|
+
let signals = 0;
|
|
40
|
+
const onSignal = () => {
|
|
41
|
+
signals += 1;
|
|
42
|
+
if (signals > 1)
|
|
43
|
+
process.exit(1);
|
|
44
|
+
stop();
|
|
45
|
+
};
|
|
46
|
+
process.on("SIGINT", onSignal);
|
|
47
|
+
process.on("SIGTERM", onSignal);
|
|
48
|
+
try {
|
|
49
|
+
const streams = {
|
|
50
|
+
input: Readable.toWeb(io.stdin),
|
|
51
|
+
output: Writable.toWeb(io.stdout),
|
|
52
|
+
};
|
|
53
|
+
return await runAcp({ ...options, env: io.env, streams, log, stop: stopped });
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
process.removeListener("SIGINT", onSignal);
|
|
57
|
+
process.removeListener("SIGTERM", onSignal);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { providerFor } from "./auth.js";
|
|
2
|
+
import { titleFromPrompt } from "../session-store.js";
|
|
3
|
+
import { safeErrorText } from "./stop-reasons.js";
|
|
4
|
+
import { AcpError, ErrorCode } from "./types.js";
|
|
5
|
+
export function createSessions(deps) {
|
|
6
|
+
const entries = new Map();
|
|
7
|
+
const find = (sessionId) => {
|
|
8
|
+
const entry = entries.get(sessionId);
|
|
9
|
+
if (!entry)
|
|
10
|
+
throw new AcpError(ErrorCode.sessionNotFound, `Unknown session ${sessionId}`, { sessionId });
|
|
11
|
+
return entry;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
async create(cwd) {
|
|
15
|
+
const provider = deps.clients ? "scripted" : providerFor(deps.settings, deps.env);
|
|
16
|
+
const meta = await deps.store.create({ cwd, provider, model: deps.settings.model ?? "" });
|
|
17
|
+
const session = await deps.open({
|
|
18
|
+
settings: deps.settings,
|
|
19
|
+
provider,
|
|
20
|
+
model: deps.settings.model,
|
|
21
|
+
sessionId: meta.id,
|
|
22
|
+
sessionFile: deps.store.sessionFile(meta.id),
|
|
23
|
+
cwd,
|
|
24
|
+
permissions: deps.permissions,
|
|
25
|
+
redaction: deps.settings.redaction,
|
|
26
|
+
thinking: deps.settings.thinking,
|
|
27
|
+
maxTokens: deps.settings.maxTokens,
|
|
28
|
+
env: deps.env,
|
|
29
|
+
onRetry: (message) => deps.log.warn(`provider retry: ${safeErrorText(message)}`, { session: meta.id }),
|
|
30
|
+
...(deps.clients ? { clients: deps.clients } : {}),
|
|
31
|
+
});
|
|
32
|
+
await deps.store.update(meta.id, { provider, model: session.model });
|
|
33
|
+
entries.set(meta.id, { session, titled: false, running: undefined });
|
|
34
|
+
deps.log.info("session created", { session: meta.id, provider, permissions: deps.permissions });
|
|
35
|
+
return meta.id;
|
|
36
|
+
},
|
|
37
|
+
has: (sessionId) => entries.has(sessionId),
|
|
38
|
+
async run(sessionId, input, onEvent) {
|
|
39
|
+
const entry = find(sessionId);
|
|
40
|
+
// checked and set before the first await: two prompts cannot both pass
|
|
41
|
+
if (entry.running)
|
|
42
|
+
throw new AcpError(ErrorCode.busy, "A prompt is already running in this session. Wait for it to end or cancel it.", { sessionId });
|
|
43
|
+
const handle = entry.session.agent.run(input);
|
|
44
|
+
const running = {
|
|
45
|
+
cancelled: false,
|
|
46
|
+
cancel() {
|
|
47
|
+
running.cancelled = true;
|
|
48
|
+
handle.cancel();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
entry.running = running;
|
|
52
|
+
try {
|
|
53
|
+
if (!entry.titled) {
|
|
54
|
+
entry.titled = true;
|
|
55
|
+
await deps.store.update(sessionId, { title: titleFromPrompt(input) });
|
|
56
|
+
}
|
|
57
|
+
for await (const event of handle.events)
|
|
58
|
+
await onEvent(event);
|
|
59
|
+
return { result: await handle.result, cancelled: running.cancelled };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
handle.cancel();
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
entry.running = undefined;
|
|
67
|
+
await deps.store.update(sessionId, {}).catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
cancel(sessionId) {
|
|
71
|
+
entries.get(sessionId)?.running?.cancel();
|
|
72
|
+
},
|
|
73
|
+
async closeAll() {
|
|
74
|
+
for (const entry of entries.values())
|
|
75
|
+
entry.running?.cancel();
|
|
76
|
+
const all = [...entries.values()];
|
|
77
|
+
entries.clear();
|
|
78
|
+
await Promise.allSettled(all.map((entry) => entry.session.close()));
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AcpError, ErrorCode } from "./types.js";
|
|
2
|
+
const PAUSE_NOTICES = {
|
|
3
|
+
approval: "The run was paused because too many actions were refused by the review. Send a message to continue.",
|
|
4
|
+
quota: "The run was paused: the provider's quota is used up.",
|
|
5
|
+
budget: "The run was paused: its budget is used up.",
|
|
6
|
+
input: "The run was paused and waits for input.",
|
|
7
|
+
blocked: "The run was paused because the goal is blocked.",
|
|
8
|
+
};
|
|
9
|
+
/** Cuts a provider error to something a client may show: one short line, with anything that looks like a credential removed. */
|
|
10
|
+
export function safeErrorText(text) {
|
|
11
|
+
const cleaned = text
|
|
12
|
+
.replace(/(authorization|x-api-key|api[-_]?key)\s*[:=]\s*(?:(?:bearer|basic)\s+)?\S+/gi, "$1: [removed]")
|
|
13
|
+
.replace(/\bBearer\s+[\w.~+/=-]+/gi, "Bearer [removed]")
|
|
14
|
+
.replace(/\bsk-[\w-]{8,}/g, "[removed]")
|
|
15
|
+
.replace(/\s+/g, " ")
|
|
16
|
+
.trim();
|
|
17
|
+
return cleaned.length > 500 ? `${cleaned.slice(0, 500)}…` : cleaned;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `done` ends the turn; a cancel the client asked for is always `cancelled` (an abort that surfaces as an error is still a cancel);
|
|
21
|
+
* a pause is a refusal with a note; a failed run is a JSON-RPC error.
|
|
22
|
+
*/
|
|
23
|
+
export function outcomeOf(result, cancelRequested) {
|
|
24
|
+
if (cancelRequested || result.reason === "cancelled")
|
|
25
|
+
return { result: { stopReason: "cancelled" } };
|
|
26
|
+
if (result.reason === "done")
|
|
27
|
+
return { result: { stopReason: "end_turn" } };
|
|
28
|
+
if (result.reason === "paused")
|
|
29
|
+
return { result: { stopReason: "refusal" }, notice: PAUSE_NOTICES[result.pause ?? ""] ?? "The run was paused." };
|
|
30
|
+
throw new AcpError(ErrorCode.internalError, `The run failed: ${safeErrorText(result.error ?? "unknown error")}`);
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The slice of the ACP wire format the agent uses, written as plain types. Nothing here imports the SDK: the pure mappers
|
|
2
|
+
// depend on these types only, and `connection.ts` is the one place where they meet the SDK's own (the compiler checks that
|
|
3
|
+
// they fit).
|
|
4
|
+
/** The JSON-RPC error codes the agent uses (the standard ones, plus two in the server-defined range). */
|
|
5
|
+
export const ErrorCode = {
|
|
6
|
+
invalidParams: -32602,
|
|
7
|
+
internalError: -32603,
|
|
8
|
+
authRequired: -32000,
|
|
9
|
+
busy: -32001,
|
|
10
|
+
sessionNotFound: -32002,
|
|
11
|
+
};
|
|
12
|
+
/** An error with a code the client can act on. The connection turns it into a JSON-RPC error; anything else becomes a plain internal error. */
|
|
13
|
+
export class AcpError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
data;
|
|
16
|
+
constructor(code, message, data) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "AcpError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.data = data;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/code/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
import { ConfigError, configDirectory, readConfigFile, resolveSettings } from "./config.js";
|
|
9
|
+
import { serveProcess } from "./acp/serve.js";
|
|
9
10
|
import { runExec } from "./exec-mode.js";
|
|
10
11
|
import { createFauxClient } from "./framework-testing.js";
|
|
11
12
|
import { parseInvocation } from "./invocation.js";
|
|
@@ -25,6 +26,7 @@ Usage:
|
|
|
25
26
|
sakuracode fork <id|--last> copy a session into a new one and continue there
|
|
26
27
|
sakuracode review [--base BRANCH | --commit SHA] [focus] review changes, then print the review
|
|
27
28
|
sakuracode sessions list sessions
|
|
29
|
+
sakuracode acp serve the Agent Client Protocol on stdin/stdout (for T3 Code and other ACP clients)
|
|
28
30
|
sakuracode providers list providers and whether their key is set
|
|
29
31
|
|
|
30
32
|
Options:
|
|
@@ -168,6 +170,12 @@ async function run(argv, io) {
|
|
|
168
170
|
return listProvidersCommand(settings, io);
|
|
169
171
|
if (invocation.mode === "sessions")
|
|
170
172
|
return listSessionsCommand(store, io);
|
|
173
|
+
if (invocation.mode === "acp") {
|
|
174
|
+
// stdout belongs to the protocol. Review mode: the command line, else the config, but never full-access unless it was asked for on the command line.
|
|
175
|
+
const asked = flagLayer(values).permissions;
|
|
176
|
+
const permissions = asked ?? (settings.permissions === "full-access" ? "auto" : settings.permissions);
|
|
177
|
+
return serveProcess({ stdin: io.stdin, stdout: io.stdout, stderr: io.stderr, env: io.env }, { settings, store, version: VERSION, permissions, ...(clients ? { clients } : {}) });
|
|
178
|
+
}
|
|
171
179
|
if (invocation.mode === "exec") {
|
|
172
180
|
let prompt = invocation.prompt;
|
|
173
181
|
if (prompt === "-" || (prompt === "" && !io.stdin.isTTY))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function messageCharacters(message) {
|
|
2
|
+
let characters = 0;
|
|
3
|
+
for (const part of message.content) {
|
|
4
|
+
if (part.type === "text")
|
|
5
|
+
characters += part.text.length;
|
|
6
|
+
else if (part.type === "tool_call")
|
|
7
|
+
characters += part.name.length + JSON.stringify(part.args ?? "").length;
|
|
8
|
+
else if (part.type === "image")
|
|
9
|
+
characters += 4000;
|
|
10
|
+
else
|
|
11
|
+
characters += JSON.stringify(part.data ?? "").length;
|
|
12
|
+
}
|
|
13
|
+
return characters;
|
|
14
|
+
}
|
|
15
|
+
/** Tokens of the log from the compaction on: the summary, the kept messages and everything after. */
|
|
16
|
+
export function estimateAfterCompaction(branch) {
|
|
17
|
+
const compaction = branch.findLastIndex((entry) => entry.type === "compaction");
|
|
18
|
+
const entry = branch[compaction];
|
|
19
|
+
if (!entry || entry.type !== "compaction")
|
|
20
|
+
return undefined;
|
|
21
|
+
const kept = branch.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
|
|
22
|
+
const from = kept === -1 ? compaction + 1 : kept;
|
|
23
|
+
let characters = entry.summary.length;
|
|
24
|
+
for (let index = from; index < branch.length; index++) {
|
|
25
|
+
const candidate = branch[index];
|
|
26
|
+
if (candidate?.type === "message")
|
|
27
|
+
characters += messageCharacters(candidate.message);
|
|
28
|
+
else if (candidate?.type === "custom_message")
|
|
29
|
+
characters += candidate.content.reduce((sum, part) => sum + (part.type === "text" ? part.text.length : 0), 0);
|
|
30
|
+
}
|
|
31
|
+
return Math.ceil(characters / 4);
|
|
32
|
+
}
|
|
33
|
+
export async function measureContext(source) {
|
|
34
|
+
const status = await source.context();
|
|
35
|
+
const branch = await source.log.branch();
|
|
36
|
+
const compaction = branch.findLastIndex((entry) => entry.type === "compaction");
|
|
37
|
+
if (compaction === -1)
|
|
38
|
+
return { tokens: status.tokens, window: status.window, estimated: false };
|
|
39
|
+
const replied = branch.slice(compaction + 1).some((entry) => entry.type === "message" && entry.message.role === "assistant" && entry.message.usage !== undefined);
|
|
40
|
+
if (replied)
|
|
41
|
+
return { tokens: status.tokens, window: status.window, estimated: false };
|
|
42
|
+
const tokens = estimateAfterCompaction(branch) ?? status.tokens;
|
|
43
|
+
return { tokens, window: status.window, estimated: true };
|
|
44
|
+
}
|
|
@@ -8,6 +8,8 @@ export function parseInvocation(positionals, flags) {
|
|
|
8
8
|
case "providers":
|
|
9
9
|
case "sessions":
|
|
10
10
|
return command === "providers" ? { mode: "providers" } : { mode: "sessions" };
|
|
11
|
+
case "acp":
|
|
12
|
+
return { mode: "acp" };
|
|
11
13
|
case "exec": {
|
|
12
14
|
if (rest[0] === "resume") {
|
|
13
15
|
const reference = last ?? rest[1];
|
|
@@ -7,6 +7,15 @@ export const PERMISSION_PRESETS = {
|
|
|
7
7
|
strict: { name: "strict", summary: "reviewed; three refusals in a row pause the run", review: true, tools: undefined, breakerConsecutive: 3 },
|
|
8
8
|
"full-access": { name: "full-access", summary: "nothing is reviewed", review: false, tools: undefined, breakerConsecutive: undefined },
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* What Shift+Tab switches to: auto, then strict, then read-only, then auto again. `full-access` is never reached by the key
|
|
12
|
+
* (it needs the explicit `/permissions full-access`); from it the key goes back to auto.
|
|
13
|
+
*/
|
|
14
|
+
export function nextPermission(current) {
|
|
15
|
+
const cycle = ["auto", "strict", "read-only"];
|
|
16
|
+
const index = cycle.indexOf(current);
|
|
17
|
+
return index === -1 ? "auto" : cycle[(index + 1) % cycle.length];
|
|
18
|
+
}
|
|
10
19
|
export function isPermissionName(value) {
|
|
11
20
|
return PERMISSION_NAMES.includes(value);
|
|
12
21
|
}
|
package/dist/code/src/reports.js
CHANGED
|
@@ -39,7 +39,7 @@ export function statusReport(input) {
|
|
|
39
39
|
["Model", `${input.model} via ${input.provider}${input.thinking ? `, thinking ${input.thinking}` : ""}`],
|
|
40
40
|
["Permissions", `${input.permissions}: ${PERMISSION_PRESETS[input.permissions].summary}`],
|
|
41
41
|
["Redaction", input.redaction ? "on" : "off"],
|
|
42
|
-
["Context", `${number(input.context.tokens)} of ${number(input.context.window)} tokens used, ${free}% left${input.context.compactAt ? `, compacts at ${number(input.context.compactAt)}` : ""}`],
|
|
42
|
+
["Context", `${input.contextEstimated ? "about " : ""}${number(input.context.tokens)} of ${number(input.context.window)} tokens used${input.contextEstimated ? " (estimate until the next reply)" : ""}, ${input.contextEstimated ? "~" : ""}${free}% left${input.context.compactAt ? `, compacts at ${number(input.context.compactAt)}` : ""}`],
|
|
43
43
|
...usageRows(input),
|
|
44
44
|
["Goal", input.goal ? `${input.goal.status}: ${input.goal.objective}` : "none"],
|
|
45
45
|
["Idle compaction", input.idleCompaction],
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// through the Transcript (what to show) and the Surface (what only a screen can do).
|
|
4
4
|
import { openAgentSession } from "./agent-session.js";
|
|
5
5
|
import { expandMentions } from "./mentions.js";
|
|
6
|
-
import { isPermissionName } from "./permissions.js";
|
|
6
|
+
import { isPermissionName, nextPermission } from "./permissions.js";
|
|
7
7
|
import { allProviders, chooseProvider, listProviders } from "./providers.js";
|
|
8
8
|
import { mcpReport, medianReplyHitRate, modelsReport, permissionsReport, statusReport } from "./reports.js";
|
|
9
9
|
import { createRunDriver } from "./run-driver.js";
|
|
10
10
|
import { sessionLabel, titleFromPrompt } from "./session-store.js";
|
|
11
11
|
import { runShell } from "./shell.js";
|
|
12
12
|
import { findCommand, helpMarkdown, parseSlash } from "./slash-commands.js";
|
|
13
|
+
import { measureContext } from "./context-estimate.js";
|
|
13
14
|
import { contextLeftPercent } from "./status-line.js";
|
|
14
15
|
import { excerpt, replayLog } from "./transcript.js";
|
|
15
16
|
import { imageMediaType } from "./images.js";
|
|
@@ -26,6 +27,13 @@ export function createController(deps) {
|
|
|
26
27
|
let session;
|
|
27
28
|
let meta;
|
|
28
29
|
let contextPercent;
|
|
30
|
+
// a mode asked for during a run; the agent is not reopened while it works
|
|
31
|
+
let pendingPermissions;
|
|
32
|
+
let contextEstimated = false;
|
|
33
|
+
let contextEstimatedShown = false;
|
|
34
|
+
let contextWindow = 0;
|
|
35
|
+
// a newer measurement wins over an older one that finishes later
|
|
36
|
+
let contextVersion = 0;
|
|
29
37
|
const firstImages = [...(deps.images ?? [])];
|
|
30
38
|
let choice = {
|
|
31
39
|
provider: deps.clients ? "scripted" : chooseProvider(deps.settings, deps.env),
|
|
@@ -35,6 +43,18 @@ export function createController(deps) {
|
|
|
35
43
|
thinking: deps.settings.thinking,
|
|
36
44
|
};
|
|
37
45
|
driver.onChange(() => surface.changed());
|
|
46
|
+
// The percentage follows the run: each finished reply carries the provider-reported usage, and a compaction changes the size at once.
|
|
47
|
+
driver.onEvent((event) => {
|
|
48
|
+
if (event.type === "message_end" && event.message.role === "assistant" && event.message.usage && contextWindow > 0) {
|
|
49
|
+
const { input, output, cacheRead, cacheWrite } = event.message.usage;
|
|
50
|
+
contextVersion++;
|
|
51
|
+
contextEstimated = false;
|
|
52
|
+
setContextPercent(contextLeftPercent(contextWindow - (input + output + cacheRead + cacheWrite), contextWindow));
|
|
53
|
+
}
|
|
54
|
+
else if (event.type === "compaction") {
|
|
55
|
+
void refreshContext().catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
38
58
|
const notice = (level, text) => transcript.add({ kind: "notice", level, text });
|
|
39
59
|
const requireSession = () => {
|
|
40
60
|
if (!session)
|
|
@@ -105,11 +125,29 @@ export function createController(deps) {
|
|
|
105
125
|
replayLog(transcript, entries);
|
|
106
126
|
notice("info", `Resumed ${sessionLabel(meta ?? target)} (${target.id.slice(0, 8)}).`);
|
|
107
127
|
}
|
|
128
|
+
async function statusContext(active) {
|
|
129
|
+
const status = await active.agent.context();
|
|
130
|
+
const measured = await measureContext(active.agent);
|
|
131
|
+
return { context: { ...status, tokens: measured.tokens, free: Math.max(0, measured.window - measured.tokens) }, contextEstimated: measured.estimated };
|
|
132
|
+
}
|
|
133
|
+
function setContextPercent(next) {
|
|
134
|
+
const flagChanged = contextEstimatedShown !== contextEstimated;
|
|
135
|
+
contextEstimatedShown = contextEstimated;
|
|
136
|
+
if (next === contextPercent && !flagChanged)
|
|
137
|
+
return;
|
|
138
|
+
contextPercent = next;
|
|
139
|
+
surface.changed();
|
|
140
|
+
}
|
|
108
141
|
async function refreshContext() {
|
|
109
142
|
if (!session)
|
|
110
143
|
return;
|
|
111
|
-
const
|
|
112
|
-
|
|
144
|
+
const version = ++contextVersion;
|
|
145
|
+
const measured = await measureContext(session.agent);
|
|
146
|
+
if (version !== contextVersion)
|
|
147
|
+
return;
|
|
148
|
+
contextWindow = measured.window;
|
|
149
|
+
contextEstimated = measured.estimated;
|
|
150
|
+
setContextPercent(contextLeftPercent(measured.window - measured.tokens, measured.window));
|
|
113
151
|
}
|
|
114
152
|
// ---------- running ----------
|
|
115
153
|
async function runInput(input, options) {
|
|
@@ -127,8 +165,18 @@ export function createController(deps) {
|
|
|
127
165
|
}
|
|
128
166
|
if (meta)
|
|
129
167
|
meta = await store.update(meta.id, {});
|
|
168
|
+
await applyPendingPermissions();
|
|
130
169
|
return outcome.result;
|
|
131
170
|
}
|
|
171
|
+
async function applyPendingPermissions() {
|
|
172
|
+
const wanted = pendingPermissions;
|
|
173
|
+
pendingPermissions = undefined;
|
|
174
|
+
if (wanted === undefined || wanted === choice.permissions)
|
|
175
|
+
return surface.changed();
|
|
176
|
+
if (await reopen({ permissions: wanted }))
|
|
177
|
+
notice("info", `Permissions: ${wanted} (Shift+Tab to change)`);
|
|
178
|
+
surface.changed();
|
|
179
|
+
}
|
|
132
180
|
function reportEnd(result) {
|
|
133
181
|
if (result.reason === "error")
|
|
134
182
|
notice("error", `The run failed: ${result.error ?? "unknown error"}`);
|
|
@@ -179,7 +227,7 @@ export function createController(deps) {
|
|
|
179
227
|
thinking: active.thinking,
|
|
180
228
|
permissions: active.permissions,
|
|
181
229
|
redaction: active.redaction,
|
|
182
|
-
|
|
230
|
+
...(await statusContext(active)),
|
|
183
231
|
usage: await active.agent.usage(),
|
|
184
232
|
medianHitRate: medianReplyHitRate(await active.agent.log.branch()),
|
|
185
233
|
goal: await active.agent.goal(),
|
|
@@ -201,6 +249,7 @@ export function createController(deps) {
|
|
|
201
249
|
return host.print(permissionsReport(choice.permissions, choice.redaction));
|
|
202
250
|
if (!isPermissionName(name))
|
|
203
251
|
return notice("error", `Unknown preset "${name}". Use auto, read-only, strict or full-access.`);
|
|
252
|
+
pendingPermissions = undefined;
|
|
204
253
|
if (await reopen({ permissions: name }))
|
|
205
254
|
notice("info", `Permissions: ${name}.`);
|
|
206
255
|
},
|
|
@@ -379,10 +428,22 @@ export function createController(deps) {
|
|
|
379
428
|
transcript.muteCompaction(false);
|
|
380
429
|
stop();
|
|
381
430
|
}
|
|
382
|
-
const after = (await requireSession().agent
|
|
431
|
+
const after = (await measureContext(requireSession().agent)).tokens;
|
|
383
432
|
notice("info", `compacted after ${idleMinutes} min idle, ${thousands(before)} to ${thousands(after)} tokens`);
|
|
384
433
|
return true;
|
|
385
434
|
},
|
|
435
|
+
async cyclePermissions() {
|
|
436
|
+
const next = nextPermission(pendingPermissions ?? choice.permissions);
|
|
437
|
+
if (driver.working || launching) {
|
|
438
|
+
pendingPermissions = next === choice.permissions ? undefined : next;
|
|
439
|
+
return surface.changed();
|
|
440
|
+
}
|
|
441
|
+
if (!session)
|
|
442
|
+
return;
|
|
443
|
+
if (await reopen({ permissions: next }))
|
|
444
|
+
notice("info", `Permissions: ${next} (Shift+Tab to change)`);
|
|
445
|
+
surface.changed();
|
|
446
|
+
},
|
|
386
447
|
statusInfo: () => ({
|
|
387
448
|
provider: choice.provider,
|
|
388
449
|
model: choice.model ?? "",
|
|
@@ -390,7 +451,9 @@ export function createController(deps) {
|
|
|
390
451
|
cwd: deps.cwd,
|
|
391
452
|
home: deps.home,
|
|
392
453
|
contextLeftPercent: contextPercent,
|
|
454
|
+
contextEstimated: contextEstimatedShown,
|
|
393
455
|
permissions: choice.permissions,
|
|
456
|
+
pendingPermissions,
|
|
394
457
|
redaction: choice.redaction,
|
|
395
458
|
working: driver.working,
|
|
396
459
|
queued: driver.pending().length,
|
|
@@ -79,5 +79,5 @@ export function complete(input) {
|
|
|
79
79
|
}
|
|
80
80
|
export function helpMarkdown() {
|
|
81
81
|
const rows = COMMANDS.map((command) => `| \`${command.usage}\` | ${command.summary} |`);
|
|
82
|
-
return ["| Command | What it does |", "|---|---|", ...rows, "", "While the agent works: **Enter** steers it, **Tab** queues a message, **Esc** cancels. `!cmd` runs a shell command yourself."].join("\n");
|
|
82
|
+
return ["| Command | What it does |", "|---|---|", ...rows, "", "While the agent works: **Enter** steers it, **Tab** queues a message, **Esc** cancels. **Shift+Tab** cycles the review mode (auto, strict, read-only; `/permissions full-access` is explicit). `!cmd` runs a shell command yourself."].join("\n");
|
|
83
83
|
}
|
|
@@ -11,12 +11,21 @@ export function shortenPath(path, home, width) {
|
|
|
11
11
|
export function contextLeftPercent(free, window) {
|
|
12
12
|
return window <= 0 ? 0 : Math.max(0, Math.min(100, Math.round((free / window) * 100)));
|
|
13
13
|
}
|
|
14
|
+
function contextText(info, pal) {
|
|
15
|
+
const text = `${info.contextEstimated ? "~" : ""}${info.contextLeftPercent}%`;
|
|
16
|
+
return (info.contextLeftPercent ?? 100) < 15 ? pal.red(text) : text;
|
|
17
|
+
}
|
|
18
|
+
function permissionsPiece(info, pal) {
|
|
19
|
+
const mode = info.pendingPermissions ?? info.permissions;
|
|
20
|
+
const paint = mode === "auto" ? pal.dim : mode === "full-access" ? pal.red : pal.yellow;
|
|
21
|
+
return info.pendingPermissions === undefined ? paint(mode) : paint(mode) + pal.dim(" (next turn)");
|
|
22
|
+
}
|
|
14
23
|
export function formatStatusLine(info, width, color) {
|
|
15
24
|
const pal = createPalette(color);
|
|
16
25
|
const pieces = [
|
|
17
26
|
pal.bold(info.model) + (info.thinking ? pal.dim(` ${info.thinking}`) : ""),
|
|
18
|
-
info.contextLeftPercent === undefined ? "" : `${info
|
|
19
|
-
info
|
|
27
|
+
info.contextLeftPercent === undefined ? "" : `${contextText(info, pal)} context left`,
|
|
28
|
+
permissionsPiece(info, pal),
|
|
20
29
|
info.redaction ? pal.magenta("redaction") : "",
|
|
21
30
|
info.queued > 0 ? pal.cyan(`${info.queued} queued`) : "",
|
|
22
31
|
shortenPath(info.cwd, info.home, Math.max(12, Math.floor(width / 3))),
|
package/dist/code/src/tui/app.js
CHANGED
|
@@ -163,6 +163,12 @@ export async function runTui(options) {
|
|
|
163
163
|
// a dropped or pasted image file becomes an @mention, which attaches it
|
|
164
164
|
const dropped = key.name === "paste" ? pastedImagePath(key.text ?? "", options.controller.cwd) : undefined;
|
|
165
165
|
const action = editor.handle(dropped ? { name: "paste", text: `@${dropped} ` } : key);
|
|
166
|
+
if (action === "shift-tab") {
|
|
167
|
+
// the command popup uses Tab; Shift+Tab never changes the mode while it is open
|
|
168
|
+
if (commands.length === 0)
|
|
169
|
+
void controller.cyclePermissions().catch((error) => showHint(error.message));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
166
172
|
if (action !== "none")
|
|
167
173
|
popupIndex = action === "tab" ? popupIndex : 0;
|
|
168
174
|
if (action === "submit")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sakuracode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "sakuracode: an open-source terminal coding agent (command-line AI coding assistant) with automatic review of every tool call. Works with DeepSeek, OpenCode Go and any OpenAI-compatible endpoint.",
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"node": ">=26"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"
|
|
32
|
+
"@agentclientprotocol/sdk": "2.4.0",
|
|
33
|
+
"quickjs-emscripten": "^0.32.0",
|
|
34
|
+
"zod": "4.6.7"
|
|
33
35
|
},
|
|
34
36
|
"publishConfig": {
|
|
35
37
|
"access": "public",
|