authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-03 19:49:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-03 19:49:01-07:00
log8515f02770a05d5f1c0f740c5223938fb6ab102b
treeb07510f6785fdb2b3cc29d5fa957332dedb02394
parente9ed849b53bc5644551aacb389f6c2094807371c
signature Signed by PGP key B5690EEEBB952194

Move worktree bootstrap to the server and persist terminal launch context (#1518)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

30 files changed, 2057 insertions(+), 264 deletions(-)

apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+2-2
...@@ -80,7 +80,7 @@ function initRepoWithCommit(...@@ -80,7 +80,7 @@ function initRepoWithCommit(
80 });80 });
81}81}
8282
83function buildLargeText(lineCount = 20_000): string {83function buildLargeText(lineCount = 5_000): string {
84 return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`)84 return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`)
85 .join("\n")85 .join("\n")
86 .concat("\n");86 .concat("\n");
...@@ -115,7 +115,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {...@@ -115,7 +115,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => {
115115
116 expect(diff).toContain("diff --git");116 expect(diff).toContain("diff --git");
117 expect(diff).not.toContain("[truncated]");117 expect(diff).not.toContain("[truncated]");
118 expect(diff).toContain("+line 19999");118 expect(diff).toContain("+line 04999");
119 }),119 }),
120 );120 );
121 });121 });
apps/server/src/git/Layers/GitManager.test.ts+128-2
...@@ -6,7 +6,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -6,7 +6,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";6import { it } from "@effect/vitest";
7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
8import { expect } from "vitest";8import { expect } from "vitest";
9import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";9import type {
10 GitActionProgressEvent,
11 GitPreparePullRequestThreadInput,
12 ModelSelection,
13 ThreadId,
14} from "@t3tools/contracts";
1015
11import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts";16import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts";
12import { type GitManagerShape } from "../Services/GitManager.ts";17import { type GitManagerShape } from "../Services/GitManager.ts";
...@@ -21,6 +26,11 @@ import { GitCore } from "../Services/GitCore.ts";...@@ -21,6 +26,11 @@ import { GitCore } from "../Services/GitCore.ts";
21import { makeGitManager } from "./GitManager.ts";26import { makeGitManager } from "./GitManager.ts";
22import { ServerConfig } from "../../config.ts";27import { ServerConfig } from "../../config.ts";
23import { ServerSettingsService } from "../../serverSettings.ts";28import { ServerSettingsService } from "../../serverSettings.ts";
29import {
30 ProjectSetupScriptRunner,
31 type ProjectSetupScriptRunnerInput,
32 type ProjectSetupScriptRunnerShape,
33} from "../../project/Services/ProjectSetupScriptRunner.ts";
2434
25interface FakeGhScenario {35interface FakeGhScenario {
26 prListSequence?: string[];36 prListSequence?: string[];
...@@ -593,7 +603,7 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe...@@ -593,7 +603,7 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe
593603
594function preparePullRequestThread(604function preparePullRequestThread(
595 manager: GitManagerShape,605 manager: GitManagerShape,
596 input: { cwd: string; reference: string; mode: "local" | "worktree" },606 input: GitPreparePullRequestThreadInput,
597) {607) {
598 return manager.preparePullRequestThread(input);608 return manager.preparePullRequestThread(input);
599}609}
...@@ -601,6 +611,7 @@ function preparePullRequestThread(...@@ -601,6 +611,7 @@ function preparePullRequestThread(
601function makeManager(input?: {611function makeManager(input?: {
602 ghScenario?: FakeGhScenario;612 ghScenario?: FakeGhScenario;
603 textGeneration?: Partial<FakeGitTextGeneration>;613 textGeneration?: Partial<FakeGitTextGeneration>;
614 setupScriptRunner?: ProjectSetupScriptRunnerShape;
604}) {615}) {
605 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);616 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
606 const textGeneration = createTextGeneration(input?.textGeneration);617 const textGeneration = createTextGeneration(input?.textGeneration);
...@@ -618,6 +629,12 @@ function makeManager(input?: {...@@ -618,6 +629,12 @@ function makeManager(input?: {
618 const managerLayer = Layer.mergeAll(629 const managerLayer = Layer.mergeAll(
619 Layer.succeed(GitHubCli, gitHubCli),630 Layer.succeed(GitHubCli, gitHubCli),
620 Layer.succeed(TextGeneration, textGeneration),631 Layer.succeed(TextGeneration, textGeneration),
632 Layer.succeed(
633 ProjectSetupScriptRunner,
634 input?.setupScriptRunner ?? {
635 runForThread: () => Effect.succeed({ status: "no-script" as const }),
636 },
637 ),
621 gitCoreLayer,638 gitCoreLayer,
622 serverSettingsLayer,639 serverSettingsLayer,
623 ).pipe(Layer.provideMerge(NodeServices.layer));640 ).pipe(Layer.provideMerge(NodeServices.layer));
...@@ -628,6 +645,8 @@ function makeManager(input?: {...@@ -628,6 +645,8 @@ function makeManager(input?: {
628 );645 );
629}646}
630647
648const asThreadId = (threadId: string) => threadId as ThreadId;
649
631const GitManagerTestLayer = GitCoreLive.pipe(650const GitManagerTestLayer = GitCoreLive.pipe(
632 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),651 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
633 Layer.provideMerge(NodeServices.layer),652 Layer.provideMerge(NodeServices.layer),
...@@ -2176,6 +2195,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2176,6 +2195,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2176 }),2195 }),
2177 );2196 );
21782197
2198 it.effect("launches setup only when creating a new PR worktree", () =>
2199 Effect.gen(function* () {
2200 const repoDir = yield* makeTempDir("t3code-git-manager-");
2201 yield* initRepo(repoDir);
2202 const remoteDir = yield* createBareRemote();
2203 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2204 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2205 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]);
2206 fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n");
2207 yield* runGit(repoDir, ["add", "setup.txt"]);
2208 yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]);
2209 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]);
2210 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]);
2211 yield* runGit(repoDir, ["checkout", "main"]);
2212
2213 const setupCalls: ProjectSetupScriptRunnerInput[] = [];
2214 const { manager } = yield* makeManager({
2215 ghScenario: {
2216 pullRequest: {
2217 number: 177,
2218 title: "Worktree setup PR",
2219 url: "https://github.com/pingdotgg/codething-mvp/pull/177",
2220 baseRefName: "main",
2221 headRefName: "feature/pr-worktree-setup",
2222 state: "open",
2223 },
2224 },
2225 setupScriptRunner: {
2226 runForThread: (setupInput) =>
2227 Effect.sync(() => {
2228 setupCalls.push(setupInput);
2229 return { status: "no-script" as const };
2230 }),
2231 },
2232 });
2233
2234 const result = yield* preparePullRequestThread(manager, {
2235 cwd: repoDir,
2236 reference: "177",
2237 mode: "worktree",
2238 threadId: asThreadId("thread-pr-setup"),
2239 });
2240
2241 expect(result.worktreePath).not.toBeNull();
2242 expect(setupCalls).toHaveLength(1);
2243 expect(setupCalls[0]).toEqual({
2244 threadId: "thread-pr-setup",
2245 projectCwd: repoDir,
2246 worktreePath: result.worktreePath as string,
2247 });
2248 }),
2249 );
2250
2179 it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>2251 it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
2180 Effect.gen(function* () {2252 Effect.gen(function* () {
2181 const repoDir = yield* makeTempDir("t3code-git-manager-");2253 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -2360,6 +2432,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2360,6 +2432,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2360 const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`);2432 const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`);
2361 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);2433 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);
23622434
2435 const setupCalls: ProjectSetupScriptRunnerInput[] = [];
2363 const { manager } = yield* makeManager({2436 const { manager } = yield* makeManager({
2364 ghScenario: {2437 ghScenario: {
2365 pullRequest: {2438 pullRequest: {
...@@ -2371,18 +2444,27 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2371,18 +2444,27 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2371 state: "open",2444 state: "open",
2372 },2445 },
2373 },2446 },
2447 setupScriptRunner: {
2448 runForThread: (setupInput) =>
2449 Effect.sync(() => {
2450 setupCalls.push(setupInput);
2451 return { status: "no-script" as const };
2452 }),
2453 },
2374 });2454 });
23752455
2376 const result = yield* preparePullRequestThread(manager, {2456 const result = yield* preparePullRequestThread(manager, {
2377 cwd: repoDir,2457 cwd: repoDir,
2378 reference: "78",2458 reference: "78",
2379 mode: "worktree",2459 mode: "worktree",
2460 threadId: asThreadId("thread-pr-existing-worktree"),
2380 });2461 });
23812462
2382 expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(2463 expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
2383 fs.realpathSync.native(worktreePath),2464 fs.realpathSync.native(worktreePath),
2384 );2465 );
2385 expect(result.branch).toBe("feature/pr-existing-worktree");2466 expect(result.branch).toBe("feature/pr-existing-worktree");
2467 expect(setupCalls).toHaveLength(0);
2386 }),2468 }),
2387 );2469 );
23882470
...@@ -2562,6 +2644,50 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2562,6 +2644,50 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2562 }),2644 }),
2563 );2645 );
25642646
2647 it.effect("does not fail PR worktree prep when setup terminal startup fails", () =>
2648 Effect.gen(function* () {
2649 const repoDir = yield* makeTempDir("t3code-git-manager-");
2650 yield* initRepo(repoDir);
2651 const remoteDir = yield* createBareRemote();
2652 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2653 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2654 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]);
2655 fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n");
2656 yield* runGit(repoDir, ["add", "setup-failure.txt"]);
2657 yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]);
2658 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]);
2659 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]);
2660 yield* runGit(repoDir, ["checkout", "main"]);
2661
2662 const { manager } = yield* makeManager({
2663 ghScenario: {
2664 pullRequest: {
2665 number: 184,
2666 title: "Setup failure PR",
2667 url: "https://github.com/pingdotgg/codething-mvp/pull/184",
2668 baseRefName: "main",
2669 headRefName: "feature/pr-setup-failure",
2670 state: "open",
2671 },
2672 },
2673 setupScriptRunner: {
2674 runForThread: () => Effect.fail(new Error("terminal start failed")),
2675 },
2676 });
2677
2678 const result = yield* preparePullRequestThread(manager, {
2679 cwd: repoDir,
2680 reference: "184",
2681 mode: "worktree",
2682 threadId: asThreadId("thread-pr-setup-failure"),
2683 });
2684
2685 expect(result.branch).toBe("feature/pr-setup-failure");
2686 expect(result.worktreePath).not.toBeNull();
2687 expect(fs.existsSync(result.worktreePath as string)).toBe(true);
2688 }),
2689 );
2690
2565 it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>2691 it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>
2566 Effect.gen(function* () {2692 Effect.gen(function* () {
2567 const repoDir = yield* makeTempDir("t3code-git-manager-");2693 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+21
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26import { GitCore, GitStatusDetails } from "../Services/GitCore.ts";26import { GitCore, GitStatusDetails } from "../Services/GitCore.ts";
27import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";27import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
28import { TextGeneration } from "../Services/TextGeneration.ts";28import { TextGeneration } from "../Services/TextGeneration.ts";
29import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts";
29import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";30import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
30import { ServerSettingsService } from "../../serverSettings.ts";31import { ServerSettingsService } from "../../serverSettings.ts";
31import type { GitManagerServiceError } from "@t3tools/contracts";32import type { GitManagerServiceError } from "@t3tools/contracts";
...@@ -552,6 +553,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -552,6 +553,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
552 const gitCore = yield* GitCore;553 const gitCore = yield* GitCore;
553 const gitHubCli = yield* GitHubCli;554 const gitHubCli = yield* GitHubCli;
554 const textGeneration = yield* TextGeneration;555 const textGeneration = yield* TextGeneration;
556 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
555 const serverSettingsService = yield* ServerSettingsService;557 const serverSettingsService = yield* ServerSettingsService;
556558
557 const createProgressEmitter = (559 const createProgressEmitter = (
...@@ -1329,6 +1331,24 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1329,6 +1331,24 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1329 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(1331 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
1330 "preparePullRequestThread",1332 "preparePullRequestThread",
1331 )(function* (input) {1333 )(function* (input) {
1334 const maybeRunSetupScript = (worktreePath: string) => {
1335 if (!input.threadId) {
1336 return Effect.void;
1337 }
1338 return projectSetupScriptRunner
1339 .runForThread({
1340 threadId: input.threadId,
1341 projectCwd: input.cwd,
1342 worktreePath,
1343 })
1344 .pipe(
1345 Effect.catch((error) =>
1346 Effect.logWarning(
1347 `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`,
1348 ).pipe(Effect.asVoid),
1349 ),
1350 );
1351 };
1332 return yield* Effect.gen(function* () {1352 return yield* Effect.gen(function* () {
1333 const normalizedReference = normalizePullRequestReference(input.reference);1353 const normalizedReference = normalizePullRequestReference(input.reference);
1334 const rootWorktreePath = canonicalizeExistingPath(input.cwd);1354 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
...@@ -1461,6 +1481,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1461,6 +1481,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1461 path: null,1481 path: null,
1462 });1482 });
1463 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);1483 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
1484 yield* maybeRunSetupScript(worktree.worktree.path);
14641485
1465 return {1486 return {
1466 pullRequest,1487 pullRequest,
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts created+166
...@@ -0,0 +1,166 @@
1import { Effect, Layer, Stream } from "effect";
2import { describe, expect, it, vi } from "vitest";
3import type { OrchestrationReadModel } from "@t3tools/contracts";
4
5import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
6import { TerminalManager } from "../../terminal/Services/Manager.ts";
7import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
8import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
9
10const emptySnapshot = (
11 scripts: OrchestrationReadModel["projects"][number]["scripts"],
12): OrchestrationReadModel =>
13 ({
14 snapshotSequence: 1,
15 updatedAt: "2026-01-01T00:00:00.000Z",
16 projects: [
17 {
18 id: "project-1",
19 title: "Project",
20 workspaceRoot: "/repo/project",
21 defaultModelSelection: null,
22 scripts,
23 createdAt: "2026-01-01T00:00:00.000Z",
24 updatedAt: "2026-01-01T00:00:00.000Z",
25 deletedAt: null,
26 },
27 ],
28 threads: [],
29 providerSessions: [],
30 providerStatuses: [],
31 pendingApprovals: [],
32 latestTurnByThreadId: {},
33 }) as unknown as OrchestrationReadModel;
34
35describe("ProjectSetupScriptRunner", () => {
36 it("returns no-script when no setup script exists", async () => {
37 const open = vi.fn();
38 const write = vi.fn();
39 const runner = await Effect.runPromise(
40 Effect.service(ProjectSetupScriptRunner).pipe(
41 Effect.provide(
42 ProjectSetupScriptRunnerLive.pipe(
43 Layer.provideMerge(
44 Layer.succeed(OrchestrationEngineService, {
45 getReadModel: () => Effect.succeed(emptySnapshot([])),
46 readEvents: () => Stream.empty,
47 dispatch: () => Effect.die(new Error("unused")),
48 streamDomainEvents: Stream.empty,
49 }),
50 ),
51 Layer.provideMerge(
52 Layer.succeed(TerminalManager, {
53 open,
54 write,
55 resize: () => Effect.void,
56 clear: () => Effect.void,
57 restart: () => Effect.die(new Error("unused")),
58 close: () => Effect.void,
59 subscribe: () => Effect.succeed(() => undefined),
60 }),
61 ),
62 ),
63 ),
64 ),
65 );
66
67 const result = await Effect.runPromise(
68 runner.runForThread({
69 threadId: "thread-1",
70 projectId: "project-1",
71 worktreePath: "/repo/worktrees/a",
72 }),
73 );
74
75 expect(result).toEqual({ status: "no-script" });
76 expect(open).not.toHaveBeenCalled();
77 expect(write).not.toHaveBeenCalled();
78 });
79
80 it("opens the deterministic setup terminal with worktree env and writes the command", async () => {
81 const open = vi.fn(() =>
82 Effect.succeed({
83 threadId: "thread-1",
84 terminalId: "setup-setup",
85 cwd: "/repo/worktrees/a",
86 worktreePath: "/repo/worktrees/a",
87 status: "running" as const,
88 pid: 123,
89 history: "",
90 exitCode: null,
91 exitSignal: null,
92 updatedAt: "2026-01-01T00:00:00.000Z",
93 }),
94 );
95 const write = vi.fn(() => Effect.void);
96 const runner = await Effect.runPromise(
97 Effect.service(ProjectSetupScriptRunner).pipe(
98 Effect.provide(
99 ProjectSetupScriptRunnerLive.pipe(
100 Layer.provideMerge(
101 Layer.succeed(OrchestrationEngineService, {
102 getReadModel: () =>
103 Effect.succeed(
104 emptySnapshot([
105 {
106 id: "setup",
107 name: "Setup",
108 command: "bun install",
109 icon: "configure",
110 runOnWorktreeCreate: true,
111 },
112 ]),
113 ),
114 readEvents: () => Stream.empty,
115 dispatch: () => Effect.die(new Error("unused")),
116 streamDomainEvents: Stream.empty,
117 }),
118 ),
119 Layer.provideMerge(
120 Layer.succeed(TerminalManager, {
121 open,
122 write,
123 resize: () => Effect.void,
124 clear: () => Effect.void,
125 restart: () => Effect.die(new Error("unused")),
126 close: () => Effect.void,
127 subscribe: () => Effect.succeed(() => undefined),
128 }),
129 ),
130 ),
131 ),
132 ),
133 );
134
135 const result = await Effect.runPromise(
136 runner.runForThread({
137 threadId: "thread-1",
138 projectCwd: "/repo/project",
139 worktreePath: "/repo/worktrees/a",
140 }),
141 );
142
143 expect(result).toEqual({
144 status: "started",
145 scriptId: "setup",
146 scriptName: "Setup",
147 terminalId: "setup-setup",
148 cwd: "/repo/worktrees/a",
149 });
150 expect(open).toHaveBeenCalledWith({
151 threadId: "thread-1",
152 terminalId: "setup-setup",
153 cwd: "/repo/worktrees/a",
154 worktreePath: "/repo/worktrees/a",
155 env: {
156 T3CODE_PROJECT_ROOT: "/repo/project",
157 T3CODE_WORKTREE_PATH: "/repo/worktrees/a",
158 },
159 });
160 expect(write).toHaveBeenCalledWith({
161 threadId: "thread-1",
162 terminalId: "setup-setup",
163 data: "bun install\r",
164 });
165 });
166});
apps/server/src/project/Layers/ProjectSetupScriptRunner.ts created+75
...@@ -0,0 +1,75 @@
1import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
2import { Effect, Layer } from "effect";
3
4import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
5import { TerminalManager } from "../../terminal/Services/Manager.ts";
6import {
7 type ProjectSetupScriptRunnerShape,
8 ProjectSetupScriptRunner,
9} from "../Services/ProjectSetupScriptRunner.ts";
10
11const makeProjectSetupScriptRunner = Effect.gen(function* () {
12 const orchestrationEngine = yield* OrchestrationEngineService;
13 const terminalManager = yield* TerminalManager;
14
15 const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>
16 Effect.gen(function* () {
17 const readModel = yield* orchestrationEngine.getReadModel();
18 const project =
19 (input.projectId
20 ? readModel.projects.find((entry) => entry.id === input.projectId)
21 : null) ??
22 (input.projectCwd
23 ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd)
24 : null) ??
25 null;
26
27 if (!project) {
28 return yield* Effect.fail(new Error("Project was not found for setup script execution."));
29 }
30
31 const script = setupProjectScript(project.scripts);
32 if (!script) {
33 return {
34 status: "no-script",
35 } as const;
36 }
37
38 const terminalId = input.preferredTerminalId ?? `setup-${script.id}`;
39 const cwd = input.worktreePath;
40 const env = projectScriptRuntimeEnv({
41 project: { cwd: project.workspaceRoot },
42 worktreePath: input.worktreePath,
43 });
44
45 yield* terminalManager.open({
46 threadId: input.threadId,
47 terminalId,
48 cwd,
49 worktreePath: input.worktreePath,
50 env,
51 });
52 yield* terminalManager.write({
53 threadId: input.threadId,
54 terminalId,
55 data: `${script.command}\r`,
56 });
57
58 return {
59 status: "started",
60 scriptId: script.id,
61 scriptName: script.name,
62 terminalId,
63 cwd,
64 } as const;
65 });
66
67 return {
68 runForThread,
69 } satisfies ProjectSetupScriptRunnerShape;
70});
71
72export const ProjectSetupScriptRunnerLive = Layer.effect(
73 ProjectSetupScriptRunner,
74 makeProjectSetupScriptRunner,
75);
apps/server/src/project/Services/ProjectSetupScriptRunner.ts created+37
...@@ -0,0 +1,37 @@
1import { ServiceMap } from "effect";
2import type { Effect } from "effect";
3
4export interface ProjectSetupScriptRunnerResultNoScript {
5 readonly status: "no-script";
6}
7
8export interface ProjectSetupScriptRunnerResultStarted {
9 readonly status: "started";
10 readonly scriptId: string;
11 readonly scriptName: string;
12 readonly terminalId: string;
13 readonly cwd: string;
14}
15
16export type ProjectSetupScriptRunnerResult =
17 | ProjectSetupScriptRunnerResultNoScript
18 | ProjectSetupScriptRunnerResultStarted;
19
20export interface ProjectSetupScriptRunnerInput {
21 readonly threadId: string;
22 readonly projectId?: string;
23 readonly projectCwd?: string;
24 readonly worktreePath: string;
25 readonly preferredTerminalId?: string;
26}
27
28export interface ProjectSetupScriptRunnerShape {
29 readonly runForThread: (
30 input: ProjectSetupScriptRunnerInput,
31 ) => Effect.Effect<ProjectSetupScriptRunnerResult, Error>;
32}
33
34export class ProjectSetupScriptRunner extends ServiceMap.Service<
35 ProjectSetupScriptRunner,
36 ProjectSetupScriptRunnerShape
37>()("t3/project/ProjectSetupScriptRunner") {}
apps/server/src/server.test.ts+423
...@@ -6,8 +6,10 @@ import {...@@ -6,8 +6,10 @@ import {
6 DEFAULT_SERVER_SETTINGS,6 DEFAULT_SERVER_SETTINGS,
7 GitCommandError,7 GitCommandError,
8 KeybindingRule,8 KeybindingRule,
9 MessageId,
9 OpenError,10 OpenError,
10 TerminalNotRunningError,11 TerminalNotRunningError,
12 type OrchestrationCommand,
11 type OrchestrationEvent,13 type OrchestrationEvent,
12 ORCHESTRATION_WS_METHODS,14 ORCHESTRATION_WS_METHODS,
13 ProjectId,15 ProjectId,
...@@ -22,6 +24,7 @@ import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";...@@ -22,6 +24,7 @@ import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
22import { Effect, FileSystem, Layer, Path, Stream } from "effect";24import { Effect, FileSystem, Layer, Path, Stream } from "effect";
23import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http";25import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
24import { RpcClient, RpcSerialization } from "effect/unstable/rpc";26import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
27import { vi } from "vitest";
2528
26import type { ServerConfigShape } from "./config.ts";29import type { ServerConfigShape } from "./config.ts";
27import { deriveServerPaths, ServerConfig } from "./config.ts";30import { deriveServerPaths, ServerConfig } from "./config.ts";
...@@ -39,6 +42,7 @@ import {...@@ -39,6 +42,7 @@ import {
39 OrchestrationEngineService,42 OrchestrationEngineService,
40 type OrchestrationEngineShape,43 type OrchestrationEngineShape,
41} from "./orchestration/Services/OrchestrationEngine.ts";44} from "./orchestration/Services/OrchestrationEngine.ts";
45import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts";
42import {46import {
43 ProjectionSnapshotQuery,47 ProjectionSnapshotQuery,
44 type ProjectionSnapshotQueryShape,48 type ProjectionSnapshotQueryShape,
...@@ -53,6 +57,10 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu...@@ -53,6 +57,10 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu
53import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";57import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
54import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";58import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
55import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";59import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
60import {
61 ProjectSetupScriptRunner,
62 type ProjectSetupScriptRunnerShape,
63} from "./project/Services/ProjectSetupScriptRunner.ts";
56import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";64import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
57import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";65import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
58import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";66import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
...@@ -125,6 +133,7 @@ const buildAppUnderTest = (options?: {...@@ -125,6 +133,7 @@ const buildAppUnderTest = (options?: {
125 open?: Partial<OpenShape>;133 open?: Partial<OpenShape>;
126 gitCore?: Partial<GitCoreShape>;134 gitCore?: Partial<GitCoreShape>;
127 gitManager?: Partial<GitManagerShape>;135 gitManager?: Partial<GitManagerShape>;
136 projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>;
128 terminalManager?: Partial<TerminalManagerShape>;137 terminalManager?: Partial<TerminalManagerShape>;
129 orchestrationEngine?: Partial<OrchestrationEngineShape>;138 orchestrationEngine?: Partial<OrchestrationEngineShape>;
130 projectionSnapshotQuery?: Partial<ProjectionSnapshotQueryShape>;139 projectionSnapshotQuery?: Partial<ProjectionSnapshotQueryShape>;
...@@ -209,6 +218,12 @@ const buildAppUnderTest = (options?: {...@@ -209,6 +218,12 @@ const buildAppUnderTest = (options?: {
209 ...options?.layers?.gitManager,218 ...options?.layers?.gitManager,
210 }),219 }),
211 ),220 ),
221 Layer.provide(
222 Layer.mock(ProjectSetupScriptRunner)({
223 runForThread: () => Effect.succeed({ status: "no-script" as const }),
224 ...options?.layers?.projectSetupScriptRunner,
225 }),
226 ),
212 Layer.provide(227 Layer.provide(
213 Layer.mock(TerminalManager)({228 Layer.mock(TerminalManager)({
214 ...options?.layers?.terminalManager,229 ...options?.layers?.terminalManager,
...@@ -1247,6 +1262,413 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1247,6 +1262,413 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1247 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1262 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1248 );1263 );
12491264
1265 it.effect(
1266 "bootstraps first-send worktree turns on the server before dispatching turn start",
1267 () =>
1268 Effect.gen(function* () {
1269 const dispatchedCommands: Array<OrchestrationCommand> = [];
1270 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
1271 Effect.succeed({
1272 worktree: {
1273 branch: "t3code/bootstrap-branch",
1274 path: "/tmp/bootstrap-worktree",
1275 },
1276 }),
1277 );
1278 const runForThread = vi.fn(
1279 (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) =>
1280 Effect.succeed({
1281 status: "started" as const,
1282 scriptId: "setup",
1283 scriptName: "Setup",
1284 terminalId: "setup-setup",
1285 cwd: "/tmp/bootstrap-worktree",
1286 }),
1287 );
1288
1289 yield* buildAppUnderTest({
1290 layers: {
1291 gitCore: {
1292 createWorktree,
1293 },
1294 orchestrationEngine: {
1295 dispatch: (command) =>
1296 Effect.sync(() => {
1297 dispatchedCommands.push(command);
1298 return { sequence: dispatchedCommands.length };
1299 }),
1300 readEvents: () => Stream.empty,
1301 },
1302 projectSetupScriptRunner: {
1303 runForThread,
1304 },
1305 },
1306 });
1307
1308 const createdAt = new Date().toISOString();
1309 const wsUrl = yield* getWsServerUrl("/ws");
1310 const response = yield* Effect.scoped(
1311 withWsRpcClient(wsUrl, (client) =>
1312 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
1313 type: "thread.turn.start",
1314 commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start"),
1315 threadId: ThreadId.makeUnsafe("thread-bootstrap"),
1316 message: {
1317 messageId: MessageId.makeUnsafe("msg-bootstrap"),
1318 role: "user",
1319 text: "hello",
1320 attachments: [],
1321 },
1322 modelSelection: defaultModelSelection,
1323 runtimeMode: "full-access",
1324 interactionMode: "default",
1325 bootstrap: {
1326 createThread: {
1327 projectId: defaultProjectId,
1328 title: "Bootstrap Thread",
1329 modelSelection: defaultModelSelection,
1330 runtimeMode: "full-access",
1331 interactionMode: "default",
1332 branch: "main",
1333 worktreePath: null,
1334 createdAt,
1335 },
1336 prepareWorktree: {
1337 projectCwd: "/tmp/project",
1338 baseBranch: "main",
1339 branch: "t3code/bootstrap-branch",
1340 },
1341 runSetupScript: true,
1342 },
1343 createdAt,
1344 }),
1345 ),
1346 );
1347
1348 assert.equal(response.sequence, 5);
1349 assert.deepEqual(
1350 dispatchedCommands.map((command) => command.type),
1351 [
1352 "thread.create",
1353 "thread.meta.update",
1354 "thread.activity.append",
1355 "thread.activity.append",
1356 "thread.turn.start",
1357 ],
1358 );
1359 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
1360 cwd: "/tmp/project",
1361 branch: "main",
1362 newBranch: "t3code/bootstrap-branch",
1363 path: null,
1364 });
1365 assert.deepEqual(runForThread.mock.calls[0]?.[0], {
1366 threadId: ThreadId.makeUnsafe("thread-bootstrap"),
1367 projectId: defaultProjectId,
1368 projectCwd: "/tmp/project",
1369 worktreePath: "/tmp/bootstrap-worktree",
1370 });
1371
1372 const setupActivities = dispatchedCommands.filter(
1373 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>
1374 command.type === "thread.activity.append",
1375 );
1376 assert.deepEqual(
1377 setupActivities.map((command) => command.activity.kind),
1378 ["setup-script.requested", "setup-script.started"],
1379 );
1380 const finalCommand = dispatchedCommands[4];
1381 assertTrue(finalCommand?.type === "thread.turn.start");
1382 if (finalCommand?.type === "thread.turn.start") {
1383 assert.equal(finalCommand.bootstrap, undefined);
1384 }
1385 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1386 );
1387
1388 it.effect("records setup-script failures without aborting bootstrap turn start", () =>
1389 Effect.gen(function* () {
1390 const dispatchedCommands: Array<OrchestrationCommand> = [];
1391 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
1392 Effect.succeed({
1393 worktree: {
1394 branch: "t3code/bootstrap-branch",
1395 path: "/tmp/bootstrap-worktree",
1396 },
1397 }),
1398 );
1399 const runForThread = vi.fn(
1400 (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) =>
1401 Effect.fail(new Error("pty unavailable")),
1402 );
1403
1404 yield* buildAppUnderTest({
1405 layers: {
1406 gitCore: {
1407 createWorktree,
1408 },
1409 orchestrationEngine: {
1410 dispatch: (command) =>
1411 Effect.sync(() => {
1412 dispatchedCommands.push(command);
1413 return { sequence: dispatchedCommands.length };
1414 }),
1415 readEvents: () => Stream.empty,
1416 },
1417 projectSetupScriptRunner: {
1418 runForThread,
1419 },
1420 },
1421 });
1422
1423 const createdAt = new Date().toISOString();
1424 const wsUrl = yield* getWsServerUrl("/ws");
1425 const response = yield* Effect.scoped(
1426 withWsRpcClient(wsUrl, (client) =>
1427 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
1428 type: "thread.turn.start",
1429 commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-setup-failure"),
1430 threadId: ThreadId.makeUnsafe("thread-bootstrap-setup-failure"),
1431 message: {
1432 messageId: MessageId.makeUnsafe("msg-bootstrap-setup-failure"),
1433 role: "user",
1434 text: "hello",
1435 attachments: [],
1436 },
1437 modelSelection: defaultModelSelection,
1438 runtimeMode: "full-access",
1439 interactionMode: "default",
1440 bootstrap: {
1441 createThread: {
1442 projectId: defaultProjectId,
1443 title: "Bootstrap Thread",
1444 modelSelection: defaultModelSelection,
1445 runtimeMode: "full-access",
1446 interactionMode: "default",
1447 branch: "main",
1448 worktreePath: null,
1449 createdAt,
1450 },
1451 prepareWorktree: {
1452 projectCwd: "/tmp/project",
1453 baseBranch: "main",
1454 branch: "t3code/bootstrap-branch",
1455 },
1456 runSetupScript: true,
1457 },
1458 createdAt,
1459 }),
1460 ),
1461 );
1462
1463 assert.equal(response.sequence, 4);
1464 assert.deepEqual(
1465 dispatchedCommands.map((command) => command.type),
1466 ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"],
1467 );
1468 const setupFailureActivity = dispatchedCommands.find(
1469 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>
1470 command.type === "thread.activity.append",
1471 );
1472 assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed");
1473 assert.deepEqual(setupFailureActivity?.activity.payload, {
1474 detail: "pty unavailable",
1475 worktreePath: "/tmp/bootstrap-worktree",
1476 });
1477 assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete"));
1478 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1479 );
1480
1481 it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () =>
1482 Effect.gen(function* () {
1483 const dispatchedCommands: Array<OrchestrationCommand> = [];
1484 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
1485 Effect.succeed({
1486 worktree: {
1487 branch: "t3code/bootstrap-branch",
1488 path: "/tmp/bootstrap-worktree",
1489 },
1490 }),
1491 );
1492 const runForThread = vi.fn(
1493 (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) =>
1494 Effect.succeed({
1495 status: "started" as const,
1496 scriptId: "setup",
1497 scriptName: "Setup",
1498 terminalId: "setup-setup",
1499 cwd: "/tmp/bootstrap-worktree",
1500 }),
1501 );
1502 let setupActivityAppendAttempt = 0;
1503
1504 yield* buildAppUnderTest({
1505 layers: {
1506 gitCore: {
1507 createWorktree,
1508 },
1509 orchestrationEngine: {
1510 dispatch: (command) => {
1511 if (
1512 command.type === "thread.activity.append" &&
1513 command.activity.kind.startsWith("setup-script.")
1514 ) {
1515 setupActivityAppendAttempt += 1;
1516 if (setupActivityAppendAttempt === 2) {
1517 return Effect.fail(
1518 new OrchestrationListenerCallbackError({
1519 listener: "domain-event",
1520 detail: "failed to append setup-script.started activity",
1521 }),
1522 );
1523 }
1524 }
1525
1526 return Effect.sync(() => {
1527 dispatchedCommands.push(command);
1528 return { sequence: dispatchedCommands.length };
1529 });
1530 },
1531 readEvents: () => Stream.empty,
1532 },
1533 projectSetupScriptRunner: {
1534 runForThread,
1535 },
1536 },
1537 });
1538
1539 const createdAt = new Date().toISOString();
1540 const wsUrl = yield* getWsServerUrl("/ws");
1541 const response = yield* Effect.scoped(
1542 withWsRpcClient(wsUrl, (client) =>
1543 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
1544 type: "thread.turn.start",
1545 commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-setup-activity-failure"),
1546 threadId: ThreadId.makeUnsafe("thread-bootstrap-setup-activity-failure"),
1547 message: {
1548 messageId: MessageId.makeUnsafe("msg-bootstrap-setup-activity-failure"),
1549 role: "user",
1550 text: "hello",
1551 attachments: [],
1552 },
1553 modelSelection: defaultModelSelection,
1554 runtimeMode: "full-access",
1555 interactionMode: "default",
1556 bootstrap: {
1557 createThread: {
1558 projectId: defaultProjectId,
1559 title: "Bootstrap Thread",
1560 modelSelection: defaultModelSelection,
1561 runtimeMode: "full-access",
1562 interactionMode: "default",
1563 branch: "main",
1564 worktreePath: null,
1565 createdAt,
1566 },
1567 prepareWorktree: {
1568 projectCwd: "/tmp/project",
1569 baseBranch: "main",
1570 branch: "t3code/bootstrap-branch",
1571 },
1572 runSetupScript: true,
1573 },
1574 createdAt,
1575 }),
1576 ),
1577 );
1578
1579 assert.equal(response.sequence, 4);
1580 assert.deepEqual(
1581 dispatchedCommands.map((command) => command.type),
1582 ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"],
1583 );
1584 const setupActivities = dispatchedCommands.filter(
1585 (command): command is Extract<OrchestrationCommand, { type: "thread.activity.append" }> =>
1586 command.type === "thread.activity.append",
1587 );
1588 assert.deepEqual(
1589 setupActivities.map((command) => command.activity.kind),
1590 ["setup-script.requested"],
1591 );
1592 assertTrue(
1593 setupActivities.every((command) => command.activity.kind !== "setup-script.failed"),
1594 );
1595 assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete"));
1596 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1597 );
1598
1599 it.effect("cleans up created bootstrap threads when worktree creation defects", () =>
1600 Effect.gen(function* () {
1601 const dispatchedCommands: Array<OrchestrationCommand> = [];
1602 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
1603 Effect.die(new Error("worktree exploded")),
1604 );
1605
1606 yield* buildAppUnderTest({
1607 layers: {
1608 gitCore: {
1609 createWorktree,
1610 },
1611 orchestrationEngine: {
1612 dispatch: (command) =>
1613 Effect.sync(() => {
1614 dispatchedCommands.push(command);
1615 return { sequence: dispatchedCommands.length };
1616 }),
1617 readEvents: () => Stream.empty,
1618 },
1619 },
1620 });
1621
1622 const createdAt = new Date().toISOString();
1623 const wsUrl = yield* getWsServerUrl("/ws");
1624 const result = yield* Effect.scoped(
1625 withWsRpcClient(wsUrl, (client) =>
1626 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
1627 type: "thread.turn.start",
1628 commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-defect"),
1629 threadId: ThreadId.makeUnsafe("thread-bootstrap-defect"),
1630 message: {
1631 messageId: MessageId.makeUnsafe("msg-bootstrap-defect"),
1632 role: "user",
1633 text: "hello",
1634 attachments: [],
1635 },
1636 modelSelection: defaultModelSelection,
1637 runtimeMode: "full-access",
1638 interactionMode: "default",
1639 bootstrap: {
1640 createThread: {
1641 projectId: defaultProjectId,
1642 title: "Bootstrap Thread",
1643 modelSelection: defaultModelSelection,
1644 runtimeMode: "full-access",
1645 interactionMode: "default",
1646 branch: "main",
1647 worktreePath: null,
1648 createdAt,
1649 },
1650 prepareWorktree: {
1651 projectCwd: "/tmp/project",
1652 baseBranch: "main",
1653 branch: "t3code/bootstrap-branch",
1654 },
1655 runSetupScript: false,
1656 },
1657 createdAt,
1658 }),
1659 ).pipe(Effect.result),
1660 );
1661
1662 assertTrue(result._tag === "Failure");
1663 assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");
1664 assert.include(result.failure.message, "worktree exploded");
1665 assert.deepEqual(
1666 dispatchedCommands.map((command) => command.type),
1667 ["thread.create", "thread.delete"],
1668 );
1669 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1670 );
1671
1250 it.effect(1672 it.effect(
1251 "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience",1673 "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience",
1252 () =>1674 () =>
...@@ -1342,6 +1764,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1342,6 +1764,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1342 threadId: "thread-1",1764 threadId: "thread-1",
1343 terminalId: "default",1765 terminalId: "default",
1344 cwd: "/tmp/project",1766 cwd: "/tmp/project",
1767 worktreePath: null,
1345 status: "running" as const,1768 status: "running" as const,
1346 pid: 1234,1769 pid: 1234,
1347 history: "",1770 history: "",
apps/server/src/server.ts+8-5
...@@ -41,6 +41,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol...@@ -41,6 +41,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol
41import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";41import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
42import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";42import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
43import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";43import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
44import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner";
44import { ObservabilityLive } from "./observability/Layers/Observability";45import { ObservabilityLive } from "./observability/Layers/Observability";
4546
46const PtyAdapterLive = Layer.unwrap(47const PtyAdapterLive = Layer.unwrap(
...@@ -158,6 +159,7 @@ const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersisten...@@ -158,6 +159,7 @@ const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersisten
158const GitLayerLive = Layer.empty.pipe(159const GitLayerLive = Layer.empty.pipe(
159 Layer.provideMerge(160 Layer.provideMerge(
160 GitManagerLive.pipe(161 GitManagerLive.pipe(
162 Layer.provideMerge(ProjectSetupScriptRunnerLive),
161 Layer.provideMerge(GitCoreLive),163 Layer.provideMerge(GitCoreLive),
162 Layer.provideMerge(GitHubCliLive),164 Layer.provideMerge(GitHubCliLive),
163 Layer.provideMerge(RoutingTextGenerationLive),165 Layer.provideMerge(RoutingTextGenerationLive),
...@@ -177,15 +179,12 @@ const WorkspaceLayerLive = Layer.mergeAll(...@@ -177,15 +179,12 @@ const WorkspaceLayerLive = Layer.mergeAll(
177 ),179 ),
178);180);
179181
180const RuntimeServicesLive = Layer.empty.pipe(182const RuntimeDependenciesLive = ReactorLayerLive.pipe(
181 Layer.provideMerge(ServerRuntimeStartupLive),
182 Layer.provideMerge(ReactorLayerLive),
183
184 // Core Services183 // Core Services
185 Layer.provideMerge(CheckpointingLayerLive),184 Layer.provideMerge(CheckpointingLayerLive),
185 Layer.provideMerge(GitLayerLive),
186 Layer.provideMerge(OrchestrationLayerLive),186 Layer.provideMerge(OrchestrationLayerLive),
187 Layer.provideMerge(ProviderLayerLive),187 Layer.provideMerge(ProviderLayerLive),
188 Layer.provideMerge(GitLayerLive),
189 Layer.provideMerge(TerminalLayerLive),188 Layer.provideMerge(TerminalLayerLive),
190 Layer.provideMerge(PersistenceLayerLive),189 Layer.provideMerge(PersistenceLayerLive),
191 Layer.provideMerge(KeybindingsLive),190 Layer.provideMerge(KeybindingsLive),
...@@ -200,6 +199,10 @@ const RuntimeServicesLive = Layer.empty.pipe(...@@ -200,6 +199,10 @@ const RuntimeServicesLive = Layer.empty.pipe(
200 Layer.provideMerge(ServerLifecycleEventsLive),199 Layer.provideMerge(ServerLifecycleEventsLive),
201);200);
202201
202const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
203 Layer.provideMerge(RuntimeDependenciesLive),
204);
205
203export const makeRoutesLayer = Layer.mergeAll(206export const makeRoutesLayer = Layer.mergeAll(
204 attachmentsRouteLayer,207 attachmentsRouteLayer,
205 projectFaviconRouteLayer,208 projectFaviconRouteLayer,
apps/server/src/terminal/Layers/Manager.test.ts+79
...@@ -428,6 +428,85 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (...@@ -428,6 +428,85 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
428 }),428 }),
429 );429 );
430430
431 it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () =>
432 Effect.gen(function* () {
433 const { manager, getEvents, baseDir } = yield* createManager();
434 const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a");
435 const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b");
436 yield* makeDirectory(firstWorktreePath);
437 yield* makeDirectory(secondWorktreePath);
438 const startedSnapshot = yield* manager.open(
439 openInput({
440 cwd: firstWorktreePath,
441 worktreePath: firstWorktreePath,
442 }),
443 );
444 const restartedSnapshot = yield* manager.restart(
445 restartInput({
446 cwd: secondWorktreePath,
447 worktreePath: secondWorktreePath,
448 }),
449 );
450
451 assert.equal(startedSnapshot.worktreePath, firstWorktreePath);
452 assert.equal(restartedSnapshot.worktreePath, secondWorktreePath);
453
454 const events = yield* getEvents;
455 const startedEvent = events.find(
456 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
457 );
458 const restartedEvent = events.find(
459 (event): event is Extract<TerminalEvent, { type: "restarted" }> =>
460 event.type === "restarted",
461 );
462
463 assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath);
464 assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath);
465 }),
466 );
467
468 it.effect("preserves worktree metadata when reopening an exited session", () =>
469 Effect.gen(function* () {
470 const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager();
471 const worktreePath = path.join(baseDir, "worktrees", "feature-a");
472 yield* makeDirectory(worktreePath);
473
474 yield* manager.open(
475 openInput({
476 cwd: worktreePath,
477 worktreePath,
478 }),
479 );
480
481 const process = ptyAdapter.processes[0];
482 expect(process).toBeDefined();
483 if (!process) return;
484 process.emitExit({ exitCode: 0, signal: 0 });
485
486 yield* waitFor(
487 Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")),
488 );
489
490 const reopenedSnapshot = yield* manager.open(
491 openInput({
492 cwd: worktreePath,
493 worktreePath,
494 }),
495 );
496
497 assert.equal(reopenedSnapshot.worktreePath, worktreePath);
498
499 const events = yield* getEvents;
500 const reopenedEvent = events
501 .toReversed()
502 .find(
503 (event): event is Extract<TerminalEvent, { type: "started" }> => event.type === "started",
504 );
505
506 assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath);
507 }),
508 );
509
431 it.effect("emits exited event and reopens with clean transcript after exit", () =>510 it.effect("emits exited event and reopens with clean transcript after exit", () =>
432 Effect.gen(function* () {511 Effect.gen(function* () {
433 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();512 const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager();
apps/server/src/terminal/Layers/Manager.ts+12
...@@ -80,6 +80,7 @@ interface TerminalStartInput {...@@ -80,6 +80,7 @@ interface TerminalStartInput {
80 threadId: string;80 threadId: string;
81 terminalId: string;81 terminalId: string;
82 cwd: string;82 cwd: string;
83 worktreePath?: string | null;
83 cols: number;84 cols: number;
84 rows: number;85 rows: number;
85 env?: Record<string, string>;86 env?: Record<string, string>;
...@@ -89,6 +90,7 @@ interface TerminalSessionState {...@@ -89,6 +90,7 @@ interface TerminalSessionState {
89 threadId: string;90 threadId: string;
90 terminalId: string;91 terminalId: string;
91 cwd: string;92 cwd: string;
93 worktreePath: string | null;
92 status: TerminalSessionStatus;94 status: TerminalSessionStatus;
93 pid: number | null;95 pid: number | null;
94 history: string;96 history: string;
...@@ -143,6 +145,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {...@@ -143,6 +145,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
143 threadId: session.threadId,145 threadId: session.threadId,
144 terminalId: session.terminalId,146 terminalId: session.terminalId,
145 cwd: session.cwd,147 cwd: session.cwd,
148 worktreePath: session.worktreePath,
146 status: session.status,149 status: session.status,
147 pid: session.pid,150 pid: session.pid,
148 history: session.history,151 history: session.history,
...@@ -1309,6 +1312,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1309,6 +1312,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1309 yield* modifyManagerState((state) => {1312 yield* modifyManagerState((state) => {
1310 session.status = "starting";1313 session.status = "starting";
1311 session.cwd = input.cwd;1314 session.cwd = input.cwd;
1315 session.worktreePath = input.worktreePath ?? null;
1312 session.cols = input.cols;1316 session.cols = input.cols;
1313 session.rows = input.rows;1317 session.rows = input.rows;
1314 session.exitCode = null;1318 session.exitCode = null;
...@@ -1577,6 +1581,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1577,6 +1581,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1577 threadId: input.threadId,1581 threadId: input.threadId,
1578 terminalId,1582 terminalId,
1579 cwd: input.cwd,1583 cwd: input.cwd,
1584 worktreePath: input.worktreePath ?? null,
1580 status: "starting",1585 status: "starting",
1581 pid: null,1586 pid: null,
1582 history,1587 history,
...@@ -1610,6 +1615,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1610,6 +1615,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1610 threadId: input.threadId,1615 threadId: input.threadId,
1611 terminalId,1616 terminalId,
1612 cwd: input.cwd,1617 cwd: input.cwd,
1618 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
1613 cols,1619 cols,
1614 rows,1620 rows,
1615 ...(input.env ? { env: input.env } : {}),1621 ...(input.env ? { env: input.env } : {}),
...@@ -1629,6 +1635,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1629,6 +1635,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1629 if (liveSession.cwd !== input.cwd || runtimeEnvChanged) {1635 if (liveSession.cwd !== input.cwd || runtimeEnvChanged) {
1630 yield* stopProcess(liveSession);1636 yield* stopProcess(liveSession);
1631 liveSession.cwd = input.cwd;1637 liveSession.cwd = input.cwd;
1638 liveSession.worktreePath = input.worktreePath ?? null;
1632 liveSession.runtimeEnv = nextRuntimeEnv;1639 liveSession.runtimeEnv = nextRuntimeEnv;
1633 liveSession.history = "";1640 liveSession.history = "";
1634 liveSession.pendingHistoryControlSequence = "";1641 liveSession.pendingHistoryControlSequence = "";
...@@ -1642,6 +1649,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1642,6 +1649,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1642 );1649 );
1643 } else if (liveSession.status === "exited" || liveSession.status === "error") {1650 } else if (liveSession.status === "exited" || liveSession.status === "error") {
1644 liveSession.runtimeEnv = nextRuntimeEnv;1651 liveSession.runtimeEnv = nextRuntimeEnv;
1652 liveSession.worktreePath = input.worktreePath ?? null;
1645 liveSession.history = "";1653 liveSession.history = "";
1646 liveSession.pendingHistoryControlSequence = "";1654 liveSession.pendingHistoryControlSequence = "";
1647 liveSession.pendingProcessEvents = [];1655 liveSession.pendingProcessEvents = [];
...@@ -1661,6 +1669,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1661,6 +1669,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1661 threadId: input.threadId,1669 threadId: input.threadId,
1662 terminalId,1670 terminalId,
1663 cwd: input.cwd,1671 cwd: input.cwd,
1672 worktreePath: liveSession.worktreePath,
1664 cols: targetCols,1673 cols: targetCols,
1665 rows: targetRows,1674 rows: targetRows,
1666 ...(input.env ? { env: input.env } : {}),1675 ...(input.env ? { env: input.env } : {}),
...@@ -1751,6 +1760,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1751,6 +1760,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1751 threadId: input.threadId,1760 threadId: input.threadId,
1752 terminalId,1761 terminalId,
1753 cwd: input.cwd,1762 cwd: input.cwd,
1763 worktreePath: input.worktreePath ?? null,
1754 status: "starting",1764 status: "starting",
1755 pid: null,1765 pid: null,
1756 history: "",1766 history: "",
...@@ -1780,6 +1790,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1780,6 +1790,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1780 session = existingSession.value;1790 session = existingSession.value;
1781 yield* stopProcess(session);1791 yield* stopProcess(session);
1782 session.cwd = input.cwd;1792 session.cwd = input.cwd;
1793 session.worktreePath = input.worktreePath ?? null;
1783 session.runtimeEnv = normalizedRuntimeEnv(input.env);1794 session.runtimeEnv = normalizedRuntimeEnv(input.env);
1784 }1795 }
17851796
...@@ -1798,6 +1809,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1798,6 +1809,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1798 threadId: input.threadId,1809 threadId: input.threadId,
1799 terminalId,1810 terminalId,
1800 cwd: input.cwd,1811 cwd: input.cwd,
1812 ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}),
1801 cols,1813 cols,
1802 rows,1814 rows,
1803 ...(input.env ? { env: input.env } : {}),1815 ...(input.env ? { env: input.env } : {}),
apps/server/src/terminal/Services/Manager.ts+1
...@@ -37,6 +37,7 @@ export interface TerminalSessionState {...@@ -37,6 +37,7 @@ export interface TerminalSessionState {
37 threadId: string;37 threadId: string;
38 terminalId: string;38 terminalId: string;
39 cwd: string;39 cwd: string;
40 worktreePath: string | null;
40 status: TerminalSessionStatus;41 status: TerminalSessionStatus;
41 pid: number | null;42 pid: number | null;
42 history: string;43 history: string;
apps/server/src/ws.ts+266-4
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import { Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";1import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";
2import {2import {
3 CommandId,
4 EventId,
5 type OrchestrationCommand,
3 type GitActionProgressEvent,6 type GitActionProgressEvent,
4 type GitManagerServiceError,7 type GitManagerServiceError,
5 OrchestrationDispatchCommandError,8 OrchestrationDispatchCommandError,
...@@ -11,6 +14,7 @@ import {...@@ -11,6 +14,7 @@ import {
11 ProjectSearchEntriesError,14 ProjectSearchEntriesError,
12 ProjectWriteFileError,15 ProjectWriteFileError,
13 OrchestrationReplayEventsError,16 OrchestrationReplayEventsError,
17 ThreadId,
14 type TerminalEvent,18 type TerminalEvent,
15 WS_METHODS,19 WS_METHODS,
16 WsRpcGroup,20 WsRpcGroup,
...@@ -41,6 +45,7 @@ import { TerminalManager } from "./terminal/Services/Manager";...@@ -41,6 +45,7 @@ import { TerminalManager } from "./terminal/Services/Manager";
41import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";45import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries";
42import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";46import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
43import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";47import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
48import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner";
4449
45const WsRpcLayer = WsRpcGroup.toLayer(50const WsRpcLayer = WsRpcGroup.toLayer(
46 Effect.gen(function* () {51 Effect.gen(function* () {
...@@ -59,6 +64,265 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -59,6 +64,265 @@ const WsRpcLayer = WsRpcGroup.toLayer(
59 const startup = yield* ServerRuntimeStartup;64 const startup = yield* ServerRuntimeStartup;
60 const workspaceEntries = yield* WorkspaceEntries;65 const workspaceEntries = yield* WorkspaceEntries;
61 const workspaceFileSystem = yield* WorkspaceFileSystem;66 const workspaceFileSystem = yield* WorkspaceFileSystem;
67 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
68
69 const serverCommandId = (tag: string) =>
70 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
71
72 const appendSetupScriptActivity = (input: {
73 readonly threadId: ThreadId;
74 readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed";
75 readonly summary: string;
76 readonly createdAt: string;
77 readonly payload: Record<string, unknown>;
78 readonly tone: "info" | "error";
79 }) =>
80 orchestrationEngine.dispatch({
81 type: "thread.activity.append",
82 commandId: serverCommandId("setup-script-activity"),
83 threadId: input.threadId,
84 activity: {
85 id: EventId.makeUnsafe(crypto.randomUUID()),
86 tone: input.tone,
87 kind: input.kind,
88 summary: input.summary,
89 payload: input.payload,
90 turnId: null,
91 createdAt: input.createdAt,
92 },
93 createdAt: input.createdAt,
94 });
95
96 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>
97 Schema.is(OrchestrationDispatchCommandError)(cause)
98 ? cause
99 : new OrchestrationDispatchCommandError({
100 message: cause instanceof Error ? cause.message : fallbackMessage,
101 cause,
102 });
103
104 const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause<unknown>) => {
105 const error = Cause.squash(cause);
106 return Schema.is(OrchestrationDispatchCommandError)(error)
107 ? error
108 : new OrchestrationDispatchCommandError({
109 message:
110 error instanceof Error ? error.message : "Failed to bootstrap thread turn start.",
111 cause,
112 });
113 };
114
115 const dispatchBootstrapTurnStart = (
116 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
117 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
118 Effect.gen(function* () {
119 const bootstrap = command.bootstrap;
120 const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command;
121 let createdThread = false;
122 let targetProjectId = bootstrap?.createThread?.projectId;
123 let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd;
124 let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null;
125
126 const cleanupCreatedThread = () =>
127 createdThread
128 ? orchestrationEngine
129 .dispatch({
130 type: "thread.delete",
131 commandId: serverCommandId("bootstrap-thread-delete"),
132 threadId: command.threadId,
133 })
134 .pipe(Effect.ignoreCause({ log: true }))
135 : Effect.void;
136
137 const recordSetupScriptLaunchFailure = (input: {
138 readonly error: unknown;
139 readonly requestedAt: string;
140 readonly worktreePath: string;
141 }) => {
142 const detail =
143 input.error instanceof Error ? input.error.message : "Unknown setup failure.";
144 return appendSetupScriptActivity({
145 threadId: command.threadId,
146 kind: "setup-script.failed",
147 summary: "Setup script failed to start",
148 createdAt: input.requestedAt,
149 payload: {
150 detail,
151 worktreePath: input.worktreePath,
152 },
153 tone: "error",
154 }).pipe(
155 Effect.ignoreCause({ log: false }),
156 Effect.flatMap(() =>
157 Effect.logWarning("bootstrap turn start failed to launch setup script", {
158 threadId: command.threadId,
159 worktreePath: input.worktreePath,
160 detail,
161 }),
162 ),
163 );
164 };
165
166 const recordSetupScriptStarted = (input: {
167 readonly requestedAt: string;
168 readonly worktreePath: string;
169 readonly scriptId: string;
170 readonly scriptName: string;
171 readonly terminalId: string;
172 }) => {
173 const payload = {
174 scriptId: input.scriptId,
175 scriptName: input.scriptName,
176 terminalId: input.terminalId,
177 worktreePath: input.worktreePath,
178 };
179 return Effect.all([
180 appendSetupScriptActivity({
181 threadId: command.threadId,
182 kind: "setup-script.requested",
183 summary: "Starting setup script",
184 createdAt: input.requestedAt,
185 payload,
186 tone: "info",
187 }),
188 appendSetupScriptActivity({
189 threadId: command.threadId,
190 kind: "setup-script.started",
191 summary: "Setup script started",
192 createdAt: new Date().toISOString(),
193 payload,
194 tone: "info",
195 }),
196 ]).pipe(
197 Effect.asVoid,
198 Effect.catch((error) =>
199 Effect.logWarning(
200 "bootstrap turn start launched setup script but failed to record setup activity",
201 {
202 threadId: command.threadId,
203 worktreePath: input.worktreePath,
204 scriptId: input.scriptId,
205 terminalId: input.terminalId,
206 detail:
207 error instanceof Error
208 ? error.message
209 : "Unknown setup activity dispatch failure.",
210 },
211 ),
212 ),
213 );
214 };
215
216 const runSetupProgram = () =>
217 bootstrap?.runSetupScript && targetWorktreePath
218 ? (() => {
219 const worktreePath = targetWorktreePath;
220 const requestedAt = new Date().toISOString();
221 return projectSetupScriptRunner
222 .runForThread({
223 threadId: command.threadId,
224 ...(targetProjectId ? { projectId: targetProjectId } : {}),
225 ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}),
226 worktreePath,
227 })
228 .pipe(
229 Effect.matchEffect({
230 onFailure: (error) =>
231 recordSetupScriptLaunchFailure({
232 error,
233 requestedAt,
234 worktreePath,
235 }),
236 onSuccess: (setupResult) => {
237 if (setupResult.status !== "started") {
238 return Effect.void;
239 }
240 return recordSetupScriptStarted({
241 requestedAt,
242 worktreePath,
243 scriptId: setupResult.scriptId,
244 scriptName: setupResult.scriptName,
245 terminalId: setupResult.terminalId,
246 });
247 },
248 }),
249 );
250 })()
251 : Effect.void;
252
253 const bootstrapProgram = Effect.gen(function* () {
254 if (bootstrap?.createThread) {
255 yield* orchestrationEngine.dispatch({
256 type: "thread.create",
257 commandId: serverCommandId("bootstrap-thread-create"),
258 threadId: command.threadId,
259 projectId: bootstrap.createThread.projectId,
260 title: bootstrap.createThread.title,
261 modelSelection: bootstrap.createThread.modelSelection,
262 runtimeMode: bootstrap.createThread.runtimeMode,
263 interactionMode: bootstrap.createThread.interactionMode,
264 branch: bootstrap.createThread.branch,
265 worktreePath: bootstrap.createThread.worktreePath,
266 createdAt: bootstrap.createThread.createdAt,
267 });
268 createdThread = true;
269 }
270
271 if (bootstrap?.prepareWorktree) {
272 const worktree = yield* git.createWorktree({
273 cwd: bootstrap.prepareWorktree.projectCwd,
274 branch: bootstrap.prepareWorktree.baseBranch,
275 newBranch: bootstrap.prepareWorktree.branch,
276 path: null,
277 });
278 targetWorktreePath = worktree.worktree.path;
279 yield* orchestrationEngine.dispatch({
280 type: "thread.meta.update",
281 commandId: serverCommandId("bootstrap-thread-meta-update"),
282 threadId: command.threadId,
283 branch: worktree.worktree.branch,
284 worktreePath: targetWorktreePath,
285 });
286 }
287
288 yield* runSetupProgram();
289
290 return yield* orchestrationEngine.dispatch(finalTurnStartCommand);
291 });
292
293 return yield* bootstrapProgram.pipe(
294 Effect.catchCause((cause) => {
295 const dispatchError = toBootstrapDispatchCommandCauseError(cause);
296 if (Cause.hasInterruptsOnly(cause)) {
297 return Effect.fail(dispatchError);
298 }
299 return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError)));
300 }),
301 );
302 });
303
304 const dispatchNormalizedCommand = (
305 normalizedCommand: OrchestrationCommand,
306 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => {
307 const dispatchEffect =
308 normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap
309 ? dispatchBootstrapTurnStart(normalizedCommand)
310 : orchestrationEngine
311 .dispatch(normalizedCommand)
312 .pipe(
313 Effect.mapError((cause) =>
314 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
315 ),
316 );
317
318 return startup
319 .enqueueCommand(dispatchEffect)
320 .pipe(
321 Effect.mapError((cause) =>
322 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
323 ),
324 );
325 };
62326
63 const loadServerConfig = Effect.gen(function* () {327 const loadServerConfig = Effect.gen(function* () {
64 const keybindingsConfig = yield* keybindings.loadConfigState;328 const keybindingsConfig = yield* keybindings.loadConfigState;
...@@ -104,9 +368,7 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -104,9 +368,7 @@ const WsRpcLayer = WsRpcGroup.toLayer(
104 ORCHESTRATION_WS_METHODS.dispatchCommand,368 ORCHESTRATION_WS_METHODS.dispatchCommand,
105 Effect.gen(function* () {369 Effect.gen(function* () {
106 const normalizedCommand = yield* normalizeDispatchCommand(command);370 const normalizedCommand = yield* normalizeDispatchCommand(command);
107 const result = yield* startup.enqueueCommand(371 const result = yield* dispatchNormalizedCommand(normalizedCommand);
108 orchestrationEngine.dispatch(normalizedCommand),
109 );
110 if (normalizedCommand.type === "thread.archive") {372 if (normalizedCommand.type === "thread.archive") {
111 yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe(373 yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe(
112 Effect.catch((error) =>374 Effect.catch((error) =>
apps/web/src/components/ChatView.browser.tsx+246-21
...@@ -33,6 +33,7 @@ import { isMacPlatform } from "../lib/utils";...@@ -33,6 +33,7 @@ import { isMacPlatform } from "../lib/utils";
33import { __resetNativeApiForTests } from "../nativeApi";33import { __resetNativeApiForTests } from "../nativeApi";
34import { getRouter } from "../router";34import { getRouter } from "../router";
35import { useStore } from "../store";35import { useStore } from "../store";
36import { useTerminalStateStore } from "../terminalStateStore";
36import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";37import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
37import { estimateTimelineMessageHeight } from "./timelineHeight";38import { estimateTimelineMessageHeight } from "./timelineHeight";
38import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";39import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
...@@ -682,6 +683,12 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {...@@ -682,6 +683,12 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
682 threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,683 threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,
683 terminalId: typeof body.terminalId === "string" ? body.terminalId : "default",684 terminalId: typeof body.terminalId === "string" ? body.terminalId : "default",
684 cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project",685 cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project",
686 worktreePath:
687 typeof body.worktreePath === "string"
688 ? body.worktreePath
689 : body.worktreePath === null
690 ? null
691 : null,
685 status: "running",692 status: "running",
686 pid: 123,693 pid: 123,
687 history: "",694 history: "",
...@@ -1149,6 +1156,13 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1149,6 +1156,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
1149 threads: [],1156 threads: [],
1150 bootstrapComplete: false,1157 bootstrapComplete: false,
1151 });1158 });
1159 useTerminalStateStore.persist.clearStorage();
1160 useTerminalStateStore.setState({
1161 terminalStateByThreadId: {},
1162 terminalLaunchContextByThreadId: {},
1163 terminalEventEntriesByKey: {},
1164 nextTerminalEventId: 1,
1165 });
1152 });1166 });
11531167
1154 afterEach(() => {1168 afterEach(() => {
...@@ -1372,6 +1386,74 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1372,6 +1386,74 @@ describe("ChatView timeline estimator parity (full app)", () => {
1372 }1386 }
1373 });1387 });
13741388
1389 it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => {
1390 const snapshot = createSnapshotForTargetUser({
1391 targetMessageId: "msg-user-launch-context-target" as MessageId,
1392 targetText: "launch context worktree override",
1393 });
1394 const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID);
1395 if (targetThread) {
1396 Object.assign(targetThread, {
1397 branch: "feature/branch",
1398 worktreePath: "/repo/worktrees/feature-branch",
1399 });
1400 }
1401
1402 useTerminalStateStore.setState({
1403 terminalStateByThreadId: {
1404 [THREAD_ID]: {
1405 terminalOpen: true,
1406 terminalHeight: 280,
1407 terminalIds: ["default"],
1408 runningTerminalIds: [],
1409 activeTerminalId: "default",
1410 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
1411 activeTerminalGroupId: "group-default",
1412 },
1413 },
1414 terminalLaunchContextByThreadId: {
1415 [THREAD_ID]: {
1416 cwd: "/repo/project",
1417 worktreePath: null,
1418 },
1419 },
1420 });
1421
1422 const mounted = await mountChatView({
1423 viewport: DEFAULT_VIEWPORT,
1424 snapshot,
1425 });
1426
1427 try {
1428 await vi.waitFor(
1429 () => {
1430 const openRequest = wsRequests.find(
1431 (request) => request._tag === WS_METHODS.terminalOpen,
1432 ) as
1433 | {
1434 _tag: string;
1435 cwd?: string;
1436 worktreePath?: string | null;
1437 env?: Record<string, string>;
1438 }
1439 | undefined;
1440 expect(openRequest).toMatchObject({
1441 _tag: WS_METHODS.terminalOpen,
1442 cwd: "/repo/project",
1443 worktreePath: null,
1444 env: {
1445 T3CODE_PROJECT_ROOT: "/repo/project",
1446 },
1447 });
1448 expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined();
1449 },
1450 { timeout: 8_000, interval: 16 },
1451 );
1452 } finally {
1453 await mounted.cleanup();
1454 }
1455 });
1456
1375 it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => {1457 it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => {
1376 setDraftThreadWithoutWorktree();1458 setDraftThreadWithoutWorktree();
13771459
...@@ -1713,7 +1795,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1713,7 +1795,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1713 }1795 }
1714 });1796 });
17151797
1716 it("runs setup scripts after preparing a pull request worktree thread", async () => {1798 it("lets the server own setup after preparing a pull request worktree thread", async () => {
1717 useComposerDraftStore.setState({1799 useComposerDraftStore.setState({
1718 draftThreadsByThreadId: {1800 draftThreadsByThreadId: {
1719 [THREAD_ID]: {1801 [THREAD_ID]: {
...@@ -1818,45 +1900,188 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1818,45 +1900,188 @@ describe("ChatView timeline estimator parity (full app)", () => {
1818 cwd: "/repo/project",1900 cwd: "/repo/project",
1819 reference: "1359",1901 reference: "1359",
1820 mode: "worktree",1902 mode: "worktree",
1903 threadId: THREAD_ID,
1821 });1904 });
1822 },1905 },
1823 { timeout: 8_000, interval: 16 },1906 { timeout: 8_000, interval: 16 },
1824 );1907 );
18251908
1909 expect(
1910 wsRequests.some(
1911 (request) =>
1912 request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r",
1913 ),
1914 ).toBe(false);
1915 } finally {
1916 await mounted.cleanup();
1917 }
1918 });
1919
1920 it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => {
1921 useTerminalStateStore.setState({
1922 terminalStateByThreadId: {},
1923 });
1924 useComposerDraftStore.setState({
1925 draftThreadsByThreadId: {
1926 [THREAD_ID]: {
1927 projectId: PROJECT_ID,
1928 createdAt: NOW_ISO,
1929 runtimeMode: "full-access",
1930 interactionMode: "default",
1931 branch: "main",
1932 worktreePath: null,
1933 envMode: "worktree",
1934 },
1935 },
1936 projectDraftThreadIdByProjectId: {
1937 [PROJECT_ID]: THREAD_ID,
1938 },
1939 });
1940
1941 const mounted = await mountChatView({
1942 viewport: DEFAULT_VIEWPORT,
1943 snapshot: withProjectScripts(createDraftOnlySnapshot(), [
1944 {
1945 id: "setup",
1946 name: "Setup",
1947 command: "bun install",
1948 icon: "configure",
1949 runOnWorktreeCreate: true,
1950 },
1951 ]),
1952 resolveRpc: (body) => {
1953 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
1954 return {
1955 sequence: fixture.snapshot.snapshotSequence + 1,
1956 };
1957 }
1958 return undefined;
1959 },
1960 });
1961
1962 try {
1963 useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");
1964 await waitForLayout();
1965
1966 const sendButton = await waitForSendButton();
1967 expect(sendButton.disabled).toBe(false);
1968 sendButton.click();
1969
1826 await vi.waitFor(1970 await vi.waitFor(
1827 () => {1971 () => {
1828 const openRequest = wsRequests.find(1972 const dispatchRequest = wsRequests.find(
1829 (request) =>1973 (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand,
1830 request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359",1974 ) as
1831 );1975 | {
1832 expect(openRequest).toMatchObject({1976 _tag: string;
1833 _tag: WS_METHODS.terminalOpen,1977 type?: string;
1834 threadId: expect.any(String),1978 bootstrap?: {
1835 cwd: "/repo/worktrees/pr-1359",1979 createThread?: { projectId?: string };
1836 env: {1980 prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string };
1837 T3CODE_PROJECT_ROOT: "/repo/project",1981 runSetupScript?: boolean;
1838 T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359",1982 };
1983 }
1984 | undefined;
1985 expect(dispatchRequest).toMatchObject({
1986 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
1987 type: "thread.turn.start",
1988 bootstrap: {
1989 createThread: {
1990 projectId: PROJECT_ID,
1991 },
1992 prepareWorktree: {
1993 projectCwd: "/repo/project",
1994 baseBranch: "main",
1995 branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/),
1996 },
1997 runSetupScript: true,
1839 },1998 },
1840 });1999 });
1841 },2000 },
1842 { timeout: 8_000, interval: 16 },2001 { timeout: 8_000, interval: 16 },
1843 );2002 );
18442003
2004 expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe(
2005 false,
2006 );
2007 expect(
2008 wsRequests.some(
2009 (request) =>
2010 request._tag === WS_METHODS.terminalWrite &&
2011 request.threadId === THREAD_ID &&
2012 request.data === "bun install\r",
2013 ),
2014 ).toBe(false);
2015 } finally {
2016 await mounted.cleanup();
2017 }
2018 });
2019
2020 it("shows the send state once bootstrap dispatch is in flight", async () => {
2021 useTerminalStateStore.setState({
2022 terminalStateByThreadId: {},
2023 });
2024 useComposerDraftStore.setState({
2025 draftThreadsByThreadId: {
2026 [THREAD_ID]: {
2027 projectId: PROJECT_ID,
2028 createdAt: NOW_ISO,
2029 runtimeMode: "full-access",
2030 interactionMode: "default",
2031 branch: "main",
2032 worktreePath: null,
2033 envMode: "worktree",
2034 },
2035 },
2036 projectDraftThreadIdByProjectId: {
2037 [PROJECT_ID]: THREAD_ID,
2038 },
2039 });
2040
2041 let resolveDispatch!: (value: { sequence: number }) => void;
2042 const dispatchPromise = new Promise<{ sequence: number }>((resolve) => {
2043 resolveDispatch = resolve;
2044 });
2045
2046 const mounted = await mountChatView({
2047 viewport: DEFAULT_VIEWPORT,
2048 snapshot: withProjectScripts(createDraftOnlySnapshot(), [
2049 {
2050 id: "setup",
2051 name: "Setup",
2052 command: "bun install",
2053 icon: "configure",
2054 runOnWorktreeCreate: true,
2055 },
2056 ]),
2057 resolveRpc: (body) => {
2058 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
2059 return dispatchPromise;
2060 }
2061 return undefined;
2062 },
2063 });
2064
2065 try {
2066 useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it");
2067 await waitForLayout();
2068
2069 const sendButton = await waitForSendButton();
2070 expect(sendButton.disabled).toBe(false);
2071 sendButton.click();
2072
1845 await vi.waitFor(2073 await vi.waitFor(
1846 () => {2074 () => {
1847 const writeRequest = wsRequests.find(2075 expect(
1848 (request) =>2076 wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand),
1849 request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r",2077 ).toBe(true);
1850 );2078 expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy();
1851 expect(writeRequest).toMatchObject({2079 expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull();
1852 _tag: WS_METHODS.terminalWrite,
1853 threadId: expect.any(String),
1854 data: "bun install\r",
1855 });
1856 },2080 },
1857 { timeout: 8_000, interval: 16 },2081 { timeout: 8_000, interval: 16 },
1858 );2082 );
1859 } finally {2083 } finally {
2084 resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 });
1860 await mounted.cleanup();2085 await mounted.cleanup();
1861 }2086 }
1862 });2087 });
apps/web/src/components/ChatView.tsx+153-151
...@@ -21,12 +21,13 @@ import {...@@ -21,12 +21,13 @@ import {
21 TerminalOpenInput,21 TerminalOpenInput,
22} from "@t3tools/contracts";22} from "@t3tools/contracts";
23import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";23import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
24import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
24import { truncate } from "@t3tools/shared/String";25import { truncate } from "@t3tools/shared/String";
25import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";26import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
26import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";27import { useQuery } from "@tanstack/react-query";
27import { useDebouncedValue } from "@tanstack/react-pacer";28import { useDebouncedValue } from "@tanstack/react-pacer";
28import { useNavigate, useSearch } from "@tanstack/react-router";29import { useNavigate, useSearch } from "@tanstack/react-router";
29import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery";30import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
30import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";31import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
31import { isElectron } from "../env";32import { isElectron } from "../env";
32import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";33import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
...@@ -111,10 +112,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl";...@@ -111,10 +112,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl";
111import {112import {
112 commandForProjectScript,113 commandForProjectScript,
113 nextProjectScriptId,114 nextProjectScriptId,
114 projectScriptCwd,
115 projectScriptRuntimeEnv,
116 projectScriptIdFromCommand,115 projectScriptIdFromCommand,
117 setupProjectScript,
118} from "~/projectScripts";116} from "~/projectScripts";
119import { SidebarTrigger } from "./ui/sidebar";117import { SidebarTrigger } from "./ui/sidebar";
120import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";118import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
...@@ -333,12 +331,14 @@ interface ChatViewProps {...@@ -333,12 +331,14 @@ interface ChatViewProps {
333 threadId: ThreadId;331 threadId: ThreadId;
334}332}
335333
336interface PendingPullRequestSetupRequest {334interface TerminalLaunchContext {
337 threadId: ThreadId;335 threadId: ThreadId;
338 worktreePath: string;336 cwd: string;
339 scriptId: string;337 worktreePath: string | null;
340}338}
341339
340type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;
341
342function useLocalDispatchState(input: {342function useLocalDispatchState(input: {
343 activeThread: Thread | undefined;343 activeThread: Thread | undefined;
344 activeLatestTurn: Thread["latestTurn"] | null;344 activeLatestTurn: Thread["latestTurn"] | null;
...@@ -409,6 +409,7 @@ function useLocalDispatchState(input: {...@@ -409,6 +409,7 @@ function useLocalDispatchState(input: {
409interface PersistentThreadTerminalDrawerProps {409interface PersistentThreadTerminalDrawerProps {
410 threadId: ThreadId;410 threadId: ThreadId;
411 visible: boolean;411 visible: boolean;
412 launchContext: PersistentTerminalLaunchContext | null;
412 focusRequestId: number;413 focusRequestId: number;
413 splitShortcutLabel: string | undefined;414 splitShortcutLabel: string | undefined;
414 newShortcutLabel: string | undefined;415 newShortcutLabel: string | undefined;
...@@ -419,6 +420,7 @@ interface PersistentThreadTerminalDrawerProps {...@@ -419,6 +420,7 @@ interface PersistentThreadTerminalDrawerProps {
419function PersistentThreadTerminalDrawer({420function PersistentThreadTerminalDrawer({
420 threadId,421 threadId,
421 visible,422 visible,
423 launchContext,
422 focusRequestId,424 focusRequestId,
423 splitShortcutLabel,425 splitShortcutLabel,
424 newShortcutLabel,426 newShortcutLabel,
...@@ -440,25 +442,32 @@ function PersistentThreadTerminalDrawer({...@@ -440,25 +442,32 @@ function PersistentThreadTerminalDrawer({
440 const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal);442 const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal);
441 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);443 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
442 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;444 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
445 const effectiveWorktreePath = useMemo(() => {
446 if (launchContext !== null) {
447 return launchContext.worktreePath;
448 }
449 return worktreePath;
450 }, [launchContext, worktreePath]);
443 const cwd = useMemo(451 const cwd = useMemo(
444 () =>452 () =>
445 project453 launchContext?.cwd ??
454 (project
446 ? projectScriptCwd({455 ? projectScriptCwd({
447 project: { cwd: project.cwd },456 project: { cwd: project.cwd },
448 worktreePath,457 worktreePath: effectiveWorktreePath,
449 })458 })
450 : null,459 : null),
451 [project, worktreePath],460 [effectiveWorktreePath, launchContext?.cwd, project],
452 );461 );
453 const runtimeEnv = useMemo(462 const runtimeEnv = useMemo(
454 () =>463 () =>
455 project464 project
456 ? projectScriptRuntimeEnv({465 ? projectScriptRuntimeEnv({
457 project: { cwd: project.cwd },466 project: { cwd: project.cwd },
458 worktreePath,467 worktreePath: effectiveWorktreePath,
459 })468 })
460 : {},469 : {},
461 [project, worktreePath],470 [effectiveWorktreePath, project],
462 );471 );
463472
464 const bumpFocusRequestId = useCallback(() => {473 const bumpFocusRequestId = useCallback(() => {
...@@ -541,6 +550,7 @@ function PersistentThreadTerminalDrawer({...@@ -541,6 +550,7 @@ function PersistentThreadTerminalDrawer({
541 <ThreadTerminalDrawer550 <ThreadTerminalDrawer
542 threadId={threadId}551 threadId={threadId}
543 cwd={cwd}552 cwd={cwd}
553 worktreePath={effectiveWorktreePath}
544 runtimeEnv={runtimeEnv}554 runtimeEnv={runtimeEnv}
545 visible={visible}555 visible={visible}
546 height={terminalState.terminalHeight}556 height={terminalState.terminalHeight}
...@@ -566,7 +576,6 @@ function PersistentThreadTerminalDrawer({...@@ -566,7 +576,6 @@ function PersistentThreadTerminalDrawer({
566export default function ChatView({ threadId }: ChatViewProps) {576export default function ChatView({ threadId }: ChatViewProps) {
567 const serverThread = useThreadById(threadId);577 const serverThread = useThreadById(threadId);
568 const setStoreThreadError = useStore((store) => store.setError);578 const setStoreThreadError = useStore((store) => store.setError);
569 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
570 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);579 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
571 const activeThreadLastVisitedAt = useUiStateStore(580 const activeThreadLastVisitedAt = useUiStateStore(
572 (store) => store.threadLastVisitedAtById[threadId],581 (store) => store.threadLastVisitedAtById[threadId],
...@@ -582,8 +591,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -582,8 +591,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
582 select: (params) => parseDiffRouteSearch(params),591 select: (params) => parseDiffRouteSearch(params),
583 });592 });
584 const { resolvedTheme } = useTheme();593 const { resolvedTheme } = useTheme();
585 const queryClient = useQueryClient();
586 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));
587 const composerDraft = useComposerThreadDraft(threadId);594 const composerDraft = useComposerThreadDraft(threadId);
588 const prompt = composerDraft.prompt;595 const prompt = composerDraft.prompt;
589 const composerImages = composerDraft.images;596 const composerImages = composerDraft.images;
...@@ -674,8 +681,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -674,8 +681,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
674 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);681 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
675 const [pullRequestDialogState, setPullRequestDialogState] =682 const [pullRequestDialogState, setPullRequestDialogState] =
676 useState<PullRequestDialogState | null>(null);683 useState<PullRequestDialogState | null>(null);
677 const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] =684 const [terminalLaunchContext, setTerminalLaunchContext] = useState<TerminalLaunchContext | null>(
678 useState<PendingPullRequestSetupRequest | null>(null);685 null,
686 );
679 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<687 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
680 Record<string, string[]>688 Record<string, string[]>
681 >({});689 >({});
...@@ -741,6 +749,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -741,6 +749,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
741 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);749 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
742 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);750 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
743 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);751 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);
752 const storeServerTerminalLaunchContext = useTerminalStateStore(
753 (s) => s.terminalLaunchContextByThreadId[threadId] ?? null,
754 );
755 const storeClearTerminalLaunchContext = useTerminalStateStore(
756 (s) => s.clearTerminalLaunchContext,
757 );
744 const threads = useStore((state) => state.threads);758 const threads = useStore((state) => state.threads);
745 const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]);759 const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]);
746 const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);760 const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);
...@@ -942,24 +956,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -942,24 +956,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
942956
943 const handlePreparedPullRequestThread = useCallback(957 const handlePreparedPullRequestThread = useCallback(
944 async (input: { branch: string; worktreePath: string | null }) => {958 async (input: { branch: string; worktreePath: string | null }) => {
945 const targetThreadId = await openOrReuseProjectDraftThread({959 await openOrReuseProjectDraftThread({
946 branch: input.branch,960 branch: input.branch,
947 worktreePath: input.worktreePath,961 worktreePath: input.worktreePath,
948 envMode: input.worktreePath ? "worktree" : "local",962 envMode: input.worktreePath ? "worktree" : "local",
949 });963 });
950 const setupScript =
951 input.worktreePath && activeProject ? setupProjectScript(activeProject.scripts) : null;
952 if (targetThreadId && input.worktreePath && setupScript) {
953 setPendingPullRequestSetupRequest({
954 threadId: targetThreadId,
955 worktreePath: input.worktreePath,
956 scriptId: setupScript.id,
957 });
958 } else {
959 setPendingPullRequestSetupRequest(null);
960 }
961 },964 },
962 [activeProject, openOrReuseProjectDraftThread],965 [openOrReuseProjectDraftThread],
963 );966 );
964967
965 useEffect(() => {968 useEffect(() => {
...@@ -1520,6 +1523,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1520,6 +1523,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1520 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,1523 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
1521 [selectedProvider, providerStatuses],1524 [selectedProvider, providerStatuses],
1522 );1525 );
1526 const activeProjectCwd = activeProject?.cwd ?? null;
1527 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
1528 const activeTerminalLaunchContext =
1529 terminalLaunchContext?.threadId === activeThreadId
1530 ? terminalLaunchContext
1531 : (storeServerTerminalLaunchContext ?? null);
1523 // Default true while loading to avoid toolbar flicker.1532 // Default true while loading to avoid toolbar flicker.
1524 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;1533 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
1525 const terminalShortcutLabelOptions = useMemo(1534 const terminalShortcutLabelOptions = useMemo(
...@@ -1740,7 +1749,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1740,7 +1749,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
1740 const targetTerminalId = shouldCreateNewTerminal1749 const targetTerminalId = shouldCreateNewTerminal
1741 ? `terminal-${randomUUID()}`1750 ? `terminal-${randomUUID()}`
1742 : baseTerminalId;1751 : baseTerminalId;
1752 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
17431753
1754 setTerminalLaunchContext({
1755 threadId: activeThreadId,
1756 cwd: targetCwd,
1757 worktreePath: targetWorktreePath,
1758 });
1744 setTerminalOpen(true);1759 setTerminalOpen(true);
1745 if (shouldCreateNewTerminal) {1760 if (shouldCreateNewTerminal) {
1746 storeNewTerminal(activeThreadId, targetTerminalId);1761 storeNewTerminal(activeThreadId, targetTerminalId);
...@@ -1753,7 +1768,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1753,7 +1768,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1753 project: {1768 project: {
1754 cwd: activeProject.cwd,1769 cwd: activeProject.cwd,
1755 },1770 },
1756 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,1771 worktreePath: targetWorktreePath,
1757 ...(options?.env ? { extraEnv: options.env } : {}),1772 ...(options?.env ? { extraEnv: options.env } : {}),
1758 });1773 });
1759 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal1774 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
...@@ -1761,6 +1776,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1761,6 +1776,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1761 threadId: activeThreadId,1776 threadId: activeThreadId,
1762 terminalId: targetTerminalId,1777 terminalId: targetTerminalId,
1763 cwd: targetCwd,1778 cwd: targetCwd,
1779 ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
1764 env: runtimeEnv,1780 env: runtimeEnv,
1765 cols: SCRIPT_TERMINAL_COLS,1781 cols: SCRIPT_TERMINAL_COLS,
1766 rows: SCRIPT_TERMINAL_ROWS,1782 rows: SCRIPT_TERMINAL_ROWS,
...@@ -1769,6 +1785,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1769,6 +1785,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1769 threadId: activeThreadId,1785 threadId: activeThreadId,
1770 terminalId: targetTerminalId,1786 terminalId: targetTerminalId,
1771 cwd: targetCwd,1787 cwd: targetCwd,
1788 ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
1772 env: runtimeEnv,1789 env: runtimeEnv,
1773 };1790 };
17741791
...@@ -1802,44 +1819,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1802,44 +1819,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1802 ],1819 ],
1803 );1820 );
18041821
1805 useEffect(() => {
1806 if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) {
1807 return;
1808 }
1809 if (pendingPullRequestSetupRequest.threadId !== activeThreadId) {
1810 return;
1811 }
1812 if (activeThread.worktreePath !== pendingPullRequestSetupRequest.worktreePath) {
1813 return;
1814 }
1815
1816 const setupScript =
1817 activeProject.scripts.find(
1818 (script) => script.id === pendingPullRequestSetupRequest.scriptId,
1819 ) ?? null;
1820 setPendingPullRequestSetupRequest(null);
1821 if (!setupScript) {
1822 return;
1823 }
1824
1825 void runProjectScript(setupScript, {
1826 cwd: pendingPullRequestSetupRequest.worktreePath,
1827 worktreePath: pendingPullRequestSetupRequest.worktreePath,
1828 rememberAsLastInvoked: false,
1829 }).catch((error) => {
1830 toastManager.add({
1831 type: "error",
1832 title: "Failed to run setup script.",
1833 description: error instanceof Error ? error.message : "An error occurred.",
1834 });
1835 });
1836 }, [
1837 activeProject,
1838 activeThread,
1839 activeThreadId,
1840 pendingPullRequestSetupRequest,
1841 runProjectScript,
1842 ]);
1843 const persistProjectScripts = useCallback(1822 const persistProjectScripts = useCallback(
1844 async (input: {1823 async (input: {
1845 projectId: ProjectId;1824 projectId: ProjectId;
...@@ -2532,6 +2511,74 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2532,6 +2511,74 @@ export default function ChatView({ threadId }: ChatViewProps) {
2532 ? (draftThread?.envMode ?? "local")2511 ? (draftThread?.envMode ?? "local")
2533 : "local";2512 : "local";
25342513
2514 useEffect(() => {
2515 if (!activeThreadId) {
2516 setTerminalLaunchContext(null);
2517 storeClearTerminalLaunchContext(threadId);
2518 return;
2519 }
2520 setTerminalLaunchContext((current) => {
2521 if (!current) return current;
2522 if (current.threadId === activeThreadId) return current;
2523 return null;
2524 });
2525 }, [activeThreadId, storeClearTerminalLaunchContext, threadId]);
2526
2527 useEffect(() => {
2528 if (!activeThreadId || !activeProjectCwd) {
2529 return;
2530 }
2531 setTerminalLaunchContext((current) => {
2532 if (!current || current.threadId !== activeThreadId) {
2533 return current;
2534 }
2535 const settledCwd = projectScriptCwd({
2536 project: { cwd: activeProjectCwd },
2537 worktreePath: activeThreadWorktreePath,
2538 });
2539 if (
2540 settledCwd === current.cwd &&
2541 (activeThreadWorktreePath ?? null) === current.worktreePath
2542 ) {
2543 storeClearTerminalLaunchContext(activeThreadId);
2544 return null;
2545 }
2546 return current;
2547 });
2548 }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]);
2549
2550 useEffect(() => {
2551 if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) {
2552 return;
2553 }
2554 const settledCwd = projectScriptCwd({
2555 project: { cwd: activeProjectCwd },
2556 worktreePath: activeThreadWorktreePath,
2557 });
2558 if (
2559 settledCwd === storeServerTerminalLaunchContext.cwd &&
2560 (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath
2561 ) {
2562 storeClearTerminalLaunchContext(activeThreadId);
2563 }
2564 }, [
2565 activeProjectCwd,
2566 activeThreadId,
2567 activeThreadWorktreePath,
2568 storeClearTerminalLaunchContext,
2569 storeServerTerminalLaunchContext,
2570 ]);
2571
2572 useEffect(() => {
2573 if (terminalState.terminalOpen) {
2574 return;
2575 }
2576 if (activeThreadId) {
2577 storeClearTerminalLaunchContext(activeThreadId);
2578 }
2579 setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current));
2580 }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]);
2581
2535 useEffect(() => {2582 useEffect(() => {
2536 if (phase !== "running") return;2583 if (phase !== "running") return;
2537 const timer = window.setInterval(() => {2584 const timer = window.setInterval(() => {
...@@ -2946,36 +2993,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2946,36 +2993,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2946 setComposerCursor(0);2993 setComposerCursor(0);
2947 setComposerTrigger(null);2994 setComposerTrigger(null);
29482995
2949 let createdServerThreadForLocalDraft = false;
2950 let turnStartSucceeded = false;2996 let turnStartSucceeded = false;
2951 let nextThreadBranch = activeThread.branch;
2952 let nextThreadWorktreePath = activeThread.worktreePath;
2953 await (async () => {2997 await (async () => {
2954 // On first message: lock in branch + create worktree if needed.
2955 if (baseBranchForWorktree) {
2956 beginLocalDispatch({ preparingWorktree: true });
2957 const newBranch = buildTemporaryWorktreeBranchName();
2958 const result = await createWorktreeMutation.mutateAsync({
2959 cwd: activeProject.cwd,
2960 branch: baseBranchForWorktree,
2961 newBranch,
2962 });
2963 nextThreadBranch = result.worktree.branch;
2964 nextThreadWorktreePath = result.worktree.path;
2965 if (isServerThread) {
2966 await api.orchestration.dispatchCommand({
2967 type: "thread.meta.update",
2968 commandId: newCommandId(),
2969 threadId: threadIdForSend,
2970 branch: result.worktree.branch,
2971 worktreePath: result.worktree.path,
2972 });
2973 // Keep local thread state in sync immediately so terminal drawer opens
2974 // with the worktree cwd/env instead of briefly using the project root.
2975 setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path);
2976 }
2977 }
2978
2979 let firstComposerImageName: string | null = null;2998 let firstComposerImageName: string | null = null;
2980 if (composerImagesSnapshot.length > 0) {2999 if (composerImagesSnapshot.length > 0) {
2981 const firstComposerImage = composerImagesSnapshot[0];3000 const firstComposerImage = composerImagesSnapshot[0];
...@@ -3003,48 +3022,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3003,48 +3022,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
3003 ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}),3022 ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}),
3004 };3023 };
30053024
3006 if (isLocalDraftThread) {
3007 await api.orchestration.dispatchCommand({
3008 type: "thread.create",
3009 commandId: newCommandId(),
3010 threadId: threadIdForSend,
3011 projectId: activeProject.id,
3012 title,
3013 modelSelection: threadCreateModelSelection,
3014 runtimeMode,
3015 interactionMode,
3016 branch: nextThreadBranch,
3017 worktreePath: nextThreadWorktreePath,
3018 createdAt: activeThread.createdAt,
3019 });
3020 createdServerThreadForLocalDraft = true;
3021 }
3022
3023 let setupScript: ProjectScript | null = null;
3024 if (baseBranchForWorktree) {
3025 setupScript = setupProjectScript(activeProject.scripts);
3026 }
3027 if (setupScript) {
3028 let shouldRunSetupScript = false;
3029 if (isServerThread) {
3030 shouldRunSetupScript = true;
3031 } else {
3032 if (createdServerThreadForLocalDraft) {
3033 shouldRunSetupScript = true;
3034 }
3035 }
3036 if (shouldRunSetupScript) {
3037 const setupScriptOptions: Parameters<typeof runProjectScript>[1] = {
3038 worktreePath: nextThreadWorktreePath,
3039 rememberAsLastInvoked: false,
3040 };
3041 if (nextThreadWorktreePath) {
3042 setupScriptOptions.cwd = nextThreadWorktreePath;
3043 }
3044 await runProjectScript(setupScript, setupScriptOptions);
3045 }
3046 }
3047
3048 // Auto-title from first message3025 // Auto-title from first message
3049 if (isFirstMessage && isServerThread) {3026 if (isFirstMessage && isServerThread) {
3050 await api.orchestration.dispatchCommand({3027 await api.orchestration.dispatchCommand({
...@@ -3065,8 +3042,37 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3065,8 +3042,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
3065 });3042 });
3066 }3043 }
30673044
3068 beginLocalDispatch({ preparingWorktree: false });
3069 const turnAttachments = await turnAttachmentsPromise;3045 const turnAttachments = await turnAttachmentsPromise;
3046 const bootstrap =
3047 isLocalDraftThread || baseBranchForWorktree
3048 ? {
3049 ...(isLocalDraftThread
3050 ? {
3051 createThread: {
3052 projectId: activeProject.id,
3053 title,
3054 modelSelection: threadCreateModelSelection,
3055 runtimeMode,
3056 interactionMode,
3057 branch: activeThread.branch,
3058 worktreePath: activeThread.worktreePath,
3059 createdAt: activeThread.createdAt,
3060 },
3061 }
3062 : {}),
3063 ...(baseBranchForWorktree
3064 ? {
3065 prepareWorktree: {
3066 projectCwd: activeProject.cwd,
3067 baseBranch: baseBranchForWorktree,
3068 branch: buildTemporaryWorktreeBranchName(),
3069 },
3070 runSetupScript: true,
3071 }
3072 : {}),
3073 }
3074 : undefined;
3075 beginLocalDispatch({ preparingWorktree: false });
3070 await api.orchestration.dispatchCommand({3076 await api.orchestration.dispatchCommand({
3071 type: "thread.turn.start",3077 type: "thread.turn.start",
3072 commandId: newCommandId(),3078 commandId: newCommandId(),
...@@ -3081,19 +3087,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3081,19 +3087,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
3081 titleSeed: title,3087 titleSeed: title,
3082 runtimeMode,3088 runtimeMode,
3083 interactionMode,3089 interactionMode,
3090 ...(bootstrap ? { bootstrap } : {}),
3084 createdAt: messageCreatedAt,3091 createdAt: messageCreatedAt,
3085 });3092 });
3086 turnStartSucceeded = true;3093 turnStartSucceeded = true;
3087 })().catch(async (err: unknown) => {3094 })().catch(async (err: unknown) => {
3088 if (createdServerThreadForLocalDraft && !turnStartSucceeded) {
3089 await api.orchestration
3090 .dispatchCommand({
3091 type: "thread.delete",
3092 commandId: newCommandId(),
3093 threadId: threadIdForSend,
3094 })
3095 .catch(() => undefined);
3096 }
3097 if (3095 if (
3098 !turnStartSucceeded &&3096 !turnStartSucceeded &&
3099 promptRef.current.length === 0 &&3097 promptRef.current.length === 0 &&
...@@ -4418,6 +4416,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4418,6 +4416,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
4418 <PullRequestThreadDialog4416 <PullRequestThreadDialog
4419 key={pullRequestDialogState.key}4417 key={pullRequestDialogState.key}
4420 open4418 open
4419 threadId={activeThread.id}
4421 cwd={activeProject?.cwd ?? null}4420 cwd={activeProject?.cwd ?? null}
4422 initialReference={pullRequestDialogState.initialReference}4421 initialReference={pullRequestDialogState.initialReference}
4423 onOpenChange={(open) => {4422 onOpenChange={(open) => {
...@@ -4457,6 +4456,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4457,6 +4456,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
4457 key={mountedThreadId}4456 key={mountedThreadId}
4458 threadId={mountedThreadId}4457 threadId={mountedThreadId}
4459 visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}4458 visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}
4459 launchContext={
4460 mountedThreadId === activeThreadId ? (activeTerminalLaunchContext ?? null) : null
4461 }
4460 focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0}4462 focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0}
4461 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}4463 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4462 newShortcutLabel={newTerminalShortcutLabel ?? undefined}4464 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
apps/web/src/components/PullRequestThreadDialog.tsx+5-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { GitResolvePullRequestResult } from "@t3tools/contracts";1import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";3import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
...@@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner";...@@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner";
2424
25interface PullRequestThreadDialogProps {25interface PullRequestThreadDialogProps {
26 open: boolean;26 open: boolean;
27 threadId: ThreadId;
27 cwd: string | null;28 cwd: string | null;
28 initialReference: string | null;29 initialReference: string | null;
29 onOpenChange: (open: boolean) => void;30 onOpenChange: (open: boolean) => void;
...@@ -32,6 +33,7 @@ interface PullRequestThreadDialogProps {...@@ -32,6 +33,7 @@ interface PullRequestThreadDialogProps {
3233
33export function PullRequestThreadDialog({34export function PullRequestThreadDialog({
34 open,35 open,
36 threadId,
35 cwd,37 cwd,
36 initialReference,38 initialReference,
37 onOpenChange,39 onOpenChange,
...@@ -130,6 +132,7 @@ export function PullRequestThreadDialog({...@@ -130,6 +132,7 @@ export function PullRequestThreadDialog({
130 const result = await preparePullRequestThreadMutation.mutateAsync({132 const result = await preparePullRequestThreadMutation.mutateAsync({
131 reference: parsedReference,133 reference: parsedReference,
132 mode,134 mode,
135 ...(mode === "worktree" ? { threadId } : {}),
133 });136 });
134 await onPrepared({137 await onPrepared({
135 branch: result.branch,138 branch: result.branch,
...@@ -147,6 +150,7 @@ export function PullRequestThreadDialog({...@@ -147,6 +150,7 @@ export function PullRequestThreadDialog({
147 parsedReference,150 parsedReference,
148 preparePullRequestThreadMutation,151 preparePullRequestThreadMutation,
149 resolvedPullRequest,152 resolvedPullRequest,
153 threadId,
150 ],154 ],
151 );155 );
152156
apps/web/src/components/ThreadTerminalDrawer.tsx+7
...@@ -212,6 +212,7 @@ interface TerminalViewportProps {...@@ -212,6 +212,7 @@ interface TerminalViewportProps {
212 terminalId: string;212 terminalId: string;
213 terminalLabel: string;213 terminalLabel: string;
214 cwd: string;214 cwd: string;
215 worktreePath?: string | null;
215 runtimeEnv?: Record<string, string>;216 runtimeEnv?: Record<string, string>;
216 onSessionExited: () => void;217 onSessionExited: () => void;
217 onAddTerminalContext: (selection: TerminalContextSelection) => void;218 onAddTerminalContext: (selection: TerminalContextSelection) => void;
...@@ -226,6 +227,7 @@ function TerminalViewport({...@@ -226,6 +227,7 @@ function TerminalViewport({
226 terminalId,227 terminalId,
227 terminalLabel,228 terminalLabel,
228 cwd,229 cwd,
230 worktreePath,
229 runtimeEnv,231 runtimeEnv,
230 onSessionExited,232 onSessionExited,
231 onAddTerminalContext,233 onAddTerminalContext,
...@@ -597,6 +599,7 @@ function TerminalViewport({...@@ -597,6 +599,7 @@ function TerminalViewport({
597 threadId,599 threadId,
598 terminalId,600 terminalId,
599 cwd,601 cwd,
602 ...(worktreePath !== undefined ? { worktreePath } : {}),
600 cols: activeTerminal.cols,603 cols: activeTerminal.cols,
601 rows: activeTerminal.rows,604 rows: activeTerminal.rows,
602 ...(runtimeEnv ? { env: runtimeEnv } : {}),605 ...(runtimeEnv ? { env: runtimeEnv } : {}),
...@@ -720,6 +723,7 @@ function TerminalViewport({...@@ -720,6 +723,7 @@ function TerminalViewport({
720interface ThreadTerminalDrawerProps {723interface ThreadTerminalDrawerProps {
721 threadId: ThreadId;724 threadId: ThreadId;
722 cwd: string;725 cwd: string;
726 worktreePath?: string | null;
723 runtimeEnv?: Record<string, string>;727 runtimeEnv?: Record<string, string>;
724 visible?: boolean;728 visible?: boolean;
725 height: number;729 height: number;
...@@ -771,6 +775,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA...@@ -771,6 +775,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
771export default function ThreadTerminalDrawer({775export default function ThreadTerminalDrawer({
772 threadId,776 threadId,
773 cwd,777 cwd,
778 worktreePath,
774 runtimeEnv,779 runtimeEnv,
775 visible = true,780 visible = true,
776 height,781 height,
...@@ -1097,6 +1102,7 @@ export default function ThreadTerminalDrawer({...@@ -1097,6 +1102,7 @@ export default function ThreadTerminalDrawer({
1097 terminalId={terminalId}1102 terminalId={terminalId}
1098 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}1103 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1099 cwd={cwd}1104 cwd={cwd}
1105 {...(worktreePath !== undefined ? { worktreePath } : {})}
1100 {...(runtimeEnv ? { runtimeEnv } : {})}1106 {...(runtimeEnv ? { runtimeEnv } : {})}
1101 onSessionExited={() => onCloseTerminal(terminalId)}1107 onSessionExited={() => onCloseTerminal(terminalId)}
1102 onAddTerminalContext={onAddTerminalContext}1108 onAddTerminalContext={onAddTerminalContext}
...@@ -1117,6 +1123,7 @@ export default function ThreadTerminalDrawer({...@@ -1117,6 +1123,7 @@ export default function ThreadTerminalDrawer({
1117 terminalId={resolvedActiveTerminalId}1123 terminalId={resolvedActiveTerminalId}
1118 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}1124 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1119 cwd={cwd}1125 cwd={cwd}
1126 {...(worktreePath !== undefined ? { worktreePath } : {})}
1120 {...(runtimeEnv ? { runtimeEnv } : {})}1127 {...(runtimeEnv ? { runtimeEnv } : {})}
1121 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}1128 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1122 onAddTerminalContext={onAddTerminalContext}1129 onAddTerminalContext={onAddTerminalContext}
apps/web/src/lib/gitReactQuery.ts+15-2
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts";1import {
2 type GitActionProgressEvent,
3 type GitStackedAction,
4 type ThreadId,
5} from "@t3tools/contracts";
2import {6import {
3 infiniteQueryOptions,7 infiniteQueryOptions,
4 mutationOptions,8 mutationOptions,
...@@ -244,13 +248,22 @@ export function gitPreparePullRequestThreadMutationOptions(input: {...@@ -244,13 +248,22 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
244 queryClient: QueryClient;248 queryClient: QueryClient;
245}) {249}) {
246 return mutationOptions({250 return mutationOptions({
247 mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => {251 mutationFn: async ({
252 reference,
253 mode,
254 threadId,
255 }: {
256 reference: string;
257 mode: "local" | "worktree";
258 threadId?: ThreadId;
259 }) => {
248 const api = ensureNativeApi();260 const api = ensureNativeApi();
249 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");261 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
250 return api.git.preparePullRequestThread({262 return api.git.preparePullRequestThread({
251 cwd: input.cwd,263 cwd: input.cwd,
252 reference,264 reference,
253 mode,265 mode,
266 ...(threadId ? { threadId } : {}),
254 });267 });
255 },268 },
256 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),269 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
apps/web/src/projectScripts.test.ts+5-3
...@@ -1,13 +1,15 @@...@@ -1,13 +1,15 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import {
3 projectScriptCwd,
4 projectScriptRuntimeEnv,
5 setupProjectScript,
6} from "@t3tools/shared/projectScripts";
27
3import {8import {
4 commandForProjectScript,9 commandForProjectScript,
5 nextProjectScriptId,10 nextProjectScriptId,
6 primaryProjectScript,11 primaryProjectScript,
7 projectScriptCwd,
8 projectScriptRuntimeEnv,
9 projectScriptIdFromCommand,12 projectScriptIdFromCommand,
10 setupProjectScript,
11} from "./projectScripts";13} from "./projectScripts";
1214
13describe("projectScripts helpers", () => {15describe("projectScripts helpers", () => {
apps/web/src/projectScripts.ts-36
...@@ -55,43 +55,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)...@@ -55,43 +55,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)
55 return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);55 return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);
56}56}
5757
58interface ProjectScriptRuntimeEnvInput {
59 project: {
60 cwd: string;
61 };
62 worktreePath?: string | null;
63 extraEnv?: Record<string, string>;
64}
65
66export function projectScriptCwd(input: {
67 project: {
68 cwd: string;
69 };
70 worktreePath?: string | null;
71}): string {
72 return input.worktreePath ?? input.project.cwd;
73}
74
75export function projectScriptRuntimeEnv(
76 input: ProjectScriptRuntimeEnvInput,
77): Record<string, string> {
78 const env: Record<string, string> = {
79 T3CODE_PROJECT_ROOT: input.project.cwd,
80 };
81 if (input.worktreePath) {
82 env.T3CODE_WORKTREE_PATH = input.worktreePath;
83 }
84 if (input.extraEnv) {
85 return { ...env, ...input.extraEnv };
86 }
87 return env;
88}
89
90export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {58export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {
91 const regular = scripts.find((script) => !script.runOnWorktreeCreate);59 const regular = scripts.find((script) => !script.runOnWorktreeCreate);
92 return regular ?? scripts[0] ?? null;60 return regular ?? scripts[0] ?? null;
93}61}
94
95export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null {
96 return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
97}
apps/web/src/routes/__root.tsx+4-14
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import {1import {
2 OrchestrationEvent,2 OrchestrationEvent,
3 ThreadId,
4 type ServerLifecycleWelcomePayload,3 type ServerLifecycleWelcomePayload,
4 type ThreadId,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import {6import {
7 Outlet,7 Outlet,
...@@ -36,7 +36,6 @@ import {...@@ -36,7 +36,6 @@ import {
36import { useStore } from "../store";36import { useStore } from "../store";
37import { useUiStateStore } from "../uiStateStore";37import { useUiStateStore } from "../uiStateStore";
38import { useTerminalStateStore } from "../terminalStateStore";38import { useTerminalStateStore } from "../terminalStateStore";
39import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
40import { migrateLocalSettingsToServer } from "../hooks/useSettings";39import { migrateLocalSettingsToServer } from "../hooks/useSettings";
41import { providerQueryKeys } from "../lib/providerReactQuery";40import { providerQueryKeys } from "../lib/providerReactQuery";
42import { projectQueryKeys } from "../lib/projectReactQuery";41import { projectQueryKeys } from "../lib/projectReactQuery";
...@@ -206,6 +205,7 @@ function EventRouter() {...@@ -206,6 +205,7 @@ function EventRouter() {
206 const removeOrphanedTerminalStates = useTerminalStateStore(205 const removeOrphanedTerminalStates = useTerminalStateStore(
207 (store) => store.removeOrphanedTerminalStates,206 (store) => store.removeOrphanedTerminalStates,
208 );207 );
208 const applyTerminalEvent = useTerminalStateStore((store) => store.applyTerminalEvent);
209 const queryClient = useQueryClient();209 const queryClient = useQueryClient();
210 const navigate = useNavigate();210 const navigate = useNavigate();
211 const pathname = useLocation({ select: (loc) => loc.pathname });211 const pathname = useLocation({ select: (loc) => loc.pathname });
...@@ -505,18 +505,7 @@ function EventRouter() {...@@ -505,18 +505,7 @@ function EventRouter() {
505 if (thread && thread.archivedAt !== null) {505 if (thread && thread.archivedAt !== null) {
506 return;506 return;
507 }507 }
508 useTerminalStateStore.getState().recordTerminalEvent(event);508 applyTerminalEvent(event);
509 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
510 if (hasRunningSubprocess === null) {
511 return;
512 }
513 useTerminalStateStore
514 .getState()
515 .setTerminalActivity(
516 ThreadId.makeUnsafe(event.threadId),
517 event.terminalId,
518 hasRunningSubprocess,
519 );
520 });509 });
521 return () => {510 return () => {
522 disposed = true;511 disposed = true;
...@@ -534,6 +523,7 @@ function EventRouter() {...@@ -534,6 +523,7 @@ function EventRouter() {
534 queryClient,523 queryClient,
535 removeTerminalState,524 removeTerminalState,
536 removeOrphanedTerminalStates,525 removeOrphanedTerminalStates,
526 applyTerminalEvent,
537 clearThreadUi,527 clearThreadUi,
538 setProjectExpanded,528 setProjectExpanded,
539 syncProjects,529 syncProjects,
apps/web/src/terminalActivity.test.ts+1
...@@ -7,6 +7,7 @@ const snapshot: TerminalSessionSnapshot = {...@@ -7,6 +7,7 @@ const snapshot: TerminalSessionSnapshot = {
7 threadId: "thread-1",7 threadId: "thread-1",
8 terminalId: "default",8 terminalId: "default",
9 cwd: "/tmp",9 cwd: "/tmp",
10 worktreePath: null,
10 status: "running",11 status: "running",
11 pid: 1234,12 pid: 1234,
12 history: "",13 history: "",
apps/web/src/terminalStateStore.test.ts+97
...@@ -39,6 +39,7 @@ function makeTerminalEvent(...@@ -39,6 +39,7 @@ function makeTerminalEvent(
39 threadId: THREAD_ID,39 threadId: THREAD_ID,
40 terminalId: "default",40 terminalId: "default",
41 cwd: "/tmp/workspace",41 cwd: "/tmp/workspace",
42 worktreePath: null,
42 status: "running",43 status: "running",
43 pid: 123,44 pid: 123,
44 history: "",45 history: "",
...@@ -56,6 +57,7 @@ describe("terminalStateStore actions", () => {...@@ -56,6 +57,7 @@ describe("terminalStateStore actions", () => {
56 useTerminalStateStore.persist.clearStorage();57 useTerminalStateStore.persist.clearStorage();
57 useTerminalStateStore.setState({58 useTerminalStateStore.setState({
58 terminalStateByThreadId: {},59 terminalStateByThreadId: {},
60 terminalLaunchContextByThreadId: {},
59 terminalEventEntriesByKey: {},61 terminalEventEntriesByKey: {},
60 nextTerminalEventId: 1,62 nextTerminalEventId: 1,
61 });63 });
...@@ -132,6 +134,23 @@ describe("terminalStateStore actions", () => {...@@ -132,6 +134,23 @@ describe("terminalStateStore actions", () => {
132 ]);134 ]);
133 });135 });
134136
137 it("ensures unknown server terminals are registered, opened, and activated", () => {
138 const store = useTerminalStateStore.getState();
139 store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true });
140
141 const terminalState = selectThreadTerminalState(
142 useTerminalStateStore.getState().terminalStateByThreadId,
143 THREAD_ID,
144 );
145 expect(terminalState.terminalOpen).toBe(true);
146 expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]);
147 expect(terminalState.activeTerminalId).toBe("setup-setup");
148 expect(terminalState.terminalGroups).toEqual([
149 { id: "group-default", terminalIds: ["default"] },
150 { id: "group-setup-setup", terminalIds: ["setup-setup"] },
151 ]);
152 });
153
135 it("allows unlimited groups while keeping each group capped at four terminals", () => {154 it("allows unlimited groups while keeping each group capped at four terminals", () => {
136 const store = useTerminalStateStore.getState();155 const store = useTerminalStateStore.getState();
137 store.splitTerminal(THREAD_ID, "terminal-2");156 store.splitTerminal(THREAD_ID, "terminal-2");
...@@ -219,6 +238,84 @@ describe("terminalStateStore actions", () => {...@@ -219,6 +238,84 @@ describe("terminalStateStore actions", () => {
219 expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]);238 expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]);
220 });239 });
221240
241 it("applies started terminal events to terminal state, launch context, and event buffer", () => {
242 const store = useTerminalStateStore.getState();
243 store.applyTerminalEvent(
244 makeTerminalEvent("started", {
245 terminalId: "setup-bootstrap",
246 snapshot: {
247 threadId: THREAD_ID,
248 terminalId: "setup-bootstrap",
249 cwd: "/tmp/worktree",
250 worktreePath: "/tmp/worktree",
251 status: "running",
252 pid: 123,
253 history: "",
254 exitCode: null,
255 exitSignal: null,
256 updatedAt: "2026-04-02T20:00:00.000Z",
257 },
258 }),
259 );
260
261 const terminalState = selectThreadTerminalState(
262 useTerminalStateStore.getState().terminalStateByThreadId,
263 THREAD_ID,
264 );
265 const entries = selectTerminalEventEntries(
266 useTerminalStateStore.getState().terminalEventEntriesByKey,
267 THREAD_ID,
268 "setup-bootstrap",
269 );
270
271 expect(terminalState.terminalOpen).toBe(true);
272 expect(terminalState.activeTerminalId).toBe("setup-bootstrap");
273 expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]);
274 expect(useTerminalStateStore.getState().terminalLaunchContextByThreadId[THREAD_ID]).toEqual({
275 cwd: "/tmp/worktree",
276 worktreePath: "/tmp/worktree",
277 });
278 expect(entries).toHaveLength(1);
279 expect(entries[0]?.event.type).toBe("started");
280 });
281
282 it("applies activity and exited terminal events to subprocess state while buffering events", () => {
283 const store = useTerminalStateStore.getState();
284 store.ensureTerminal(THREAD_ID, "terminal-2", { open: true, active: true });
285
286 store.applyTerminalEvent(
287 makeTerminalEvent("activity", {
288 terminalId: "terminal-2",
289 hasRunningSubprocess: true,
290 }),
291 );
292 expect(
293 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
294 .runningTerminalIds,
295 ).toEqual(["terminal-2"]);
296
297 store.applyTerminalEvent(
298 makeTerminalEvent("exited", {
299 terminalId: "terminal-2",
300 exitCode: 0,
301 exitSignal: null,
302 }),
303 );
304
305 const terminalState = selectThreadTerminalState(
306 useTerminalStateStore.getState().terminalStateByThreadId,
307 THREAD_ID,
308 );
309 const entries = selectTerminalEventEntries(
310 useTerminalStateStore.getState().terminalEventEntriesByKey,
311 THREAD_ID,
312 "terminal-2",
313 );
314
315 expect(terminalState.runningTerminalIds).toEqual([]);
316 expect(entries.map((entry) => entry.event.type)).toEqual(["activity", "exited"]);
317 });
318
222 it("clears buffered terminal events when a thread terminal state is removed", () => {319 it("clears buffered terminal events when a thread terminal state is removed", () => {
223 const store = useTerminalStateStore.getState();320 const store = useTerminalStateStore.getState();
224 store.recordTerminalEvent(makeTerminalEvent("output"));321 store.recordTerminalEvent(makeTerminalEvent("output"));
apps/web/src/terminalStateStore.ts+166-22
...@@ -5,10 +5,11 @@...@@ -5,10 +5,11 @@
5 * API constrained to store actions/selectors.5 * API constrained to store actions/selectors.
6 */6 */
77
8import type { TerminalEvent, ThreadId } from "@t3tools/contracts";8import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
9import { create } from "zustand";9import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";10import { createJSONStorage, persist } from "zustand/middleware";
11import { resolveStorage } from "./lib/storage";11import { resolveStorage } from "./lib/storage";
12import { terminalRunningSubprocessFromEvent } from "./terminalActivity";
12import {13import {
13 DEFAULT_THREAD_TERMINAL_HEIGHT,14 DEFAULT_THREAD_TERMINAL_HEIGHT,
14 DEFAULT_THREAD_TERMINAL_ID,15 DEFAULT_THREAD_TERMINAL_ID,
...@@ -26,6 +27,11 @@ interface ThreadTerminalState {...@@ -26,6 +27,11 @@ interface ThreadTerminalState {
26 activeTerminalGroupId: string;27 activeTerminalGroupId: string;
27}28}
2829
30export interface ThreadTerminalLaunchContext {
31 cwd: string;
32 worktreePath: string | null;
33}
34
29export interface TerminalEventEntry {35export interface TerminalEventEntry {
30 id: number;36 id: number;
31 event: TerminalEvent;37 event: TerminalEvent;
...@@ -245,6 +251,40 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[...@@ -245,6 +251,40 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[
245 }));251 }));
246}252}
247253
254function appendTerminalEventEntry(
255 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
256 nextTerminalEventId: number,
257 event: TerminalEvent,
258) {
259 const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId);
260 const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;
261 const nextEntry: TerminalEventEntry = {
262 id: nextTerminalEventId,
263 event,
264 };
265 const nextEntries =
266 currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER
267 ? [...currentEntries.slice(1), nextEntry]
268 : [...currentEntries, nextEntry];
269
270 return {
271 terminalEventEntriesByKey: {
272 ...terminalEventEntriesByKey,
273 [key]: nextEntries,
274 },
275 nextTerminalEventId: nextTerminalEventId + 1,
276 };
277}
278
279function launchContextFromStartEvent(
280 event: Extract<TerminalEvent, { type: "started" | "restarted" }>,
281): ThreadTerminalLaunchContext {
282 return {
283 cwd: event.snapshot.cwd,
284 worktreePath: event.snapshot.worktreePath,
285 };
286}
287
248function upsertTerminalIntoGroups(288function upsertTerminalIntoGroups(
249 state: ThreadTerminalState,289 state: ThreadTerminalState,
250 terminalId: string,290 terminalId: string,
...@@ -498,20 +538,29 @@ export function selectTerminalEventEntries(...@@ -498,20 +538,29 @@ export function selectTerminalEventEntries(
498538
499interface TerminalStateStoreState {539interface TerminalStateStoreState {
500 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;540 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
541 terminalLaunchContextByThreadId: Record<ThreadId, ThreadTerminalLaunchContext>;
501 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;542 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;
502 nextTerminalEventId: number;543 nextTerminalEventId: number;
503 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;544 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
504 setTerminalHeight: (threadId: ThreadId, height: number) => void;545 setTerminalHeight: (threadId: ThreadId, height: number) => void;
505 splitTerminal: (threadId: ThreadId, terminalId: string) => void;546 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
506 newTerminal: (threadId: ThreadId, terminalId: string) => void;547 newTerminal: (threadId: ThreadId, terminalId: string) => void;
548 ensureTerminal: (
549 threadId: ThreadId,
550 terminalId: string,
551 options?: { open?: boolean; active?: boolean },
552 ) => void;
507 setActiveTerminal: (threadId: ThreadId, terminalId: string) => void;553 setActiveTerminal: (threadId: ThreadId, terminalId: string) => void;
508 closeTerminal: (threadId: ThreadId, terminalId: string) => void;554 closeTerminal: (threadId: ThreadId, terminalId: string) => void;
555 setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void;
556 clearTerminalLaunchContext: (threadId: ThreadId) => void;
509 setTerminalActivity: (557 setTerminalActivity: (
510 threadId: ThreadId,558 threadId: ThreadId,
511 terminalId: string,559 terminalId: string,
512 hasRunningSubprocess: boolean,560 hasRunningSubprocess: boolean,
513 ) => void;561 ) => void;
514 recordTerminalEvent: (event: TerminalEvent) => void;562 recordTerminalEvent: (event: TerminalEvent) => void;
563 applyTerminalEvent: (event: TerminalEvent) => void;
515 clearTerminalState: (threadId: ThreadId) => void;564 clearTerminalState: (threadId: ThreadId) => void;
516 removeTerminalState: (threadId: ThreadId) => void;565 removeTerminalState: (threadId: ThreadId) => void;
517 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;566 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
...@@ -541,6 +590,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -541,6 +590,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
541590
542 return {591 return {
543 terminalStateByThreadId: {},592 terminalStateByThreadId: {},
593 terminalLaunchContextByThreadId: {},
544 terminalEventEntriesByKey: {},594 terminalEventEntriesByKey: {},
545 nextTerminalEventId: 1,595 nextTerminalEventId: 1,
546 setTerminalOpen: (threadId, open) =>596 setTerminalOpen: (threadId, open) =>
...@@ -551,33 +601,104 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -551,33 +601,104 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
551 updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)),601 updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)),
552 newTerminal: (threadId, terminalId) =>602 newTerminal: (threadId, terminalId) =>
553 updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)),603 updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)),
604 ensureTerminal: (threadId, terminalId, options) =>
605 updateTerminal(threadId, (state) => {
606 let nextState = state;
607 if (!state.terminalIds.includes(terminalId)) {
608 nextState = newThreadTerminal(nextState, terminalId);
609 }
610 if (options?.active === false) {
611 nextState = {
612 ...nextState,
613 activeTerminalId: state.activeTerminalId,
614 activeTerminalGroupId: state.activeTerminalGroupId,
615 };
616 }
617 if (options?.active ?? true) {
618 nextState = setThreadActiveTerminal(nextState, terminalId);
619 }
620 if (options?.open) {
621 nextState = setThreadTerminalOpen(nextState, true);
622 }
623 return normalizeThreadTerminalState(nextState);
624 }),
554 setActiveTerminal: (threadId, terminalId) =>625 setActiveTerminal: (threadId, terminalId) =>
555 updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)),626 updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)),
556 closeTerminal: (threadId, terminalId) =>627 closeTerminal: (threadId, terminalId) =>
557 updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)),628 updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)),
629 setTerminalLaunchContext: (threadId, context) =>
630 set((state) => ({
631 terminalLaunchContextByThreadId: {
632 ...state.terminalLaunchContextByThreadId,
633 [threadId]: context,
634 },
635 })),
636 clearTerminalLaunchContext: (threadId) =>
637 set((state) => {
638 if (!state.terminalLaunchContextByThreadId[threadId]) {
639 return state;
640 }
641 const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId;
642 return { terminalLaunchContextByThreadId: rest };
643 }),
558 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>644 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
559 updateTerminal(threadId, (state) =>645 updateTerminal(threadId, (state) =>
560 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),646 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
561 ),647 ),
562 recordTerminalEvent: (event) =>648 recordTerminalEvent: (event) =>
649 set((state) =>
650 appendTerminalEventEntry(
651 state.terminalEventEntriesByKey,
652 state.nextTerminalEventId,
653 event,
654 ),
655 ),
656 applyTerminalEvent: (event) =>
563 set((state) => {657 set((state) => {
564 const key = terminalEventBufferKey(event.threadId as ThreadId, event.terminalId);658 const threadId = ThreadId.makeUnsafe(event.threadId);
565 const currentEntries =659 let nextTerminalStateByThreadId = state.terminalStateByThreadId;
566 state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;660 let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId;
567 const nextEntry: TerminalEventEntry = {661
568 id: state.nextTerminalEventId,662 if (event.type === "started" || event.type === "restarted") {
663 nextTerminalStateByThreadId = updateTerminalStateByThreadId(
664 nextTerminalStateByThreadId,
665 threadId,
666 (current) => {
667 let nextState = current;
668 if (!current.terminalIds.includes(event.terminalId)) {
669 nextState = newThreadTerminal(nextState, event.terminalId);
670 }
671 nextState = setThreadActiveTerminal(nextState, event.terminalId);
672 nextState = setThreadTerminalOpen(nextState, true);
673 return normalizeThreadTerminalState(nextState);
674 },
675 );
676 nextTerminalLaunchContextByThreadId = {
677 ...nextTerminalLaunchContextByThreadId,
678 [threadId]: launchContextFromStartEvent(event),
679 };
680 }
681
682 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
683 if (hasRunningSubprocess !== null) {
684 nextTerminalStateByThreadId = updateTerminalStateByThreadId(
685 nextTerminalStateByThreadId,
686 threadId,
687 (current) =>
688 setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess),
689 );
690 }
691
692 const nextEventState = appendTerminalEventEntry(
693 state.terminalEventEntriesByKey,
694 state.nextTerminalEventId,
569 event,695 event,
570 };696 );
571 const nextEntries =697
572 currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER
573 ? [...currentEntries.slice(1), nextEntry]
574 : [...currentEntries, nextEntry];
575 return {698 return {
576 terminalEventEntriesByKey: {699 terminalStateByThreadId: nextTerminalStateByThreadId,
577 ...state.terminalEventEntriesByKey,700 terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId,
578 [key]: nextEntries,701 ...nextEventState,
579 },
580 nextTerminalEventId: state.nextTerminalEventId + 1,
581 };702 };
582 }),703 }),
583 clearTerminalState: (threadId) =>704 clearTerminalState: (threadId) =>
...@@ -587,6 +708,9 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -587,6 +708,9 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
587 threadId,708 threadId,
588 () => createDefaultThreadTerminalState(),709 () => createDefaultThreadTerminalState(),
589 );710 );
711 const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined;
712 const { [threadId]: _removed, ...remainingLaunchContexts } =
713 state.terminalLaunchContextByThreadId;
590 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };714 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
591 let removedEventEntries = false;715 let removedEventEntries = false;
592 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {716 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
...@@ -597,18 +721,21 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -597,18 +721,21 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
597 }721 }
598 if (722 if (
599 nextTerminalStateByThreadId === state.terminalStateByThreadId &&723 nextTerminalStateByThreadId === state.terminalStateByThreadId &&
724 !hadLaunchContext &&
600 !removedEventEntries725 !removedEventEntries
601 ) {726 ) {
602 return state;727 return state;
603 }728 }
604 return {729 return {
605 terminalStateByThreadId: nextTerminalStateByThreadId,730 terminalStateByThreadId: nextTerminalStateByThreadId,
731 terminalLaunchContextByThreadId: remainingLaunchContexts,
606 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,732 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
607 };733 };
608 }),734 }),
609 removeTerminalState: (threadId) =>735 removeTerminalState: (threadId) =>
610 set((state) => {736 set((state) => {
611 const hasThreadState = state.terminalStateByThreadId[threadId] !== undefined;737 const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined;
738 const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined;
612 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };739 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
613 let removedEventEntries = false;740 let removedEventEntries = false;
614 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {741 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
...@@ -617,13 +744,16 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -617,13 +744,16 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
617 removedEventEntries = true;744 removedEventEntries = true;
618 }745 }
619 }746 }
620 if (!hasThreadState && !removedEventEntries) {747 if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) {
621 return state;748 return state;
622 }749 }
623 const next = { ...state.terminalStateByThreadId };750 const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId };
624 delete next[threadId];751 delete nextTerminalStateByThreadId[threadId];
752 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId };
753 delete nextLaunchContexts[threadId];
625 return {754 return {
626 terminalStateByThreadId: next,755 terminalStateByThreadId: nextTerminalStateByThreadId,
756 terminalLaunchContextByThreadId: nextLaunchContexts,
627 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,757 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
628 };758 };
629 }),759 }),
...@@ -632,6 +762,9 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -632,6 +762,9 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
632 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(762 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(
633 (id) => !activeThreadIds.has(id as ThreadId),763 (id) => !activeThreadIds.has(id as ThreadId),
634 );764 );
765 const orphanedLaunchContextIds = Object.keys(
766 state.terminalLaunchContextByThreadId,
767 ).filter((id) => !activeThreadIds.has(id as ThreadId));
635 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };768 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
636 let removedEventEntries = false;769 let removedEventEntries = false;
637 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {770 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
...@@ -641,13 +774,24 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -641,13 +774,24 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
641 removedEventEntries = true;774 removedEventEntries = true;
642 }775 }
643 }776 }
644 if (orphanedIds.length === 0 && !removedEventEntries) return state;777 if (
778 orphanedIds.length === 0 &&
779 orphanedLaunchContextIds.length === 0 &&
780 !removedEventEntries
781 ) {
782 return state;
783 }
645 const next = { ...state.terminalStateByThreadId };784 const next = { ...state.terminalStateByThreadId };
646 for (const id of orphanedIds) {785 for (const id of orphanedIds) {
647 delete next[id as ThreadId];786 delete next[id as ThreadId];
648 }787 }
788 const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId };
789 for (const id of orphanedLaunchContextIds) {
790 delete nextLaunchContexts[id as ThreadId];
791 }
649 return {792 return {
650 terminalStateByThreadId: next,793 terminalStateByThreadId: next,
794 terminalLaunchContextByThreadId: nextLaunchContexts,
651 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,795 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
652 };796 };
653 }),797 }),
packages/contracts/src/git.ts+2-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";2import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
33
4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
5const GIT_LIST_BRANCHES_MAX_LIMIT = 200;5const GIT_LIST_BRANCHES_MAX_LIMIT = 200;
...@@ -147,6 +147,7 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({...@@ -147,6 +147,7 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({
147 cwd: TrimmedNonEmptyStringSchema,147 cwd: TrimmedNonEmptyStringSchema,
148 reference: GitPullRequestReference,148 reference: GitPullRequestReference,
149 mode: GitPreparePullRequestThreadMode,149 mode: GitPreparePullRequestThreadMode,
150 threadId: Schema.optional(ThreadId),
150});151});
151export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type;152export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type;
152153
packages/contracts/src/orchestration.test.ts+41
...@@ -195,6 +195,47 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (...@@ -195,6 +195,47 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
195 }),195 }),
196);196);
197197
198it.effect("accepts bootstrap metadata in thread.turn.start", () =>
199 Effect.gen(function* () {
200 const parsed = yield* decodeThreadTurnStartCommand({
201 type: "thread.turn.start",
202 commandId: "cmd-turn-bootstrap",
203 threadId: "thread-1",
204 message: {
205 messageId: "msg-bootstrap",
206 role: "user",
207 text: "hello",
208 attachments: [],
209 },
210 bootstrap: {
211 createThread: {
212 projectId: "project-1",
213 title: "Bootstrap thread",
214 modelSelection: {
215 provider: "codex",
216 model: "gpt-5.4",
217 },
218 runtimeMode: "full-access",
219 interactionMode: "default",
220 branch: null,
221 worktreePath: null,
222 createdAt: "2026-01-01T00:00:00.000Z",
223 },
224 prepareWorktree: {
225 projectCwd: "/tmp/workspace",
226 baseBranch: "main",
227 branch: "t3code/example",
228 },
229 runSetupScript: true,
230 },
231 createdAt: "2026-01-01T00:00:00.000Z",
232 });
233 assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1");
234 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main");
235 assert.strictEqual(parsed.bootstrap?.runSetupScript, true);
236 }),
237);
238
198it.effect("decodes thread.created runtime mode for historical events", () =>239it.effect("decodes thread.created runtime mode for historical events", () =>
199 Effect.gen(function* () {240 Effect.gen(function* () {
200 const parsed = yield* decodeThreadCreatedPayload({241 const parsed = yield* decodeThreadCreatedPayload({
packages/contracts/src/orchestration.ts+27
...@@ -384,6 +384,31 @@ const ThreadInteractionModeSetCommand = Schema.Struct({...@@ -384,6 +384,31 @@ const ThreadInteractionModeSetCommand = Schema.Struct({
384 createdAt: IsoDateTime,384 createdAt: IsoDateTime,
385});385});
386386
387const ThreadTurnStartBootstrapCreateThread = Schema.Struct({
388 projectId: ProjectId,
389 title: TrimmedNonEmptyString,
390 modelSelection: ModelSelection,
391 runtimeMode: RuntimeMode,
392 interactionMode: ProviderInteractionMode,
393 branch: Schema.NullOr(TrimmedNonEmptyString),
394 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
395 createdAt: IsoDateTime,
396});
397
398const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({
399 projectCwd: TrimmedNonEmptyString,
400 baseBranch: TrimmedNonEmptyString,
401 branch: Schema.optional(TrimmedNonEmptyString),
402});
403
404const ThreadTurnStartBootstrap = Schema.Struct({
405 createThread: Schema.optional(ThreadTurnStartBootstrapCreateThread),
406 prepareWorktree: Schema.optional(ThreadTurnStartBootstrapPrepareWorktree),
407 runSetupScript: Schema.optional(Schema.Boolean),
408});
409
410export type ThreadTurnStartBootstrap = typeof ThreadTurnStartBootstrap.Type;
411
387export const ThreadTurnStartCommand = Schema.Struct({412export const ThreadTurnStartCommand = Schema.Struct({
388 type: Schema.Literal("thread.turn.start"),413 type: Schema.Literal("thread.turn.start"),
389 commandId: CommandId,414 commandId: CommandId,
...@@ -400,6 +425,7 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -400,6 +425,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
400 interactionMode: ProviderInteractionMode.pipe(425 interactionMode: ProviderInteractionMode.pipe(
401 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),426 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
402 ),427 ),
428 bootstrap: Schema.optional(ThreadTurnStartBootstrap),
403 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),429 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
404 createdAt: IsoDateTime,430 createdAt: IsoDateTime,
405});431});
...@@ -418,6 +444,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({...@@ -418,6 +444,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
418 titleSeed: Schema.optional(TrimmedNonEmptyString),444 titleSeed: Schema.optional(TrimmedNonEmptyString),
419 runtimeMode: RuntimeMode,445 runtimeMode: RuntimeMode,
420 interactionMode: ProviderInteractionMode,446 interactionMode: ProviderInteractionMode,
447 bootstrap: Schema.optional(ThreadTurnStartBootstrap),
421 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),448 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
422 createdAt: IsoDateTime,449 createdAt: IsoDateTime,
423});450});
packages/contracts/src/terminal.test.ts+26
...@@ -63,6 +63,7 @@ describe("TerminalOpenInput", () => {...@@ -63,6 +63,7 @@ describe("TerminalOpenInput", () => {
63 const parsed = decodeSync(TerminalOpenInput, {63 const parsed = decodeSync(TerminalOpenInput, {
64 threadId: "thread-1",64 threadId: "thread-1",
65 cwd: "/tmp/project",65 cwd: "/tmp/project",
66 worktreePath: "/tmp/project/.t3/worktrees/feature-a",
66 cols: 100,67 cols: 100,
67 rows: 24,68 rows: 24,
68 env: {69 env: {
...@@ -74,6 +75,7 @@ describe("TerminalOpenInput", () => {...@@ -74,6 +75,7 @@ describe("TerminalOpenInput", () => {
74 T3CODE_PROJECT_ROOT: "/tmp/project",75 T3CODE_PROJECT_ROOT: "/tmp/project",
75 CUSTOM_FLAG: "1",76 CUSTOM_FLAG: "1",
76 });77 });
78 expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a");
77 });79 });
7880
79 it("rejects invalid env keys", () => {81 it("rejects invalid env keys", () => {
...@@ -157,6 +159,7 @@ describe("TerminalSessionSnapshot", () => {...@@ -157,6 +159,7 @@ describe("TerminalSessionSnapshot", () => {
157 threadId: "thread-1",159 threadId: "thread-1",
158 terminalId: DEFAULT_TERMINAL_ID,160 terminalId: DEFAULT_TERMINAL_ID,
159 cwd: "/tmp/project",161 cwd: "/tmp/project",
162 worktreePath: null,
160 status: "running",163 status: "running",
161 pid: 1234,164 pid: 1234,
162 history: "hello\n",165 history: "hello\n",
...@@ -205,4 +208,27 @@ describe("TerminalEvent", () => {...@@ -205,4 +208,27 @@ describe("TerminalEvent", () => {
205 }),208 }),
206 ).toBe(true);209 ).toBe(true);
207 });210 });
211
212 it("accepts started events with snapshot worktree metadata", () => {
213 expect(
214 decodes(TerminalEvent, {
215 type: "started",
216 threadId: "thread-1",
217 terminalId: DEFAULT_TERMINAL_ID,
218 createdAt: new Date().toISOString(),
219 snapshot: {
220 threadId: "thread-1",
221 terminalId: DEFAULT_TERMINAL_ID,
222 cwd: "/tmp/project/.t3/worktrees/feature-a",
223 worktreePath: "/tmp/project/.t3/worktrees/feature-a",
224 status: "running",
225 pid: 1234,
226 history: "",
227 exitCode: null,
228 exitSignal: null,
229 updatedAt: new Date().toISOString(),
230 },
231 }),
232 ).toBe(true);
233 });
208});234});
packages/contracts/src/terminal.ts+3
...@@ -37,6 +37,7 @@ export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionIn...@@ -37,6 +37,7 @@ export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionIn
37export const TerminalOpenInput = Schema.Struct({37export const TerminalOpenInput = Schema.Struct({
38 ...TerminalSessionInput.fields,38 ...TerminalSessionInput.fields,
39 cwd: TrimmedNonEmptyStringSchema,39 cwd: TrimmedNonEmptyStringSchema,
40 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
40 cols: Schema.optional(TerminalColsSchema),41 cols: Schema.optional(TerminalColsSchema),
41 rows: Schema.optional(TerminalRowsSchema),42 rows: Schema.optional(TerminalRowsSchema),
42 env: Schema.optional(TerminalEnvSchema),43 env: Schema.optional(TerminalEnvSchema),
...@@ -62,6 +63,7 @@ export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>...@@ -62,6 +63,7 @@ export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>
62export const TerminalRestartInput = Schema.Struct({63export const TerminalRestartInput = Schema.Struct({
63 ...TerminalSessionInput.fields,64 ...TerminalSessionInput.fields,
64 cwd: TrimmedNonEmptyStringSchema,65 cwd: TrimmedNonEmptyStringSchema,
66 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
65 cols: TerminalColsSchema,67 cols: TerminalColsSchema,
66 rows: TerminalRowsSchema,68 rows: TerminalRowsSchema,
67 env: Schema.optional(TerminalEnvSchema),69 env: Schema.optional(TerminalEnvSchema),
...@@ -82,6 +84,7 @@ export const TerminalSessionSnapshot = Schema.Struct({...@@ -82,6 +84,7 @@ export const TerminalSessionSnapshot = Schema.Struct({
82 threadId: Schema.String.check(Schema.isNonEmpty()),84 threadId: Schema.String.check(Schema.isNonEmpty()),
83 terminalId: Schema.String.check(Schema.isNonEmpty()),85 terminalId: Schema.String.check(Schema.isNonEmpty()),
84 cwd: Schema.String.check(Schema.isNonEmpty()),86 cwd: Schema.String.check(Schema.isNonEmpty()),
87 worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema),
85 status: TerminalSessionStatus,88 status: TerminalSessionStatus,
86 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),89 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
87 history: Schema.String,90 history: Schema.String,
packages/shared/package.json+4
...@@ -47,6 +47,10 @@...@@ -47,6 +47,10 @@
47 "./String": {47 "./String": {
48 "types": "./src/String.ts",48 "types": "./src/String.ts",
49 "import": "./src/String.ts"49 "import": "./src/String.ts"
50 },
51 "./projectScripts": {
52 "types": "./src/projectScripts.ts",
53 "import": "./src/projectScripts.ts"
50 }54 }
51 },55 },
52 "scripts": {56 "scripts": {
packages/shared/src/projectScripts.ts created+37
...@@ -0,0 +1,37 @@
1import type { ProjectScript } from "@t3tools/contracts";
2
3interface ProjectScriptRuntimeEnvInput {
4 project: {
5 cwd: string;
6 };
7 worktreePath?: string | null;
8 extraEnv?: Record<string, string>;
9}
10
11export function projectScriptCwd(input: {
12 project: {
13 cwd: string;
14 };
15 worktreePath?: string | null;
16}): string {
17 return input.worktreePath ?? input.project.cwd;
18}
19
20export function projectScriptRuntimeEnv(
21 input: ProjectScriptRuntimeEnvInput,
22): Record<string, string> {
23 const env: Record<string, string> = {
24 T3CODE_PROJECT_ROOT: input.project.cwd,
25 };
26 if (input.worktreePath) {
27 env.T3CODE_WORKTREE_PATH = input.worktreePath;
28 }
29 if (input.extraEnv) {
30 return { ...env, ...input.extraEnv };
31 }
32 return env;
33}
34
35export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
36 return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
37}