From b29de405fbe2e48529994e5fdb88da063d3edd16 Mon Sep 17 00:00:00 2001 From: yb-yu Date: Thu, 22 Oct 2026 19:22:00 +0300 Subject: [PATCH 1/4] feat(cli): new --member picks multi-repo members; project list names them On a multi-repo project, `termic new` had no way to say which members a task needs: a worktree create went through the single-repo task_create (no member worktrees at all), and on a plain-folder host the server pinned the main checkout, which links every member's live checkout. An orchestrator agent could not create the task the New Task dialog creates, and `project list` did not say which projects are multi-repo or name their members, so it had no names to pass. `--member ` (repeatable; MCP task_new `members`) names them. handle_new resolves the names against the disk-read project (case-insensitive, project order) and sends the webview root_paths plus a concrete mode: worktree unless --main, also on a plain-folder host, matching the dialog's canWorktree. The webview then calls task_create_multi (a plain-folder member is linked) or task_open_repo with its member filter. Without --member nothing changes. A wrong ask fails before anything is created, with the valid names: an unknown member lists the project's members, and --member on a single-repo project lists the registered multi-repo projects (or says none is registered). --member is also refused alongside --from, --checkout, --resume or a review. `project list` reports each project's `type` ("single" or "multi", the Project.type key) and a multi-repo project's members (name, root_path, non_git, base_branch), and marks those rows in text. `new --help`, TERMIC_CLI_HELP, MCP_INSTRUCTIONS and the bundled termic skill now say how to find one and name its members; the skill's control-surface reference no longer says task_new has no member list. Protocol v24: a v23 server would drop the field and create a task with no member worktrees, or with every member linked. Refs #424 --- docs/cli-agent-instructions.md | 24 ++ docs/e2e-coverage.md | 1 + docs/ipc.md | 27 ++ e2e/specs/cli.e2e.ts | 158 ++++++++++++ src-tauri/assets/skills/termic/SKILL.md | 10 + .../termic/references/control-surface.md | 15 +- src-tauri/src/cli_server.rs | 232 +++++++++++++++++- src-tauri/src/lib.rs | 2 +- src-tauri/src/mcp_server.rs | 18 +- src/lib/cliRpc.ts | 50 +++- termic-cli/src/lib.rs | 48 +++- termic-cli/src/output.rs | 63 ++++- termic-proto/src/lib.rs | 91 ++++++- 13 files changed, 707 insertions(+), 32 deletions(-) diff --git a/docs/cli-agent-instructions.md b/docs/cli-agent-instructions.md index c1762fe29..b42b5b282 100644 --- a/docs/cli-agent-instructions.md +++ b/docs/cli-agent-instructions.md @@ -210,6 +210,30 @@ Rules that matter: - Task names must be unique per project; a duplicate name is a clean error, so pick a fresh name or archive the old task first. +### Multi-repo projects + +A multi-repo project holds several repos, possibly many (every service +a team owns, say), plus a host folder whose CLAUDE.md and AGENTS.md +every task of it sees. Each task picks the members it needs and gets +its own worktree of each, on one task branch. + +Find one first. `project list` (MCP `project_list`) marks each +multi-repo project and lists its members; with `--json`, `type` is +"multi" and `members` holds each one's `name` and `root_path`: + + "$TERMIC_CLI" project list + +Then name the members the task needs with `--member `, once per +member (MCP `task_new` `members`): each gets its own worktree on the +task branch, a plain-folder member is linked, and the rest stay out. +With `--main` the named members' live checkouts are linked instead. An +unknown name fails before anything is created and lists the project's +members. Without `--member` no member gets a worktree, so pass it +whenever the work touches a member repo: + + "$TERMIC_CLI" new fix-login --project shop --member api --member web \ + -p "..." + ### Your task group Every task you create with `new` from inside your own task joins YOUR diff --git a/docs/e2e-coverage.md b/docs/e2e-coverage.md index 6e3158e27..565ae4004 100644 --- a/docs/e2e-coverage.md +++ b/docs/e2e-coverage.md @@ -78,6 +78,7 @@ until `make e2e` is green and this file reflects it. | ✅ CLI tab titles, store side (unit) | `new_tab` with a title sets it as a locked, durable rename (`customTitle`, `custom_title`) and refuses one the RESTORED strip already answers to by title or cli id, adding nothing; `rename_tab` renames and locks, refuses another tab's title or cli id but not the tab's own, writes nothing for an unchanged title, resets with `""` (a tab nobody renamed is a no-op), and answers a vanished tab or a stopped task with typed sentinels. `clearTabCustomTitle` restores the AUTOMATIC title instead of leaving the old name in `title` | `store/cliTabTitle.integration.test.ts`, `store/app.test.ts` | | ✅ CLI adopt worktree (GH #169) | Over the real socket: `new --from` adopts an externally-created worktree (project resolved from its repo, name derived from its branch), re-adopting the same path and a plain non-worktree dir refuse cleanly, and `--resume` on both `new` and `tab` refuses an agent without id-resume support; `--resume` SEEDING (task agent_session_ids, tab sessionId, `--resume ` arg composition keeping `--name`) is pinned by `cli_server.rs` tests + `agents.test.ts` + `store/cliTab.integration.test.ts` | `cli.e2e.ts` | | ✅ CLI checkout existing branch | Over the real socket: `new --checkout /` with no name creates a worktree task on the local tracking branch, named after the branch minus its remote; `checkout` with the main checkout is refused before anything is created. Shape guards (with `from`, blank branch, non-git project) and forwarding are pinned by `cli_server.rs` tests, flag conflicts by `termic-cli` parse tests | `cli.e2e.ts` | +| ✅ CLI multi-repo members (proto v24) | The real sidecar on a plain-folder multi-repo host: `new --member ALPHA` (case-insensitive, no `--worktree`) creates a worktree task whose composition is alpha alone, a real worktree of alpha on the task branch, with the unnamed member untouched and `taskGitStatus` listing only the host and alpha; `--member alpha --member beta` creates a worktree task whose composition is exactly alpha and beta, each a real worktree on the one task branch, with `taskGitStatus` listing the host, alpha and beta; `--member beta --main` links only beta's live checkout into the host; an unknown name exits 1 naming it and the members, with no task and no branch created; `--member` on the single-repo fixture project exits 1 naming the multi-repo projects (this one among them, the fixture not), with no task and no branch created; `project list --json` reports that project as `type: "multi"` with both members and their bases, while a single-repo row stays `type: "single"` with no members. Resolution (project order, repeats, case), the plain-folder no-pin rule, the `--main` mode and the shape refusals (single-repo project, its message with and without a registered multi-repo project, with `from` / `checkout` / `pr` / `resume`) are pinned by `cli_server.rs` tests, flag conflicts by `termic-cli` parse tests, the MCP `members` param by `mcp_server.rs`, the `project list` rows by `cli_server.rs` and `termic-proto` tests and the `termic-cli` text goldens | `cli.e2e.ts` | | ✅ CLI prompt library (Phase 4) | Over the real socket: `prompts` lists the shipped library (ids + flags, no bodies); `prompts show` resolves ids and case-insensitive live titles, including disabled prompts, and a miss is a typed not_found; `send -P` composes body + blank line + `-p` text into the real agent; a bad `-P` on `new` fails fast with no task created. Selector precedence/ambiguity, composition, empty-body refusal and fail-fast ordering are pinned by `cli_server.rs` tests; the live-store contract by `store/cliPrompts.integration.test.ts` | `cli.e2e.ts` | | ✅ Windowless completion | An agent finishing while Termic is windowless still flags unread/done, even for the task that was active | `app.e2e.ts` | | ✅ Failed resume opens the agent's picker (GH #311) | `fakeclaude` reproduces claude's shapes (a dead id prints "No conversation found" and exits 1; `--resume` alone is the picker; a lone Esc leaves it): a stored id that no longer resolves spawns `--resume` with no id instead of `--session-id`, the toast offers the picker and carries the agent's own line, the id picked is stored and resumed on the next relaunch, and leaving the picker starts a fresh session exactly once; the picker spawn carries no `--name`; a sibling main-checkout task's conversation picked there is not stored and the toast names its task; a clean quit (Ctrl+C, exit 0) inside the failure window keeps the stored id and opens no picker. Controls: without the picker the first three fail; without the fallback the third does | `agent.e2e.ts` | diff --git a/docs/ipc.md b/docs/ipc.md index 47f569bee..1119358eb 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -255,6 +255,33 @@ set (the name is the OTHER machine's profile), and `task_create` with `checkout_existing: true` checks `branch` out as it exists instead of cutting it from `base_branch`, which then only sets the diff baseline. `checkout_existing_branch` (lib.rs) runs first and turns the request into a local branch (local as is; `origin/x` or a bare remote-only `x` fetched and tracked) or an error; it never falls through to the new-branch path. The New Task dialog's "Existing branch" mode and `termic new --checkout` (proto v14, `Command::New.checkout`) are the two callers. The flag is saved on the task (`Task.checkout_existing`), so `task_restore` brings a branch archive deleted back the same way (`ensure_restore_branch`) instead of cutting it from the base. See [ui.md](ui.md) "Checking out an existing branch". +### Multi-repo members from the CLI + +`termic new --member ` (proto v24, `Command::New.members`, MCP +`task_new` `members`) is how a CLI or MCP create gets member worktrees. +The wire carries member NAMES; `handle_new` resolves them against the +disk-read project (`resolve_new_members`: case-insensitive, project +order, repeats collapsed), refuses an unknown one as not_found listing +the members, refuses `members` on a single-repo project (naming the +registered multi-repo projects, or saying there are none) and alongside +`from` / `checkout` / `pr` / `resume`, and sends the `new_task` RPC the +member root_paths plus a concrete mode: "main" only when asked for, +otherwise "worktree", also on a plain-folder host, which keeps the +dialog's `canWorktree = isMulti || !hostNonGit` instead of the +single-repo plain-folder pin. The webview's `new_task` handler then +calls `task_create_multi` (each member `worktree`, or `repo_root` when +it is a plain folder; member branch and base left to Rust's fallbacks) +or `task_open_repo` with the `members` filter. Without `members` nothing +changes: a worktree create is a single-repo `task_create` of the host +with no members, and a main-checkout create (the only kind on a +plain-folder host) links every member. + +`termic project list` (MCP `project_list`) is where a caller finds +those names: `ProjectInfo` carries `type` ("single" or "multi", the +`Project.type` key) and a multi project's `members`, each one's +`name`, `root_path`, `non_git` and `base_branch` +(`ProjectMemberInfo`), filled by `project_info` from the disk read. + ### Non-blocking task creation (GH #242) `task_create`/`task_create_multi` being `spawn_blocking` on the Rust side keeps the WKWebView event loop free, but that alone doesn't stop the FRONTEND from blocking: the old New Task dialog (and, separately, the sidebar `+` quick-create row via `QuickCreateProgressDialog`) each `await`ed the call with a dialog locked open, so the whole window was unusable until `git worktree add` + the file copy finished on a big repo. There were TWO of these blocking implementations (the full dialog and the "quick create" inline path in `Sidebar.tsx`/`quickTask.ts`) — both needed the same fix, since a caller with the id ahead of time gets the same treatment regardless of which UI it came from. The fix doesn't use `useUI.setBusy` (that dims the entire app for one in-flight task, the wrong blast radius) — instead: diff --git a/e2e/specs/cli.e2e.ts b/e2e/specs/cli.e2e.ts index ada397ecd..6ec79039b 100644 --- a/e2e/specs/cli.e2e.ts +++ b/e2e/specs/cli.e2e.ts @@ -1204,6 +1204,164 @@ describe("termic new --checkout: an existing branch into a new worktree", () => }); }); +// `termic new --member` (proto v24): an orchestrator names the member repos a +// multi-repo task needs. Before it, the CLI cut a host-only worktree on a git +// host and, on a plain-folder host, opened the main checkout with EVERY member +// linked. The host here is a plain folder, the case that used to be pinned to +// the main checkout, driven through the real sidecar. +// +// Fixture: the git.e2e.ts multi-repo shape, a non-git host plus two throwaway +// member repos under one tmp dir, torn down completely afterwards. +describe("termic new --member: only the named members of a multi-repo task", () => { + const PROJECT = "e2e-cli-multi"; + const gitIn = (cwd: string, args: string) => + execSync(`git ${args}`, { cwd, encoding: "utf8" }).trim(); + const cli = (...args: string[]) => + runCli(["--no-launch", "--json", "new", ...args, "--project", PROJECT, "--agent", "fakeagent"], + { TERMIC_DATA_DIR: dataDir }); + const stored = (taskId: string) => + JSON.parse(fs.readFileSync(path.join(dataDir, "tasks", `${taskId}.json`), "utf8")); + const repo = (name: string) => path.join(tmp, name); + + let tmp = ""; + let projectId = ""; + + before(async () => { + await waitForAppShell(); + await requireTermicApi(); + await waitForClisDetected(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-cli-multi-")); + fs.mkdirSync(repo("host")); + for (const name of ["alpha", "beta"]) { + fs.mkdirSync(repo(name)); + gitIn(repo(name), "init -b main -q"); + fs.writeFileSync(path.join(repo(name), "README.md"), `# ${name}\n`); + gitIn(repo(name), "add ."); + gitIn(repo(name), "-c user.email=e2e@termic.dev -c user.name=e2e commit -q -m init"); + } + projectId = await browser.execute(async (host, alpha, beta, name) => { + const t = window.__termic!; + // A run that died before its teardown leaves the project behind. + for (const p of t.useApp.getState().projects.filter((p: any) => p.name === name)) { + try { await t.ipc.projectRemove(p.id); } catch { /* has live tasks */ } + } + // Explicit base: these repos have no remote to derive one from. + const spec = (root_path: string, n: string) => ({ + root_path, name: n, base_branch: "main", setup_script: "", run_script: "", archive_script: "", + }); + const proj = await t.ipc.projectAddMulti(host, name, [spec(alpha, "alpha"), spec(beta, "beta")], true); + await t.useApp.getState().loadAll(); + return proj.id as string; + }, repo("host"), repo("alpha"), repo("beta"), PROJECT); + }); + + after(async () => { + await browser.execute(async (pid) => { + const t = window.__termic!; + for (const task of t.useApp.getState().tasks) { + if (task.project_id === pid && !task.archived) await t.ipc.taskArchive(task.id, true); + } + try { await t.ipc.projectRemove(pid); } catch { /* already gone */ } + await t.useApp.getState().loadAll(); + }, projectId); + rmTree(tmp, { bestEffort: true }); + }); + + it("project list reports it as multi-repo with its members, and a single repo as single", () => { + const projects = JSON.parse(runCli(["--no-launch", "--json", "project", "list"], + { TERMIC_DATA_DIR: dataDir })).projects; + const p = projects.find((p: any) => p.name === PROJECT); + expect(p.type).toBe("multi"); + expect(p.members.map((m: any) => [m.name, fs.realpathSync.native(m.root_path), m.non_git, m.base_branch])).toEqual([ + ["alpha", fs.realpathSync.native(repo("alpha")), false, "main"], + ["beta", fs.realpathSync.native(repo("beta")), false, "main"], + ]); + const single = projects.find((p: any) => p.name === "fixture-repo"); + expect(single.type).toBe("single"); + expect(single.members).toBeUndefined(); + }); + + it("worktrees only the named member, on the task branch", async () => { + // Case-insensitive, and no --worktree: naming a member means a worktree. + const out = JSON.parse(cli("cli-multi-alpha", "--member", "ALPHA")); + const task = stored(out.task.id); + expect(task.is_main_checkout).toBe(false); + expect(task.composition.map((m: any) => [m.dir_name, m.mode])).toEqual([["alpha", "worktree"]]); + // A real worktree of alpha on the task branch, not its live checkout. + const alpha = task.composition[0]; + expect(alpha.branch).toBe(task.branch); + expect(gitIn(alpha.path, "rev-parse --abbrev-ref HEAD")).toBe(task.branch); + expect(fs.realpathSync.native(alpha.path)).not.toBe(fs.realpathSync.native(repo("alpha"))); + // beta was not named: no branch cut there, and not in the task's repos. + expect(gitIn(repo("beta"), `branch --list ${JSON.stringify(task.branch)}`)).toBe(""); + const st = await browser.execute((id) => window.__termic!.ipc.taskGitStatus(id), out.task.id); + expect(st.repos.map((r: any) => r.dir_name)).toEqual(["", "alpha"]); + }); + + it("worktrees every named member, each on the one task branch", async () => { + const out = JSON.parse(cli("cli-multi-both", "--member", "alpha", "--member", "beta")); + const task = stored(out.task.id); + expect(task.is_main_checkout).toBe(false); + expect(task.composition.map((m: any) => [m.dir_name, m.mode])).toEqual([ + ["alpha", "worktree"], + ["beta", "worktree"], + ]); + for (const m of task.composition) { + expect(gitIn(m.path, "rev-parse --abbrev-ref HEAD")).toBe(task.branch); + expect(fs.realpathSync.native(m.path)).not.toBe(fs.realpathSync.native(repo(m.dir_name))); + } + const st = await browser.execute((id) => window.__termic!.ipc.taskGitStatus(id), out.task.id); + expect(st.repos.map((r: any) => r.dir_name)).toEqual(["", "alpha", "beta"]); + }); + + it("--main links only the named member's live checkout into the host", async () => { + const out = JSON.parse(cli("cli-multi-main", "--member", "beta", "--main")); + const task = stored(out.task.id); + expect(task.is_main_checkout).toBe(true); + expect(task.composition.map((m: any) => [m.dir_name, m.mode])).toEqual([["beta", "repo_root"]]); + expect(fs.realpathSync.native(path.join(repo("host"), "beta"))).toBe(fs.realpathSync.native(repo("beta"))); + expect(fs.existsSync(path.join(repo("host"), "alpha"))).toBe(false); + }); + + it("refuses an unknown member, listing the members, and creates nothing", async () => { + let err = ""; + try { + cli("cli-multi-typo", "--member", "alpha", "--member", "gamma"); + } catch (e) { + err = (e as Error).message; + } + expect(err).toContain("exit 1"); + expect(err).toContain("\"gamma\""); + expect(err).toContain("alpha, beta"); + const list = await rpc({ cmd: "list", project: PROJECT }); + expect(list.ok).toBe(true); + expect(list.data.tasks.some((t: any) => t.name === "cli-multi-typo")).toBe(false); + expect(gitIn(repo("alpha"), "branch --list \"*cli-multi-typo*\"")).toBe(""); + }); + + it("refuses --member on a single-repo project, naming the multi-repo ones, and creates nothing", async () => { + let err = ""; + try { + runCli(["--no-launch", "--json", "new", "cli-multi-single", "--member", "alpha", + "--project", "fixture-repo", "--agent", "fakeagent"], { TERMIC_DATA_DIR: dataDir }); + } catch (e) { + err = (e as Error).message; + } + expect(err).toContain("exit 1"); + expect(err).toContain("is not a multi-repo project; --member needs one"); + const named = /\(multi-repo projects: ([^)]*)\)/.exec(err)?.[1].split(", "); + expect(named).toContain(PROJECT); + expect(named).not.toContain("fixture-repo"); + const list = await rpc({ cmd: "list", project: "fixture-repo" }); + expect(list.ok).toBe(true); + expect(list.data.tasks.some((t: any) => t.name === "cli-multi-single")).toBe(false); + const fixtureRoot = await browser.execute(() => + (window.__termic!.useApp.getState().projects + .find((p: any) => p.name === "fixture-repo") as any).root_path); + expect(gitIn(fixtureRoot, "branch --list \"*cli-multi-single*\"")).toBe(""); + }); +}); + describe("termic new task parameters (GH #287)", () => { let taskId = ""; diff --git a/src-tauri/assets/skills/termic/SKILL.md b/src-tauri/assets/skills/termic/SKILL.md index d763eef92..5db266a3d 100644 --- a/src-tauri/assets/skills/termic/SKILL.md +++ b/src-tauri/assets/skills/termic/SKILL.md @@ -95,6 +95,16 @@ Rules that matter: - `--account ` (MCP `account`) runs the new task's agent on one of its named accounts (Settings > Agents > credentials). - Task names must be unique per project. +## Multi-Repo Projects + +A multi-repo project holds several repos, possibly many, plus a host folder whose CLAUDE.md and AGENTS.md every task of it sees. Its repos are its members, and each task picks the ones it needs. `"$TERMIC_CLI" project list` (MCP `project_list`) shows which projects are multi-repo and names their members; with `--json`, `type` is `"multi"` and `members` holds each one's `name` and `root_path`. + +Name the members a task needs with `--member `, once per member (MCP `task_new` `members`). Each named member gets its own worktree on the task branch, and the rest stay out. Without `--member` no member gets a worktree, so pass it whenever the work touches a member repo: + + "$TERMIC_CLI" new fix-login --project shop --member api --member web -p "..." + +An unknown member name, or `--member` on a project that is not multi-repo, fails before anything is created and lists the valid names: the project's members, or the multi-repo projects. Read the names from `project list` rather than guessing them. + ## Your Task Group Every task you create with `new` from inside your own task joins YOUR task's group: the sidebar draws them as one coloured block with a caption, led by your task, so the user can see which tasks you started. A worker that creates tasks adds them to the same group (groups do not nest). `new --no-group` keeps a task out. diff --git a/src-tauri/assets/skills/termic/references/control-surface.md b/src-tauri/assets/skills/termic/references/control-surface.md index 08c531b88..47277af6d 100644 --- a/src-tauri/assets/skills/termic/references/control-surface.md +++ b/src-tauri/assets/skills/termic/references/control-surface.md @@ -19,7 +19,7 @@ discovery result is authoritative; do not invent a missing tool or flag. | --- | --- | --- | | `list` | `task_list` | Tasks and work states | | `status` | `task_status` | Task details, path, and tabs | -| `new` | `task_new` | Create a task and optionally start an agent | +| `new` | `task_new` | Create a task, with any multi-repo members, and optionally start an agent | | `review` | `task_review` | Create a PR review task | | `send` | `task_send` | Prompt or resume an agent | | `wait` | `task_wait` | Wait for a settled turn | @@ -42,7 +42,7 @@ discovery result is authoritative; do not invent a missing tool or flag. | `scratchpad list` | `scratchpad_list` | List notes | | `scratchpad rm` | `scratchpad_remove` | Delete a note | | `scratchpad clear` | `scratchpad_clear` | Delete all notes in a target scope | -| `project list` | `project_list` | List registered projects | +| `project list` | `project_list` | List registered projects, their type, and multi-repo members | | `project add` | `project_add` | Register a repository or plain folder | | `project remove` | `project_remove` | Unregister a project and delete its tasks | | `schedule list` | `schedule_list` | List recurring schedules | @@ -106,10 +106,13 @@ or MCP `project`, retain the returned IDs, and coordinate using `send` / `task_send`. A task created in another project is linked by `spawned_by`, but does not join a group in the first project's sidebar list. -That is distinct from one composed, multi-repository task. The `new` / -`task_new` interface has no repository-member list; do not invent a -`members` argument. Use the app's multi-repository flow when that layout -is required, or separate linked tasks when the assignment permits it. +That is distinct from one composed, multi-repository task, which lives in +a multi-repo project. `project list` / `project_list` shows which projects +are multi-repo and names their members. Pass each member the task needs as +`--member `, repeated (MCP `task_new` `members`): each named member +gets a worktree on the task branch, and without it no member gets one. An +unknown name, or a project that is not multi-repo, fails and lists the +valid names. CLI global `--profile ` explicitly routes commands to a profile. Otherwise the named project's profile is used, falling back to diff --git a/src-tauri/src/cli_server.rs b/src-tauri/src/cli_server.rs index fded8a773..773e4cd30 100644 --- a/src-tauri/src/cli_server.rs +++ b/src-tauri/src/cli_server.rs @@ -1210,6 +1210,28 @@ pub(crate) fn resolve_project_for_worktree<'a>( }) } +/// `new --member` (v24): the named members of a multi-repo project as +/// root_paths, in PROJECT order, so the order and repeats the caller typed +/// change nothing (a filter, like `task_open_repo`'s `members`). Names match +/// case-insensitively, like project names. +fn resolve_new_members(proj: &Project, names: &[String]) -> Result, proto::ErrorBody> { + let is = |m: &crate::ProjectMember, n: &str| m.name.eq_ignore_ascii_case(n.trim()); + if let Some(unknown) = names.iter().find(|n| !proj.members.iter().any(|m| is(m, n))) { + let have: Vec<&str> = proj.members.iter().map(|m| m.name.as_str()).collect(); + return Err(proto::ErrorBody { + code: ErrorCode::NotFound, + message: format!( + "project \"{}\" has no member \"{}\" (members: {})", + proj.name, + unknown.trim(), + have.join(", ") + ), + data: None, + }); + } + Ok(proj.members.iter().filter(|m| names.iter().any(|n| is(m, n))).map(|m| m.root_path.clone()).collect()) +} + fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Reply { let Command::New { name, @@ -1232,6 +1254,7 @@ fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Re cwd, parent_task, pr, + members, } = &req.cmd else { unreachable!("handle_new called with a non-new command") @@ -1284,6 +1307,14 @@ fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Re if *pr == Some(0) { return fail(ErrorCode::BadRequest, "pr needs a pull request number".into()); } + // Members shape (v24): task_create_multi cuts fresh member worktrees and + // has no adopt, existing-branch or session-seed path. + if !members.is_empty() && (from.is_some() || checkout.is_some() || pr.is_some() || resume.is_some()) { + return fail( + ErrorCode::BadRequest, + "--member creates a fresh multi-repo task; it cannot combine with --from, --checkout, --resume or a review".into(), + ); + } let review_name = pr.map(|n| format!("review-pr-{n}")); let mut trimmed = name.trim(); // A review is named after its PR unless the caller named it. @@ -1360,12 +1391,36 @@ fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Re ); } + let member_paths = if members.is_empty() { + None + } else if proj.project_type != crate::ProjectType::Multi { + let multi: Vec<&str> = + projects.iter().filter(|p| p.project_type == crate::ProjectType::Multi).map(|p| p.name.as_str()).collect(); + let valid = if multi.is_empty() { + "no multi-repo project is registered".to_string() + } else { + format!("multi-repo projects: {}", multi.join(", ")) + }; + return fail( + ErrorCode::BadRequest, + format!("project \"{}\" is not a multi-repo project; --member needs one ({valid})", proj.name), + ); + } else { + match resolve_new_members(proj, members) { + Ok(paths) => Some(paths), + Err(e) => return Reply { id: id.clone(), ok: false, data: None, error: Some(e) }, + } + }; + // Non-git projects cannot host worktrees (the GUI forces the main // checkout for them); an explicit --worktree is an impossible ask, // and an unspecified mode is pinned to main HERE, against the // disk-read project, so a lagging webview store cannot fall back to - // a remembered worktree mode. - if proj.non_git && mode.as_deref() == Some("worktree") { + // a remembered worktree mode. Members are the exception: a plain-folder + // multi-repo host still worktrees its members (the dialog's + // `canWorktree`), and naming them means worktrees unless --main says + // otherwise, never the remembered mode. + if proj.non_git && member_paths.is_none() && mode.as_deref() == Some("worktree") { return fail( ErrorCode::BadRequest, format!( @@ -1374,7 +1429,11 @@ fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Re ), ); } - let mode = if proj.non_git { &Some("main".to_string()) } else { mode }; + let mode = match (&member_paths, proj.non_git) { + (Some(_), _) => Some(if mode.as_deref() == Some("main") { "main" } else { "worktree" }), + (None, true) => Some("main"), + (None, false) => mode.as_deref(), + }; // Same-name collision is a clean error naming the existing task, // never cleanup (docs/plans/cli.md: task_create_sync's orphan @@ -1510,6 +1569,7 @@ fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Re "prompt": prompt, "promptId": prompt_id, "pr": pr, + "members": member_paths, }); // Forward setup output; heartbeat on idle. A dead client cannot // cancel the create (it already committed app-side), so emit @@ -3758,12 +3818,27 @@ fn handle_schedule( // ───────────────────────────── projects ────────────────────────────── fn project_info(p: &Project, tasks: &[Task]) -> proto::ProjectInfo { + let multi = p.project_type == crate::ProjectType::Multi; proto::ProjectInfo { id: p.id.clone(), name: p.name.clone(), root_path: p.root_path.clone(), tasks: tasks.iter().filter(|t| !t.archived && t.project_id == p.id).count() as u32, default_agent: p.default_cli.clone(), + project_type: if multi { "multi" } else { "single" }.into(), + members: if multi { + p.members + .iter() + .map(|m| proto::ProjectMemberInfo { + name: m.name.clone(), + root_path: m.root_path.clone(), + non_git: m.non_git, + base_branch: m.base_branch.clone(), + }) + .collect() + } else { + Vec::new() + }, } } @@ -6892,6 +6967,7 @@ mod tests { cwd: None, parent_task: None, pr: None, + members: Vec::new(), } } @@ -7274,6 +7350,142 @@ mod tests { assert!(err.message.contains("could not create the task"), "{}", err.message); } + // ── new --member: a multi-repo task's members (v24) ────────────── + + /// The stub plus a multi-repo project "mono" with members api, Web and + /// a plain-folder docs; `non_git_host` makes its host a plain folder. + fn multi_host(non_git_host: bool) -> StubHost { + let mut host = StubHost::default(); + let mut mono = project("p3", "mono", "/repo/mono"); + mono.project_type = crate::ProjectType::Multi; + mono.non_git = non_git_host; + mono.members = [("api", "/src/api", false), ("Web", "/src/web", false), ("docs", "/src/docs", true)] + .into_iter() + .map(|(name, root, non_git)| crate::ProjectMember { + name: name.into(), + root_path: root.into(), + non_git, + ..Default::default() + }) + .collect(); + host.projects.push(mono); + host.script_rpc("new_task", Ok(serde_json::json!({ "taskId": "nw1", "spawned": true }))); + host + } + + fn member_cmd(name: &str, members: &[&str]) -> Command { + let mut cmd = new_cmd(name, Some("mono")); + if let Command::New { members: m, .. } = &mut cmd { + *m = members.iter().map(|s| s.to_string()).collect(); + } + cmd + } + + /// The `new_task` params a successful create sent. + fn sent(host: &StubHost, cmd: Command) -> serde_json::Value { + let reply = handle(&req(cmd, Some("tok")), host); + assert!(reply.ok, "{reply:?}"); + host.rpc_calls.lock().unwrap().last().unwrap().1.clone() + } + + #[test] + fn new_members_forward_root_paths_in_project_order_as_a_worktree_task() { + let host = multi_host(false); + // Typed order, case and repeats change nothing: project order, once each. + let params = sent(&host, member_cmd("t1", &["docs", "API", "api"])); + assert_eq!(params["projectId"], "p3"); + assert_eq!(params["members"], serde_json::json!(["/src/api", "/src/docs"])); + // Members mean worktrees, never the GUI's remembered mode. + assert_eq!(params["mode"], "worktree"); + let params = sent(&host, member_cmd("t2", &["WEB", " web "])); + assert_eq!(params["members"], serde_json::json!(["/src/web"])); + // No members: the pre-v24 params, mode left to the webview. + let params = sent(&host, new_cmd("t3", Some("mono"))); + assert!(params["members"].is_null(), "{params}"); + assert!(params["mode"].is_null(), "{params}"); + } + + #[test] + fn new_members_with_main_link_the_named_checkouts() { + let host = multi_host(false); + let mut cmd = member_cmd("t1", &["api"]); + if let Command::New { mode, .. } = &mut cmd { + *mode = Some("main".into()); + } + let params = sent(&host, cmd); + assert_eq!(params["mode"], "main"); + assert_eq!(params["members"], serde_json::json!(["/src/api"])); + } + + #[test] + fn new_members_on_a_plain_folder_host_still_worktree() { + // The host folder has no git, but its members do: the dialog's + // `canWorktree` holds for a multi project, so no main-checkout pin. + let host = multi_host(true); + let params = sent(&host, member_cmd("t1", &["api"])); + assert_eq!(params["mode"], "worktree"); + let mut cmd = member_cmd("t2", &["api"]); + if let Command::New { mode, .. } = &mut cmd { + *mode = Some("worktree".into()); + } + assert_eq!(sent(&host, cmd)["mode"], "worktree", "an explicit worktree is not refused"); + // Without members the plain-folder pin is unchanged. + let params = sent(&host, new_cmd("t3", Some("mono"))); + assert_eq!(params["mode"], "main"); + assert!(params["members"].is_null(), "{params}"); + } + + #[test] + fn new_member_refusals_fire_before_any_rpc() { + let mut host = multi_host(false); + let mut infra = project("p4", "infra", "/repo/infra"); + infra.project_type = crate::ProjectType::Multi; + host.projects.push(infra); + // An unknown name, among good ones: named, with the members to pick from. + let err = handle(&req(member_cmd("t1", &["api", "billing"]), Some("tok")), &host).error.unwrap(); + assert_eq!(err.code, ErrorCode::NotFound); + assert!(err.message.contains("\"billing\""), "{}", err.message); + assert!(err.message.contains("api, Web, docs"), "{}", err.message); + // A single-repo project has no members to name: the refusal names the + // projects that do, or says there are none. + let on_web = || { + let mut cmd = member_cmd("t1", &["api"]); + if let Command::New { project, .. } = &mut cmd { + *project = Some("web".into()); + } + cmd + }; + let err = handle(&req(on_web(), Some("tok")), &host).error.unwrap(); + assert_eq!(err.code, ErrorCode::BadRequest); + assert_eq!( + err.message, + "project \"web\" is not a multi-repo project; --member needs one (multi-repo projects: mono, infra)" + ); + let err = handle(&req(on_web(), Some("tok")), &StubHost::default()).error.unwrap(); + assert_eq!(err.code, ErrorCode::BadRequest); + assert_eq!( + err.message, + "project \"web\" is not a multi-repo project; --member needs one (no multi-repo project is registered)" + ); + // The multi create has no adopt, existing-branch, review or resume path. + let shapes: [fn(&mut Command); 4] = [ + |c| if let Command::New { from, .. } = c { *from = Some("/elsewhere/wt".into()) }, + |c| if let Command::New { checkout, .. } = c { *checkout = Some("alice/fix".into()) }, + |c| if let Command::New { pr, .. } = c { *pr = Some(7) }, + |c| if let Command::New { resume, agent, .. } = c { + *resume = Some("SESSION-X".into()); + *agent = Some("claude".into()); + }, + ]; + for shape in shapes { + let mut cmd = member_cmd("t1", &["api"]); + shape(&mut cmd); + let err = handle(&req(cmd, Some("tok")), &host).error.unwrap(); + assert_eq!(err.code, ErrorCode::BadRequest, "{}", err.message); + } + assert!(host.rpc_calls.lock().unwrap().is_empty()); + } + // ── tab / agents (GH #138) ─────────────────────────────────────── fn tab_cmd(task: &str, kind: proto::TabKind) -> Command { @@ -8552,6 +8764,20 @@ mod tests { assert!(host.ops.lock().unwrap().is_empty()); } + // ── project list: each project's type and members (v24) ────────── + + #[test] + fn project_list_reports_each_projects_type_and_a_multi_projects_members() { + let host = multi_host(false); + let Some(ReplyData::ProjectList(l)) = handle(&req(Command::ProjectList, Some("tok")), &host).data else { panic!() }; + let mono = l.projects.iter().find(|p| p.name == "mono").unwrap(); + assert_eq!(mono.project_type, "multi"); + let rows: Vec<_> = mono.members.iter().map(|m| (m.name.as_str(), m.root_path.as_str(), m.non_git)).collect(); + assert_eq!(rows, [("api", "/src/api", false), ("Web", "/src/web", false), ("docs", "/src/docs", true)]); + let web = l.projects.iter().find(|p| p.name == "web").unwrap(); + assert_eq!((web.project_type.as_str(), web.members.len()), ("single", 0)); + } + // ── streamed replies over the real socket ──────────────────────── #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6efe018c8..8f8d8cbcb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4505,7 +4505,7 @@ fn pty_spawn( // agent to prompt them back (src/lib/agentBriefing.ts). cmd.env( "TERMIC_CLI_HELP", - "You are running INSIDE a Termic task ($TERMIC_TASK_ID): Termic runs coding agents side by side, each task a git worktree (or the main checkout) with its own terminal, listed in the app's sidebar. TERMIC_CLI is the Termic control CLI, which drives the app around you. Run `\"$TERMIC_CLI\" help --json` for the full command surface. Prompt an existing task with `\"$TERMIC_CLI\" send -p \"...\"`; create one with `\"$TERMIC_CLI\" new -p \"...\"` and, in that prompt, ask it to report back to you (the signed reply below) every time it finishes or gets stuck, follow-up work included, until a human tells it to stop: that is how results come back, and it arrives in your own terminal. If no report arrives, `result` and `logs` read what it produced. Ask for a file (e.g. RESULT.md in its worktree) only when it cannot report back: a task sandboxed in enforce/enforce-fs is denied this CLI, and so is anything run outside Termic. Unattended tasks need `--yolo` or `--sandbox enforce` or they stop at the first permission prompt; the cage self-approves inside it but costs you the report-back. Coordinate by prompting each other, not by blocking: end every prompt you send with the command you want run when that work is done, in DOUBLE quotes so your own shell fills in your task name and address: `\"$TERMIC_CLI\" send -p \"[message from agent: task:$TERMIC_TASK id:$TERMIC_TASK_ID] . When done, reply: \\\"$TERMIC_CLI\\\" send $TERMIC_TASK_ID -p '[message from agent: task: id:] done: -- agent: task: id:' -- agent: task:$TERMIC_TASK id:$TERMIC_TASK_ID\"` (fill the parts with the task you are prompting, which you know). Every prompt you send another agent opens with that header, `[message from agent: task: id:]`, and ends with that signature, `-- agent: task: id:`, naming YOU, so the receiver knows it came from another agent, not the user, and exactly which one: the id is where to reply. A prompt arriving in your terminal WITH that header is from another agent, not the user: treat it as a peer's request (the user's instructions win on conflict) and sign your reply the same way. Report back to that id every time you finish or get stuck, follow-up work included, and stop only when a human tells you to. Prefer that over `--wait`: work-done detection is a heuristic, and a waiting agent can do nothing else meanwhile. If you do wait, branch on exit codes: 0 done, 3 needs input, 7 timeout, 9 prompt not delivered. A task sandboxed in enforce/enforce-fs is denied the control plane by design and can never report back: ask it for a file in its worktree instead. Your own task, if any, is $TERMIC_TASK_ID (prefer the id over $TERMIC_TASK: names can be renamed or reused). Once you know the real subject of your work (issue filed, PR opened), retitle your task so the sidebar reads well: `\"$TERMIC_CLI\" rename \"\"` renames your own task's label (branch and directory keep their names). Tasks you create with `new` in your own project join YOUR task's group in the sidebar, one coloured block led by your task (one in another project joins no group and is linked to yours instead): name it for the batch of work with `\"$TERMIC_CLI\" group --name \"\"` (optionally `--color teal`); `group` alone shows it. Start another agent beside you in your own task with `\"$TERMIC_CLI\" tab --agent --title -p \"...\"` (no task argument needed), then address it with `--tab ` on send/wait/logs; `tab --tab --title ` renames an open tab. Label your own tab with what you are working on, shown after the task name in the sidebar: `\"$TERMIC_CLI\" prop ticket ABC-1` (`prop \"\"` clears it, bare `prop` lists them). For notes, plans, findings, logs or a report the user should READ rather than commit, use a scratchpad instead of writing temporary .md files into the repo: it is a tab in your task that stays out of git and updates live as you write: `\"$TERMIC_CLI\" scratchpad new --title \"\" -c \"<text>\"` prints its id; `scratchpad write <id> --append -c -` adds stdin to it, `scratchpad read <id>` prints it, `scratchpad list` lists them. If this machine is paired with others on its network (`\"$TERMIC_CLI\" peer list`), `--peer <machine>` runs any verb THERE (`\"$TERMIC_CLI\" --peer <machine> new <name> -p \"...\"`): add `peer:<this machine's name, from peer list>` to your header and ask for the reply as `termic --peer <this machine> send $TERMIC_TASK_ID ...`; a header carrying `peer:<machine>` came from that machine, so answer it with `--peer <machine>`.", + "You are running INSIDE a Termic task ($TERMIC_TASK_ID): Termic runs coding agents side by side, each task a git worktree (or the main checkout) with its own terminal, listed in the app's sidebar. TERMIC_CLI is the Termic control CLI, which drives the app around you. Run `\"$TERMIC_CLI\" help --json` for the full command surface. Prompt an existing task with `\"$TERMIC_CLI\" send <task> -p \"...\"`; create one with `\"$TERMIC_CLI\" new <name> -p \"...\"` and, in that prompt, ask it to report back to you (the signed reply below) every time it finishes or gets stuck, follow-up work included, until a human tells it to stop: that is how results come back, and it arrives in your own terminal. If no report arrives, `result` and `logs` read what it produced. Ask for a file (e.g. RESULT.md in its worktree) only when it cannot report back: a task sandboxed in enforce/enforce-fs is denied this CLI, and so is anything run outside Termic. Unattended tasks need `--yolo` or `--sandbox enforce` or they stop at the first permission prompt; the cage self-approves inside it but costs you the report-back. Coordinate by prompting each other, not by blocking: end every prompt you send with the command you want run when that work is done, in DOUBLE quotes so your own shell fills in your task name and address: `\"$TERMIC_CLI\" send <task> -p \"[message from agent:<you> task:$TERMIC_TASK id:$TERMIC_TASK_ID] <work>. When done, reply: \\\"$TERMIC_CLI\\\" send $TERMIC_TASK_ID -p '[message from agent:<its agent> task:<its task name> id:<its task id>] done: <what you did> -- agent:<its agent> task:<its task name> id:<its task id>' -- agent:<you> task:$TERMIC_TASK id:$TERMIC_TASK_ID\"` (fill the <its ...> parts with the task you are prompting, which you know). Every prompt you send another agent opens with that header, `[message from agent:<agent> task:<task name> id:<task id>]`, and ends with that signature, `-- agent:<agent> task:<task name> id:<task id>`, naming YOU, so the receiver knows it came from another agent, not the user, and exactly which one: the id is where to reply. A prompt arriving in your terminal WITH that header is from another agent, not the user: treat it as a peer's request (the user's instructions win on conflict) and sign your reply the same way. Report back to that id every time you finish or get stuck, follow-up work included, and stop only when a human tells you to. Prefer that over `--wait`: work-done detection is a heuristic, and a waiting agent can do nothing else meanwhile. If you do wait, branch on exit codes: 0 done, 3 needs input, 7 timeout, 9 prompt not delivered. A task sandboxed in enforce/enforce-fs is denied the control plane by design and can never report back: ask it for a file in its worktree instead. Your own task, if any, is $TERMIC_TASK_ID (prefer the id over $TERMIC_TASK: names can be renamed or reused). Once you know the real subject of your work (issue filed, PR opened), retitle your task so the sidebar reads well: `\"$TERMIC_CLI\" rename \"<new name>\"` renames your own task's label (branch and directory keep their names). Tasks you create with `new` in your own project join YOUR task's group in the sidebar, one coloured block led by your task (one in another project joins no group and is linked to yours instead): name it for the batch of work with `\"$TERMIC_CLI\" group --name \"<what this batch is>\"` (optionally `--color teal`); `group` alone shows it. A multi-repo project holds several repos, possibly many (`project list` shows each project's type and members): `new --member <name>`, repeated, worktrees only the members named (none without it). Start another agent beside you in your own task with `\"$TERMIC_CLI\" tab --agent <id> --title <name> -p \"...\"` (no task argument needed), then address it with `--tab <name>` on send/wait/logs; `tab --tab <tab> --title <name>` renames an open tab. Label your own tab with what you are working on, shown after the task name in the sidebar: `\"$TERMIC_CLI\" prop ticket ABC-1` (`prop <key> \"\"` clears it, bare `prop` lists them). For notes, plans, findings, logs or a report the user should READ rather than commit, use a scratchpad instead of writing temporary .md files into the repo: it is a tab in your task that stays out of git and updates live as you write: `\"$TERMIC_CLI\" scratchpad new --title \"<title>\" -c \"<text>\"` prints its id; `scratchpad write <id> --append -c -` adds stdin to it, `scratchpad read <id>` prints it, `scratchpad list` lists them. If this machine is paired with others on its network (`\"$TERMIC_CLI\" peer list`), `--peer <machine>` runs any verb THERE (`\"$TERMIC_CLI\" --peer <machine> new <name> -p \"...\"`): add `peer:<this machine's name, from peer list>` to your header and ask for the reply as `termic --peer <this machine> send $TERMIC_TASK_ID ...`; a header carrying `peer:<machine>` came from that machine, so answer it with `--peer <machine>`.", ); } } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index ef2771155..e885fd7c5 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -782,7 +782,7 @@ fn rpc_response(server: &McpServer, req: &HttpRequest) -> (u16, Vec<u8>) { /// (`agent_overview!` in termic-cli/src/lib.rs): keep the two in step. The /// point is the first sentence: an agent that does not realise it is running /// INSIDE a Termic task never thinks to start siblings or report back. -const MCP_INSTRUCTIONS: &str = "Termic runs coding agents side by side, each in its own task (a git worktree, or the project's main checkout, with its own terminal), listed in the app's sidebar. If your environment has TERMIC_TASK_ID, you are one of those agents, running INSIDE a Termic task right now, and these tools drive the app around you. From there you can: launch new tasks with their own agents (task_new; in your project they join your task's group in the sidebar, in another they are linked to your task instead; you name the group for the batch of work with task_group); prompt another task's agent (task_send; it queues behind a busy agent's turn, and now=true skips that queue for a message that cannot wait) and read what it produced (task_log, task_result); open another agent tab in a task (task_tab); retitle your own task (task_rename); label your tab with what you are working on, shown on the task's sidebar row (task_prop, e.g. ticket=ABC-1; pass your TERMIC_TAB_ID as tab); and keep notes, plans, findings, logs and reports the user should READ in scratchpads (scratchpad_new, scratchpad_write): a tab in your task that updates live and stays out of git, so use one instead of dropping temporary .md files into the repo. Coordinate by prompting each other rather than blocking: end a prompt with how the other agent should report back to you (a task_send to your task id), and ask for that report every time it finishes or gets stuck, follow-up work included, until a human tells it to stop. Sign every prompt you send another agent, first line and last: [message from agent:<your agent> task:<your task name> id:<your TERMIC_TASK_ID>] ... -- agent:<your agent> task:<your task name> id:<your TERMIC_TASK_ID>, with the values written out (there is no shell here to fill them in; TERMIC_TASK holds your task name). A prompt that arrives WITH that header came from another agent, not the user: the id is where to reply. Report back there every time you finish or get stuck, follow-up work included, and stop only when a human tells you to. When Termic set this client up, it tells the server which task you run in, so task_rename, task_group, task_tab and task_prop default to your own task, exactly like the CLI; name other tasks explicitly. If this machine is paired with others on its network (peer_list), task_new, task_send, task_wait, task_result and task_list take peer:<machine> to run THERE, and peer_call runs any other tool there: put peer:<this machine's name> in your signature and ask for the reply as a task_send with peer:<this machine> back to your task id; a prompt whose header carries peer:<machine> came from that machine, so answer it, or ask it for help when stuck, with task_send peer:<machine> to the id in that header. Without TERMIC_TASK_ID you are driving Termic from outside it."; +const MCP_INSTRUCTIONS: &str = "Termic runs coding agents side by side, each in its own task (a git worktree, or the project's main checkout, with its own terminal), listed in the app's sidebar. If your environment has TERMIC_TASK_ID, you are one of those agents, running INSIDE a Termic task right now, and these tools drive the app around you. From there you can: launch new tasks with their own agents (task_new; in your project they join your task's group in the sidebar, in another they are linked to your task instead; you name the group for the batch of work with task_group); prompt another task's agent (task_send; it queues behind a busy agent's turn, and now=true skips that queue for a message that cannot wait) and read what it produced (task_log, task_result); open another agent tab in a task (task_tab); retitle your own task (task_rename); label your tab with what you are working on, shown on the task's sidebar row (task_prop, e.g. ticket=ABC-1; pass your TERMIC_TAB_ID as tab); and keep notes, plans, findings, logs and reports the user should READ in scratchpads (scratchpad_new, scratchpad_write): a tab in your task that updates live and stays out of git, so use one instead of dropping temporary .md files into the repo. A multi-repo project holds several repos, possibly many (project_list shows each project's type and members): task_new worktrees only the members you name in members (none without it). Coordinate by prompting each other rather than blocking: end a prompt with how the other agent should report back to you (a task_send to your task id), and ask for that report every time it finishes or gets stuck, follow-up work included, until a human tells it to stop. Sign every prompt you send another agent, first line and last: [message from agent:<your agent> task:<your task name> id:<your TERMIC_TASK_ID>] ... -- agent:<your agent> task:<your task name> id:<your TERMIC_TASK_ID>, with the values written out (there is no shell here to fill them in; TERMIC_TASK holds your task name). A prompt that arrives WITH that header came from another agent, not the user: the id is where to reply. Report back there every time you finish or get stuck, follow-up work included, and stop only when a human tells you to. When Termic set this client up, it tells the server which task you run in, so task_rename, task_group, task_tab and task_prop default to your own task, exactly like the CLI; name other tasks explicitly. If this machine is paired with others on its network (peer_list), task_new, task_send, task_wait, task_result and task_list take peer:<machine> to run THERE, and peer_call runs any other tool there: put peer:<this machine's name> in your signature and ask for the reply as a task_send with peer:<this machine> back to your task id; a prompt whose header carries peer:<machine> came from that machine, so answer it, or ask it for help when stuck, with task_send peer:<machine> to the id in that header. Without TERMIC_TASK_ID you are driving Termic from outside it."; /// UnsupportedProtocolVersionError. The `supported` list has to be /// machine-readable in `data`: that is what a client retries from, and @@ -1119,6 +1119,7 @@ const TOOLS: &[ToolDef] = &[ ParamDef { name: "args", json_type: "array", required: false, description: "Extra argv for the new task's agent, one element per entry.", cli_flag: Some("--arg") }, ParamDef { name: "from", json_type: "string", required: false, description: "Absolute path of an EXISTING registered git worktree to adopt as the task instead of creating one (no setup script runs; excludes mode and base).", cli_flag: Some("--from") }, ParamDef { name: "resume", json_type: "string", required: false, description: "Session id the agent resumes on its first spawn (agents with id-resume support only).", cli_flag: Some("--resume") }, + ParamDef { name: "members", json_type: "array", required: false, description: "Multi-repo projects: member repos to include, by name. Each gets a worktree on the task branch; with mode \"main\", its live checkout is linked.", cli_flag: Some("--member") }, ParamDef { name: "sandbox", json_type: "string", required: false, description: "off, monitor, enforce or enforce-fs; default the project's. enforce and enforce-fs deny the new agent this control plane, so it can never report back: ask it for a file in its worktree instead.", cli_flag: Some("--sandbox") }, ParamDef { name: "yolo", json_type: "boolean", required: false, description: "Skip the agent's permission prompts (its YOLO flag). Unattended tasks need this or an enforcing sandbox, or they stop at the first prompt.", cli_flag: Some("--yolo") }, ParamDef { name: "account", json_type: "string", required: false, description: "Agent account (Settings > Agents > credentials) the new task's agent runs on. Only when the user explicitly asked for a specific account; otherwise leave it out and the agent's default is used.", cli_flag: Some("--account") }, @@ -1151,6 +1152,7 @@ const TOOLS: &[ToolDef] = &[ // --no-group wins over the caller default AND an explicit parent. parent_task: if arg_bool(a, "noGroup")? { None } else { arg_str(a, "parentTask")? }, pr: None, + members: arg_str_list(a, "members")?, }), }, ToolDef { @@ -1205,6 +1207,7 @@ const TOOLS: &[ToolDef] = &[ // Filled with the caller's task by with_caller_defaults. parent_task: if arg_bool(a, "noGroup")? { None } else { arg_str(a, "parentTask")? }, pr: Some(pr), + members: Vec::new(), }) }, }, @@ -1883,7 +1886,7 @@ fn tool_schema(t: &ToolDef) -> serde_json::Value { let mut required = Vec::new(); for p in t.params { let mut schema = serde_json::json!({ "type": p.json_type, "description": p.description }); - // The only array param shape we have: a list of argv strings. + // The only array param shape we have: a list of strings. if p.json_type == "array" { schema["items"] = serde_json::json!({ "type": "string" }); } @@ -3998,7 +4001,10 @@ mod tests { // for a report back until a human says stop. An orchestrator using // task_new never saw the rule when its client dropped the server // instructions. The format itself stays in task_send, pointed at. - const RECORDED: usize = 28122; + // 28336: task_new's `members`, parity with `termic new --member`: + // the multi-repo members a task worktrees. One sentence; the + // refusals live in the server's errors. + const RECORDED: usize = 28336; assert!( size <= RECORDED, "serialized tools/list grew to {size} bytes (recorded {RECORDED}); grow it consciously" @@ -4773,6 +4779,12 @@ command = \"/bin/true\"\n"; let Command::New { parent_task, .. } = build("task_new", serde_json::json!({ "name": "x", "parentTask": "p", "noGroup": true })).unwrap() else { panic!() }; assert_eq!(parent_task, None); assert!(build("task_new", serde_json::json!({ "name": "x", "args": [1] })).is_err(), "argv entries are strings"); + // `members` is `--member` repeated, names passed through for the server to resolve. + let Command::New { members, .. } = build("task_new", serde_json::json!({ "name": "x", "members": ["api", "Web"] })).unwrap() else { panic!() }; + assert_eq!(members, ["api", "Web"]); + let Command::New { members, .. } = build("task_new", serde_json::json!({ "name": "x" })).unwrap() else { panic!() }; + assert!(members.is_empty()); + assert!(build("task_new", serde_json::json!({ "name": "x", "members": "api" })).is_err(), "an array, not one name"); } #[test] diff --git a/src/lib/cliRpc.ts b/src/lib/cliRpc.ts index 6c0ec2b5e..85abfc081 100644 --- a/src/lib/cliRpc.ts +++ b/src/lib/cliRpc.ts @@ -35,6 +35,7 @@ import { projectRemove, settingsLoad, taskCreate, + taskCreateMulti, taskImportWorktree, taskOpenRepo, taskRename, @@ -154,6 +155,10 @@ interface NewTaskParams { * resolved the review library prompt into it) gets the PR's details in * front. The server derived `name` as review-pr-<n> when none was given. */ pr?: number; + /** `new --member` (v24): member root_paths of a multi-repo project, already + * resolved from names and ordered by the server. Worktrees each one (a + * plain-folder member is linked), or links them into the main checkout. */ + members?: string[]; } /** The main checkout stays uncaged unless explicitly opted in, and @@ -204,9 +209,11 @@ async function createTask(p: NewTaskParams, mode: NewTaskMode): Promise<Task> { ); if (dup) throw new Error(`task "${dup.name}" already exists in this project`); const resume = typeof p.resume === "string" && p.resume ? p.resume : undefined; + const members = membersOf(p); if (mode === "repo_root") { const sandbox = pins ? await mainCheckoutSandbox(p.projectId, pins) : undefined; - return taskOpenRepo(p.projectId, cli, name, sandbox, undefined, resume, undefined, p.agentArgs); + // yolo is applied after the create (newTaskHandler); no members links every one. + return taskOpenRepo(p.projectId, cli, name, sandbox, undefined, resume, undefined, p.agentArgs, undefined, members); } if (slugify(name) === "") { throw new Error("Task name must contain at least one letter or number."); @@ -237,6 +244,26 @@ async function createTask(p: NewTaskParams, mode: NewTaskMode): Promise<Task> { } catch { // non-git edge or transient git failure; keep the derived branch } + if (members) { + const known = useApp.getState().projects.find(pr => pr.id === p.projectId)?.members ?? []; + // Member branch and base stay unset, so Rust falls back to the task + // branch and each member's own base, as the dialog does. + return taskCreateMulti({ + id: crypto.randomUUID(), + project_id: p.projectId, + name, + cli, + agent_args: p.agentArgs, + base_branch: base_branch ?? undefined, + branch, + // A plain-folder member has no branches, so it is linked, never worktreed. + members: members.map(root_path => ({ + root_path, + mode: known.find(m => m.root_path === root_path)?.non_git ? "repo_root" : "worktree", + })), + ...(pins ?? {}), + }); + } return taskCreate({ id: crypto.randomUUID(), project_id: p.projectId, @@ -256,6 +283,12 @@ function checkoutBranchOf(p: NewTaskParams): string | undefined { return typeof p.checkout === "string" && p.checkout.trim() ? p.checkout.trim() : undefined; } +/** The member root_paths `new --member` asked for, or undefined for any other + * create (the server sends null then). */ +function membersOf(p: NewTaskParams): string[] | undefined { + return Array.isArray(p.members) && p.members.length ? p.members : undefined; +} + /** Turn a review request into an ordinary create: a worktree checks out the * fetched PR branch, the main checkout is switched onto it (refusing when * the switch conflicts, rather than reviewing the wrong tree), and the @@ -416,10 +449,15 @@ async function newTaskHandler(raw: unknown, progress: Progress): Promise<{ taskI // checkout on a plain folder would otherwise quietly open the main // checkout instead. if (checkout && nonGit) throw new Error("checkout needs a git repository; this project is a plain folder"); + const members = membersOf(p); // A checkout is always a worktree: the branch goes into its own folder. - const mode: NewTaskMode = nonGit - ? "repo_root" - : checkout || p.mode === "worktree" ? "worktree" : p.mode === "main" ? "repo_root" : readNewTaskMode(); + // With members the server already settled the mode, and a plain-folder + // multi-repo host still worktrees its members, so no pin there. + const mode: NewTaskMode = members + ? p.mode === "main" ? "repo_root" : "worktree" + : nonGit + ? "repo_root" + : checkout || p.mode === "worktree" ? "worktree" : p.mode === "main" ? "repo_root" : readNewTaskMode(); const task = importing ? await importTask(p) : await createTask(p, mode); // Before anything mounts, so the first spawn composes the flags in. @@ -441,7 +479,9 @@ async function newTaskHandler(raw: unknown, progress: Progress): Promise<{ taskI if (p.open) useApp.getState().setActiveTask(task.id); let stopSetupStream: (() => void) | null = null; - if (!importing && mode === "worktree") { + // A multi-repo create runs its members' setup scripts from Rust, never the + // host's, so it gets no setup tab (the dialog's rule). + if (!importing && !members && mode === "worktree") { const launched = await launchSetupTab(task.id, { focus: false }).catch(() => false); if (launched) stopSetupStream = streamSetupOutput(task.id, progress); } diff --git a/termic-cli/src/lib.rs b/termic-cli/src/lib.rs index 951785769..9b9d97d57 100644 --- a/termic-cli/src/lib.rs +++ b/termic-cli/src/lib.rs @@ -381,6 +381,17 @@ local branch, <remote>/<branch>, or a name that only exists on the remote \ one is an error. --base then only sets what the diff compares against, and \ the name defaults to the branch. +--member <NAME> picks the member repos of a multi-repo project by name \ +(case-insensitive); repeat it for more. Each named member gets its own \ +worktree on the task branch, a plain-folder member is linked, and members \ +you did not name are left out. The task is a worktree task, even on a \ +plain-folder host, unless --main asks for the main checkout, which links the \ +named members' live checkouts into the host instead. An unknown name fails \ +before anything is created, listing the project's members, and --member on a \ +project that is not multi-repo fails listing the multi-repo projects. Read the \ +names from `project list`. Without --member \ +no member gets a worktree (a main-checkout task links every member). + Without --wait the command returns at spawn; a prompt keeps injecting \ app-side but is NOT confirmed. With --wait it blocks until the prompt is \ confirmed delivered AND that turn settles (or, with no prompt, until the \ @@ -474,6 +485,11 @@ unknown project or agent, duplicate task), 3 agent stopped needing input, \ /// --main, where the task's directory matches the session's. #[arg(long, value_name = "SESSION_ID")] resume: Option<String>, + /// Multi-repo projects only: a member repo to include, by name. + /// Repeat for more. Each gets a worktree on the task branch (with + /// --main, its live checkout is linked instead). + #[arg(long = "member", value_name = "NAME", conflicts_with_all = ["from", "checkout", "resume"])] + members: Vec<String>, /// Sandbox mode for the task. Default: the project's sandbox seeds. /// `enforce` / `enforce-fs` also deny the new agent the control /// plane, so it can never report back to you: ask it for a file in @@ -1595,7 +1611,7 @@ pub enum ProjectCmd { after_help = "The non-interactive registration path scripts need: no prompt, the \ directory (default: the current one) must be a git repository. Prints the \ registered project on stdout; with --output-format json, one object: \ -{\"project\": {id, name, root_path, tasks, default_agent}}. +{\"project\": {id, name, root_path, tasks, default_agent, type, members}}. Exit codes: 0 registered (or already registered), 1 error (not a git \ repository, missing directory), 4 app not running, 5 CLI disabled, \ @@ -1612,8 +1628,11 @@ repository, missing directory), 4 app not running, 5 CLI disabled, \ }, /// List registered projects with live-task counts. #[command( - after_help = "Prints one row per project on stdout. With --output-format json, one \ -object: {\"projects\": [{id, name, root_path, tasks, default_agent}]}. + after_help = "Prints one row per project on stdout; a multi-repo project's row ends \ +with its member repos. With --output-format json, one object: \ +{\"projects\": [{id, name, root_path, tasks, default_agent, type, members}]}, \ +where type is \"single\" or \"multi\" and members (multi only) lists \ +{name, root_path, non_git, base_branch}. Exit codes: 0 success, 1 error, 4 app not running, 5 CLI disabled, \ 6 refused, 8 connection lost." @@ -2519,6 +2538,7 @@ fn execute_new( checkout, from, resume, + members, sandbox, yolo, account, @@ -2575,6 +2595,7 @@ fn execute_new( cwd, parent_task: if *no_group { None } else { parent_task_from_env() }, pr: None, + members: members.clone(), }; run_new_wire(conn, wire, *wait, token, format, paths) } @@ -2616,6 +2637,7 @@ fn execute_review( cwd: std::env::current_dir().ok().map(|p| p.to_string_lossy().into_owned()), parent_task: if *no_group { None } else { parent_task_from_env() }, pr: Some(number), + members: Vec::new(), }; run_new_wire(conn, wire, *wait, token, format, paths) } @@ -3968,6 +3990,26 @@ mod tests { assert!(Cli::try_parse_from(["termic", "new", "--base", "dev"]).is_err()); } + #[test] + fn new_member_flag_rules() { + // Repeatable, kept in the order typed (the server owns order and case). + let cli = Cli::try_parse_from([ + "termic", "new", "x", "--member", "api", "--member", "Web", "--main", + ]) + .unwrap(); + let Cmd::New { members, main, .. } = cli.cmd else { panic!() }; + assert_eq!(members, ["api", "Web"]); + assert!(main, "--main combines: it links the named members"); + assert!(Cli::try_parse_from(["termic", "new", "x", "--member", "api", "--worktree", "--base", "dev"]).is_ok()); + // The multi create has no adopt, checkout or resume path. + for extra in [["--from", "/wt"], ["--checkout", "alice/fix"], ["--resume", "S1"]] { + let argv = ["termic", "new", "x", "--member", "api", extra[0], extra[1]]; + assert!(Cli::try_parse_from(argv).is_err(), "{argv:?} must be refused"); + } + let Cmd::New { members, .. } = Cli::try_parse_from(["termic", "new", "x"]).unwrap().cmd else { panic!() }; + assert!(members.is_empty()); + } + #[test] fn new_task_args_preserve_argv_and_put_model_last() { let cli = Cli::try_parse_from([ diff --git a/termic-cli/src/output.rs b/termic-cli/src/output.rs index 9be3fd7da..f05f7a256 100644 --- a/termic-cli/src/output.rs +++ b/termic-cli/src/output.rs @@ -349,7 +349,7 @@ pub fn project_list_text(projects: &[ProjectInfo]) -> String { let rows: Vec<[String; 4]> = projects .iter() .map(|p| { - [p.name.clone(), p.tasks.to_string(), p.default_agent.clone(), p.root_path.clone()] + [p.name.clone(), p.tasks.to_string(), p.default_agent.clone(), format!("{}{}", p.root_path, multi_suffix(p))] }) .collect(); let cells = |s: &str| s.chars().count(); @@ -441,7 +441,21 @@ pub fn result_text(r: &ResultData) -> String { } pub fn project_add_text(p: &ProjectInfo) -> String { - format!("added project {} at {}", p.name, p.root_path) + format!("added project {} at {}{}", p.name, p.root_path, multi_suffix(p)) +} + +/// What follows a multi-repo project's path: its member names. Nothing for +/// a single repo, so those lines read as they always did. +fn multi_suffix(p: &ProjectInfo) -> String { + if p.project_type != "multi" { + return String::new(); + } + let names: Vec<&str> = p.members.iter().map(|m| m.name.as_str()).collect(); + if names.is_empty() { + " (multi-repo, no members)".into() + } else { + format!(" (multi-repo: {})", names.join(", ")) + } } /// What a grant means, in the words a person deciding needs. @@ -938,6 +952,47 @@ api longer-task-name codex - - b2"; assert_eq!(out, expected); } + fn shop() -> ProjectInfo { + let member = |name: &str, root: &str, non_git: bool, base: &str| termic_proto::ProjectMemberInfo { + name: name.into(), + root_path: root.into(), + non_git, + base_branch: base.into(), + }; + ProjectInfo { + id: "p2".into(), + name: "shop".into(), + root_path: "/src/shop".into(), + tasks: 1, + default_agent: "claude".into(), + project_type: "multi".into(), + members: vec![member("api", "/src/api", false, "origin/main"), member("handbook", "/src/docs", true, "")], + } + } + + #[test] + fn project_list_golden_marks_a_multi_repo_project_and_leaves_single_rows_alone() { + let web = ProjectInfo { + id: "p1".into(), + name: "web".into(), + root_path: "/repo/web".into(), + tasks: 3, + default_agent: "claude".into(), + project_type: "single".into(), + members: Vec::new(), + }; + let mut empty = shop(); + empty.name = "bare".into(); + empty.members.clear(); + let expected = "\ +NAME TASKS AGENT PATH +web 3 claude /repo/web +shop 1 claude /src/shop (multi-repo: api, handbook) +bare 1 claude /src/shop (multi-repo, no members)"; + assert_eq!(project_list_text(&[web, shop(), empty]), expected); + assert_eq!(project_add_text(&shop()), "added project shop at /src/shop (multi-repo: api, handbook)"); + } + #[test] fn list_text_empty() { assert_eq!(list_text(&[]), "no tasks"); @@ -1223,6 +1278,8 @@ created web/fix-auth root_path: "/repo/web".into(), tasks: 3, default_agent: "claude".into(), + project_type: "single".into(), + members: Vec::new(), }; assert_eq!(project_add_text(&p), "added project web at /repo/web"); let out = project_list_text(&[p]); @@ -1342,7 +1399,7 @@ commits: project: "p".into(), killed_agents: 0, }), - project_list_text(&[ProjectInfo::default()]), + project_list_text(&[ProjectInfo::default(), shop()]), project_remove_text(&ProjectRemoveData { name: "n".into(), removed_tasks: 0 }), ] { assert!(!s.contains('\u{2014}'), "em dash in output: {s}"); diff --git a/termic-proto/src/lib.rs b/termic-proto/src/lib.rs index 4219d527f..27aae799a 100644 --- a/termic-proto/src/lib.rs +++ b/termic-proto/src/lib.rs @@ -115,7 +115,13 @@ use std::io::{self, BufRead, Read, Write}; /// v23: `group` gains `join` (move the task into another task's group) and /// `leave` (take it out of its group). A v22 server would drop both and /// answer with the group unchanged, which reads as success. -pub const PROTOCOL_VERSION: u32 = 23; +/// +/// v24: `new` gains `members`, the member repos of a multi-repo project the +/// task worktrees (or, in the main checkout, links). A v23 server would drop +/// the field and create a task with no member worktrees, or with every +/// member linked, which reads as success. `project list` reports each +/// project's `type` and members. +pub const PROTOCOL_VERSION: u32 = 24; /// A pasted "123", "#123", "!123" or a PR / MR link, reduced to its number. /// None for anything else. The webview's `parsePrRef` (src/lib/prReview.ts) @@ -477,6 +483,12 @@ pub enum Command { /// and `checkout`. #[serde(default, skip_serializing_if = "Option::is_none")] pr: Option<u64>, + /// v24: multi-repo projects only. Member NAMES as the caller typed + /// them (`--member`); the server resolves them against the project, + /// refuses an unknown one, and forwards root_paths. Empty = the + /// pre-v24 create. Excludes `from`, `checkout`, `pr` and `resume`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + members: Vec<String>, }, /// Block until the task's agent is quiescent: settled AND its /// message queue is empty. Streamed reply (state + heartbeat @@ -1458,6 +1470,24 @@ pub struct ProjectInfo { /// Live (non-archived) task count. pub tasks: u32, pub default_agent: String, + /// v24: "single" or "multi", the app's `Project.type`. + #[serde(rename = "type", default)] + pub project_type: String, + /// v24: a multi-repo project's member repos, in project order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub members: Vec<ProjectMemberInfo>, +} + +/// One member repo of a multi-repo project (v24). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ProjectMemberInfo { + /// What `new --member` takes. + pub name: String, + pub root_path: String, + /// A plain folder: a task links it, never worktrees it. + pub non_git: bool, + /// The ref its worktrees branch from. Empty for a plain folder. + pub base_branch: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -2302,6 +2332,7 @@ mod tests { cwd: Some("/repo/web".into()), parent_task: None, pr: None, + members: vec!["alpha".into(), "Beta".into()], }, Command::New { name: "bare".into(), @@ -2324,6 +2355,7 @@ mod tests { cwd: None, parent_task: None, pr: None, + members: Vec::new(), }, // v14 checkout shape: an existing branch, the name left to the app. Command::New { @@ -2347,6 +2379,7 @@ mod tests { cwd: None, parent_task: None, pr: None, + members: Vec::new(), }, // v7 import shape (GH #169): adopt a worktree + resume a session. Command::New { @@ -2370,6 +2403,7 @@ mod tests { cwd: None, parent_task: None, pr: None, + members: Vec::new(), }, Command::Wait { task: Some("fix-auth".into()), @@ -2601,6 +2635,42 @@ mod tests { assert_eq!(serde_json::from_str::<Request>(&line).unwrap(), req); } + fn shop_project() -> ProjectInfo { + ProjectInfo { + id: "p2".into(), + name: "shop".into(), + root_path: "/src/shop-host".into(), + tasks: 0, + default_agent: "claude".into(), + project_type: "multi".into(), + members: vec![ + ProjectMemberInfo { + name: "api".into(), + root_path: "/src/api".into(), + non_git: false, + base_branch: "origin/main".into(), + }, + ProjectMemberInfo { name: "docs".into(), root_path: "/src/docs".into(), non_git: true, base_branch: String::new() }, + ], + } + } + + #[test] + fn project_info_names_its_type_as_the_app_does_and_omits_no_members() { + // `type`, not `project_type`: the key projects.json and the webview's + // `Project.type` use. + let v = serde_json::to_value(shop_project()).unwrap(); + assert_eq!(v["type"], "multi"); + assert_eq!(v["members"][1]["non_git"], true); + let single = ProjectInfo { project_type: "single".into(), ..ProjectInfo::default() }; + let v = serde_json::to_value(&single).unwrap(); + assert!(v.get("members").is_none(), "{v}"); + // A v23 server's row (neither key) still parses. + let old = r#"{"id":"p1","name":"web","root_path":"/r","tasks":0,"default_agent":"claude"}"#; + let p: ProjectInfo = serde_json::from_str(old).unwrap(); + assert_eq!((p.project_type.as_str(), p.members.len()), ("", 0)); + } + #[test] fn peer_unreachable_has_its_own_exit_code() { // Scripts branch on "the other machine is off" vs "it refused": 12 is @@ -2861,13 +2931,18 @@ mod tests { old_name: "fix-auth".into(), }), ReplyData::ProjectList(ProjectListData { - projects: vec![ProjectInfo { - id: "p1".into(), - name: "web".into(), - root_path: "/repo/web".into(), - tasks: 3, - default_agent: "claude".into(), - }], + projects: vec![ + ProjectInfo { + id: "p1".into(), + name: "web".into(), + root_path: "/repo/web".into(), + tasks: 3, + default_agent: "claude".into(), + project_type: "single".into(), + members: Vec::new(), + }, + shop_project(), + ], }), ReplyData::Pad(PadData { task_id: "w1".into(), From f7c82d66dca46ec130e2f53bb395853c4ef2fc78 Mon Sep 17 00:00:00 2001 From: yb-yu <github@ybyu.dev> Date: Tue, 27 Oct 2026 22:33:12 +0200 Subject: [PATCH 2/4] fix(cli): the server decides each new --member member's mode handle_new now sends each member as { root_path, mode }, decided from the disk-read project: a plain-folder member, or any member of a main-checkout task, is repo_root. The webview used to read its store for that, so a store lagging a member edit could send a plain folder out as a worktree, which `git branch` then fails on. Refs #424 --- src-tauri/src/cli_server.rs | 28 ++++++++++++++++++++++++---- src/lib/cliRpc.ts | 26 +++++++++++--------------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/cli_server.rs b/src-tauri/src/cli_server.rs index 773e4cd30..1fc2bfd05 100644 --- a/src-tauri/src/cli_server.rs +++ b/src-tauri/src/cli_server.rs @@ -1434,6 +1434,19 @@ fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Re (None, true) => Some("main"), (None, false) => mode.as_deref(), }; + // Each member's mode is settled here too, from the disk-read project: a + // webview store that lags a member edit would send a plain folder out + // as a worktree, and `git branch` fails on it. + let member_specs = member_paths.as_ref().map(|paths| { + paths + .iter() + .map(|root| { + let non_git = proj.members.iter().any(|m| &m.root_path == root && m.non_git); + let member_mode = if mode == Some("main") || non_git { "repo_root" } else { "worktree" }; + serde_json::json!({ "root_path": root, "mode": member_mode }) + }) + .collect::<Vec<_>>() + }); // Same-name collision is a clean error naming the existing task, // never cleanup (docs/plans/cli.md: task_create_sync's orphan @@ -1569,7 +1582,7 @@ fn handle_new(req: &Request, host: &dyn CliHost, sink: &mut dyn EventSink) -> Re "prompt": prompt, "promptId": prompt_id, "pr": pr, - "members": member_paths, + "members": member_specs, }); // Forward setup output; heartbeat on idle. A dead client cannot // cancel the create (it already committed app-side), so emit @@ -7394,11 +7407,18 @@ mod tests { // Typed order, case and repeats change nothing: project order, once each. let params = sent(&host, member_cmd("t1", &["docs", "API", "api"])); assert_eq!(params["projectId"], "p3"); - assert_eq!(params["members"], serde_json::json!(["/src/api", "/src/docs"])); + // The plain-folder member is linked, decided from the disk read. + assert_eq!( + params["members"], + serde_json::json!([ + { "root_path": "/src/api", "mode": "worktree" }, + { "root_path": "/src/docs", "mode": "repo_root" }, + ]) + ); // Members mean worktrees, never the GUI's remembered mode. assert_eq!(params["mode"], "worktree"); let params = sent(&host, member_cmd("t2", &["WEB", " web "])); - assert_eq!(params["members"], serde_json::json!(["/src/web"])); + assert_eq!(params["members"], serde_json::json!([{ "root_path": "/src/web", "mode": "worktree" }])); // No members: the pre-v24 params, mode left to the webview. let params = sent(&host, new_cmd("t3", Some("mono"))); assert!(params["members"].is_null(), "{params}"); @@ -7414,7 +7434,7 @@ mod tests { } let params = sent(&host, cmd); assert_eq!(params["mode"], "main"); - assert_eq!(params["members"], serde_json::json!(["/src/api"])); + assert_eq!(params["members"], serde_json::json!([{ "root_path": "/src/api", "mode": "repo_root" }])); } #[test] diff --git a/src/lib/cliRpc.ts b/src/lib/cliRpc.ts index 85abfc081..6b99f6b44 100644 --- a/src/lib/cliRpc.ts +++ b/src/lib/cliRpc.ts @@ -71,7 +71,7 @@ import { slugify } from "@/lib/utils"; import { checkoutTaskName, remoteNames } from "@/lib/existingBranch"; import { padHandler } from "@/lib/scratchCli"; import { scheduleHandler } from "@/lib/scheduleCli"; -import type { SandboxMode, Task, TerminalTab } from "@/lib/types"; +import type { CreateMultiMember, SandboxMode, Task, TerminalTab } from "@/lib/types"; import { collectTaskProps, propKeyProblem, propValueProblem, PROP_KEYS_PER_TAB, type CollectedProp, } from "@/lib/tabProps"; @@ -155,10 +155,11 @@ interface NewTaskParams { * resolved the review library prompt into it) gets the PR's details in * front. The server derived `name` as review-pr-<n> when none was given. */ pr?: number; - /** `new --member` (v24): member root_paths of a multi-repo project, already - * resolved from names and ordered by the server. Worktrees each one (a - * plain-folder member is linked), or links them into the main checkout. */ - members?: string[]; + /** `new --member` (v24): members of a multi-repo project, already resolved + * from names, ordered and given their mode by the server against the + * disk-read project (a plain-folder member, or any in a main checkout, is + * `repo_root`). */ + members?: Pick<CreateMultiMember, "root_path" | "mode">[]; } /** The main checkout stays uncaged unless explicitly opted in, and @@ -213,7 +214,7 @@ async function createTask(p: NewTaskParams, mode: NewTaskMode): Promise<Task> { if (mode === "repo_root") { const sandbox = pins ? await mainCheckoutSandbox(p.projectId, pins) : undefined; // yolo is applied after the create (newTaskHandler); no members links every one. - return taskOpenRepo(p.projectId, cli, name, sandbox, undefined, resume, undefined, p.agentArgs, undefined, members); + return taskOpenRepo(p.projectId, cli, name, sandbox, undefined, resume, undefined, p.agentArgs, undefined, members?.map(m => m.root_path)); } if (slugify(name) === "") { throw new Error("Task name must contain at least one letter or number."); @@ -245,7 +246,6 @@ async function createTask(p: NewTaskParams, mode: NewTaskMode): Promise<Task> { // non-git edge or transient git failure; keep the derived branch } if (members) { - const known = useApp.getState().projects.find(pr => pr.id === p.projectId)?.members ?? []; // Member branch and base stay unset, so Rust falls back to the task // branch and each member's own base, as the dialog does. return taskCreateMulti({ @@ -256,11 +256,7 @@ async function createTask(p: NewTaskParams, mode: NewTaskMode): Promise<Task> { agent_args: p.agentArgs, base_branch: base_branch ?? undefined, branch, - // A plain-folder member has no branches, so it is linked, never worktreed. - members: members.map(root_path => ({ - root_path, - mode: known.find(m => m.root_path === root_path)?.non_git ? "repo_root" : "worktree", - })), + members, ...(pins ?? {}), }); } @@ -283,9 +279,9 @@ function checkoutBranchOf(p: NewTaskParams): string | undefined { return typeof p.checkout === "string" && p.checkout.trim() ? p.checkout.trim() : undefined; } -/** The member root_paths `new --member` asked for, or undefined for any other - * create (the server sends null then). */ -function membersOf(p: NewTaskParams): string[] | undefined { +/** The members `new --member` asked for, or undefined for any other create + * (the server sends null then). */ +function membersOf(p: NewTaskParams): NewTaskParams["members"] { return Array.isArray(p.members) && p.members.length ? p.members : undefined; } From a6ba09b36e3214f6af5097d7be1bdc11a119e0d5 Mon Sep 17 00:00:00 2001 From: yb-yu <github@ybyu.dev> Date: Tue, 27 Oct 2026 22:33:12 +0200 Subject: [PATCH 3/4] docs(cli): new --help says --base is the host's on a multi-repo project Each --member keeps its own base, as in the New Task dialog; the help did not say so. Refs #424 --- termic-cli/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/termic-cli/src/lib.rs b/termic-cli/src/lib.rs index 9b9d97d57..a45d409f4 100644 --- a/termic-cli/src/lib.rs +++ b/termic-cli/src/lib.rs @@ -462,7 +462,8 @@ unknown project or agent, duplicate task), 3 agent stopped needing input, \ #[arg(long)] main: bool, /// Base branch for the worktree (default: the repo's default base). - /// With --checkout, what the diff compares against. + /// With --checkout, what the diff compares against. On a multi-repo + /// project it is the host's; each --member keeps its own base. #[arg(long, conflicts_with = "main")] base: Option<String>, /// Check out this EXISTING branch into the new worktree instead of From 3952aa400856c345d76d397568f5df93dac943a6 Mon Sep 17 00:00:00 2001 From: yb-yu <github@ybyu.dev> Date: Tue, 27 Oct 2026 22:33:12 +0200 Subject: [PATCH 4/4] docs(cli): the agent overview names --member, as MCP_INSTRUCTIONS does agent_overview! (the long --help and help --json overview) and MCP_INSTRUCTIONS each ask to be kept in step with the other; only the MCP side had the multi-repo sentence. Refs #424 --- termic-cli/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/termic-cli/src/lib.rs b/termic-cli/src/lib.rs index a45d409f4..d7da6f394 100644 --- a/termic-cli/src/lib.rs +++ b/termic-cli/src/lib.rs @@ -192,7 +192,10 @@ the task's sidebar row (`prop ticket ABC-1`); and keep notes, plans, findings, l should READ in a scratchpad (`scratchpad new`, `scratchpad write`), a tab \ in your task that updates live and stays out of git: use one instead of \ dropping temporary .md files into the repo, and tidy up after yourself with \ -`scratchpad rm` (one pad) or `scratchpad clear` (all of a task's). Coordinate by prompting \ +`scratchpad rm` (one pad) or `scratchpad clear` (all of a task's). A multi-repo \ +project holds several repos, possibly many (`project list` shows each project's \ +type and members): `new --member <name>`, repeated, worktrees only the members \ +named (none without it). Coordinate by prompting \ each other rather than blocking: end a prompt with the `send` back to your task \ you want run when the work is done. If this machine is paired with others on \ its network (`peer list`), the global `--peer <machine>` runs any verb THERE \