Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
aa5103d
fix(acp): report model context window
yan-ad Aug 6, 2026
912c2cc
fix(config): align provider filter types
yan-ad Aug 6, 2026
0ac4467
feat(acp): stream full tool output
yan-ad Sep 2, 2026
e44c50d
fix(acp): preserve permission tool call IDs
yan-ad Sep 2, 2026
8a2e6d0
feat(acp): stream structured tool content
yan-ad Sep 2, 2026
0416dc3
feat(acp): preserve provider stop reasons
yan-ad Sep 2, 2026
a44d286
feat(acp): stream apply patch diffs
yan-ad Sep 2, 2026
62ece17
feat(acp): preserve stable message IDs
yan-ad Sep 2, 2026
39818cb
feat(acp): add compact command
yan-ad Sep 2, 2026
c9a234a
feat(acp): forward interactive questions
yan-ad Sep 2, 2026
9d22be6
feat(acp): embed client terminals
yan-ad Sep 2, 2026
1f404d7
feat(acp): retain authoritative usage
yan-ad Sep 2, 2026
0c25dd2
feat(acp): persist prompt attachments
yan-ad Sep 2, 2026
dcb56a3
feat(acp): support audio prompts
yan-ad Sep 2, 2026
5a1af7a
feat(acp): show permission edit metadata
yan-ad Sep 2, 2026
6e2ed01
test(acp): cover stdio subprocess lifecycle
yan-ad Sep 2, 2026
2680c45
feat(acp): preserve structured MCP results
yan-ad Sep 2, 2026
202402f
feat(acp): complete capability matrix
yan-ad Sep 2, 2026
8de5129
Merge remote-tracking branch 'upstream/main' into feat/acp-capability…
yan-ad Sep 4, 2026
0062927
Merge remote-tracking branch 'origin/fix/acp-context-window' into fea…
yan-ad Sep 7, 2026
4dec059
fix(acp): wire slash commands end to end
yan-ad Sep 7, 2026
310150a
fix(tui): expose chat-only commands
yan-ad Sep 7, 2026
b26111d
feat(compaction): apply OpenCode settings
yan-ad Sep 8, 2026
6edd9c9
fix(acp): stream live context usage
yan-ad Sep 10, 2026
ba2cc1d
Merge upstream main into ACP capability branch
yan-ad Sep 10, 2026
6f4e8df
style(llm): format merged imports
yan-ad Sep 10, 2026
6b4f1f0
fix(acp): keep transport open on session errors
yan-ad Sep 15, 2026
3c27e4b
fix(acp): remember permission grants per session
yan-ad Sep 15, 2026
c6f9fb0
fix(tools): sanitize unsupported schema regex
yan-ad Sep 15, 2026
372cbdc
fix(acp): harden lifecycle and prepare registry authentication
Blankeos Oct 3, 2026
fc4786b
Merge branch 'main' into feat/acp-capability-gaps
Blankeos Oct 3, 2026
153ee1d
test(acp): update model fixtures for integrated context metadata
Blankeos Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ json5 = "0.4"
schemars = "1.0"
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
agent-client-protocol = { version = "=2.0.0", features = ["unstable_session_fork"] }
agent-client-protocol = { version = "=2.0.0", features = ["unstable_elicitation", "unstable_session_fork", "unstable_auth_methods"] }
clap_complete = "4.5"
ignore = "0.4"
copypasta = "0.10"
Expand Down
67 changes: 49 additions & 18 deletions _docs/acp.mdx

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions _docs/config/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,26 @@ Use `tui.compactMode` to explicitly control compact mode and its sticky message

`compactMode` takes priority over the preference saved by `/compact-mode`. Without a config value, crabcode restores the last `/compact-mode` choice; new installations default to enabled. `compact_mode` is accepted as an alias.

## Context compaction

Crabcode applies the OpenCode-compatible `compaction` settings to persisted TUI and ACP sessions. Request-time pruning is also inherited by print mode and subagents:

```jsonc title="opencode.jsonc"
{
"compaction": {
"auto": true,
"prune": false,
"reserved": 10000
}
}
```

