Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ The bundle generates its modified modules at build time and never rewrites third
|---|---|
| [Changelog](docs/CHANGELOG.md) | Every release, newest first |
| [Non-interactive runs](docs/exec.md) | `dscode exec`, JSON output, resume and effort/model/permission flags |
| [ACP clients](docs/acp.md) | `dscode acp`, custom providers, client approval and protocol limits |
| [Triggers](docs/triggers.md) | Event-driven runs with fresh or persistent sessions: cron, delay jobs, supervised script loops, `/trigger` TUI management, agent tools, the run log |
| [Session bridge](docs/session-bridge.md) | `dscode sessions`, `send`, `read`, `watch` against a live session |
| [Session communication](docs/session-communication.md) | Agent-side messaging, delivery modes, budgets and de-duplication |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ Bundle 在构建阶段生成修改后的模块,不在使用者机器上改第
|---|---|
| [更新日志](docs/CHANGELOG.md) | 每次发布,最新在前 |
| [非交互执行](docs/exec.md) | `dscode exec`、JSON 输出、resume 与 effort/model/permission 参数 |
| [ACP 客户端](docs/acp.md) | `dscode acp`、自定义模型服务、客户端审批和协议限制 |
| [触发器](docs/triggers.md) | 由事件触发运行,可新建或复用会话:cron、延迟任务、托管脚本循环、`/trigger` TUI 管理、agent 工具、运行日志 |
| [会话接入](docs/session-bridge.md) | 用 `dscode sessions`、`send`、`read`、`watch` 接入运行中的会话 |
| [Session 通信](docs/session-communication.md) | Agent 侧消息、投递方式、预算与去重 |
Expand Down
2 changes: 2 additions & 0 deletions bin/dscode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { runDoctorCli } from '../scripts/doctor-cli.mjs';
import { selfUpdate } from '../scripts/self-update.mjs';
import { runExec } from '../scripts/exec.mjs';
import { runTriggerCli } from '../scripts/trigger.mjs';
import { runAcp } from '../scripts/acp.mjs';

