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
2 changes: 1 addition & 1 deletion LICENSES/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ their upstream originals byte for byte.
revision. It is copied in its original language.

The public source revision declares version 3.14.0. It is distinct from the
Desktop 3.16.3 artifact pinned by this project's runtime lock. These notices
Desktop 3.17.1 artifact pinned by this project's runtime lock. These notices
preserve upstream attribution; they are not a claim that this CLI was built
from that exact commit. The third-party inventory covers multiple upstream
distributions and may list components absent from our npm package.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,10 @@ models demonstrate explicit overrides and manual configuration.
中文文档:[配置说明](./docs/CONFIGURATION.zh-CN.md) ·
[Provider 配置字段参考](./docs/PROVIDER_CONFIG.zh-CN.md)。
The project follows the current upstream runtime and configuration schema.
Only the currently pinned runtime is supported; historical runtime versions are
not a compatibility target. Runtime upgrades update the lock, package version,
patches and regression tests together. CI rebuilds that exact artifact for
reproducibility rather than selecting a new version during each build.

Three model-access paths are supported: Z.AI/BigModel OAuth, Z.AI/BigModel
Coding Plan API key, or a direct API key with a custom provider. For detailed
Expand Down
8 changes: 6 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,11 @@ project or plugin servers. Use `enabled: false` on a named server, or
Enabled plugins also contribute servers. Their definitions come from the
plugin's `.mcp.json` or manifest, and normally appear as
`plugin:<plugin-name>:<server-name>`. Keep standalone user MCP definitions in
`setting.json`; this CLI does not automatically read `.agents/mcp.json`.
`setting.json`. Project `.agents/mcp.json` declarations are also discovered by
runtime 0.17.1. Project-scoped MCP servers require workspace trust before they
launch; an `untrusted` server is not a connection failure. The CLI currently has
no trust-management command; approve the workspace declarations in Desktop,
which shares the workspace trust store with the CLI.
Plugin controls remain under `plugins`, hook declarations under
`hooks.events.<Event>`, and tool permissions under `permission` in `setting.json`.

