Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/cli-agent-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`, 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
Expand Down
1 change: 1 addition & 0 deletions docs/e2e-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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 <remote>/<branch>` 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` |
Expand Down
27 changes: 27 additions & 0 deletions docs/ipc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` (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:
Expand Down
158 changes: 158 additions & 0 deletions e2e/specs/cli.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";

Expand Down
10 changes: 10 additions & 0 deletions src-tauri/assets/skills/termic/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ Rules that matter:
- `--account <name>` (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 <name>`, 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.
Expand Down
Loading
Loading