- `auto` defaults to `true`. TUI and ACP sessions check the effective model context window before a request and after a completed turn, then summarize persisted context when the usable window is reached.
- `prune` defaults to `false`. When enabled, stale tool outputs may be shortened or cleared only in the provider-facing request; persisted session history and rendered tool cards remain intact.
- `reserved` is an optional token buffer subtracted from the context window. Without an explicit value, Crabcode reserves the smaller of the model output limit and 20,000 tokens.

Set `compaction` to `false` to disable automatic compaction and request-time tool-output pruning. Manual `/compact` remains available.

## Permissions

crabcode reads the OpenCode-compatible `permission` field. Rules resolve to `allow`, `ask`, or `deny`, with later matching rules taking precedence.
Expand Down
2 changes: 1 addition & 1 deletion _docs/config/opencode-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Blank cells mean that runtime behavior is not supported by that project today. `
| `permission` | ✅ | ✅ | Global tool permission rules are enforced during AI SDK tool execution. |
| `instructions` | ✅ | ✅ | Loads the listed files relative to the project root (or from absolute and `~/` paths) and appends their contents to the system prompt. Unreadable files produce a config warning. |
| `tools` | ✅ | ✅ | Global tool enable/disable map. Disabled tools are removed before agent-specific tool policies are applied. |
| `compaction` | ✅ | | Accepted and parsed (`false` or `{ "auto", "prune" }`), but compaction behavior is not applied yet. |
| `compaction` | ✅ | ✅ | Applies `auto` and `reserved` to persisted TUI/ACP sessions; `prune` also applies to print and subagent requests. Manual `/compact` remains available when auto-compaction is disabled. |
| `watcher` | ✅ | ✅ | Controls file suggestions in command autocomplete. Use `false` to disable them or `{ "ignore": ["path"] }` to exclude paths. |
| `formatter` | ✅ | | Accepted and parsed by file extension, but configured formatter commands are not run yet. |
| `disabled_providers` | ✅ | ✅ | Removes the listed provider IDs from model discovery and selection. |
Expand Down
2 changes: 1 addition & 1 deletion _docs/prompt-caching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Grok Build’s sampler sends that header so cli-chat-proxy emits `response.doom_

Doom-loop recovery matches Grok Build’s sampler, not a client tool-name babysitter. Only confident `tail_repetition:{n}@thinking` from `response.doom_loop_check` resamples: skip that generation’s tools, inject `RECOVERY_REMINDER` into the **next request only** (same `<system_reminder>` user-role item as `ConversationItem::system_reminder` — not a visible user turn). After `DEFAULT_MAX_RETRIES` (2) the abort is disarmed and the turn continues. Repeating reads or shell commands are not a loop — they usually mean earlier tool results were cleared.

Tool-result pruning matches Grok Build `prune_conversation` (`.devrefs/references/xai-org/grok-build/crates/codegen/xai-chat-state/src/actor/request_builder.rs`): **only when estimated tokens exceed 50% of a 500k window** (`should_prune`), last 3 user turns stay intact, older large results soft-trim at 4000/1500/1500, age ≥ 10 hard-clear. Those numbers have not changed in Grok Build; the important gate is not pruning a short session at all.
Tool-result pruning follows the OpenCode `compaction.prune` contract and is disabled by default. When enabled, the latest two user turns and roughly 40,000 tokens of newer tool output stay intact; older candidates are cleared only when doing so reclaims more than 20,000 tokens. `skill` results are always protected. This mutates only the provider-facing request, not persisted history or rendered tool cards.

**Parent-cached aux** (e.g. max-steps text-only summary): keeps parent `prompt_cache_key` + session/conv so the conversation prefix can reuse the main turn's KV cache; assigns a fresh `aux-…` req id.

