authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-25 13:09:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 13:09:30-07:00
log1a0c915c41024ddb47e16befd9fc730c1173af02
treed249003d1c51e52f50e56517e006e64a3263d561
parentfd996d15e220144ac6e66670169aa87d074ce846
signature Signed by PGP key B5690EEEBB952194

fix(terminal): settling a thread closes its idle shells (#13673)

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

9 files changed, 318 insertions(+), 11 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+2
......@@ -26,6 +26,7 @@ import * as Tracer from "effect/Tracer";
2626
2727import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts";
2828import { TextGeneration } from "../src/textGeneration/TextGeneration.ts";
29import * as TerminalManager from "../src/terminal/Manager.ts";
2930import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
3031import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
3132import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
......@@ -340,6 +341,7 @@ export const makeOrchestrationIntegrationHarness = (
340341 tryHandlePromptCommand: () => Effect.succeed(false),
341342 }),
342343 ),
344 Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })),
343345 Layer.provideMerge(runtimeServicesLayer),
344346 Layer.provideMerge(gitWorkflowLayer),
345347 Layer.provideMerge(textGenerationLayer),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+75
......@@ -55,6 +55,7 @@ import {
5555import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
5656import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
5757import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
58import { TerminalManager } from "../../terminal/Manager.ts";
5859import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
5960import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
6061import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
......@@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => {
307308 }),
308309 );
309310 const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void);
311 const closeIdleTerminals = vi.fn((_: { readonly threadId: string }) => Effect.void);
310312 const createWorktree = vi.fn(
311313 (input: { readonly refName: string; readonly path: string | null }) =>
312314 Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }),
......@@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => {
490492 generateThreadTitle,
491493 }),
492494 ),
495 Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })),
493496 Layer.provideMerge(ServerSettingsService.layerTest()),
494497 Layer.provideMerge(SqlitePersistenceMemory),
495498 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
......@@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => {
621624 renameBranch,
622625 pruneWorktrees,
623626 createWorktree,
627 closeIdleTerminals,
624628 refreshStatus,
625629 generateBranchName,
626630 generateThreadTitle,
......@@ -4339,6 +4343,77 @@ describe("ProviderCommandReactor", () => {
43394343 expect(thread?.settledOverride).toBe("settled");
43404344 expect(thread?.session?.status).toBe("stopped");
43414345 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
4346 expect(harness.closeIdleTerminals).toHaveBeenCalledWith({
4347 threadId: ThreadId.make("thread-1"),
4348 });
43424349 }),
43434350 );
4351
4352 effectIt.effect("closes idle terminals when a thread without a session settles", () =>
4353 Effect.gen(function* () {
4354 const harness = yield* Effect.promise(() => createHarness());
4355 const terminalsClosed = yield* Deferred.make<void>();
4356 harness.closeIdleTerminals.mockImplementation(() =>
4357 Deferred.succeed(terminalsClosed, undefined).pipe(Effect.asVoid),
4358 );
4359
4360 yield* harness.engine.dispatch({
4361 type: "thread.settle",
4362 commandId: CommandId.make("cmd-settle-without-session"),
4363 threadId: ThreadId.make("thread-1"),
4364 });
4365 yield* Deferred.await(terminalsClosed);
4366 yield* Effect.promise(() => harness.drain());
4367
4368 expect(harness.closeIdleTerminals).toHaveBeenCalledWith({
4369 threadId: ThreadId.make("thread-1"),
4370 });
4371 expect(harness.stopSession).not.toHaveBeenCalled();
4372 }),
4373 );
4374
4375 effectIt.effect(
4376 "keeps terminals when the thread is un-settled before its settle event runs",
4377 () =>
4378 Effect.gen(function* () {
4379 const harness = yield* Effect.promise(() => createHarness());
4380 const threadId = ThreadId.make("thread-1");
4381 const firstCloseStarted = yield* Deferred.make<void>();
4382 const releaseFirstClose = yield* Deferred.make<void>();
4383 harness.closeIdleTerminals.mockImplementationOnce(() =>
4384 Deferred.succeed(firstCloseStarted, undefined).pipe(
4385 Effect.andThen(Deferred.await(releaseFirstClose)),
4386 ),
4387 );
4388
4389 yield* harness.engine.dispatch({
4390 type: "thread.settle",
4391 commandId: CommandId.make("cmd-settle-first"),
4392 threadId,
4393 });
4394 // The reactor is busy with the first settle while the user changes their mind.
4395 yield* Deferred.await(firstCloseStarted);
4396 yield* harness.engine.dispatch({
4397 type: "thread.unsettle",
4398 commandId: CommandId.make("cmd-unsettle-first"),
4399 threadId,
4400 reason: "user",
4401 });
4402 yield* harness.engine.dispatch({
4403 type: "thread.settle",
4404 commandId: CommandId.make("cmd-settle-second"),
4405 threadId,
4406 });
4407 yield* harness.engine.dispatch({
4408 type: "thread.unsettle",
4409 commandId: CommandId.make("cmd-unsettle-second"),
4410 threadId,
4411 reason: "user",
4412 });
4413 yield* Deferred.succeed(releaseFirstClose, undefined);
4414 yield* Effect.promise(() => harness.drain());
4415
4416 expect(harness.closeIdleTerminals).toHaveBeenCalledTimes(1);
4417 }),
4418 );
43444419});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10-5
......@@ -65,6 +65,7 @@ import {
6565import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
6666import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
6767import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
68import * as TerminalManager from "../../terminal/Manager.ts";
6869const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError);
6970const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
7071const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
......@@ -222,6 +223,7 @@ const make = Effect.gen(function* () {
222223 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
223224 const textGeneration = yield* TextGeneration;
224225 const serverSettingsService = yield* ServerSettingsService;
226 const terminalManager = yield* TerminalManager.TerminalManager;
225227 /** Environment settings with the thread's project overrides applied. */
226228 const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
227229 const settings = yield* serverSettingsService.getSettings;
......@@ -1822,11 +1824,14 @@ const make = Effect.gen(function* () {
18221824 return;
18231825 case "thread.settled": {
18241826 const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId);
1825 if (
1826 Option.isNone(thread) ||
1827 thread.value.session == null ||
1828 thread.value.session.status === "stopped"
1829 ) {
1827 // A thread re-engaged before this event ran keeps its shells and session.
1828 if (Option.isNone(thread) || thread.value.settledOverride !== "settled") {
1829 return;
1830 }
1831 // Idle shells close so they stop holding the worktree. A terminal that
1832 // runs a command (a dev server, an editor) stays for the user to close.
1833 yield* terminalManager.closeIdle({ threadId: event.payload.threadId });
1834 if (thread.value.session == null || thread.value.session.status === "stopped") {
18301835 return;
18311836 }
18321837 yield* orchestrationEngine.dispatch({
apps/server/src/project/AgentSessionImporter.test.ts+2
......@@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr
5656import { ServerSettingsService } from "../serverSettings.ts";
5757import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
5858import { TextGeneration } from "../textGeneration/TextGeneration.ts";
59import { TerminalManager } from "../terminal/Manager.ts";
5960import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts";
6061import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
6162import { importRecentAgentThreads } from "./AgentSessionImporter.ts";
......@@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => {
931932 Layer.provide(Layer.mock(GitWorkflowService)({})),
932933 Layer.provide(Layer.mock(VcsStatusBroadcaster)({})),
933934 Layer.provide(Layer.mock(TextGeneration)({})),
935 Layer.provide(Layer.mock(TerminalManager)({ closeIdle: () => Effect.void })),
934936 Layer.provide(ServerSettingsService.layerTest()),
935937 );
936938
apps/server/src/project/ProjectSetupScriptRunner.test.ts+72-2
......@@ -58,7 +58,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
5858 });
5959
6060type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> &
61 Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe">>;
61 Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe" | "closeIdle">>;
6262
6363const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>
6464 Layer.succeed(TerminalManager.TerminalManager, {
......@@ -67,6 +67,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>
6767 clear: () => Effect.void,
6868 restart: () => Effect.die(new Error("unused")),
6969 close: () => Effect.void,
70 closeIdle: () => Effect.void,
7071 subscribe: () => Effect.succeed(() => undefined),
7172 subscribeMetadata: () => Effect.succeed(() => undefined),
7273 ...overrides,
......@@ -262,6 +263,7 @@ describe("ProjectSetupScriptRunner", () => {
262263 listener = null;
263264 });
264265 });
266 const closeIdle = vi.fn(() => Effect.void);
265267 const project = makeProject([
266268 {
267269 id: "setup",
......@@ -329,14 +331,82 @@ describe("ProjectSetupScriptRunner", () => {
329331 ]);
330332 // The subscription is torn down once the sentinel arrives.
331333 expect(listener).toBeNull();
334 // A failed run keeps its shell open for a look.
335 expect(closeIdle).not.toHaveBeenCalled();
332336 }).pipe(
333 Effect.provide(testLayer(project, { open, write, subscribe })),
337 Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })),
334338 Effect.provideService(HostProcessPlatform, "linux"),
335339 Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),
336340 );
337341 },
338342 );
339343
344 it.effect("closes the idle setup shell after a clean exit", () => {
345 const open = vi.fn(() =>
346 Effect.succeed({
347 threadId: "thread-1",
348 terminalId: "setup-setup",
349 cwd: "/repo/worktrees/a",
350 worktreePath: "/repo/worktrees/a",
351 status: "running" as const,
352 pid: 123,
353 history: "",
354 exitCode: null,
355 exitSignal: null,
356 label: "setup-setup",
357 updatedAt: "2026-01-01T00:00:00.000Z",
358 }),
359 );
360 let written = "";
361 const write = vi.fn((input: { data: string }) =>
362 Effect.sync(() => void (written = input.data)),
363 );
364 let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null;
365 const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect<void>) => {
366 listener = next;
367 return Effect.succeed(() => {
368 listener = null;
369 });
370 });
371 const closeIdle = vi.fn(() => Effect.void);
372 const project = makeProject([
373 {
374 id: "setup",
375 name: "Setup",
376 command: "bun install",
377 icon: "configure",
378 runOnWorktreeCreate: true,
379 },
380 ]);
381
382 return Effect.gen(function* () {
383 const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
384 const result = yield* runner.runForThread({
385 threadId: "thread-1",
386 projectCwd: "/repo/project",
387 worktreePath: "/repo/worktrees/a",
388 observeCompletion: {},
389 });
390 if (result.status !== "started" || !result.completion) {
391 return yield* Effect.die("expected an observed setup run");
392 }
393 const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0];
394 yield* listener!({
395 threadId: "thread-1",
396 terminalId: "setup-setup",
397 type: "output",
398 data: `${sentinel}0\r\n`,
399 });
400
401 expect((yield* result.completion).exitCode).toBe(0);
402 expect(closeIdle).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup" });
403 }).pipe(
404 Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })),
405 Effect.provideService(HostProcessPlatform, "linux"),
406 Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),
407 );
408 });
409
340410 it.effect("unsubscribes from terminal output when the command cannot be written", () => {
341411 const open = vi.fn(() =>
342412 Effect.succeed({
apps/server/src/project/ProjectSetupScriptRunner.ts+12-1
......@@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted {
3636 * Resolves when the script's shell prints the completion sentinel. The
3737 * exit code is null when the terminal exited or was closed before the
3838 * sentinel arrived. Only present when `observeCompletion` was requested.
39 * An exit code of 0 closes the setup shell if it has nothing left running.
3940 */
4041 readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>;
4142}
......@@ -412,6 +413,16 @@ export const make = Effect.gen(function* () {
412413 Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())),
413414 );
414415
416 // A clean run leaves only an idle prompt behind; its output stays in the
417 // terminal history. A failed run keeps its shell open for a look.
418 const completion = observed?.completion.pipe(
419 Effect.tap(({ exitCode }) =>
420 exitCode === 0
421 ? terminalManager.closeIdle({ threadId: input.threadId, terminalId })
422 : Effect.void,
423 ),
424 );
425
415426 return {
416427 status: "started",
417428 scriptId: script.id,
......@@ -420,7 +431,7 @@ export const make = Effect.gen(function* () {
420431 terminalId,
421432 cwd,
422433 async: script.async !== false,
423 ...(observed ? { completion: observed.completion } : {}),
434 ...(completion ? { completion } : {}),
424435 } as const;
425436 });
426437
apps/server/src/terminal/Manager.test.ts+67
......@@ -1230,6 +1230,73 @@ it.layer(
12301230 }),
12311231 );
12321232
1233 it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () =>
1234 Effect.gen(function* () {
1235 // FakePtyAdapter assigns pids from 9000 in open order.
1236 const { manager, ptyAdapter } = yield* createManager(5, {
1237 processTable: Effect.succeed([
1238 { pid: 9000, ppid: 1, name: "zsh" },
1239 // An async prompt worker: a copy of the shell with no children.
1240 { pid: 100, ppid: 9000, name: "zsh" },
1241 { pid: 9001, ppid: 1, name: "zsh" },
1242 { pid: 200, ppid: 9001, name: "node" },
1243 { pid: 9002, ppid: 1, name: "zsh" },
1244 // A subshell with a child is real work.
1245 { pid: 300, ppid: 9002, name: "zsh" },
1246 { pid: 301, ppid: 300, name: "sleep" },
1247 { pid: 9003, ppid: 1, name: "zsh" },
1248 ]),
1249 }).pipe(Effect.provide(withHostPlatform("linux")));
1250 yield* manager.open(openInput({ terminalId: "idle" }));
1251 yield* manager.open(openInput({ terminalId: "dev-server" }));
1252 yield* manager.open(openInput({ terminalId: "subshell" }));
1253 yield* manager.open(openInput({ threadId: "thread-2" }));
1254
1255 yield* manager.closeIdle({ threadId: "thread-1" });
1256
1257 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([
1258 true,
1259 false,
1260 false,
1261 false,
1262 ]);
1263 }),
1264 );
1265
1266 it.effect("keeps terminals that get input or output while closeIdle checks them", () =>
1267 Effect.gen(function* () {
1268 const ptyAdapter = new FakePtyAdapter();
1269 // The typed command's process misses the snapshot, but its input or echo lands.
1270 let duringCheck: (pid: number) => Effect.Effect<void> = () => Effect.void;
1271 const { manager, getEvents } = yield* createManager(5, {
1272 ptyAdapter,
1273 subprocessPollIntervalMs: 60_000,
1274 subprocessInspector: (pid) =>
1275 duringCheck(pid).pipe(
1276 Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
1277 ),
1278 });
1279 yield* manager.open(openInput({ terminalId: "typed" }));
1280 yield* manager.open(openInput({ terminalId: "echoed" }));
1281 const [typed, echoed] = ptyAdapter.processes;
1282 duringCheck = (pid) =>
1283 pid === typed!.pid
1284 ? manager
1285 .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" })
1286 .pipe(Effect.orDie)
1287 : Effect.gen(function* () {
1288 echoed!.emitData("make build\r\n");
1289 yield* waitFor(
1290 Effect.map(getEvents, (events) => events.some((event) => event.type === "output")),
1291 );
1292 }).pipe(Effect.orDie);
1293
1294 yield* manager.closeIdle({ threadId: "thread-1" });
1295
1296 expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]);
1297 }),
1298 );
1299
12331300 it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () =>
12341301 Effect.gen(function* () {
12351302 const fallbackCalls: Array<number> = [];
apps/server/src/terminal/Manager.ts+75-2
......@@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service<
198198 */
199199 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
200200
201 /**
202 * Close a thread's terminals that wait at an idle shell prompt. A terminal
203 * that runs a command stays open. When `terminalId` is set, only that
204 * terminal is considered. Used when a thread settles and when a setup
205 * script finishes.
206 */
207 readonly closeIdle: (input: {
208 readonly threadId: string;
209 readonly terminalId?: string;
210 }) => Effect.Effect<void>;
211
201212 /**
202213 * Subscribe to terminal runtime events with a direct callback.
203214 *
......@@ -275,6 +286,8 @@ interface TerminalSessionState {
275286 exitSignal: number | null;
276287 updatedAt: string;
277288 eventSequence: number;
289 /** Counts writes, so closeIdle can see input that has not echoed yet. */
290 inputCount: number;
278291 cols: number;
279292 rows: number;
280293 process: PtyAdapter.PtyProcess | null;
......@@ -692,7 +705,17 @@ function deriveSubprocessInspectResult(
692705 terminalPid: number,
693706 platform: NodeJS.Platform,
694707): TerminalSubprocessInspectResult {
695 const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0];
708 const commandName = (pid: number) =>
709 normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform);
710 const shellName = commandName(terminalPid);
711 // Async prompt themes fork the shell into a helper that waits with no
712 // children of its own. That copy is not a command the user started.
713 const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find(
714 (pid) =>
715 shellName === null ||
716 commandName(pid) !== shellName ||
717 (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0,
718 );
696719 if (childPid === undefined) {
697720 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
698721 }
......@@ -707,7 +730,7 @@ function deriveSubprocessInspectResult(
707730 pending.push(pid);
708731 }
709732 }
710 const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform);
733 const normalized = commandName(childPid);
711734 return {
712735 hasRunningSubprocess: true,
713736 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
......@@ -2537,6 +2560,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
25372560 exitSignal: null,
25382561 updatedAt: yield* nowIso,
25392562 eventSequence: 0,
2563 inputCount: 0,
25402564 cols,
25412565 rows,
25422566 process: null,
......@@ -2877,6 +2901,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
28772901 terminalId,
28782902 });
28792903 }
2904 session.inputCount += 1;
28802905 yield* Effect.try({
28812906 try: () => process.write(input.data),
28822907 catch: (cause) =>
......@@ -2958,6 +2983,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
29582983 exitSignal: null,
29592984 updatedAt: yield* nowIso,
29602985 eventSequence: 0,
2986 inputCount: 0,
29612987 cols,
29622988 rows,
29632989 process: null,
......@@ -3042,6 +3068,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
30423068 }),
30433069 );
30443070
3071 const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) =>
3072 withThreadLock(
3073 input.threadId,
3074 Effect.gen(function* () {
3075 const running = (yield* sessionsForThread(input.threadId)).filter(
3076 (session): session is TerminalSessionState & { pid: number } =>
3077 session.status === "running" &&
3078 Number.isInteger(session.pid) &&
3079 (input.terminalId === undefined || session.terminalId === input.terminalId),
3080 );
3081 if (running.length === 0) return;
3082 // A command started during the process check can miss the snapshot,
3083 // but its input or echo still lands. Both counters only grow, so the
3084 // sum changes when either one does.
3085 const activityMark = (session: TerminalSessionState) =>
3086 session.eventSequence + session.inputCount;
3087 const marks = new Map(
3088 running.map((session) => [session.terminalId, activityMark(session)]),
3089 );
3090 // Inspect now instead of trusting the last poll, so a command started
3091 // since then keeps its terminal.
3092 const { inspector } = yield* acquireSubprocessInspector;
3093 yield* Effect.forEach(
3094 running,
3095 (session) =>
3096 inspector(session.pid).pipe(
3097 Effect.flatMap((result) =>
3098 result.hasRunningSubprocess ||
3099 activityMark(session) !== marks.get(session.terminalId)
3100 ? Effect.void
3101 : closeSession(input.threadId, session.terminalId, false),
3102 ),
3103 ),
3104 { discard: true },
3105 );
3106 }),
3107 ).pipe(
3108 // The process check failed, so every terminal stays open.
3109 Effect.catch((error) =>
3110 Effect.logWarning("failed to close idle terminals", {
3111 threadId: input.threadId,
3112 error: error.message,
3113 }),
3114 ),
3115 );
3116
30453117 return TerminalManager.of({
30463118 open,
30473119 attachStream,
......@@ -3050,6 +3122,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
30503122 clear,
30513123 restart,
30523124 close,
3125 closeIdle,
30533126 subscribe,
30543127 subscribeMetadata,
30553128 });
docs/user/thread-sidebar.md+3-1
......@@ -90,7 +90,9 @@ Choose **Settle thread** from its menu to move finished work out of the active l
9090without deleting the conversation. **Un-settle thread** restores it to active work
9191and prevents automatic settlement until new activity resumes the usual rules.
9292Manually settling an idle thread dismisses unanswered async questions without
93sending an answer or restarting the agent.
93sending an answer or restarting the agent. Settling also closes the thread's
94terminals that wait at an idle prompt, and keeps their output. A terminal that
95runs a command, such as a dev server, stays open.
9496
9597By default, environments settle inactive threads after three days and settle
9698threads whose pull request merged. A closed pull request can also settle an idle