Expand All @@ -201,7 +205,7 @@ Plugin controls remain under `plugins`, hook declarations under
These paths distinguish the packaged CLI from the upstream application. The
source references below are pinned to upstream
[`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e).
This package's runtime lock selects the separate Desktop 3.16.3 artifact; the
This package's runtime lock selects the separate Desktop 3.17.1 / runtime 0.17.1 artifact; the
public source snapshot is not an assertion of that artifact's build commit.
The configuration boundaries were rechecked on 2026-10-20 against
[`0a1fc56` / `0.17.0`](https://github.com/zai-org/ZCode/tree/0a1fc56d279fd1001f78387bd81aec1c26767411).
Expand Down
6 changes: 4 additions & 2 deletions docs/CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,17 @@ MCP 服务器按名称合并。当前 runtime 中,**同名服务器的用户

已启用的插件也会提供 MCP,定义来自插件的 `.mcp.json` 或 manifest,通常显示为
`plugin:<插件名>:<服务器名>`。独立的用户 MCP 定义放在 `setting.json`;
此 CLI 不会自动读取 `.agents/mcp.json`。
runtime 0.17.1 也会发现项目中的 `.agents/mcp.json`。
项目级 MCP 服务器需要工作区信任授权才会启动;`untrusted` 状态不是连接失败。
CLI 目前没有信任管理命令,可在 Desktop 中批准相应工作区声明,两者共用工作区信任存储。
插件控制仍在 `setting.json` 的 `plugins` 下,hook 声明在 `hooks.events.<Event>` 下,
工具权限在 `permission` 下。

### 上游源码与配置路径的区别

下面区分本发行包与上游应用。源码引用固定在上游
[`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e)。
本项目 runtime lock 选择的是独立发布的 Desktop 3.16.3 产物,不能据此认定该产物
本项目 runtime lock 选择的是独立发布的 Desktop 3.17.1 / runtime 0.17.1 产物,不能据此认定该产物
由这个公开源码提交构建。
2026-10-20 已针对
[`0a1fc56` / `0.17.0`](https://github.com/zai-org/ZCode/tree/0a1fc56d279fd1001f78387bd81aec1c26767411)
Expand Down
8 changes: 7 additions & 1 deletion docs/SQLITE_CONCURRENCY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ On 2026-10-23 it was rechecked again against Desktop 3.16.3, which keeps runtime
0.17.0 but makes `commitForkBundle`, `commitSharedContextImportBundle` and
`transitionSharedContextImport` commit synchronously, so no transaction stays
open across an `await`.
The Desktop 3.17.1 / runtime 0.17.1 audit also covers `commitMessageBundle`:
its synchronous message/part transaction rolls back before throwing and uses
stable IDs. Recovery requires this method and retries the complete bundle,
including compaction and multi-part message persistence.
The 0.17.0 revision replaces the synchronous constructor and the separate
`openStartup()` migration with one `SqliteSessionStore.open()` path, adds
`softDeleteSession`, and runs checksum-validated migrations with a backup
Expand Down Expand Up @@ -111,6 +115,8 @@ requests. They cover:
starving its commit, plus caller-owned transactions retaining rollback ownership;
- a failure after message/part insertion that rolls back before input promotion
retries, and an idempotent partial part write that cannot overtake a newer update;
- a partial message bundle rolling back before replay, without duplicate messages
or parts, and rejection of stores missing the native bundle transaction;
- non-BUSY errors and partially committed goal counters never being replayed;
- closing a store cancelling both backoff and pending writes;
- 100 usage facts requiring one cleanup transaction when expired rows exist,
Expand All @@ -131,7 +137,7 @@ is not used as a substitute for Node's SQLite driver.

## Follow-up boundary

The current lock selects Desktop 3.16.3 / runtime 0.17.0. Rebuild with
The current lock selects Desktop 3.17.1 / runtime 0.17.1. Rebuild with
`sync:locked` before release validation; `sync:local` records a local App source
and does not establish the locked installer's provenance. The regressions do
not establish that every production workload is free of contention. Keep #162
Expand Down
2 changes: 1 addition & 1 deletion docs/THIRD_PARTY_CONTENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ compatibility patches applied.

The [upstream notices shipped here](../LICENSES/README.md) come from a separately
pinned public source revision. That revision declares version 3.14.0; the current
runtime lock selects Desktop 3.16.3. The notices are a conservative upstream
runtime lock selects Desktop 3.17.1. The notices are a conservative upstream
inventory, not proof that the extracted binary was built from that exact commit
or that every listed component is included in this npm package. Review artifact
contents and applicable terms when updating the runtime.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-app-cli",
"version": "3.16.3-41",
"version": "3.17.1-41",
"description": "Unofficial terminal client for the ZCode agent runtime",
"keywords": [
"agent",
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
hasRuntimeSqliteBusyTimeout,
hasRuntimeSqliteWriteRecovery,
hasRuntimeStreamEofFinishGuard,
hasRuntimeTrackedDetachedAgentLifecycle,
patchRuntimeGoalFailurePause,
patchRuntimeHttpNoContent,
patchRuntimeLoginModelDefaults,
Expand Down Expand Up @@ -146,6 +147,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource
|| (patchEnabled("agent-auto-background")
&& !runtimeSource.includes("autoBackgroundMs:this.config.subagents?.autoBackgroundMs??1e3,outputRootDir:"))
|| (patchEnabled("detached-agent-lifecycle")
&& !hasRuntimeTrackedDetachedAgentLifecycle(runtimeSource)
&& runtimeSource.split("Detached background agent lifecycle failed").length < 3)
|| !runtimeSource.includes('e?.waitForIdle===!0&&t.runtime?.getActiveForegroundExecutionId')
|| (patchEnabled("terminal-tool-projection")
Expand Down
2 changes: 2 additions & 0 deletions scripts/runtime-test-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { fileURLToPath } from "node:url";

const root = fileURLToPath(new URL("../", import.meta.url));

export const runtimeStartupTimeoutMs = 20_000;

/** Runtime tools inherit absolute ZCODE paths; changing HOME alone cannot isolate a check. */
export function runtimeTestEnv(
testHome: string,
Expand Down
4 changes: 2 additions & 2 deletions scripts/smoke-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";

import { nextBuildVersion } from "./release-version.ts";
import { runtimeTestEnv } from "./runtime-test-env.ts";
import { runtimeStartupTimeoutMs, runtimeTestEnv } from "./runtime-test-env.ts";

const root = join(import.meta.dir, "..");
const runtime = join(root, "vendor", "zcode.cjs");
Expand Down Expand Up @@ -157,7 +157,7 @@ const timeout = setTimeout(() => {

let interactionError: unknown;
try {
await waitFor("welcome screen", /Ask a task about this workspace/i);
await waitFor("welcome screen", /Ask a task about this workspace/i, 0, runtimeStartupTimeoutMs);
if (!plainText(output).includes("Ask a task about this workspace")) {
throw new Error("Regular TUI did not render the session welcome intro.");
}
Expand Down
17 changes: 14 additions & 3 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,9 +464,19 @@ export function patchRuntimeAgentAutoBackground(runtime: string): string {
return runtime.replace(anchor, marker);
}

/** 0.17.1 tracks detached lifecycle promises and records rejections itself. */
export function hasRuntimeTrackedDetachedAgentLifecycle(runtime: string): boolean {
const detached = /onSessionStartFailed:([A-Za-z_$][\w$]*)\.reject\},[A-Za-z_$][\w$]*\.dispose\),!0\);try\{await \1\.promise\}/u
.test(runtime);
const tracker = /\.then\(\(\)=>[A-Za-z_$][\w$]*\.delete\([A-Za-z_$][\w$]*\),[A-Za-z_$][\w$]*=>\{[A-Za-z_$][\w$]*\.delete\([A-Za-z_$][\w$]*\),[A-Za-z_$][\w$]*&&[A-Za-z_$][\w$]*\.push\(/u
.test(runtime);
return detached && tracker;
}

/** Keep a detached Agent lifecycle failure from terminating the entire CLI process. */
export function patchRuntimeDetachedAgentLifecycle(runtime: string): string {
const marker = 'Detached background agent lifecycle failed';
if (hasRuntimeTrackedDetachedAgentLifecycle(runtime)) return runtime;
const detachedRunnerPattern = /(onSessionStartFailed:([A-Za-z_$][\w$]*)\.reject\},[A-Za-z_$][\w$]*\.dispose)\);try\{await \2\.promise\}/gu;
const patched = runtime.replace(
detachedRunnerPattern,
Expand Down Expand Up @@ -548,7 +558,7 @@ export function patchRuntimeTuiBridge(runtime: string): string {
const activeTranscriptPattern = /sessionStore\.messages\(\{sessionID:([A-Za-z_$][\w$]*)\.sessionId\}\),[A-Za-z_$][\w$]*=await \1\.sessionStore\.getSession\(\1\.sessionId\);return/u;
const activeTurnSteerPattern = /(\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\2\?\.inputId,queryId:\2\?\.queryId,expectedTurnId:\2\?\.expectedTurnId,)(?:delivery:"guide",)?(?:pendingInputId:\2\?\.pendingInputId,)?input:/u;
const activeTurnGuidePattern = /\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\1\?\.inputId,queryId:\1\?\.queryId,expectedTurnId:\1\?\.expectedTurnId,delivery:"guide",pendingInputId:\1\?\.pendingInputId,input:/u;
const nativeActiveTurnSteerPattern = /([A-Za-z_$][\w$]*)\?\.delivery==="steer_active_turn".{0,700}?\.steerTurn\(\{commandKind:\1\?\.commandKind,delivery:[^,}]+,expectedTurnId:\1\?\.expectedTurnId,input:[^,}]+,(?:inputPresentation:[^,}]+,)?inputId:\1\?\.inputId,intent:.{1,160}?,queryId:\1\?\.queryId,/u;
const nativeActiveTurnSteerPattern = /([A-Za-z_$][\w$]*)\?\.delivery==="steer_active_turn".{0,700}?\.steerTurn\(\{automationId:\1\?\.automationId,commandKind:\1\?\.commandKind,delivery:[^,}]+,expectedTurnId:\1\?\.expectedTurnId,input:[^,}]+,(?:inputPresentation:[^,}]+,)?inputId:\1\?\.inputId,intent:.{1,160}?,queryId:\1\?\.queryId,/u;
const nativePromptAdmissionPattern = /\.runtime\.admitPrompt\([^{}]{0,500}\{\.\.\.([A-Za-z_$][\w$]*),delivery:[A-Za-z_$][\w$]*,traceContext:\1\?\.traceContext/u;
const legacyStartedTurnResultPattern = /return ([A-Za-z_$][\w$]*)\.kind!=="started_turn"\?\1:([A-Za-z_$][\w$]*)\(\1\.result,([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\)/u;
const supportsActiveTurnSteer = (value: string): boolean => (
Expand Down Expand Up @@ -1052,7 +1062,7 @@ export function patchRuntimeSharedConfig(runtime: string): string {
const initImporter = importer && initializer(importer.index), initRepository = repository && initializer(repository.index);
const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(runtime);
const loader = /([A-Za-z_$][\w$]*)=\(0,[A-Za-z_$][\w$]*\.readFileSync\)\(([A-Za-z_$][\w$]*),"utf-8"\),([A-Za-z_$][\w$]*)=JSON\.parse\(\1\),([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\3\);/u.exec(runtime);
const validation = loader && new RegExp(`let ([A-Za-z_$][\\w$]*)=([A-Za-z_$][\\w$]*)\\(${escapeRegExpName(loader[3]!)}\\);return\\{config:\\1\\.config`, "u")
const validation = loader && new RegExp(`let ([A-Za-z_$][\\w$]*)=([A-Za-z_$][\\w$]*)\\(${escapeRegExpName(loader[3]!)},[A-Za-z_$][\\w$]*\\.scope\\);return\\{config:\\1\\.config`, "u")
.exec(runtime.slice(loader.index, loader.index + 1000));
if (!file || !importer || !repository || !initImporter || !initRepository || !main || !loader || !validation) {
throw new Error("ZCode runtime is incompatible with shared configuration (settings/migration anchors missing).");
Expand All @@ -1062,7 +1072,7 @@ export function patchRuntimeSharedConfig(runtime: string): string {
.replace(file[0], `${file[1]}="setting.json",${file[2]}="~/.zcode/cli"`)
// Merge shared preferences after native file migrations have run, so a
// plugin migration cannot accidentally persist inherited Desktop values.
.replace(validation[0], validation[0].replace(`${validation[2]}(${loader[3]})`, `${validation[2]}(${bridge}.mergeDesktopSettings(${loader[3]},${loader[2]},process.env))`))
.replace(validation[0], validation[0].replace(`${validation[2]}(${loader[3]}`, `${validation[2]}(${bridge}.mergeDesktopSettings(${loader[3]},${loader[2]},process.env)`))
.replace(main[0], `${main[0]}if(process.env.ZCODE_CLI_MIGRATE_CONFIG==="1"){try{${initRepository}();${initImporter}();await ${bridge}.migrateLegacyProviders({Repository:${repository[1]},importLegacy:${importer[1]},env:process.env})}catch($zError){process.stderr.write(($zError instanceof Error?$zError.message:"CLI configuration migration failed")+"\\n"),process.exitCode=1}return}`);
}

Expand Down Expand Up @@ -1869,6 +1879,7 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [
requirement: "optional",
apply: patchRuntimeDetachedAgentLifecycle,
verify: (runtime) => runtime.includes("Detached background agent lifecycle failed")
|| hasRuntimeTrackedDetachedAgentLifecycle(runtime)
},
{
id: "agent-auto-background",
Expand Down
2 changes: 1 addition & 1 deletion src/runtime-sqlite-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const sqliteWriteRecoveryPolicy = {
// synchronously, so they never leave a transaction open across an await.
export const sqliteReplayableWrites = [
"createSession", "updateSession", "softDeleteSession", "createForkedSessionWithMetadata", "commitForkBundle",
"commitSharedContextImportBundle", "transitionSharedContextImport", "saveMessage",
"commitMessageBundle", "commitSharedContextImportBundle", "transitionSharedContextImport", "saveMessage",
"removeMessage", "savePart", "removePart", "saveSessionEntry", "saveSessionInput",
"updateSessionInputs", "promoteSessionInput", "markSessionInputPromoted", "settleSessionInput",
"commitPermissionFullAccess", "updateTodos", "recordInputHistory", "saveProjectPermission",
Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/runtime-env.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { runtimeTestEnv } from "../../scripts/runtime-test-env.ts";
export { runtimeStartupTimeoutMs, runtimeTestEnv } from "../../scripts/runtime-test-env.ts";
2 changes: 2 additions & 0 deletions test/node/session-lifecycle.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const helper = require("../../vendor/cli-config.cjs");
function runtime(store, directory) {
return {
sessionId: "sess_native_lifecycle", sessionPersisted: false, sessionStore: store,
// 0.17.1 writes recorded session features after the first persist.
sessionFeatures: new Set(), sessionFeaturesWrite: Promise.resolve(),
workingDirectory: directory, appVersion: "test", config: { taskType: "interactive", mode: "build" },
rootTraceContext: { traceId: "trace_native_lifecycle" },
getSessionModelSelection: () => undefined,
Expand Down
50 changes: 50 additions & 0 deletions test/node/sqlite-recovery.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ function interceptRuns(db, beforeRun) {
return () => { db.prepare = prepare; };
}

test("recovery requires the native message bundle transaction", async () => {
await withDirectory(async (directory, dbPath) => {
const commitMessageBundle = Store.prototype.commitMessageBundle;
Store.prototype.commitMessageBundle = undefined;
try {
await assert.rejects(Store.openStartup({ dbPath }), /SQLite recovery requires native commitMessageBundle/);
} finally { Store.prototype.commitMessageBundle = commitMessageBundle; }
});
});

test("lock recovery yields to timers and restores the native timeout", { timeout: 10_000 }, async () => {
await withDirectory(async (directory, dbPath) => {
const store = await Store.openStartup({ dbPath });
Expand Down Expand Up @@ -157,6 +167,46 @@ test("idempotent part recovery preserves sequence and cannot overwrite a newer q
});
});

test("message bundles roll back partial writes before retrying without duplicating records", async () => {
await withDirectory(async (directory, dbPath) => {
const store = await Store.openStartup({ dbPath });
try {
await store.createSession(sessionInput(directory, "session"));
const first = promotion("session", "first"), second = promotion("session", "second");
let failed = false, sawPartialWrite = false, rollbacks = 0;
const execute = store.db.exec.bind(store.db);
store.db.exec = sql => {
const result = execute(sql);
if (sql === "rollback") {
rollbacks++;
assert.equal(store.db.prepare("select count(*) AS n from message").get().n, 0);
assert.equal(store.db.prepare("select count(*) AS n from part").get().n, 0);
}
return result;
};
interceptRuns(store.db, sql => {
if (!failed && sql.startsWith("insert into message")
&& store.db.prepare("select count(*) AS n from part").get().n === 1) {
failed = true;
sawPartialWrite = true;
throw Object.assign(new Error("busy during message bundle"), { errcode: 5 });
}
});
await store.commitMessageBundle({
sessionID: "session",
messages: [first, second].map(data => ({ info: data.message, parts: data.parts }))
});
assert.equal(sawPartialWrite, true);
assert.equal(rollbacks, 1);
assert.equal(store.db.isTransaction, false);
assert.equal(store.db.prepare("select count(*) AS n from message").get().n, 2);
assert.equal(store.db.prepare("select count(*) AS n from part").get().n, 2);
assert.equal(sqliteRecoveryStats(store).lastRecovery.operation, "commitMessageBundle");
assert.equal(sqliteRecoveryStats(store).lastRecovery.attempts, 2);
} finally { store.close(); }
});
});

test("non-BUSY failures and partially committed goal counters are not replayed", async () => {
await withDirectory(async (directory, dbPath) => {
const store = await Store.openStartup({ dbPath });
Expand Down
Loading
Loading