Expand Down
32 changes: 31 additions & 1 deletion crabcode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@
],
"type": "object"
},
"CompactionConfigFile": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": false,
"properties": {
"auto": {
"default": true,
"description": "Automatically compact when estimated context reaches the usable window.",
"type": "boolean"
},
"prune": {
"default": false,
"description": "Remove stale tool outputs from provider requests while preserving durable history.",
"type": "boolean"
},
"reserved": {
"description": "Token buffer reserved before the context limit for compaction and model output.",
"minimum": 0,
"type": "integer"
}
},
"type": "object"
}
]
},
"ImageOpenWith": {
"anyOf": [
{
Expand Down Expand Up @@ -351,7 +379,9 @@
]
},
"command": true,
"compaction": true,
"compaction": {
"$ref": "#/$defs/CompactionConfigFile"
},
"default_agent": true,
"disabled_providers": true,
"enabled_providers": true,
Expand Down
3 changes: 3 additions & 0 deletions dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ publish-jobs = ["homebrew"]
# cargo-dist validates generated CI files byte-for-byte by default; this allows
# intentional workflow edits such as changelog/release and Homebrew publishing customizations.
allow-dirty = ["ci"]
# Registry clients support gzip tarballs, not cargo-dist's default xz archives.
unix-archive = ".tar.gz"
windows-archive = ".zip"
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
70 changes: 48 additions & 22 deletions npm/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function getPlatformInfo() {
}

const target = platformMap[platform][arch];
const extension = platform === "win32" ? ".zip" : ".tar.xz";
const extension = platform === "win32" ? ".zip" : ".tar.gz";
const binaryName = platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;

return {
Expand All @@ -49,24 +49,49 @@ function getPlatformInfo() {
};
}

function fetchResponse(url, redirects = 0) {
return new Promise((resolve, reject) => {
if (redirects > 5) return reject(new Error("Too many download redirects"));
https.get(url, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
res.resume();
if (!res.headers.location) return reject(new Error("Missing redirect location"));
resolve(fetchResponse(new URL(res.headers.location, url).href, redirects + 1));
} else if (res.statusCode === 200) {
resolve(res);
} else {
res.resume();
const error = new Error(`Failed to download: ${res.statusCode} ${res.statusMessage}`);
error.statusCode = res.statusCode;
reject(error);
}
}).on("error", reject);
});
}

async function downloadArchive(info, dest, download = downloadFile) {
try {
await download(info.url, dest);
return info;
} catch (error) {
// Older releases published xz only. Do not retry transient/network errors.
if (info.extension !== ".tar.gz" || error.statusCode !== 404) throw error;
const legacy = {
...info,
extension: ".tar.xz",
filename: info.filename.replace(/\.tar\.gz$/, ".tar.xz"),
url: info.url.replace(/\.tar\.gz$/, ".tar.xz"),
};
await download(legacy.url, dest);
return legacy;
}
}

async function downloadFile(url, dest) {
console.log(`Downloading ${url}...`);
console.error(`Downloading ${url}...`);

const response = await fetchResponse(url);
const file = fs.createWriteStream(dest);
const response = await new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode === 302 || res.statusCode === 301) {
https.get(res.headers.location, resolve).on("error", reject);
} else if (res.statusCode === 200) {
resolve(res);
} else {
reject(new Error(`Failed to download: ${res.statusCode} ${res.statusMessage}`));
}
})
.on("error", reject);
});