const args = process.argv.slice(2);
// Once a session starts the DSH CLI owns `--version`, so DSCODE answers it here instead.
if (args[0] === '--version' || args[0] === '-v') console.log(manifest.version);
else if (args[0] === 'acp') runAcp(args.slice(1)).then(code => { process.exitCode = code; }, error => { console.error(error.message); process.exitCode = 1; });
else {
if (args[0] === 'resume') {
args.shift();
Expand Down
4 changes: 3 additions & 1 deletion docker/e2e.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
# gate cannot cover: a nested Seatbelt profile is refused inside a dscode session,
# so `verify:hub` (install the bundle, boot the installed profile, fail an upgrade,
# roll back) can never run there, and nothing verified the harness on Linux at all.
FROM node:22-bookworm-slim
# The Docker Official Images mirror avoids Docker Hub's anonymous pull quota on
# shared CI runners while retaining the same Node/Debian image.
FROM public.ecr.aws/docker/library/node:22-bookworm-slim

# git: verify:hub initialises a repository and the worktree probe creates its own.
# curl: install.sh needs it on the piped path, and its unit tests run it for real.
Expand Down
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Per-release notes live in [`docs/releases/`](releases/); the entries below summa

<!-- Add upcoming changes under Unreleased. -->

## [Unreleased]

### Added

- `dscode acp` serves standard ACP stdio clients with the DSCODE agent preset and configured providers. It supports native DSH session controls and forwards approval requests to the client. See [ACP clients](acp.md) for setup and inherited protocol limitations.

## [0.7.35] - 2026-10-08

- Promote the verified Desktop package to both `preview` and `latest` so default installations receive this fix instead of 0.7.33. Promotion requires a matching public-install receipt.
Expand Down
31 changes: 31 additions & 0 deletions docs/acp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ACP clients

Run `dscode acp` to expose the DSCODE agent preset through Agent Client Protocol over newline-delimited JSON-RPC on stdin/stdout. The terminal interface is disabled. The ACP client supplies an absolute workspace directory when creating a session; tools run on the computer running DSCODE.

## Connect a client

Configure your client's custom ACP agent with executable `dscode` and arguments `["acp"]`. If the editor cannot find commands installed through nvm, use the absolute path printed by `command -v dscode`. Keep stdin open for the lifetime of the connection.

To select an initial model explicitly, use:

```sh
dscode acp --model custom-your-provider-id/your-model-id
```

Without `--model`, the bridge uses DSCODE's saved default provider and model. Set up credentials and custom services in the normal TUI first with `/login` or `/provider custom`. The stable custom provider ID is recorded in `~/.dscode/providers.yaml`; it is not the service's display name. API keys stay in the existing credential store. Clients supporting ACP configuration options can select an advertised model and reasoning effort after creating a session.

For a source checkout, the equivalent executable is `node` with arguments `["/absolute/path/to/dscode/bin/dscode.mjs", "acp"]`. Source and npm installations use their existing, separate state directories. `--patch FILE` adds a trusted composition overlay and can be repeated. `dscode acp --help` prints usage without starting the runtime or installing a profile.

## Behavior and limits

- New and resumed ACP agents mount the `dscode` preset, including its tools and provider adapters. The bridge reuses DSH's native ACP implementation rather than translating terminal output.
- ACP sessions use the `ask` permission preset. Tool requests requiring approval are sent to the ACP client; the client can allow once, reject, or cancel. Normal sandbox rules still apply. The terminal's automatic model review is not the approval decision maker for these sessions.
- Standard session creation, prompts, cancellation, model configuration, listing, resume, close and MCP attachment are inherited from the pinned DSH bridge. Resume reconnects a session without replaying its transcript.
- The inherited bridge identifies itself as `deepseek-harness-acp`. It does not advertise `session/load`, modes, slash commands, terminal delegation, client filesystem operations or interactive question forms. Clients requiring those features need additional integration; a successful protocol handshake does not establish full editor compatibility.
- Title generation, session-card generation and memory generation are disabled in this transport. Configured providers, tools and other integrations remain available; ACP is not an offline mode.
- Stdout is reserved for protocol frames. First-install progress and launcher diagnostics go to stderr. Trusted custom patches must also respect this rule.
- Closing stdin shuts down the runtime. SIGTERM is forwarded by the launcher. Sessions persist in the same installation's state directory, and a running ACP process holds the normal installation lock against upgrades.

## Verification

`node scripts/verify-acp.mjs` exercises the source CLI with a fixture model and custom API, without paid model requests. Unit tests cover argument validation, preset composition, source and npm launcher routing, cleanup and signal forwarding. Client-specific UI behavior must be verified in the intended editor.
2 changes: 1 addition & 1 deletion packages/launcher/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ import { runEmailClient } from '../../plugins/email/cli.mjs';
const release = JSON.parse(readFileSync(new URL('./release.json', import.meta.url), 'utf8'));
const args = process.argv.slice(2);
if (args[0] === '--version') console.log(release.version);
else if (args[0] === '--help' || args[0] === '-h') console.log(`DSCODE ${release.version}\n\n dscode Start; first launch installs the Hub preset\n dscode install [version] Install an exact preset release\n dscode update [version] Upgrade (default: launcher release)\n dscode history List retained preset revisions\n dscode rollback [revision] Restore a retained revision\n dscode doctor [--local|--preview] Analyze recent runtime logs and session traces\n dscode exec [options] [prompt] Run one prompt headless (dscode exec --help)\n dscode trigger <command> Events, cron and delay jobs (dscode trigger --help)\n dscode email configure FILE Import a Google Desktop OAuth client JSON\n dscode email alias set NAME ADDRESS | list | remove NAME\n dscode email connect Connect Gmail in the browser\n dscode email sync|status Sync or inspect Gmail OAuth\n dscode email imap-sync|imap-status Sync or inspect IMAP (/email then i to set up)\n dscode sessions List running sessions\n dscode send ID [--steer|--defer] TEXT Send or leave a note\n --title TITLE Set the session title when sending\n dscode read ID [--after SEQ] Read an immutable event page\n dscode watch ID [--after SEQ] Subscribe to session events\n dscode mailbox ID Read messages and deferred notes\n dscode watch-mailbox ID Subscribe to mailbox events\n dscode reply ID --reply-to REQUEST TEXT Send a final reply\n dscode cancel ID MESSAGE Cancel a request or unclaimed message\n dscode new-task ID Explicitly reset communication budget while idle\n dscode resume [SESSION_ID] Resume a session (default: latest)\n dscode --continue Continue the previous session\n dscode --resume SESSION_ID Resume a session\n dscode --cwd DIRECTORY Work in a directory\n\nState: $DSCODE_HOME or ~/.local/share/dscode-hub\nProvider credentials: /login [deepseek|openrouter] in the TUI saves to ~/.dscode/credentials.yaml,\nand /provider switches between DeepSeek and OpenRouter.\nExisting DEEPSEEK_API_KEY or OPENROUTER_API_KEY environment variables take precedence.`);
else if (args[0] === '--help' || args[0] === '-h') console.log(`DSCODE ${release.version}\n\n dscode Start; first launch installs the Hub preset\n dscode install [version] Install an exact preset release\n dscode update [version] Upgrade (default: launcher release)\n dscode history List retained preset revisions\n dscode rollback [revision] Restore a retained revision\n dscode doctor [--local|--preview] Analyze recent runtime logs and session traces\n dscode acp [options] Serve ACP clients over stdio\n dscode exec [options] [prompt] Run one prompt headless (dscode exec --help)\n dscode trigger <command> Events, cron and delay jobs (dscode trigger --help)\n dscode email configure FILE Import a Google Desktop OAuth client JSON\n dscode email alias set NAME ADDRESS | list | remove NAME\n dscode email connect Connect Gmail in the browser\n dscode email sync|status Sync or inspect Gmail OAuth\n dscode email imap-sync|imap-status Sync or inspect IMAP (/email then i to set up)\n dscode sessions List running sessions\n dscode send ID [--steer|--defer] TEXT Send or leave a note\n --title TITLE Set the session title when sending\n dscode read ID [--after SEQ] Read an immutable event page\n dscode watch ID [--after SEQ] Subscribe to session events\n dscode mailbox ID Read messages and deferred notes\n dscode watch-mailbox ID Subscribe to mailbox events\n dscode reply ID --reply-to REQUEST TEXT Send a final reply\n dscode cancel ID MESSAGE Cancel a request or unclaimed message\n dscode new-task ID Explicitly reset communication budget while idle\n dscode resume [SESSION_ID] Resume a session (default: latest)\n dscode --continue Continue the previous session\n dscode --resume SESSION_ID Resume a session\n dscode --cwd DIRECTORY Work in a directory\n\nState: $DSCODE_HOME or ~/.local/share/dscode-hub\nProvider credentials: /login [deepseek|openrouter] in the TUI saves to ~/.dscode/credentials.yaml,\nand /provider switches between DeepSeek and OpenRouter.\nExisting DEEPSEEK_API_KEY or OPENROUTER_API_KEY environment variables take precedence.`);
else (args[0] === 'email' ? runEmailClient(args.slice(1)) : CLIENT_COMMANDS.includes(args[0]) ? runClient(args, stateHome()) : run(args, release)).catch(error => { console.error(formatFailure(error)); process.exitCode = 1; });
28 changes: 25 additions & 3 deletions packages/launcher/manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const require = createRequire(import.meta.url);
const dscodePath = fileURLToPath(new URL('./cli.mjs', import.meta.url));
// The exec CLI ships beside the published launcher; a source checkout reads it from plugins/. Only `dscode exec` loads it.
const loadExecCli = () => import(existsSync(new URL('./exec/cli.mjs', import.meta.url)) ? './exec/cli.mjs' : '../../plugins/exec/cli.mjs');
const loadAcpCli = () => import(existsSync(new URL('./acp/cli.mjs', import.meta.url)) ? './acp/cli.mjs' : '../../plugins/acp/cli.mjs');
// The trigger CLI ships beside the published launcher too (the trigger modules are
// dependency-free apart from `yaml`); a source checkout reads them from plugins/.
const loadTriggerModules = async () => {
Expand Down Expand Up @@ -50,6 +51,7 @@ export function brewManaged(directory = dirname(fileURLToPath(import.meta.url)),

export function commandPlan(args, release, installed, launcherVersion) {
const [command, ...rest] = args;
if (command === 'acp') return { acp: rest, install: !installed };
if (command === 'exec') return { exec: rest, install: !installed };
if (command === 'trigger') return { trigger: rest, install: !installed };
if (command === 'resume') {
Expand Down Expand Up @@ -265,6 +267,12 @@ export async function run(args, release) {
}
triggerRequest = { modules, triggerArgs };
}
let acpOptions, acpCli;
if (args[0] === 'acp') {
acpCli = await loadAcpCli();
acpOptions = acpCli.parseAcpArgs(args.slice(1));
if (acpOptions.help) { console.log(acpCli.USAGE); return; }
}
let execOptions, execPrompt;
if (args[0] === 'exec') {
const cli = await loadExecCli();
Expand Down Expand Up @@ -312,10 +320,10 @@ Move it aside to install a managed profile, for example:
catch (error) { throw hubFailure(error); }
}
if (plan.install) {
(execOptions ? console.error : console.log)(`Installing DSCODE ${release.version} from dshpluginhub.ai…`);
(execOptions ? console.error : console.log)('This downloads the pinned Harness runtime and plugins with npm; on a slow registry it can take several minutes.');
(execOptions || acpOptions ? console.error : console.log)(`Installing DSCODE ${release.version} from dshpluginhub.ai…`);
(execOptions || acpOptions ? console.error : console.log)('This downloads the pinned Harness runtime and plugins with npm; on a slow registry it can take several minutes.');
try {
await exec(hub, ['profile','apply',release.slug,'--version',release.version,'--profile','dscode'], process.cwd(), releaseLock, undefined, execOptions ? 'stderr' : 'inherit');
await exec(hub, ['profile','apply',release.slug,'--version',release.version,'--profile','dscode'], process.cwd(), releaseLock, undefined, execOptions || acpOptions ? 'stderr' : 'inherit');
} catch (error) { throw hubFailure(error); }
}
const metadata = JSON.parse(readFileSync(join(profile,'node_modules',release.bundle,'package.json'),'utf8'));
Expand All @@ -326,6 +334,20 @@ Move it aside to install a managed profile, for example:
const overlays = ['mcp.local.yml','harness.local.yml'].flatMap(file => {
const path=join(home,'config',file); return existsSync(path) ? ['--patch',path] : [];
});
if (acpOptions) {
const runner = join(profile, 'node_modules', release.bundle, 'plugins/acp/index.mjs');
if (!existsSync(runner)) throw Error('This DSCODE installation has no ACP runner. Run dscode update first.');
const scratch = mkdtempSync(join(tmpdir(), 'dscode-acp-'));
try {
const overlay = join(scratch, 'acp.patch.yml');
writeFileSync(overlay, acpCli.acpOverlay(runner, acpOptions));
lease = await registerRun(home);
const { code, signal } = await spawnRun(dsh, ['--profile', 'dscode', ...overlays, ...acpOptions.patches.flatMap(path => ['--patch', resolve(path)]), '--patch', overlay],
{ lease, started: releaseLock, nodeArgs: ['--disable-warning=ExperimentalWarning'] });
process.exitCode = code ?? (signal ? 130 : 0);
} finally { rmSync(scratch, { recursive: true, force: true }); }
return;
}
if (execOptions) {
// One headless turn through the installed bundle's exec runner; the turn's exit code is the launcher's.
const runner = join(profile, 'node_modules', release.bundle, 'plugins/exec/index.mjs');
Expand Down
45 changes: 45 additions & 0 deletions plugins/acp/cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export const USAGE = `Usage: dscode acp [options]
Serve Agent Client Protocol over stdio using the DSCODE agent preset.
The client supplies each session's workspace. Stdout contains only ACP frames.

Options:
--model PROVIDER/ID initial model (default: the saved default model)
--patch FILE additional composition overlay (repeatable)
-h, --help show this help

Configure credentials and custom providers in the TUI before connecting.
Permission requests are answered by the ACP client. Disconnecting exits.`;

export function parseAcpArgs(argv) {
const options = { patches: [], help: false };
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') { options.help = true; continue; }
if (!['--model', '--patch'].includes(arg)) throw Error(`Unknown ACP option: ${arg}`);
const value = argv[++index];
if (!value || value.startsWith('-')) throw Error(`${arg} requires a value`);
if (arg === '--patch') options.patches.push(value);
else {
if (!/^[^/\s]+\/\S+$/.test(value)) throw Error('--model expects provider/model');
options.model = value;
}
}
return options;
}

export function acpOverlay(entry, options = {}) {
return `${['tui-startup', 'tui-runner', 'session-title-llm'].map(id => `- id: ${id}\n disabled: true`).join('\n')}
- id: dscode-session-cards
config:
enabled: false
- id: dscode-memory
config:
generate: false
- insert:
- id: acp-app-startup
name: '@deepseek-ai/dsh-acp-app'
- id: dscode-acp
name: ${JSON.stringify(entry)}
config: ${JSON.stringify(options.model ? { model: options.model } : {})}
`;
}
44 changes: 44 additions & 0 deletions plugins/acp/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { apply as applyAcp } from '@deepseek-ai/dsh-acp';
import z from '@deepseek-ai/schemastery';

export const name = 'dscode-acp';
export const inject = ['acpAppStartup', 'agents', 'agentPresets', 'agentDefaultModel', 'permissionPresets', 'llm', 'sessionPersistence', 'sessions', 'dscodeCustom'];
export const Config = z.object({ model: z.string() });

// Scope the factory to this bridge: TUI and delegated agents keep their own
// composition. ACP still owns model selection, MCP attachment and disposal.
export function presetAgents(ctx) {
const wrap = method => async (options) => {
const handle = await ctx.agents[method]({
...options,
...(method === 'create' ? { meta: { ...options.meta, agentPreset: 'dscode' } } : {}),
setup: async (agentCtx, agent) => {
await ctx.agentPresets.mount(agentCtx, 'dscode');
await options.setup?.(agentCtx, agent);
},
});
ctx.permissionPresets.set(handle.agent.session, 'ask');
return handle;
};
const overrides = { create: wrap('create'), resume: wrap('resume') };
return new Proxy(ctx.agents, {
get(target, key) {
if (Object.hasOwn(overrides, key)) return overrides[key];
const value = Reflect.get(target, key, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
}

export function startAcp(ctx, config = {}, bridge = applyAcp) {
const selection = config.model
? { provider: config.model.slice(0, config.model.indexOf('/')), model: config.model.slice(config.model.indexOf('/') + 1) }
: ctx.agentDefaultModel.currentSelection();
const scoped = ctx.extend({ agents: presetAgents(ctx) });
bridge(scoped, { provider: selection.provider, model: selection.model });
}

export async function apply(ctx, config) {
await ctx.get('dscodeCustom').refresh();
startAcp(ctx, config);
}
Loading
Loading