authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 00:18:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 00:18:04-07:00
log079e4bccdcffc56c54bd69523ed77c006553ea21
tree4bdb871a9f770b624f003e50a350f472d35ec53a
parenta1830915ecf70e3ad4fbe8efb15ae9f18517eb54
signature Signed by PGP key B5690EEEBB952194

feat(server): run a project action when a worktree thread settles (#16290)

Co-authored-by: spoukyii <61633921+spoukyii@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

20 files changed, 409 insertions(+), 56 deletions(-)

apps/mobile/src/features/terminal/terminalMenu.ts-4
......@@ -156,10 +156,6 @@ export function resolveProjectScriptTerminalId(input: {
156156 return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix);
157157}
158158
159export function projectScriptMenuLabel(script: ProjectScript): string {
160 return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name;
161}
162
163159export function projectScriptMenuIcon(icon: ProjectScript["icon"]) {
164160 if (icon === "test") return "flask";
165161 if (icon === "lint") return "checklist";
apps/mobile/src/features/threads/ThreadGitControls.tsx+1-1
......@@ -23,9 +23,9 @@ import {
2323 basename,
2424 getTerminalStatusLabel,
2525 projectScriptMenuIcon,
26 projectScriptMenuLabel,
2726 type TerminalMenuSession,
2827} from "../terminal/terminalMenu";
28import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
2929
3030function truncateMiddle(value: string, maxLength: number): string {
3131 if (value.length <= maxLength) {
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+132-2
......@@ -30,6 +30,7 @@ import * as Stream from "effect/Stream";
3030import { TestClock } from "effect/testing";
3131
3232import * as GitManager from "../git/GitManager.ts";
33import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
3334import * as PullRequestService from "../pullRequest/PullRequestService.ts";
3435import * as ServerActivation from "../serverActivation.ts";
3536import * as ServerSettings from "../serverSettings.ts";
......@@ -486,6 +487,12 @@ interface HarnessOptions {
486487 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];
487488 readonly existingWorktreePaths?: ReadonlyArray<string>;
488489 readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect<void>;
490 /** Runs before each settle script start; a failure fails that start. */
491 readonly onScriptRun?: (
492 input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput,
493 ) => Effect.Effect<void>;
494 /** Runs inside each `closeIdle` call. */
495 readonly onCloseIdle?: () => Effect.Effect<void>;
489496 /** Threads `getThread` returns when a `thread.settled` event is handled. */
490497 readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>;
491498}
......@@ -514,6 +521,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
514521 const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
515522 const domainEvents = yield* PubSub.unbounded<OrchestrationV2DomainEvent>();
516523 const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>();
524 const scriptRuns =
525 yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>();
517526
518527 const updateSettings = (patch: ServerSettingsPatch) =>
519528 Effect.gen(function* () {
......@@ -599,7 +608,15 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
599608 dispatch,
600609 }),
601610 Layer.mock(TerminalManager.TerminalManager)({
602 closeIdle: (input) => Queue.offer(closedIdle, input).pipe(Effect.asVoid),
611 closeIdle: (input) =>
612 Queue.offer(closedIdle, input).pipe(Effect.andThen(options.onCloseIdle?.() ?? Effect.void)),
613 }),
614 Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
615 runForThread: (input) =>
616 Queue.offer(scriptRuns, input).pipe(
617 Effect.andThen(options.onScriptRun?.(input) ?? Effect.void),
618 Effect.as({ status: "no-script" } as const),
619 ),
603620 }),
604621 Layer.mock(GitManager.GitManager)({
605622 branchPullRequest,
......@@ -631,6 +648,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
631648 summaryRecovery,
632649 invalidatedCwds,
633650 closedIdle,
651 scriptRuns,
634652 publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event),
635653 updateSettings,
636654 publishMerge: PubSub.publish(mergedPullRequests, {
......@@ -1010,6 +1028,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {
10101028 const appThread = (
10111029 id: string,
10121030 settledOverride: OrchestrationV2AppThread["settledOverride"],
1031 worktreePath: string | null = null,
10131032 ): OrchestrationV2AppThread => {
10141033 const threadId = ThreadId.make(id);
10151034 return {
......@@ -1023,7 +1042,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {
10231042 runtimeMode: "full-access",
10241043 interactionMode: "default",
10251044 branch: null,
1026 worktreePath: null,
1045 worktreePath,
10271046 activeProviderThreadId: null,
10281047 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
10291048 forkedFrom: null,
......@@ -1086,6 +1105,117 @@ describe("ThreadSettlementServiceV2 terminals", () => {
10861105 }),
10871106 ),
10881107 );
1108
1109 it.effect("runs the settle script in the thread's own worktree", () =>
1110 Effect.scoped(
1111 Effect.gen(function* () {
1112 const shared = appThread("shared-checkout-thread", "settled");
1113 const missing = appThread("removed-worktree-thread", "settled", "/worktrees/removed");
1114 const worktree = appThread("worktree-thread", "settled", "/worktrees/thread");
1115 const fixture = yield* makeHarness({
1116 snapshot: makeSnapshot([]),
1117 currentThreads: [shared, missing, worktree],
1118 existingWorktreePaths: ["/worktrees/thread"],
1119 });
1120
1121 yield* Effect.gen(function* () {
1122 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1123 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1124 // Events run in order, so the first run belongs to the last event
1125 // only if the shared checkout and the removed worktree were skipped.
1126 yield* fixture.publishEvent(settledEvent(shared));
1127 yield* fixture.publishEvent(settledEvent(missing));
1128 yield* fixture.publishEvent(settledEvent(worktree));
1129 const run = yield* Queue.take(fixture.scriptRuns);
1130 assert.strictEqual(run.threadId, worktree.id);
1131 assert.strictEqual(run.worktreePath, "/worktrees/thread");
1132 assert.strictEqual(run.trigger, "settle");
1133 }).pipe(Effect.provide(fixture.layer));
1134 }),
1135 ),
1136 );
1137
1138 it.effect("skips the settle script for a thread re-engaged while its shells close", () =>
1139 Effect.scoped(
1140 Effect.gen(function* () {
1141 const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging");
1142 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
1143 const currentThreads = [settled, marker];
1144 const fixture = yield* makeHarness({
1145 snapshot: makeSnapshot([]),
1146 currentThreads,
1147 existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"],
1148 // The user sends a message while the first close checks processes.
1149 onCloseIdle: () =>
1150 Effect.sync(() => {
1151 currentThreads[0] = { ...settled, settledOverride: "active" };
1152 }),
1153 });
1154
1155 yield* Effect.gen(function* () {
1156 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1157 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1158 yield* fixture.publishEvent(settledEvent(settled));
1159 yield* fixture.publishEvent(settledEvent(marker));
1160 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);
1161 }).pipe(Effect.provide(fixture.layer));
1162 }),
1163 ),
1164 );
1165
1166 it.effect("runs the settle script once when a settlement is re-emitted", () =>
1167 Effect.scoped(
1168 Effect.gen(function* () {
1169 const repeated = appThread("repeated-thread", "settled", "/worktrees/repeated");
1170 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
1171 const fixture = yield* makeHarness({
1172 snapshot: makeSnapshot([]),
1173 currentThreads: [repeated, marker],
1174 existingWorktreePaths: ["/worktrees/repeated", "/worktrees/marker"],
1175 });
1176
1177 yield* Effect.gen(function* () {
1178 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1179 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1180 // Settling an already settled thread emits the same settlement again.
1181 yield* fixture.publishEvent(settledEvent(repeated));
1182 yield* fixture.publishEvent(settledEvent(repeated));
1183 yield* fixture.publishEvent(settledEvent(marker));
1184 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, repeated.id);
1185 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);
1186 }).pipe(Effect.provide(fixture.layer));
1187 }),
1188 ),
1189 );
1190
1191 it.effect("retries the settle script on a re-emitted settlement after a failed start", () =>
1192 Effect.scoped(
1193 Effect.gen(function* () {
1194 const thread = appThread("failed-start-thread", "settled", "/worktrees/failed-start");
1195 const starts = yield* Ref.make(0);
1196 const fixture = yield* makeHarness({
1197 snapshot: makeSnapshot([]),
1198 currentThreads: [thread],
1199 existingWorktreePaths: ["/worktrees/failed-start"],
1200 onScriptRun: () =>
1201 Ref.updateAndGet(starts, (count) => count + 1).pipe(
1202 Effect.flatMap((count) =>
1203 count === 1 ? Effect.die(new Error("terminal failed to open")) : Effect.void,
1204 ),
1205 ),
1206 });
1207
1208 yield* Effect.gen(function* () {
1209 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1210 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1211 yield* fixture.publishEvent(settledEvent(thread));
1212 yield* fixture.publishEvent(settledEvent(thread));
1213 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id);
1214 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id);
1215 }).pipe(Effect.provide(fixture.layer));
1216 }),
1217 ),
1218 );
10891219});
10901220
10911221describe("ThreadSettlementServiceV2 single-thread sweeps", () => {
apps/server/src/orchestration-v2/ThreadSettlementService.ts+33-5
......@@ -20,6 +20,7 @@ import type * as Scope from "effect/Scope";
2020import * as Stream from "effect/Stream";
2121
2222import * as GitManager from "../git/GitManager.ts";
23import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
2324import * as PullRequestService from "../pullRequest/PullRequestService.ts";
2425import * as ServerSettings from "../serverSettings.ts";
2526import { forkParked } from "../serverActivation.ts";
......@@ -265,6 +266,10 @@ export const make = Effect.gen(function* () {
265266 const crypto = yield* Crypto.Crypto;
266267 const fileSystem = yield* FileSystem.FileSystem;
267268 const terminals = yield* TerminalManager.TerminalManager;
269 const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
270 // Settling a settled thread re-emits thread.settled with the same settledAt,
271 // so this keeps the settle action to one run per settlement.
272 const settleActionRunAt = new Map<ThreadId, number>();
268273
269274 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (
270275 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
......@@ -514,20 +519,43 @@ export const make = Effect.gen(function* () {
514519
515520 // Settling closes the thread's shells that sit at an idle prompt, so they stop
516521 // holding the worktree. A terminal running a command (a dev server, an
517 // editor) stays for the user to close.
518 const closeIdleTerminals = Effect.fn("ThreadSettlementServiceV2.closeIdleTerminals")(
522 // editor) stays for the user to close. Then the project's settle script runs
523 // in the thread's own worktree; a thread in the shared checkout skips it,
524 // because other threads may still be working there.
525 const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")(
519526 function* (threadId: ThreadId) {
520527 // A thread re-engaged before this event ran keeps its shells.
528 const settled = yield* projections.getThread(threadId);
529 if (settled.settledOverride !== "settled") return;
530 yield* terminals.closeIdle({ threadId });
531 const worktreePath = settled.worktreePath;
532 if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return;
533 // Closing and the worktree check wait on I/O. A thread re-engaged
534 // meanwhile is working again, so its worktree is no place for cleanup.
521535 const thread = yield* projections.getThread(threadId);
522536 if (thread.settledOverride !== "settled") return;
523 yield* terminals.closeIdle({ threadId });
537 const settledAtMs = toMillis(thread.settledAt);
538 if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return;
539 const run = yield* projectScripts.runForThread({
540 threadId,
541 projectId: thread.projectId,
542 worktreePath,
543 trigger: "settle",
544 // A clean exit closes the script's shell so it does not hold the worktree.
545 observeCompletion: {},
546 });
547 // Recorded after a successful start, so a failed start retries on the next event.
548 settleActionRunAt.set(threadId, settledAtMs);
549 if (run.status === "started" && run.completion) {
550 yield* run.completion.pipe(Effect.forkDetach);
551 }
524552 },
525553 (effect, threadId) =>
526554 effect.pipe(
527555 Effect.catchCause((cause) =>
528556 Cause.hasInterruptsOnly(cause)
529557 ? Effect.failCause(cause)
530 : Effect.logWarning("closing idle terminals after settlement failed", {
558 : Effect.logWarning("cleaning up a settled thread failed", {
531559 threadId,
532560 cause: Cause.pretty(cause),
533561 }),
......@@ -538,7 +566,7 @@ export const make = Effect.gen(function* () {
538566 const processEvent = (event: OrchestrationV2DomainEvent) => {
539567 switch (event.type) {
540568 case "thread.settled":
541 return closeIdleTerminals(event.threadId);
569 return cleanUpSettledThread(event.threadId);
542570 case "thread.pull-request-synced":
543571 case "provider-session.detached":
544572 return worker.enqueue(event.threadId);
apps/server/src/project/ProjectSetupScriptRunner.test.ts+62-1
......@@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
22import { assert, it, vi } from "@effect/vitest";
33import { ProjectId } from "@t3tools/contracts";
44import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
56import * as Layer from "effect/Layer";
67import * as Option from "effect/Option";
78
......@@ -29,6 +30,9 @@ it.effect("resolves setup scripts through the standalone project service", () =>
2930 const write = vi.fn(
3031 (_input: Parameters<TerminalManager.TerminalManager["Service"]["write"]>[0]) => Effect.void,
3132 );
33 const closeIdle = vi.fn(
34 (_input: Parameters<TerminalManager.TerminalManager["Service"]["closeIdle"]>[0]) => Effect.void,
35 );
3236 const listeners: Array<Parameters<TerminalManager.TerminalManager["Service"]["subscribe"]>[0]> =
3337 [];
3438 const subscribe: TerminalManager.TerminalManager["Service"]["subscribe"] = (listener) =>
......@@ -52,6 +56,14 @@ it.effect("resolves setup scripts through the standalone project service", () =>
5256 icon: "configure" as const,
5357 runOnWorktreeCreate: true,
5458 },
59 {
60 id: "clean",
61 name: "Clean",
62 command: "cargo clean",
63 icon: "build" as const,
64 runOnWorktreeCreate: false,
65 runOnSettle: true,
66 },
5567 ],
5668 createdAt: "2026-06-20T00:00:00.000Z",
5769 updatedAt: "2026-06-20T00:00:00.000Z",
......@@ -63,7 +75,7 @@ it.effect("resolves setup scripts through the standalone project service", () =>
6375 Layer.mock(ProjectService.ProjectService)({
6476 getById: () => Effect.succeed(Option.some(project)),
6577 }),
66 Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe }),
78 Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe, closeIdle }),
6779 ServerSettings.layerTest(),
6880 NodeCrypto.layer,
6981 ),
......@@ -117,5 +129,54 @@ it.effect("resolves setup scripts through the standalone project service", () =>
117129 });
118130 assert.deepEqual(lines, ["Downloading 10%", "Downloading 20%", "Done"]);
119131 yield* listener({ type: "closed", threadId: "thread-1", terminalId: "setup-setup" });
132
133 const settle = yield* runner.runForThread({
134 threadId: "thread-1",
135 projectId,
136 worktreePath: "/repo-worktree",
137 trigger: "settle",
138 });
139 const settleTerminalId = settle.status === "started" ? settle.terminalId : "";
140 assert.match(settleTerminalId, /^settle-clean-/);
141 assert.equal(write.mock.calls.at(-1)?.[0].data, "cargo clean\r");
142
143 // A clean run closes its shell once the prompt is back, not at the
144 // sentinel, so the prompt redraw is not taken for new activity.
145 const observedSettle = yield* runner.runForThread({
146 threadId: "thread-1",
147 projectId,
148 worktreePath: "/repo-worktree",
149 trigger: "settle",
150 observeCompletion: {},
151 });
152 const observedTerminalId = observedSettle.status === "started" ? observedSettle.terminalId : "";
153 // Each settle gets its own shell, so a busy one is never typed into.
154 assert.notEqual(observedTerminalId, settleTerminalId);
155 const token = /__T3_SETUP_DONE___(\w+):/.exec(write.mock.calls.at(-1)?.[0].data ?? "")?.[1];
156 const settleListener = listeners.at(-1)!;
157 const completion = yield* Effect.forkChild(
158 observedSettle.status === "started" && observedSettle.completion
159 ? observedSettle.completion
160 : Effect.die("no completion"),
161 );
162 yield* settleListener({
163 type: "output",
164 threadId: "thread-1",
165 terminalId: observedTerminalId,
166 data: `\r\n__T3_SETUP_DONE___${token}:0\r\n`,
167 });
168 yield* Effect.yieldNow;
169 assert.equal(closeIdle.mock.calls.length, 0);
170 yield* settleListener({
171 type: "output",
172 threadId: "thread-1",
173 terminalId: observedTerminalId,
174 data: "$ ",
175 });
176 assert.deepEqual((yield* Fiber.join(completion)).exitCode, 0);
177 assert.deepEqual(closeIdle.mock.calls[0]?.[0], {
178 threadId: "thread-1",
179 terminalId: observedTerminalId,
180 });
120181 }).pipe(Effect.provide(layer));
121182});
apps/server/src/project/ProjectSetupScriptRunner.ts+30-4
......@@ -3,6 +3,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos
33import {
44 projectScriptRuntimeEnv,
55 resolveProjectScripts,
6 settleProjectScript,
67 setupProjectScript,
78} from "@t3tools/shared/projectScripts";
89
......@@ -60,6 +61,8 @@ export interface ProjectSetupScriptRunnerInput {
6061 readonly projectCwd?: string;
6162 readonly worktreePath: string;
6263 readonly preferredTerminalId?: string;
64 /** Which project script to run. Defaults to the worktree setup script. */
65 readonly trigger?: "setup" | "settle";
6366 readonly project?: {
6467 readonly id: ProjectId;
6568 readonly workspaceRoot: string;
......@@ -225,6 +228,9 @@ export const make = Effect.gen(function* () {
225228 Effect.gen(function* () {
226229 const startedAtMs = yield* Clock.currentTimeMillis;
227230 const done = yield* Deferred.make<ProjectSetupScriptCompletion>();
231 // The shell redraws its prompt just after the sentinel. Closing before
232 // that would read the redraw as new activity and keep an idle shell.
233 const promptReturned = yield* Deferred.make<void>();
228234 let lineBuffer = "";
229235 let settled = false;
230236
......@@ -278,15 +284,26 @@ export const make = Effect.gen(function* () {
278284 if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) {
279285 lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH);
280286 }
281 return Effect.forEach(lines, handleLine, { discard: true });
287 return Effect.forEach(lines, handleLine, { discard: true }).pipe(
288 // A prompt has no newline, so it is what remains once the sentinel is in.
289 Effect.andThen(
290 Effect.suspend(() =>
291 settled && lineBuffer.length > 0
292 ? Deferred.succeed(promptReturned, undefined).pipe(Effect.asVoid)
293 : Effect.void,
294 ),
295 ),
296 );
282297 }
283298 if (event.type === "exited" || event.type === "closed") {
284 return settle(null);
299 return settle(null).pipe(Effect.andThen(Deferred.succeed(promptReturned, undefined)));
285300 }
286301 return Effect.void;
287302 });
288303
289304 const completion = Deferred.await(done).pipe(
305 // A shell with an empty prompt never prints one; do not wait forever.
306 Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))),
290307 Effect.ensuring(Effect.sync(() => unsubscribe())),
291308 );
292309 return { completion, unsubscribe };
......@@ -348,14 +365,23 @@ export const make = Effect.gen(function* () {
348365 }),
349366 ),
350367 );
351 const script = setupProjectScript(resolveProjectScripts(settings, project));
368 const trigger = input.trigger ?? "setup";
369 const scripts = resolveProjectScripts(settings, project);
370 const script =
371 trigger === "settle" ? settleProjectScript(scripts) : setupProjectScript(scripts);
352372 if (!script) {
353373 return {
354374 status: "no-script",
355375 } as const;
356376 }
357377
358 const terminalId = input.preferredTerminalId ?? `setup-${script.id}`;
378 // A thread settles again after it is resumed, and an earlier settle shell
379 // may still be busy; typing into it would feed its foreground program.
380 const terminalId =
381 input.preferredTerminalId ??
382 (trigger === "settle"
383 ? `settle-${script.id}-${(yield* crypto.randomUUIDv4.pipe(Effect.orDie)).slice(0, 8)}`
384 : `setup-${script.id}`);
359385 const cwd = input.worktreePath;
360386 const env = {
361387 ...projectScriptRuntimeEnv({
apps/web/src/components/ChatView.tsx+6-13
......@@ -320,6 +320,7 @@ import {
320320 commandForProjectScript,
321321 nextProjectScriptId,
322322 projectScriptIdFromCommand,
323 releaseClaimedRoles,
323324} from "~/projectScripts";
324325import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
325326import { registerFaviconProjectForThread } from "~/browserFaviconStore";
......@@ -5231,14 +5232,10 @@ export default function ChatView(props: ChatViewProps) {
52315232 activeProjectScripts.map((script) => script.id),
52325233 );
52335234 const nextScript = buildProjectScript(nextId, input);
5234 const nextScripts = input.runOnWorktreeCreate
5235 ? [
5236 ...activeProjectScripts.map((script) =>
5237 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
5238 ),
5239 nextScript,
5240 ]
5241 : [...activeProjectScripts, nextScript];
5235 const nextScripts = [
5236 ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)),
5237 nextScript,
5238 ];
52425239
52435240 return persistProjectScripts({
52445241 projectId: activeProject.id,
......@@ -5266,11 +5263,7 @@ export default function ChatView(props: ChatViewProps) {
52665263
52675264 const updatedScript = buildProjectScript(existingScript.id, input);
52685265 const nextScripts = activeProjectScripts.map((script) =>
5269 script.id === scriptId
5270 ? updatedScript
5271 : input.runOnWorktreeCreate
5272 ? { ...script, runOnWorktreeCreate: false }
5273 : script,
5266 script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input),
52745267 );
52755268
52765269 return persistProjectScripts({
apps/web/src/components/ProjectScriptsControl.tsx+10-18
......@@ -12,6 +12,7 @@ import {
1212 isAtomCommandInterrupted,
1313 squashAtomCommandFailure,
1414} from "@t3tools/client-runtime/state/runtime";
15import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
1516import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react";
1617import React, { useCallback, useMemo, useState } from "react";
1718
......@@ -147,6 +148,7 @@ export default function ProjectScriptsControl({
147148 icon: fileScript.icon ?? "play",
148149 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
149150 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
151 runOnSettle: fileScript.runOnSettle ?? false,
150152 ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}),
151153 previewUrl: fileScript.previewUrl ?? null,
152154 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
......@@ -166,7 +168,7 @@ export default function ProjectScriptsControl({
166168
167169 const importMenuItems = importableScripts.length > 0 && (
168170 <>
169 {primaryScript && <MenuSeparator />}
171 {scripts.length > 0 && <MenuSeparator />}
170172 <MenuGroup>
171173 <MenuGroupLabel>From t3.json</MenuGroupLabel>
172174 {importableScripts.map((fileScript) => (
......@@ -202,9 +204,7 @@ export default function ProjectScriptsControl({
202204 onClick={() => onRunScript?.(script)}
203205 >
204206 <ScriptIcon icon={script.icon} className="size-4" />
205 <MenuItemLabel>
206 {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name}
207 </MenuItemLabel>
207 <MenuItemLabel>{projectScriptMenuLabel(script)}</MenuItemLabel>
208208 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
209209 {shortcutLabel &&
210210 (presentation === "menu" ? (
......@@ -266,7 +266,7 @@ export default function ProjectScriptsControl({
266266 </MenuShortcut>
267267 </MenuItem>
268268 )}
269 {primaryScript || importableScripts.length > 0 ? (
269 {scripts.length > 0 || importableScripts.length > 0 ? (
270270 <MenuSub
271271 open={actionsMenuOpen.scripts}
272272 onOpenChange={(open) =>
......@@ -369,7 +369,9 @@ export default function ProjectScriptsControl({
369369 </MenuPopup>
370370 </Menu>
371371 </ActionGroup>
372 ) : importableScripts.length > 0 ? (
372 ) : scripts.length > 0 || importableScripts.length > 0 ? (
373 // No one-click action (only a settle action, or only t3.json imports),
374 // so the saved actions stay reachable through this menu.
373375 isPanel ? (
374376 <div
375377 role="group"
......@@ -408,11 +410,7 @@ export default function ProjectScriptsControl({
408410 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
409411 </MenuTrigger>
410412 <MenuPopup align="end" anchor={panelAnchorRef} className="w-(--anchor-width)">
411 {importMenuItems}
412 <MenuItem onClick={openAddDialog}>
413 <PlusIcon className="size-4" />
414 Add action
415 </MenuItem>
413 {scriptItems}
416414 </MenuPopup>
417415 </Menu>
418416 </div>
......@@ -433,13 +431,7 @@ export default function ProjectScriptsControl({
433431 </span>
434432 <ChevronDownIcon className="size-3.5" />
435433 </MenuTrigger>
436 <MenuPopup align="end">
437 {importMenuItems}
438 <MenuItem onClick={openAddDialog}>
439 <PlusIcon className="size-4" />
440 Add action
441 </MenuItem>
442 </MenuPopup>
434 <MenuPopup align="end">{scriptItems}</MenuPopup>
443435 </Menu>
444436 )
445437 ) : (
apps/web/src/components/projectScriptEditor.permissions.test.tsx+1
......@@ -64,6 +64,7 @@ const request: ProjectScriptEditorRequest = {
6464 icon: "test",
6565 runOnWorktreeCreate: false,
6666 waitForSetup: false,
67 runOnSettle: false,
6768 keybinding: "mod+k",
6869 previewUrl: null,
6970 autoOpenPreview: false,
apps/web/src/components/projectScriptEditor.tsx+13
......@@ -92,6 +92,7 @@ export interface NewProjectScriptInput {
9292 runOnWorktreeCreate: boolean;
9393 /** Setup scripts only: hold the agent until the script exits. */
9494 waitForSetup: boolean;
95 runOnSettle: boolean;
9596 /** Omit to preserve the current shortcut when the form did not edit it. */
9697 keybinding?: string | null;
9798 /** Optional URL to open in the in-app preview when this script runs. */
......@@ -108,6 +109,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {
108109 icon: "play",
109110 runOnWorktreeCreate: false,
110111 waitForSetup: false,
112 runOnSettle: false,
111113 keybinding: null,
112114 previewUrl: null,
113115 autoOpenPreview: false,
......@@ -133,6 +135,7 @@ export function editorRequestForScript(
133135 icon: script.icon,
134136 runOnWorktreeCreate: script.runOnWorktreeCreate,
135137 waitForSetup: script.runOnWorktreeCreate && script.async === false,
138 runOnSettle: script.runOnSettle ?? false,
136139 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),
137140 previewUrl: script.previewUrl ?? null,
138141 autoOpenPreview: script.autoOpenPreview ?? false,
......@@ -175,6 +178,7 @@ export function ProjectScriptEditorDialog({
175178 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);
176179 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
177180 const [waitForSetup, setWaitForSetup] = useState(false);
181 const [runOnSettle, setRunOnSettle] = useState(false);
178182 const [keybinding, setKeybinding] = useState("");
179183 const [previewUrl, setPreviewUrl] = useState("");
180184 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
......@@ -206,6 +210,7 @@ export function ProjectScriptEditorDialog({
206210 setIconPickerOpen(false);
207211 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);
208212 setWaitForSetup(request.initial.waitForSetup);
213 setRunOnSettle(request.initial.runOnSettle);
209214 setKeybinding(request.initial.keybinding ?? "");
210215 setPreviewUrl(request.initial.previewUrl ?? "");
211216 setAutoOpenPreview(request.initial.autoOpenPreview);
......@@ -279,6 +284,7 @@ export function ProjectScriptEditorDialog({
279284 icon,
280285 runOnWorktreeCreate,
281286 waitForSetup: runOnWorktreeCreate && waitForSetup,
287 runOnSettle,
282288 ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding
283289 ? { keybinding: keybindingRule?.key ?? null }
284290 : {}),
......@@ -443,6 +449,13 @@ export function ProjectScriptEditorDialog({
443449 onCheckedChange={(checked) => setWaitForSetup(Boolean(checked))}
444450 />
445451 </label>
452 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
453 <span>Run in the thread's worktree when the thread settles</span>
454 <Switch
455 checked={runOnSettle}
456 onCheckedChange={(checked) => setRunOnSettle(Boolean(checked))}
457 />
458 </label>
446459 <label
447460 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
448461 previewUrl.trim().length === 0 ? "opacity-60" : ""
apps/web/src/components/settings/ProjectActionsList.tsx+5
......@@ -38,6 +38,11 @@ export function ProjectActionsList({
3838 setup
3939 </span>
4040 ) : null}
41 {script.runOnSettle ? (
42 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-2xs font-normal text-muted-foreground">
43 on settle
44 </span>
45 ) : null}
4146 {script.previewUrl ? (
4247 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-2xs font-normal text-muted-foreground max-sm:hidden">
4348 preview · desktop only
apps/web/src/components/settings/ProjectActionsSettings.tsx+1
......@@ -115,6 +115,7 @@ export function ProjectActionsSettings() {
115115 icon: fileScript.icon ?? "play",
116116 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
117117 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
118 runOnSettle: fileScript.runOnSettle ?? false,
118119 keybinding: null,
119120 previewUrl: fileScript.previewUrl ?? null,
120121 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
apps/web/src/components/settings/useProjectScriptSettings.ts+2-5
......@@ -28,6 +28,7 @@ import {
2828 buildProjectScript,
2929 commandForProjectScript,
3030 nextProjectScriptId,
31 releaseClaimedRoles,
3132} from "../../projectScripts";
3233import { useProjects } from "../../state/entities";
3334import { serverEnvironment } from "../../state/server";
......@@ -194,11 +195,7 @@ export function useProjectScriptSettings(
194195 return persist(
195196 (current) => {
196197 const updated = current.map((script) =>
197 script.id === id
198 ? next
199 : input.runOnWorktreeCreate
200 ? { ...script, runOnWorktreeCreate: false }
201 : script,
198 script.id === id ? next : releaseClaimedRoles(script, input),
202199 );
203200 return scriptId === null ? [...updated, next] : updated;
204201 },
apps/web/src/projectScripts.test.ts+62-1
......@@ -4,6 +4,8 @@ import { describe, expect, it } from "vite-plus/test";
44import {
55 projectScriptCwd,
66 projectScriptRuntimeEnv,
7 projectScriptMenuLabel,
8 settleProjectScript,
79 setupProjectScript,
810} from "@t3tools/shared/projectScripts";
911
......@@ -13,6 +15,7 @@ import {
1315 nextProjectScriptId,
1416 primaryProjectScript,
1517 projectScriptIdFromCommand,
18 releaseClaimedRoles,
1619} from "./projectScripts";
1720
1821describe("projectScripts helpers", () => {
......@@ -24,6 +27,7 @@ describe("projectScripts helpers", () => {
2427 icon: "debug",
2528 runOnWorktreeCreate: false,
2629 waitForSetup: false,
30 runOnSettle: false,
2731 previewUrl: "http://localhost:5733",
2832 autoOpenPreview: true,
2933 }),
......@@ -46,6 +50,7 @@ describe("projectScripts helpers", () => {
4650 icon: "test",
4751 runOnWorktreeCreate: false,
4852 waitForSetup: false,
53 runOnSettle: false,
4954 previewUrl: null,
5055 autoOpenPreview: false,
5156 }),
......@@ -65,6 +70,7 @@ describe("projectScripts helpers", () => {
6570 icon: "configure",
6671 previewUrl: null,
6772 autoOpenPreview: false,
73 runOnSettle: false,
6874 } as const;
6975 expect(
7076 buildProjectScript("setup", { ...input, runOnWorktreeCreate: true, waitForSetup: true }),
......@@ -108,7 +114,7 @@ describe("projectScripts helpers", () => {
108114 expect(nextProjectScriptId("!!!", [])).toBe("script");
109115 });
110116
111 it("resolves primary and setup scripts", () => {
117 it("resolves primary, setup, and settle scripts", () => {
112118 const scripts = [
113119 {
114120 id: "setup",
......@@ -117,6 +123,14 @@ describe("projectScripts helpers", () => {
117123 icon: "configure" as const,
118124 runOnWorktreeCreate: true,
119125 },
126 {
127 id: "clean",
128 name: "Clean",
129 command: "cargo clean",
130 icon: "build" as const,
131 runOnWorktreeCreate: false,
132 runOnSettle: true,
133 },
120134 {
121135 id: "test",
122136 name: "Test",
......@@ -128,6 +142,53 @@ describe("projectScripts helpers", () => {
128142
129143 expect(primaryProjectScript(scripts)?.id).toBe("test");
130144 expect(setupProjectScript(scripts)?.id).toBe("setup");
145 expect(settleProjectScript(scripts)?.id).toBe("clean");
146 // Cleanup is never the one-click run button, even when it is all there is.
147 expect(primaryProjectScript(scripts.filter((script) => script.id === "clean"))).toBeNull();
148 });
149
150 it("labels every lifecycle role a script runs in", () => {
151 const script = {
152 id: "both",
153 name: "Both",
154 command: "true",
155 icon: "play" as const,
156 runOnWorktreeCreate: true,
157 };
158 expect(projectScriptMenuLabel(script)).toBe("Both (setup)");
159 expect(projectScriptMenuLabel({ ...script, runOnSettle: true })).toBe(
160 "Both (setup, on settle)",
161 );
162 expect(projectScriptMenuLabel({ ...script, runOnWorktreeCreate: false })).toBe("Both");
163 });
164
165 it("moves the setup and settle roles to the script that claims them", () => {
166 const input = {
167 name: "Next",
168 command: "true",
169 icon: "play",
170 waitForSetup: false,
171 previewUrl: null,
172 autoOpenPreview: false,
173 } as const;
174 const holder = {
175 id: "holder",
176 name: "Holder",
177 command: "true",
178 icon: "play" as const,
179 runOnWorktreeCreate: true,
180 runOnSettle: true,
181 };
182
183 expect(
184 releaseClaimedRoles(holder, { ...input, runOnWorktreeCreate: false, runOnSettle: true }),
185 ).toMatchObject({ runOnWorktreeCreate: true, runOnSettle: false });
186 expect(
187 releaseClaimedRoles(holder, { ...input, runOnWorktreeCreate: true, runOnSettle: false }),
188 ).toMatchObject({ runOnWorktreeCreate: false, runOnSettle: true });
189 expect(
190 releaseClaimedRoles(holder, { ...input, runOnWorktreeCreate: false, runOnSettle: false }),
191 ).toBe(holder);
131192 });
132193
133194 it("builds default runtime env for scripts", () => {
apps/web/src/projectScripts.ts+22-2
......@@ -13,6 +13,7 @@ export interface ProjectScriptInput {
1313 readonly icon: ProjectScript["icon"];
1414 readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];
1515 readonly waitForSetup: boolean;
16 readonly runOnSettle: boolean;
1617 readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null;
1718 readonly autoOpenPreview: boolean;
1819}
......@@ -25,6 +26,7 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje
2526 icon: input.icon,
2627 runOnWorktreeCreate: input.runOnWorktreeCreate,
2728 ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}),
29 ...(input.runOnSettle ? { runOnSettle: true } : {}),
2830 ...(input.previewUrl === null
2931 ? {}
3032 : {
......@@ -34,6 +36,24 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje
3436 };
3537}
3638
39/**
40 * A project runs at most one setup script and one settle script, so saving a
41 * script that claims either role takes it from the script that held it.
42 */
43export function releaseClaimedRoles(
44 script: ProjectScript,
45 saved: ProjectScriptInput,
46): ProjectScript {
47 const releaseSetup = saved.runOnWorktreeCreate && script.runOnWorktreeCreate;
48 const releaseSettle = saved.runOnSettle && script.runOnSettle === true;
49 if (!releaseSetup && !releaseSettle) return script;
50 return {
51 ...script,
52 ...(releaseSetup ? { runOnWorktreeCreate: false } : {}),
53 ...(releaseSettle ? { runOnSettle: false } : {}),
54 };
55}
56
3757function normalizeScriptId(value: string): string {
3858 const cleaned = value
3959 .trim()
......@@ -87,6 +107,6 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)
87107}
88108
89109export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null {
90 const regular = scripts.find((script) => !script.runOnWorktreeCreate);
91 return regular ?? scripts[0] ?? null;
110 const regular = scripts.find((script) => !script.runOnWorktreeCreate && !script.runOnSettle);
111 return regular ?? scripts.find((script) => !script.runOnSettle) ?? null;
92112}
docs/user/thread-sidebar.md+7
......@@ -134,6 +134,13 @@ sending an answer or restarting the agent. Settling also closes the thread's
134134terminals that wait at an idle prompt, and keeps their output. A terminal that
135135runs a command, such as a dev server, stays open.
136136
137To reclaim disk space from settled work, turn on **Run in the thread's worktree when the
138thread settles** for one of the project's actions, or set `"runOnSettle": true` on a
139`t3.json` script, for example `cargo clean`. It runs each time a thread in its own
140worktree settles, manually or automatically, even if a terminal there still runs a
141command such as a dev server. Threads in the project's main checkout skip it. Its terminal
142closes when the command succeeds and stays open when it fails.
143
137144On web and desktop, press a thread's **Settle** button and drag up or down to
138145settle every thread in that section between it and the one you release on.
139146The **Un-settle** and **Wake** buttons work the same way in their sections.
packages/contracts/src/project.ts+2
......@@ -36,6 +36,8 @@ export const ProjectScript = Schema.Struct({
3636 command: TrimmedNonEmptyString,
3737 icon: ProjectScriptIcon,
3838 runOnWorktreeCreate: Schema.Boolean,
39 /** Run in the thread's worktree each time the thread settles. */
40 runOnSettle: Schema.optional(Schema.Boolean),
3941 /** Start the agent while setup runs unless explicitly disabled. */
4042 async: Schema.optional(Schema.Boolean),
4143 previewUrl: Schema.optional(TrimmedNonEmptyString),
packages/contracts/src/t3ProjectFile.ts+6
......@@ -43,6 +43,12 @@ export const T3ProjectFileScript = Schema.Struct({
4343 "When true, the script runs automatically after a worktree is created for a new thread.",
4444 }),
4545 ),
46 runOnSettle: Schema.optionalKey(
47 Schema.Boolean.annotate({
48 description:
49 "When true, the script runs in the thread's worktree each time the thread settles, for example to delete build output. Threads without their own worktree skip it.",
50 }),
51 ),
4652 async: Schema.optionalKey(
4753 Schema.Boolean.annotate({
4854 description:
packages/shared/src/projectScripts.ts+13
......@@ -73,3 +73,16 @@ export function projectScriptRuntimeEnv(
7373export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
7474 return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
7575}
76
77/** Menu label naming the lifecycle roles a script runs in, e.g. "Clean (on settle)". */
78export function projectScriptMenuLabel(script: ProjectScript): string {
79 const roles = [
80 ...(script.runOnWorktreeCreate ? ["setup"] : []),
81 ...(script.runOnSettle ? ["on settle"] : []),
82 ];
83 return roles.length === 0 ? script.name : `${script.name} (${roles.join(", ")})`;
84}
85
86export function settleProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
87 return scripts.find((script) => script.runOnSettle === true) ?? null;
88}
packages/shared/src/t3ProjectFile.test.ts+1
......@@ -51,6 +51,7 @@ describe("buildT3ProjectFileJsonSchema", () => {
5151 "icon",
5252 "name",
5353 "previewUrl",
54 "runOnSettle",
5455 "runOnWorktreeCreate",
5556 ]);
5657 });