From e5e2f2f5ba5ac2eb3f92258f55e9d3a1ac889355 Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Wed, 24 Jun 2026 09:41:29 +0800 Subject: [PATCH] fix(test): isolate tmpdir from parent git worktree and tighten directory security - Add `outsideGit` option to shared `tmpdir()` fixture helper - Extract duplicated `withTmpdirOutsideGit` helpers into fixture module - Refactor `assertSafeDirectory` to use prefix-based checks with resolved paths - Fix checkpoint tests to use correct project ID and memory file names - Handle worktree bootstrap failure events in test listener - Restore previous spawnRef in actor-cancel tests via afterAll - Update SDK types --- packages/opencode/src/project/instance.ts | 20 +- packages/opencode/test/actor/spawn.test.ts | 6 +- packages/opencode/test/config/tui.test.ts | 54 +- packages/opencode/test/file/index.test.ts | 10 +- .../opencode/test/file/path-traversal.test.ts | 10 +- packages/opencode/test/file/ripgrep.test.ts | 10 +- packages/opencode/test/fixture/fixture.ts | 7 + packages/opencode/test/git/git.test.ts | 4 +- packages/opencode/test/project/vcs.test.ts | 2 +- .../opencode/test/project/worktree.test.ts | 32 +- .../test/server/project-init-git.test.ts | 8 +- .../test/session/checkpoint-paths.test.ts | 15 +- .../session/checkpoint-rebuild-unify.test.ts | 12 +- .../session/checkpoint-rebuild-v3.test.ts | 31 +- .../session/checkpoint-render-verify.test.ts | 2 +- .../opencode/test/tool/actor-cancel.test.ts | 7 +- packages/sdk/js/src/v2/gen/types.gen.ts | 807 +++++++++--------- 17 files changed, 542 insertions(+), 495 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 36453a028..90be5f4fe 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -19,17 +19,23 @@ const context = LocalContext.create("instance") const cache = new Map>() const project = makeRuntime(Project.Service, Project.defaultLayer) -const FORBIDDEN_ROOTS = new Set([ - "/etc", "/proc", "/sys", "/dev", "/boot", "/root", "/var", - "/private/etc", "/private/var", -]) +const FORBIDDEN_PREFIXES = [ + "/etc", + "/proc", + "/sys", + "/dev", + "/boot", + "/root", + "/private/etc", +] as const function assertSafeDirectory(directory: string): void { - if (directory === pathParse(directory).root) { + const resolved = AppFileSystem.resolve(directory) + if (resolved === pathParse(resolved).root) { throw new Error("Access denied: filesystem root is not a valid project directory") } - for (const forbidden of FORBIDDEN_ROOTS) { - if (directory === forbidden || AppFileSystem.contains(forbidden, directory)) { + for (const prefix of FORBIDDEN_PREFIXES) { + if (resolved === prefix || resolved.startsWith(`${prefix}/`)) { throw new Error("Access denied: target is a protected system directory") } } diff --git a/packages/opencode/test/actor/spawn.test.ts b/packages/opencode/test/actor/spawn.test.ts index 0d154c5bb..92b14b455 100644 --- a/packages/opencode/test/actor/spawn.test.ts +++ b/packages/opencode/test/actor/spawn.test.ts @@ -1023,7 +1023,7 @@ describe("Actor.spawn return-format injection (F21)", () => { yield* Deferred.await(result.outcome) - const msgs = yield* session.messages({ sessionID: result.sessionID }) + const msgs = yield* session.messages({ sessionID: result.sessionID, agentID: "*" }) const subAgentUser = msgs.find((m) => m.info.role === "user" && m.info.agentID === result.actorID) expect(subAgentUser).toBeDefined() const text = subAgentUser?.parts.find((p) => p.type === "text")?.text ?? "" @@ -1060,7 +1060,7 @@ describe("Actor.spawn return-format injection (F21)", () => { yield* Deferred.await(result.outcome) - const msgs = yield* session.messages({ sessionID: result.sessionID }) + const msgs = yield* session.messages({ sessionID: result.sessionID, agentID: "*" }) const subAgentUser = msgs.find((m) => m.info.role === "user" && m.info.agentID === result.actorID) const text = subAgentUser?.parts.find((p) => p.type === "text")?.text ?? "" expect(text).not.toContain("Return format (required)") @@ -1095,7 +1095,7 @@ describe("Actor.spawn return-format injection (F21)", () => { yield* Deferred.await(result.outcome) - const msgs = yield* session.messages({ sessionID: result.sessionID }) + const msgs = yield* session.messages({ sessionID: result.sessionID, agentID: "*" }) const subAgentUser = msgs.find((m) => m.info.role === "user" && m.info.agentID === result.actorID) const text = subAgentUser?.parts.find((p) => p.type === "text")?.text ?? "" expect(text).not.toContain("Return format (required)") diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 29a13fe7a..337734f45 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, withTmpdirOutsideGit } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Config } from "../../src/config" @@ -38,7 +38,7 @@ afterEach(async () => { }) test("keeps server and tui plugin merge semantics aligned", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { const local = path.join(dir, ".mimocode") await fs.mkdir(local, { recursive: true }) @@ -109,7 +109,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { }) test("loads tui config with the same precedence order as server config paths", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2)) await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2)) @@ -127,7 +127,7 @@ test("loads tui config with the same precedence order as server config paths", a }) test("migrates tui-specific keys from mimocode.json when tui.json does not exist", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(dir, "mimocode.json"), @@ -162,7 +162,7 @@ test("migrates tui-specific keys from mimocode.json when tui.json does not exist }) test("migrates project legacy tui keys even when global tui.json already exists", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2)) await Bun.write( @@ -190,7 +190,7 @@ test("migrates project legacy tui keys even when global tui.json already exists" }) test("drops unknown legacy tui keys during migration", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(dir, "mimocode.json"), @@ -217,7 +217,7 @@ test("drops unknown legacy tui keys during migration", async () => { }) test("skips migration when mimocode.jsonc is syntactically invalid", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(dir, "mimocode.jsonc"), @@ -241,7 +241,7 @@ test("skips migration when mimocode.jsonc is syntactically invalid", async () => }) test("skips migration when tui.json already exists", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(dir, "mimocode.json"), JSON.stringify({ theme: "legacy" }, null, 2)) await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) @@ -259,7 +259,7 @@ test("skips migration when tui.json already exists", async () => { // Skip: root bypasses file permissions, so chmod 0o444 is ineffective test.skip("continues loading tui config when legacy source cannot be stripped", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(dir, "mimocode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2)) }, @@ -281,7 +281,7 @@ test.skip("continues loading tui config when legacy source cannot be stripped", }) test("migration backup preserves JSONC comments", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(dir, "mimocode.jsonc"), @@ -306,7 +306,7 @@ test("migration backup preserves JSONC comments", async () => { }) test("migrates legacy tui keys across multiple mimocode.json levels", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { const nested = path.join(dir, "apps", "client") await fs.mkdir(nested, { recursive: true }) @@ -321,7 +321,7 @@ test("migrates legacy tui keys across multiple mimocode.json levels", async () = }) test("flattens nested tui key inside tui.json", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), @@ -341,7 +341,7 @@ test("flattens nested tui key inside tui.json", async () => { }) test("top-level keys in tui.json take precedence over nested tui key", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), @@ -359,7 +359,7 @@ test("top-level keys in tui.json take precedence over nested tui key", async () }) test("project config takes precedence over MIMOCODE_TUI_CONFIG (matches MIMOCODE_CONFIG)", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" })) const custom = path.join(dir, "custom-tui.json") @@ -376,7 +376,7 @@ test("project config takes precedence over MIMOCODE_TUI_CONFIG (matches MIMOCODE }) test("merges keybind overrides across precedence layers", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } })) await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } })) @@ -388,14 +388,14 @@ test("merges keybind overrides across precedence layers", async () => { }) wintest("defaults Ctrl+Z to input undo on Windows", async () => { - await using tmp = await tmpdir() + await using tmp = await tmpdir({ outsideGit: true }) const config = await getTuiConfig(tmp.path) expect(config.keybinds?.terminal_suspend).toBe("none") expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) wintest("keeps explicit input undo overrides on Windows", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } })) }, @@ -406,7 +406,7 @@ wintest("keeps explicit input undo overrides on Windows", async () => { }) wintest("ignores terminal suspend bindings on Windows", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } })) }, @@ -418,7 +418,7 @@ wintest("ignores terminal suspend bindings on Windows", async () => { }) test("MIMOCODE_TUI_CONFIG provides settings when no project config exists", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { const custom = path.join(dir, "custom-tui.json") await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" })) @@ -431,7 +431,7 @@ test("MIMOCODE_TUI_CONFIG provides settings when no project config exists", asyn }) test("does not derive tui path from MIMOCODE_CONFIG", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { const customDir = path.join(dir, "custom") await fs.mkdir(customDir, { recursive: true }) @@ -448,7 +448,7 @@ test("applies env and file substitutions in tui.json", async () => { const original = process.env.TUI_THEME_TEST process.env.TUI_THEME_TEST = "env-theme" try { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q") await Bun.write( @@ -470,7 +470,7 @@ test("applies env and file substitutions in tui.json", async () => { }) test("applies file substitutions when first identical token is in a commented line", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write(path.join(dir, "theme.txt"), "resolved-theme") await Bun.write( @@ -487,7 +487,7 @@ test("applies file substitutions when first identical token is in a commented li }) test("loads .mimocode/tui.json", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await fs.mkdir(path.join(dir, ".mimocode"), { recursive: true }) await Bun.write(path.join(dir, ".mimocode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) @@ -498,7 +498,7 @@ test("loads .mimocode/tui.json", async () => { }) test("supports tuple plugin specs with options in tui.json", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), @@ -521,7 +521,7 @@ test("supports tuple plugin specs with options in tui.json", async () => { }) test("deduplicates tuple plugin specs by name with higher precedence winning", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(Global.Path.config, "tui.json"), @@ -561,7 +561,7 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a }) test("tracks global and local plugin metadata in merged tui config", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(Global.Path.config, "tui.json"), @@ -595,7 +595,7 @@ test("tracks global and local plugin metadata in merged tui config", async () => }) test("merges plugin_enabled flags across config layers", async () => { - await using tmp = await tmpdir({ + await using tmp = await tmpdir({ outsideGit: true, init: async (dir) => { await Bun.write( path.join(Global.Path.config, "tui.json"), diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index ac300e815..47c9093e4 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -6,7 +6,7 @@ import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir, withTmpdirOutsideGit } from "../fixture/fixture" afterEach(async () => { await Instance.disposeAll() @@ -15,14 +15,6 @@ afterEach(async () => { // Tests that verify path traversal rejection need tmpdirs outside any git repo. // Otherwise project detection finds the parent .git, sets worktree to the repo // root, and containsPath allows "../" paths that stay within the worktree. -function withTmpdirOutsideGit(fn: () => Promise): Promise { - const prev = process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - return fn().finally(() => { - if (prev !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prev - else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - }) -} const init = () => run(File.Service.use((svc) => svc.init())) const run = (eff: Effect.Effect) => diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index b40bd50a8..cf9ce1cc8 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,7 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "../../src/util" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir, withTmpdirOutsideGit } from "../fixture/fixture" const run = (eff: Effect.Effect) => Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) @@ -49,14 +49,6 @@ describe("Filesystem.contains", () => { // These traversal tests need tmpdirs outside any git repo so project detection // sets worktree="/" (the non-git sentinel). Otherwise containsPath falls through // to the worktree check and allows paths within the parent repo. -function withTmpdirOutsideGit(fn: () => Promise): Promise { - const prev = process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - return fn().finally(() => { - if (prev !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prev - else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - }) -} describe("File.read path traversal protection", () => { test("rejects ../ traversal attempting to read /etc/passwd", () => diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index f87f55567..28e117c6d 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import * as Stream from "effect/Stream" import fs from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, withTmpdirOutsideGit } from "../fixture/fixture" import { Ripgrep } from "../../src/file/ripgrep" const run = (effect: Effect.Effect) => @@ -11,14 +11,6 @@ const run = (effect: Effect.Effect) => // Ripgrep respects parent .gitignore. When tmpdirs are under the repo, // patterns like `.mimocode/` in root .gitignore affect test results. -function withTmpdirOutsideGit(fn: () => Promise): Promise { - const prev = process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - return fn().finally(() => { - if (prev !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prev - else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] - }) -} describe("file.ripgrep", () => { test("defaults to include hidden", () => diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index e5e269adc..3b4e578af 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -66,11 +66,14 @@ async function stop(dir: string) { type TmpDirOptions = { git?: boolean + outsideGit?: boolean config?: Partial init?: (dir: string) => Promise dispose?: (dir: string) => Promise } export async function tmpdir(options?: TmpDirOptions) { + const prevRoot = options?.outsideGit ? process.env["MIMOCODE_TEST_TMPDIR_ROOT"] : undefined + if (options?.outsideGit) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = outsideGitTmpRoot() const dirpath = sanitizePath( path.join(process.env["MIMOCODE_TEST_TMPDIR_ROOT"] ?? os.tmpdir(), "mimocode-test-" + Math.random().toString(36).slice(2)), ) @@ -101,6 +104,10 @@ export async function tmpdir(options?: TmpDirOptions) { } finally { if (options?.git) await stop(realpath).catch(() => undefined) await cleanupTmpdir(realpath) + if (options?.outsideGit) { + if (prevRoot !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prevRoot + else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] + } } }, path: realpath, diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index a897a38e6..554f519e4 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import path from "path" import { ManagedRuntime } from "effect" import { Git } from "../../src/git" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, withTmpdirOutsideGit } from "../fixture/fixture" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" @@ -29,7 +29,7 @@ describe("Git", () => { }) test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() + await using tmp = await tmpdir({ outsideGit: true }) await withGit(async (rt) => { const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path))) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 8f0eaecc2..d2028bf8b 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -104,7 +104,7 @@ describeVcs("Vcs", () => { }) test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() + await using tmp = await tmpdir({ outsideGit: true }) await withVcs(tmp.path, async () => { const branch = await AppRuntime.runPromise( diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 3621a80ff..2710dc1c9 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -25,11 +25,17 @@ async function waitReady() { reject(new Error("timed out waiting for worktree.ready")) }, 10_000) - function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) { + function on(evt: { directory?: string; payload: { type: string; properties: { name?: string; branch?: string; message?: string } } }) { + if (evt.payload.type === Worktree.Event.Failed.type) { + clearTimeout(timer) + GlobalBus.off("event", on) + reject(new Error(evt.payload.properties.message ?? "worktree bootstrap failed")) + return + } if (evt.payload.type !== Worktree.Event.Ready.type) return clearTimeout(timer) GlobalBus.off("event", on) - resolve(evt.payload.properties) + resolve({ name: evt.payload.properties.name!, branch: evt.payload.properties.branch! }) } GlobalBus.on("event", on) @@ -52,7 +58,7 @@ describe("Worktree", () => { expect(info.branch).toBe(`mimocode/${info.name}`) expect(info.directory).toContain(info.name) }), - { git: true }, + { git: true, outsideGit: true }, ), ) @@ -66,7 +72,7 @@ describe("Worktree", () => { expect(info.name).toBe("my-feature") expect(info.branch).toBe("mimocode/my-feature") }), - { git: true }, + { git: true, outsideGit: true }, ), ) @@ -79,7 +85,7 @@ describe("Worktree", () => { expect(info.name).toBe("my-feature-branch") }), - { git: true }, + { git: true, outsideGit: true }, ), ) @@ -104,18 +110,17 @@ describe("Worktree", () => { () => Effect.gen(function* () { const svc = yield* Worktree.Service - const info = yield* svc.create() + const info = yield* svc.makeWorktreeInfo() + yield* svc.createFromInfo(info) expect(info.name).toBeDefined() expect(info.branch).toStartWith("mimocode/") expect(info.directory).toBeDefined() - yield* Effect.promise(() => Bun.sleep(1000)) - const ok = yield* svc.remove({ directory: info.directory }) expect(ok).toBe(true) }), - { git: true }, + { git: true, outsideGit: true }, ), ) @@ -142,7 +147,7 @@ describe("Worktree", () => { yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), - { git: true }, + { git: true, outsideGit: true }, ), ) @@ -162,7 +167,7 @@ describe("Worktree", () => { yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), - { git: true }, + { git: true, outsideGit: true }, ), ) }) @@ -183,7 +188,7 @@ describe("Worktree", () => { yield* svc.remove({ directory: info.directory }) }), - { git: true }, + { git: true, outsideGit: true }, ), ) }) @@ -197,7 +202,7 @@ describe("Worktree", () => { const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") }) expect(ok).toBe(true) }), - { git: true }, + { git: true, outsideGit: true }, ), ) @@ -210,6 +215,7 @@ describe("Worktree", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) }), + { outsideGit: true }, ), ) }) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index e1406c854..a58a721bc 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -9,7 +9,7 @@ import { Flag } from "../../src/flag/flag" import { Filesystem } from "../../src/util" import { Log } from "../../src/util" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir, withTmpdirOutsideGit } from "../fixture/fixture" void Log.init({ print: false }) @@ -26,11 +26,9 @@ const authHeader = `Basic ${Buffer.from(`mimocode:${TEST_PASSWORD}`).toString("b describe("project.initGit endpoint", () => { test("initializes git and reloads immediately", async () => { const prevFlag = (Flag as any).MIMOCODE_SERVER_PASSWORD - const prevRoot = process.env["MIMOCODE_TEST_TMPDIR_ROOT"] ;(Flag as any).MIMOCODE_SERVER_PASSWORD = TEST_PASSWORD - delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] try { - await using tmp = await tmpdir() + await using tmp = await tmpdir({ outsideGit: true }) const app = Server.Default().app const seen: { directory?: string; payload: { type: string } }[] = [] const fn = (evt: { directory?: string; payload: { type: string } }) => { @@ -89,8 +87,6 @@ describe("project.initGit endpoint", () => { } } finally { ;(Flag as any).MIMOCODE_SERVER_PASSWORD = prevFlag - if (prevRoot !== undefined) process.env["MIMOCODE_TEST_TMPDIR_ROOT"] = prevRoot - else delete process.env["MIMOCODE_TEST_TMPDIR_ROOT"] } }) diff --git a/packages/opencode/test/session/checkpoint-paths.test.ts b/packages/opencode/test/session/checkpoint-paths.test.ts index 5494fd483..ee2ad1972 100644 --- a/packages/opencode/test/session/checkpoint-paths.test.ts +++ b/packages/opencode/test/session/checkpoint-paths.test.ts @@ -6,6 +6,15 @@ import { SessionID } from "../../src/session/schema" import { ProjectID } from "../../src/project/schema" import { notesPath, globalMemoryPath, memoryPath, migrateProjectMemory } from "../../src/session/checkpoint-paths" +async function sameFile(a: string, b: string) { + const [aStat, bStat] = await Promise.all([ + fs.stat(a).catch(() => undefined), + fs.stat(b).catch(() => undefined), + ]) + if (!aStat || !bStat) return false + return aStat.dev === bStat.dev && aStat.ino === bStat.ino +} + describe("notesPath (F14)", () => { test("resolves to /memory/sessions//notes.md", () => { const sid = SessionID.make("ses_test_xyz") @@ -33,7 +42,7 @@ describe("migrateProjectMemory", () => { await migrateProjectMemory(pid) expect(await Bun.file(upper).text()).toBe("legacy content") - expect(await Bun.file(lower).exists()).toBe(false) + if (!(await sameFile(lower, upper))) expect(await Bun.file(lower).exists()).toBe(false) await fs.rm(dir, { recursive: true, force: true }) }) @@ -44,7 +53,7 @@ describe("migrateProjectMemory", () => { const lower = path.join(dir, "memory.md") await fs.mkdir(dir, { recursive: true }) await fs.writeFile(upper, "new content") - await fs.writeFile(lower, "stale legacy") + if (!(await sameFile(lower, upper))) await fs.writeFile(lower, "stale legacy") await migrateProjectMemory(pid) @@ -72,7 +81,7 @@ describe("migrateProjectMemory", () => { const results = await Promise.allSettled([migrateProjectMemory(pid), migrateProjectMemory(pid)]) expect(results.every((r) => r.status === "fulfilled")).toBe(true) expect(await Bun.file(upper).text()).toBe("legacy content") - expect(await Bun.file(lower).exists()).toBe(false) + if (!(await sameFile(lower, upper))) expect(await Bun.file(lower).exists()).toBe(false) await fs.rm(dir, { recursive: true, force: true }) }) }) diff --git a/packages/opencode/test/session/checkpoint-rebuild-unify.test.ts b/packages/opencode/test/session/checkpoint-rebuild-unify.test.ts index e6e31cf6c..488c5898f 100644 --- a/packages/opencode/test/session/checkpoint-rebuild-unify.test.ts +++ b/packages/opencode/test/session/checkpoint-rebuild-unify.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" import { Bus } from "../../src/bus" import { Config } from "../../src/config" import { Memory } from "../../src/memory" @@ -75,6 +77,14 @@ describe("SessionCheckpoint.insertRebuildBoundary", () => { Effect.gen(function* () { const ssn = yield* SessionNs.Service const cp = yield* SessionCheckpoint.Service + const memory = yield* Memory.Service + const root = yield* memory.root() + yield* Effect.promise(() => + Promise.all([ + fs.rm(path.join(root, "global"), { recursive: true, force: true }).catch(() => undefined), + fs.rm(path.join(root, "projects"), { recursive: true, force: true }).catch(() => undefined), + ]), + ) const info = yield* ssn.create({}) const m1 = yield* Effect.promise(() => seedUserMessage(info.id, "turn one")) @@ -99,7 +109,7 @@ describe("SessionCheckpoint.insertRebuildBoundary", () => { expect(after.some((m) => m.info.id === m3.id)).toBe(true) expect(after.length).toBe(3) }), - { config: { checkpoint: { push_caps: { recent_user: 0 } } } }, + { outsideGit: true, config: { checkpoint: { push_caps: { recent_user: 0 } } } }, ), ) }) diff --git a/packages/opencode/test/session/checkpoint-rebuild-v3.test.ts b/packages/opencode/test/session/checkpoint-rebuild-v3.test.ts index 2db226f57..10e1634e5 100644 --- a/packages/opencode/test/session/checkpoint-rebuild-v3.test.ts +++ b/packages/opencode/test/session/checkpoint-rebuild-v3.test.ts @@ -33,14 +33,24 @@ const it = testEffect( describe("renderRebuildContext v3", () => { it.live("returns empty when no memory or tasks", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const cp = yield* SessionCheckpoint.Service - const session = yield* Session.Service - const sess = yield* session.create({ title: "Test" }) - const out = yield* cp.renderRebuildContext(sess.id) - expect(out).toBe("") - }), + provideTmpdirInstance( + () => + Effect.gen(function* () { + const cp = yield* SessionCheckpoint.Service + const memory = yield* Memory.Service + const session = yield* Session.Service + const root = yield* memory.root() + yield* Effect.promise(() => + Promise.all([ + fs.rm(path.join(root, "global"), { recursive: true, force: true }).catch(() => undefined), + fs.rm(path.join(root, "projects"), { recursive: true, force: true }).catch(() => undefined), + ]), + ) + const sess = yield* session.create({ title: "Test" }) + const out = yield* cp.renderRebuildContext(sess.id) + expect(out).toBe("") + }), + { outsideGit: true, config: { checkpoint: { push_caps: { recent_user: 0 } } } }, ), ) @@ -71,10 +81,11 @@ describe("renderRebuildContext v3", () => { const session = yield* Session.Service const sess = yield* session.create({ title: "Test" }) const root = yield* memory.root() - const projDir = path.join(root, "projects", "global") + const projectID = Instance.project.id + const projDir = path.join(root, "projects", projectID) yield* Effect.promise(() => fs.mkdir(projDir, { recursive: true })) yield* Effect.promise(() => - fs.writeFile(path.join(projDir, "memory.md"), "用 Bun 不用 npm"), + fs.writeFile(path.join(projDir, "MEMORY.md"), "用 Bun 不用 npm"), ) const out = yield* cp.renderRebuildContext(sess.id) diff --git a/packages/opencode/test/session/checkpoint-render-verify.test.ts b/packages/opencode/test/session/checkpoint-render-verify.test.ts index 12e3ac038..475ddc1b8 100644 --- a/packages/opencode/test/session/checkpoint-render-verify.test.ts +++ b/packages/opencode/test/session/checkpoint-render-verify.test.ts @@ -85,7 +85,7 @@ describe("v5 verify (visual)", () => { const root = yield* memory.root() const sessDir = path.join(root, "sessions", sess.id) const taskDir = path.join(root, "sessions", sess.id, "tasks", t2.id) - const projDir = path.join(root, "projects", "global") + const projDir = path.join(root, "projects", Instance.project.id) yield* Effect.promise(async () => { await fs.mkdir(sessDir, { recursive: true }) diff --git a/packages/opencode/test/tool/actor-cancel.test.ts b/packages/opencode/test/tool/actor-cancel.test.ts index 4cf933bc0..77020af4c 100644 --- a/packages/opencode/test/tool/actor-cancel.test.ts +++ b/packages/opencode/test/tool/actor-cancel.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect } from "bun:test" +import { afterAll, afterEach, beforeAll, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" @@ -59,7 +59,9 @@ function parseOutput(output: string): CancelResponse { // test (with fiber interruption) lives in test/actor/spawn.test.ts. const cancelled: Array<{ sessionID: SessionID; actorID: string; mode: "graceful" | "forced" }> = [] let installedRegistry: ActorRegistry.Interface | undefined +let previousSpawnRef: ActorInterface | undefined beforeAll(() => { + previousSpawnRef = spawnRef.current spawnRef.current = { spawn: () => Effect.die("spawn not used in cancel tests"), cancel: (sessionID, actorID, mode) => @@ -72,6 +74,9 @@ beforeAll(() => { getForkContext: () => Effect.succeed(undefined), } satisfies ActorInterface }) +afterAll(() => { + spawnRef.current = previousSpawnRef +}) function ctxFor(sessionID: SessionID) { return { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5133d93db..8307566e6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,308 +4,6 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventActorRegistered = { - type: "actor.registered" - properties: { - sessionID: string - actorID: string - mode: "peer" | "subagent" | "main" - parentActorID?: string - description: string - agent: string - background: boolean - } -} - -export type EventActorStatus = { - type: "actor.status" - properties: { - sessionID: string - actorID: string - status: "pending" | "running" | "idle" - lastOutcome?: "success" | "failure" | "cancelled" - turnCount: number - lastTurnTime: number - error?: string - } -} - -export type EventActorStuck = { - type: "actor.stuck" - properties: { - sessionID: string - actorID: string - description: string - lastTurnTime: number - stuckDuration: number - } -} - -export type EventWriterCachePerf = { - type: "writer.cache_perf" - properties: { - sessionID: string - writerActorID: string - status: "completed" | "failed" - total_input_tokens: number - cache_read_tokens: number - cache_write_tokens: number - cache_hit_rate: number - num_llm_calls: number - } -} - -export type EventInboxArrived = { - type: "inbox.arrived" - properties: { - receiverSessionID: string - receiverActorID: string - senderSessionID?: string - senderActorID?: string - inboxID: string - type: string - } -} - -export type EventTaskCreated = { - type: "task.created" - properties: { - sessionID: string - task: { - id: string - session_id: string - parent_task_id?: string - status: "open" | "in_progress" | "blocked" | "done" | "abandoned" - summary: string - owner?: string - created_at: number - last_event_at: number - ended_at?: number - cleanup_after?: number - } - } -} - -export type EventTaskUpdated = { - type: "task.updated" - properties: { - sessionID: string - task: { - id: string - session_id: string - parent_task_id?: string - status: "open" | "in_progress" | "blocked" | "done" | "abandoned" - summary: string - owner?: string - created_at: number - last_event_at: number - ended_at?: number - cleanup_after?: number - } - kind: "started" | "unstarted" | "blocked" | "unblocked" | "done" | "abandoned" | "renamed" - } -} - -export type EventTeamCreated = { - type: "team.created" - properties: { - teamID: string - creatorSessionID: string - } -} - -export type EventTeamMemberJoined = { - type: "team.member.joined" - properties: { - teamID: string - sessionID: string - agent: string - role: string - } -} - -export type EventMetricsModelCall = { - type: "metrics.model_call" - properties: { - sessionID: string - finish_reason: string - ttft_ms?: number - latency_ms: number - cached_read_tokens: number - model_id: string - provider: string - total_tokens_in: number - total_tokens_out: number - } -} - -export type EventMetricsToolCall = { - type: "metrics.tool_call" - properties: { - sessionID: string - tool_name: string - input_bytes: number - output_bytes: number - tool_call_id: string - tool_call_status: "success" | "error" | "cancelled" - } -} - -export type EventMetricsAgentRequest = { - type: "metrics.agent_request" - properties: { - sessionID: string - phase: string - task_type: string - surface: string - total_tokens_in: number - total_tokens_out: number - files_changed: number - validation_status: string - } -} - -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - -export type EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - -export type EventTuiInstructionsLoaded = { - type: "tui.instructions.loaded" - properties: { - /** - * Display labels of loaded instruction files: worktree-relative path, ~-path, or absolute - */ - files: Array - } -} - -export type EventWorkflowPhase = { - type: "workflow.phase" - properties: { - sessionID: string - runID: string - title: string - } -} - -export type EventWorkflowLog = { - type: "workflow.log" - properties: { - sessionID: string - runID: string - message: string - } -} - -export type EventWorkflowStarted = { - type: "workflow.started" - properties: { - sessionID: string - runID: string - name: string - } -} - -export type EventWorkflowFinished = { - type: "workflow.finished" - properties: { - sessionID: string - runID: string - status: "completed" | "failed" | "cancelled" - error?: string - } -} - -export type EventWorkflowAgentFailed = { - type: "workflow.agent_failed" - properties: { - sessionID: string - runID: string - actorID?: string - agentType: string - label?: string - phase?: string - reason: "over-cap" | "spawn-reject" | "timeout" | "actor-error" | "no-deliverable" - errorMessage?: string - } -} - -export type EventWorkflowChildFailed = { - type: "workflow.child_failed" - properties: { - sessionID: string - runID: string - childRunID: string - name: string - status: "failed" | "cancelled" - error?: string - } -} - export type Project = { id: string worktree: string @@ -342,6 +40,20 @@ export type EventServerInstanceDisposed = { } } +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventFileEdited = { type: "file.edited" properties: { @@ -426,6 +138,69 @@ export type EventPermissionReplied = { } } +export type EventActorRegistered = { + type: "actor.registered" + properties: { + sessionID: string + actorID: string + mode: "peer" | "subagent" | "main" + parentActorID?: string + description: string + agent: string + background: boolean + } +} + +export type EventActorStatus = { + type: "actor.status" + properties: { + sessionID: string + actorID: string + status: "pending" | "running" | "idle" + lastOutcome?: "success" | "failure" | "cancelled" + turnCount: number + lastTurnTime: number + error?: string + } +} + +export type EventActorStuck = { + type: "actor.stuck" + properties: { + sessionID: string + actorID: string + description: string + lastTurnTime: number + stuckDuration: number + } +} + +export type EventWriterCachePerf = { + type: "writer.cache_perf" + properties: { + sessionID: string + writerActorID: string + status: "completed" | "failed" + total_input_tokens: number + cache_read_tokens: number + cache_write_tokens: number + cache_hit_rate: number + num_llm_calls: number + } +} + +export type EventInboxArrived = { + type: "inbox.arrived" + properties: { + receiverSessionID: string + receiverActorID: string + senderSessionID?: string + senderActorID?: string + inboxID: string + type: string + } +} + export type SnapshotFileDiff = { file: string patch: string @@ -707,6 +482,45 @@ export type EventBashInteractiveReplied = { } } +export type EventTaskCreated = { + type: "task.created" + properties: { + sessionID: string + task: { + id: string + session_id: string + parent_task_id?: string + status: "open" | "in_progress" | "blocked" | "done" | "abandoned" + summary: string + owner?: string + created_at: number + last_event_at: number + ended_at?: number + cleanup_after?: number + } + } +} + +export type EventTaskUpdated = { + type: "task.updated" + properties: { + sessionID: string + task: { + id: string + session_id: string + parent_task_id?: string + status: "open" | "in_progress" | "blocked" | "done" | "abandoned" + summary: string + owner?: string + created_at: number + last_event_at: number + ended_at?: number + cleanup_after?: number + } + kind: "started" | "unstarted" | "blocked" | "unblocked" | "done" | "abandoned" | "renamed" + } +} + export type Todo = { /** * Brief description of the task @@ -721,63 +535,186 @@ export type Todo = { export type EventTodoUpdated = { type: "todo.updated" properties: { - sessionID: string - todos: Array + sessionID: string + todos: Array + } +} + +export type EventTeamCreated = { + type: "team.created" + properties: { + teamID: string + creatorSessionID: string + } +} + +export type EventTeamMemberJoined = { + type: "team.member.joined" + properties: { + teamID: string + sessionID: string + agent: string + role: string + } +} + +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + message?: string + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionGoal = { + type: "session.goal" + properties: { + sessionID: string + goal?: { + condition: string + } + lastVerdict?: { + ok: boolean + impossible?: boolean + reason: string + attempt: number + messageID?: string + error?: boolean + } + } +} + +export type EventMetricsModelCall = { + type: "metrics.model_call" + properties: { + sessionID: string + finish_reason: string + ttft_ms?: number + latency_ms: number + cached_read_tokens: number + model_id: string + provider: string + total_tokens_in: number + total_tokens_out: number + } +} + +export type EventMetricsToolCall = { + type: "metrics.tool_call" + properties: { + sessionID: string + tool_name: string + input_bytes: number + output_bytes: number + tool_call_id: string + tool_call_status: "success" | "error" | "cancelled" + } +} + +export type EventMetricsAgentRequest = { + type: "metrics.agent_request" + properties: { + sessionID: string + phase: string + task_type: string + surface: string + total_tokens_in: number + total_tokens_out: number + files_changed: number + validation_status: string + } +} + +export type EventSessionCompacted = { + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - message?: string - } - -export type EventSessionStatus = { - type: "session.status" +export type EventTuiCommandExecute = { + type: "tui.command.execute" properties: { - sessionID: string - status: SessionStatus + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string } } -export type EventSessionIdle = { - type: "session.idle" +export type EventTuiToastShow = { + type: "tui.toast.show" properties: { - sessionID: string + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number } } -export type EventSessionGoal = { - type: "session.goal" +export type EventTuiSessionSelect = { + type: "tui.session.select" properties: { + /** + * Session ID to navigate to + */ sessionID: string - goal?: { - condition: string - } - lastVerdict?: { - ok: boolean - impossible?: boolean - reason: string - attempt: number - messageID?: string - error?: boolean - } } } -export type EventSessionCompacted = { - type: "session.compacted" +export type EventTuiInstructionsLoaded = { + type: "tui.instructions.loaded" properties: { - sessionID: string + /** + * Display labels of loaded instruction files: worktree-relative path, ~-path, or absolute + */ + files: Array } } @@ -867,6 +804,69 @@ export type EventPtyDeleted = { } } +export type EventWorkflowPhase = { + type: "workflow.phase" + properties: { + sessionID: string + runID: string + title: string + } +} + +export type EventWorkflowLog = { + type: "workflow.log" + properties: { + sessionID: string + runID: string + message: string + } +} + +export type EventWorkflowStarted = { + type: "workflow.started" + properties: { + sessionID: string + runID: string + name: string + } +} + +export type EventWorkflowFinished = { + type: "workflow.finished" + properties: { + sessionID: string + runID: string + status: "completed" | "failed" | "cancelled" + error?: string + } +} + +export type EventWorkflowAgentFailed = { + type: "workflow.agent_failed" + properties: { + sessionID: string + runID: string + actorID?: string + agentType: string + label?: string + phase?: string + reason: "over-cap" | "spawn-reject" | "timeout" | "actor-error" | "no-deliverable" + errorMessage?: string + } +} + +export type EventWorkflowChildFailed = { + type: "workflow.child_failed" + properties: { + sessionID: string + runID: string + childRunID: string + name: string + status: "failed" | "cancelled" + error?: string + } +} + export type EventWorkspaceReady = { type: "workspace.ready" properties: { @@ -1498,33 +1498,10 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventServerConnected - | EventGlobalDisposed - | EventActorRegistered - | EventActorStatus - | EventActorStuck - | EventWriterCachePerf - | EventInboxArrived - | EventTaskCreated - | EventTaskUpdated - | EventTeamCreated - | EventTeamMemberJoined - | EventMetricsModelCall - | EventMetricsToolCall - | EventMetricsAgentRequest - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventTuiInstructionsLoaded - | EventWorkflowPhase - | EventWorkflowLog - | EventWorkflowStarted - | EventWorkflowFinished - | EventWorkflowAgentFailed - | EventWorkflowChildFailed | EventProjectUpdated | EventServerInstanceDisposed + | EventServerConnected + | EventGlobalDisposed | EventFileEdited | EventFileWatcherUpdated | EventLspClientDiagnostics @@ -1534,6 +1511,11 @@ export type GlobalEvent = { | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied + | EventActorRegistered + | EventActorStatus + | EventActorStuck + | EventWriterCachePerf + | EventInboxArrived | EventSessionDiff | EventSessionError | EventSessionRetryAttempt @@ -1546,11 +1528,23 @@ export type GlobalEvent = { | EventSessionCwd | EventBashInteractiveAsked | EventBashInteractiveReplied + | EventTaskCreated + | EventTaskUpdated | EventTodoUpdated + | EventTeamCreated + | EventTeamMemberJoined | EventSessionStatus | EventSessionIdle | EventSessionGoal + | EventMetricsModelCall + | EventMetricsToolCall + | EventMetricsAgentRequest | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventTuiInstructionsLoaded | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -1561,6 +1555,12 @@ export type GlobalEvent = { | EventPtyUpdated | EventPtyExited | EventPtyDeleted + | EventWorkflowPhase + | EventWorkflowLog + | EventWorkflowStarted + | EventWorkflowFinished + | EventWorkflowAgentFailed + | EventWorkflowChildFailed | EventWorkspaceReady | EventWorkspaceFailed | EventWorkspaceRestore @@ -1926,6 +1926,19 @@ export type Config = { */ urls?: Array } + /** + * Compose mode configuration + */ + compose?: { + /** + * Directory where compose skills save specs, plans, and reports. Relative paths are passed to the agent prompt verbatim; set docs_absolute: true to anchor them to the project root. Defaults to docs/compose. + */ + docs?: string + /** + * Whether the docs directory injected into the compose prompt is an absolute path. When false (default), a relative `docs` value is passed through verbatim. When true, a relative `docs` is resolved against the active worktree root so it is unambiguous regardless of the agent's working directory. Ignored when `docs` is already absolute. + */ + docs_absolute?: boolean + } watcher?: { ignore?: Array } @@ -2171,6 +2184,14 @@ export type Config = { * Token cap for §11 Open notes section of checkpoint.md (writer-side budget validation). Default: 800. */ open_notes?: number + /** + * Token cap for the recent user input section (verbatim user messages from the live DB, FIFO eviction). Default: 16000. Set 0 to disable. + */ + recent_user?: number + /** + * Per-message cap inside recent user input section; oversized messages get head/tail truncation with messageID elision marker. Default: 2000. + */ + recent_user_per_msg?: number } /** * Number of days after task done/abandoned before it's filtered out of `list({include_archived: false})`. Rows are NOT deleted — see v9 for true GC. Default: 7. @@ -2662,33 +2683,10 @@ export type File = { } export type Event = - | EventServerConnected - | EventGlobalDisposed - | EventActorRegistered - | EventActorStatus - | EventActorStuck - | EventWriterCachePerf - | EventInboxArrived - | EventTaskCreated - | EventTaskUpdated - | EventTeamCreated - | EventTeamMemberJoined - | EventMetricsModelCall - | EventMetricsToolCall - | EventMetricsAgentRequest - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventTuiInstructionsLoaded - | EventWorkflowPhase - | EventWorkflowLog - | EventWorkflowStarted - | EventWorkflowFinished - | EventWorkflowAgentFailed - | EventWorkflowChildFailed | EventProjectUpdated | EventServerInstanceDisposed + | EventServerConnected + | EventGlobalDisposed | EventFileEdited | EventFileWatcherUpdated | EventLspClientDiagnostics @@ -2698,6 +2696,11 @@ export type Event = | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied + | EventActorRegistered + | EventActorStatus + | EventActorStuck + | EventWriterCachePerf + | EventInboxArrived | EventSessionDiff | EventSessionError | EventSessionRetryAttempt @@ -2710,11 +2713,23 @@ export type Event = | EventSessionCwd | EventBashInteractiveAsked | EventBashInteractiveReplied + | EventTaskCreated + | EventTaskUpdated | EventTodoUpdated + | EventTeamCreated + | EventTeamMemberJoined | EventSessionStatus | EventSessionIdle | EventSessionGoal + | EventMetricsModelCall + | EventMetricsToolCall + | EventMetricsAgentRequest | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventTuiInstructionsLoaded | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -2725,6 +2740,12 @@ export type Event = | EventPtyUpdated | EventPtyExited | EventPtyDeleted + | EventWorkflowPhase + | EventWorkflowLog + | EventWorkflowStarted + | EventWorkflowFinished + | EventWorkflowAgentFailed + | EventWorkflowChildFailed | EventWorkspaceReady | EventWorkspaceFailed | EventWorkspaceRestore @@ -4545,7 +4566,7 @@ export type SessionMessagesData = { directory?: string workspace?: string /** - * Maximum number of messages to return + * Maximum number of messages to return (max 1000) */ limit?: number before?: string