response.pipe(file);
return new Promise((resolve, reject) => {
file.on("finish", () => {
Expand All @@ -81,14 +106,15 @@ async function downloadFile(url, dest) {
}

function extractArchive(archivePath, extractDir, platformInfo) {
console.log("Extracting binary...");
console.error("Extracting binary...");

const cmd =
platformInfo.extension === ".zip"
? `unzip -o "${archivePath}" -d "${extractDir}" 2>/dev/null || powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`
: `tar -xf "${archivePath}" -C "${extractDir}"`;

execSync(cmd, { stdio: "inherit" });
// Extraction diagnostics must never corrupt the ACP stdout transport.
execSync(cmd, { stdio: ["ignore", 2, 2] });
}

function logInstallFailure(error) {
Expand All @@ -102,14 +128,14 @@ function logInstallFailure(error) {

async function install({ exitOnComplete = false } = {}) {
try {
const platformInfo = getPlatformInfo();
let platformInfo = getPlatformInfo();
const binDir = path.join(__dirname, "bin");
const archivePath = path.join(__dirname, platformInfo.filename);
const binaryPath = path.join(binDir, platformInfo.binaryName);

if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true });

await downloadFile(platformInfo.url, archivePath);
platformInfo = await downloadArchive(platformInfo, archivePath);
extractArchive(archivePath, __dirname, platformInfo);

const extractedBinaryPath = path.join(__dirname, platformInfo.binaryName);
Expand All @@ -130,7 +156,7 @@ async function install({ exitOnComplete = false } = {}) {
}

fs.unlinkSync(archivePath);
console.log(`crabcode v${VERSION} installed successfully!`);
console.error(`crabcode v${VERSION} installed successfully!`);

if (exitOnComplete) {
process.exit(0);
Expand All @@ -155,4 +181,4 @@ if (require.main === module) {
install({ exitOnComplete: true });
}

module.exports = { getPlatformInfo, install };
module.exports = { getPlatformInfo, downloadArchive, install };
36 changes: 36 additions & 0 deletions npm/install.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { getPlatformInfo, downloadArchive } = require('./install');

const info = {
extension: '.tar.gz', filename: 'crabcode-test.tar.gz',
url: 'https://example.com/crabcode-test.tar.gz',
};

test('current platform uses registry-compatible archives', () => {
assert.equal(getPlatformInfo().extension, process.platform === 'win32' ? '.zip' : '.tar.gz');
});

test('gzip download succeeds without fallback', async () => {
const urls = [];
assert.equal(await downloadArchive(info, '/unused', async (url) => urls.push(url)), info);
assert.deepEqual(urls, [info.url]);
});

test('older xz assets are retried only after gzip 404', async () => {
const urls = [];
const result = await downloadArchive(info, '/unused', async (url) => {
urls.push(url);
if (urls.length === 1) throw Object.assign(new Error('missing'), { statusCode: 404 });
});
assert.equal(result.extension, '.tar.xz');
assert.deepEqual(urls, [info.url, 'https://example.com/crabcode-test.tar.xz']);
});

test('network failures and Windows zip failures do not fallback', async () => {
for (const [asset, error] of [[info, new Error('network')], [{ ...info, extension: '.zip' }, Object.assign(new Error('missing'), { statusCode: 404 })]]) {
let attempts = 0;
await assert.rejects(downloadArchive(asset, '/unused', async () => { attempts++; throw error; }), error);
assert.equal(attempts, 1);
}
});
40 changes: 40 additions & 0 deletions scripts/verify-acp-registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Provider-free clean-home check of the registry's legacy ACP auth handshake."""
import argparse
import json
import os
import select
import subprocess
import tempfile


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('binary', help='Path to a built crabcode executable')
args = parser.parse_args()
binary = os.path.abspath(args.binary)
with tempfile.TemporaryDirectory(prefix='crabcode-registry-') as home:
env = {key: value for key, value in os.environ.items() if key in ('PATH', 'SystemRoot', 'TMPDIR', 'TEMP', 'TMP')}
env.update(HOME=home, XDG_CONFIG_HOME=home, XDG_STATE_HOME=home)
proc = subprocess.Popen([binary, 'acp', '--cwd', home], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
try:
request = {'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {'protocolVersion': 1, 'clientCapabilities': {'_meta': {'terminal-auth': True}}}}
proc.stdin.write(json.dumps(request) + '\n')
proc.stdin.flush()
if not select.select([proc.stdout], [], [], 30)[0]:
raise RuntimeError('ACP initialize timed out')
response = json.loads(proc.stdout.readline())
methods = response.get('result', {}).get('authMethods', [])
assert response.get('id') == 1, response
assert any(method.get('_meta', {}).get('terminal-auth', {}).get('args') == ['acp', '--login'] for method in methods), methods
proc.stdin.close()
assert proc.wait(timeout=10) == 0
print('ACP clean-home registry auth discovery: passed (no login/provider requests)')
finally:
if proc.poll() is None:
proc.kill()
proc.wait()


if __name__ == '__main__':
main()
Loading
Loading