tinker-agent 2.9.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/README.md +30 -1
  3. package/package.json +5 -3
  4. package/src/agent/loop.ts +50 -13
  5. package/src/agent/runtime-hosted-session.ts +443 -0
  6. package/src/agent/runtime-provider-retry.ts +115 -0
  7. package/src/agent/runtime-session-contracts.ts +11 -0
  8. package/src/agent/runtime-session.ts +29 -0
  9. package/src/cli/command-line.ts +26 -2
  10. package/src/cli/connect-runner.tsx +26 -0
  11. package/src/cli/main.ts +26 -0
  12. package/src/cli/output.ts +1 -1
  13. package/src/cli/public-cli-contract.ts +18 -0
  14. package/src/cli/public-config-contract.ts +1 -1
  15. package/src/cli/serve-runner.ts +45 -0
  16. package/src/cli/serve-runtime.ts +100 -0
  17. package/src/cli/tui-runner.tsx +1 -0
  18. package/src/context/context-swap-renderer.ts +14 -0
  19. package/src/events/types.ts +8 -0
  20. package/src/image/abortable-file-open.ts +54 -0
  21. package/src/image/image-asset-store.ts +7 -1
  22. package/src/model/fake-model-client.ts +20 -1
  23. package/src/model/openai-model-utils.ts +45 -1
  24. package/src/model/openai-responses-mapping.ts +11 -0
  25. package/src/model/openai-responses-stream.ts +14 -0
  26. package/src/observation/observation-builder.ts +87 -37
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/scoped-query-database.ts +27 -0
  36. package/src/session/session-history-access.ts +4 -3
  37. package/src/session/session-store.ts +7 -3
  38. package/src/tools/bash-task.ts +26 -16
  39. package/src/tools/bash.ts +44 -2
  40. package/src/tools/glob.ts +107 -19
  41. package/src/tools/grep-output.ts +130 -0
  42. package/src/tools/grep-pagination.ts +73 -0
  43. package/src/tools/grep-path.ts +11 -0
  44. package/src/tools/grep-snippets.ts +111 -0
  45. package/src/tools/grep.ts +148 -155
  46. package/src/tools/read.ts +0 -9
  47. package/src/tools/ripgrep.ts +19 -26
  48. package/src/tools/shell-process.ts +30 -4
  49. package/src/tools/task-stop.ts +2 -1
  50. package/src/tools/terminal-screen.ts +11 -2
  51. package/src/tools/types.ts +30 -2
  52. package/src/tui/app.tsx +45 -3
  53. package/src/tui/components/ask-user.tsx +15 -8
  54. package/src/tui/components/prompt-input.tsx +14 -6
  55. package/src/tui/components/timeline.tsx +17 -9
  56. package/src/tui/event-store.ts +25 -2
  57. package/src/tui/file-mention.ts +29 -5
  58. package/src/tui/remote-app.tsx +210 -0
  59. package/src/tui/tui-projection-store.ts +5 -2
  60. package/src/tui/tui-session-controller.ts +8 -0
  61. package/src/tui/workspace-file-search.ts +21 -0
@@ -0,0 +1,219 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createUuidV7 } from "../ids/uuid-v7";
3
+ import { parseSessionId } from "../ids/runtime-id";
4
+ import { SessionCatalog } from "../session/session-catalog";
5
+ import {
6
+ HostedSession,
7
+ type HostedRuntimeFactory,
8
+ } from "../agent/runtime-hosted-session";
9
+ import type { RemoteWorkspaceConfig } from "./config";
10
+ import { RemoteServiceStore, type ManagedSessionRecord } from "./service-store";
11
+ import {
12
+ RemoteError,
13
+ type RemoteOperationInput,
14
+ type OperationReceipt,
15
+ type RemoteSessionInfo,
16
+ } from "./protocol";
17
+
18
+ export class RemoteService {
19
+ readonly epoch = randomUUID();
20
+ private readonly hosted = new Map<string, HostedSession>();
21
+ private submitting: Promise<void> = Promise.resolve();
22
+ private stopping = false;
23
+ constructor(
24
+ readonly store: RemoteServiceStore,
25
+ readonly workspaces: readonly RemoteWorkspaceConfig[],
26
+ private readonly factory: HostedRuntimeFactory,
27
+ private readonly homeRoot?: string,
28
+ ) {}
29
+
30
+ async initialize(): Promise<void> {
31
+ // Reacquire every managed canonical lease; no prompt is resubmitted on boot.
32
+ for (const record of this.store.sessions()) {
33
+ if (!record.initialized) continue;
34
+ try {
35
+ await this.session(record.id).open();
36
+ } catch {
37
+ /* A failed workspace/session remains visible with its error. */
38
+ }
39
+ }
40
+ }
41
+ workspace(id: string): RemoteWorkspaceConfig {
42
+ const workspace = this.workspaces.find((workspace) => workspace.id === id);
43
+ if (!workspace)
44
+ throw new RemoteError(
45
+ 404,
46
+ "WORKSPACE_NOT_FOUND",
47
+ "This workspace is not configured on the Mac.",
48
+ );
49
+ return workspace;
50
+ }
51
+ session(id: string): HostedSession {
52
+ const existing = this.hosted.get(id);
53
+ if (existing) return existing;
54
+ const record = this.store.session(id);
55
+ if (!record)
56
+ throw new RemoteError(
57
+ 404,
58
+ "SESSION_NOT_MANAGED",
59
+ "Attach this local session before connecting to it.",
60
+ );
61
+ const workspace = this.workspace(record.workspaceId);
62
+ if (record.workspacePath !== workspace.path)
63
+ throw new RemoteError(
64
+ 409,
65
+ "WORKSPACE_CHANGED",
66
+ "The managed session belongs to a different workspace path.",
67
+ );
68
+ const hosted = new HostedSession(record, this.store, this.epoch, this.factory);
69
+ this.hosted.set(id, hosted);
70
+ return hosted;
71
+ }
72
+ async listSessions(workspaceId: string): Promise<RemoteSessionInfo[]> {
73
+ const workspace = this.workspace(workspaceId);
74
+ const local = await new SessionCatalog({
75
+ workspaceRoot: workspace.path,
76
+ homeRoot: this.homeRoot,
77
+ }).listAll();
78
+ const managed = this.store
79
+ .sessions()
80
+ .filter((record) => record.workspaceId === workspaceId);
81
+ return [
82
+ ...managed.map((record) => {
83
+ const view = this.hosted.get(record.id)?.view();
84
+ return (
85
+ view?.session ?? {
86
+ id: record.id,
87
+ workspaceId,
88
+ title: record.title,
89
+ modelName: record.modelName,
90
+ owner: "service" as const,
91
+ status: record.initialized ? "idle" : "interrupted",
92
+ updatedAt: record.updatedAt,
93
+ }
94
+ );
95
+ }),
96
+ ...local
97
+ .filter((summary) => !managed.some((record) => record.id === summary.sessionId))
98
+ .map((summary) => ({
99
+ id: summary.sessionId,
100
+ workspaceId,
101
+ title: summary.firstUserPromptPreview ?? "Empty session",
102
+ modelName: summary.modelName,
103
+ owner: "local" as const,
104
+ status: summary.status,
105
+ updatedAt: summary.updatedAt,
106
+ })),
107
+ ].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
108
+ }
109
+
110
+ submit(input: RemoteOperationInput, device: string): Promise<OperationReceipt> {
111
+ // Serializes acceptance and any async catalog lookup, never task execution.
112
+ const result = this.submitting.then(() => this.accept(input, device));
113
+ this.submitting = result.then(
114
+ () => undefined,
115
+ () => undefined,
116
+ );
117
+ return result;
118
+ }
119
+ private async accept(
120
+ input: RemoteOperationInput,
121
+ device: string,
122
+ ): Promise<OperationReceipt> {
123
+ const existing = this.store.existing(input, device);
124
+ if (existing) return existing;
125
+ if (this.stopping)
126
+ throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
127
+ if (input.kind === "create" || input.kind === "adopt") {
128
+ const workspace = this.workspace(input.workspaceId);
129
+ const id = input.kind === "create" ? createUuidV7() : input.sessionId;
130
+ if (this.store.sessions().length >= 128)
131
+ throw new RemoteError(
132
+ 409,
133
+ "SESSION_LIMIT",
134
+ "The service has reached its 128 managed session limit.",
135
+ );
136
+ let record: ManagedSessionRecord;
137
+ if (input.kind === "adopt") {
138
+ if (this.store.session(id))
139
+ throw new RemoteError(
140
+ 409,
141
+ "ALREADY_MANAGED",
142
+ "This session is already owned by the service.",
143
+ );
144
+ const summary = await new SessionCatalog({
145
+ workspaceRoot: workspace.path,
146
+ homeRoot: this.homeRoot,
147
+ }).get(parseSessionId(id));
148
+ if (summary.status !== "resumable" && summary.status !== "interrupted")
149
+ throw new RemoteError(
150
+ 409,
151
+ "SESSION_UNAVAILABLE",
152
+ "Exit its local TUI before attaching this session; it must be resumable.",
153
+ );
154
+ record = {
155
+ id,
156
+ workspaceId: workspace.id,
157
+ workspacePath: workspace.path,
158
+ title: summary.firstUserPromptPreview ?? "Empty session",
159
+ modelName: summary.modelName,
160
+ owner: "service",
161
+ status: "accepted",
162
+ updatedAt: new Date().toISOString(),
163
+ initialized: true,
164
+ };
165
+ } else {
166
+ record = {
167
+ id,
168
+ workspaceId: workspace.id,
169
+ workspacePath: workspace.path,
170
+ title: input.title ?? "New session",
171
+ modelName: "",
172
+ owner: "service",
173
+ status: "accepted",
174
+ updatedAt: new Date().toISOString(),
175
+ initialized: false,
176
+ };
177
+ }
178
+ const receipt = this.store.accept(input, device, id, record);
179
+ const hosted = this.session(id);
180
+ void hosted.open().then(
181
+ () => {
182
+ this.store.update({ ...receipt, status: "completed" });
183
+ hosted.receiptChanged();
184
+ },
185
+ (error: unknown) => {
186
+ this.store.update({
187
+ ...receipt,
188
+ status: "failed",
189
+ error: error instanceof Error ? error.message : String(error),
190
+ });
191
+ },
192
+ );
193
+ return receipt;
194
+ }
195
+ const session = this.session(input.sessionId);
196
+ // Attach/initialization must be complete before a new mutation can be accepted.
197
+ await session.open();
198
+ session.validate(input);
199
+ const receipt = this.store.accept(input, device, input.sessionId);
200
+ if (input.kind === "prompt") session.enqueue(receipt);
201
+ else session.control(input, receipt);
202
+ return receipt;
203
+ }
204
+
205
+ async close(): Promise<void> {
206
+ this.stopping = true;
207
+ await this.submitting;
208
+ const results = await Promise.allSettled(
209
+ [...this.hosted.values()].map((session) => session.close()),
210
+ );
211
+ await this.store.close();
212
+ const errors = results.filter((result) => result.status === "rejected");
213
+ if (errors.length)
214
+ throw new AggregateError(
215
+ errors.map((result) => result.reason as unknown),
216
+ "Remote service shutdown failed.",
217
+ );
218
+ }
219
+ }
@@ -0,0 +1,95 @@
1
+ import type { RemoteChange, RemoteCursor, RemoteFrame, RemoteView } from "./protocol";
2
+
3
+ /** Synchronous cursor allocation; transport delivery is always outside the runtime. */
4
+ export class RemoteSyncHub {
5
+ private sequence = 0;
6
+ private readonly ring: { frame: RemoteFrame; bytes: number }[] = [];
7
+ private ringBytes = 0;
8
+ private readonly listeners = new Set<(frame: RemoteFrame) => void>();
9
+ private pending: RemoteFrame[] = [];
10
+ private scheduled = false;
11
+ constructor(
12
+ readonly epoch: string,
13
+ private readonly readView: () => RemoteView,
14
+ private readonly capacity = 256,
15
+ ) {}
16
+
17
+ snapshot(): RemoteFrame {
18
+ return {
19
+ version: 1,
20
+ type: "snapshot",
21
+ epoch: this.epoch,
22
+ sequence: this.sequence,
23
+ view: this.readView(),
24
+ };
25
+ }
26
+ publish(change: RemoteChange): void {
27
+ const frame: RemoteFrame = {
28
+ version: 1,
29
+ type: "event",
30
+ epoch: this.epoch,
31
+ sequence: ++this.sequence,
32
+ change,
33
+ };
34
+ const bytes = Buffer.byteLength(JSON.stringify(frame));
35
+ this.ring.push({ frame, bytes });
36
+ this.ringBytes += bytes;
37
+ while (this.ring.length > this.capacity || this.ringBytes > 8 * 1024 * 1024)
38
+ this.ringBytes -= this.ring.shift()!.bytes;
39
+ // A single scheduled delivery per tick; no subscriber can hold up append().
40
+ this.pending.push(frame);
41
+ if (this.pending.length > this.capacity) this.pending = [this.snapshot()];
42
+ if (!this.scheduled) {
43
+ this.scheduled = true;
44
+ setTimeout(() => this.deliver(), 0);
45
+ }
46
+ }
47
+ subscribe(
48
+ cursor: RemoteCursor | undefined,
49
+ listener: (frame: RemoteFrame) => void,
50
+ ): () => void {
51
+ // No await between reading the cursor/view and installing the subscription.
52
+ let last = this.sequence;
53
+ const oldest = this.ring[0]?.frame.sequence ?? this.sequence + 1;
54
+ const replay =
55
+ cursor?.epoch === this.epoch &&
56
+ cursor.sequence >= oldest - 1 &&
57
+ cursor.sequence <= this.sequence
58
+ ? this.ring
59
+ .filter(({ frame }) => frame.sequence > cursor.sequence)
60
+ .map(({ frame }) => frame)
61
+ : [this.snapshot()];
62
+ // Same-cursor reconnect still needs a handshake to mark the link synchronized.
63
+ if (replay.length === 0) replay.push(this.snapshot());
64
+ const guarded = (frame: RemoteFrame) => {
65
+ if (frame.sequence <= last) return;
66
+ last = frame.sequence;
67
+ listener(frame);
68
+ };
69
+ this.listeners.add(guarded);
70
+ try {
71
+ for (const frame of replay) listener(frame);
72
+ } catch {
73
+ this.listeners.delete(guarded);
74
+ }
75
+ return () => this.listeners.delete(guarded);
76
+ }
77
+ private deliver(): void {
78
+ this.scheduled = false;
79
+ const pending = this.pending;
80
+ this.pending = [];
81
+ for (const frame of pending) {
82
+ for (const listener of this.listeners) {
83
+ try {
84
+ listener(frame);
85
+ } catch {
86
+ this.listeners.delete(listener);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ close(): void {
92
+ this.listeners.clear();
93
+ this.pending = [];
94
+ }
95
+ }
@@ -0,0 +1,143 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { SessionId } from "../ids/runtime-id";
3
+ import { verifyReadableSessionSchema } from "./session-schema";
4
+ import { decodeStoredToolCalls } from "./session-store-record-codecs";
5
+
6
+ export type RemoteMessage = {
7
+ id: string;
8
+ ordinal: number;
9
+ role: "user" | "assistant" | "tool";
10
+ text: string;
11
+ turnId: string;
12
+ turnStatus: string;
13
+ createdAt: string;
14
+ name?: string;
15
+ toolCallId?: string;
16
+ toolCalls?: { id: string; name: string; arguments: string }[];
17
+ };
18
+
19
+ export type RemoteHistoryPage = {
20
+ messages: RemoteMessage[];
21
+ hasMore: boolean;
22
+ beforeOrdinal?: number;
23
+ };
24
+
25
+ /** A read-only canonical projection; open tails are legal and never synthesized. */
26
+ export class RemoteHistoryReader {
27
+ private readonly database: Database;
28
+ constructor(databasePath: string, sessionId: SessionId, workspaceRoot: string) {
29
+ this.database = new Database(databasePath, { readonly: true, strict: true });
30
+ try {
31
+ verifyReadableSessionSchema(this.database, sessionId);
32
+ const identity = this.database
33
+ .query("SELECT session_id, workspace_root FROM session_meta")
34
+ .get() as { session_id: string; workspace_root: string } | null;
35
+ if (
36
+ identity?.session_id !== sessionId ||
37
+ identity.workspace_root !== workspaceRoot
38
+ ) {
39
+ throw new Error(
40
+ "Remote history identity does not match its workspace/session.",
41
+ );
42
+ }
43
+ } catch (error) {
44
+ this.database.close();
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ page(before = Number.MAX_SAFE_INTEGER, limit = 80): RemoteHistoryPage {
50
+ const rows = this.database
51
+ .query(
52
+ `${MESSAGE_SELECT} WHERE m.role <> 'system' AND m.ordinal < ? ORDER BY m.ordinal DESC LIMIT ?`,
53
+ )
54
+ .all(before, limit + 1) as MessageRow[];
55
+ const hasMore = rows.length > limit;
56
+ const messages = rows.slice(0, limit).reverse().map(projectMessage);
57
+ return {
58
+ messages,
59
+ hasMore,
60
+ ...(messages[0] ? { beforeOrdinal: messages[0].ordinal } : {}),
61
+ };
62
+ }
63
+
64
+ after(ordinal: number): RemoteMessage[] {
65
+ return (
66
+ this.database
67
+ .query(
68
+ `${MESSAGE_SELECT} WHERE m.role <> 'system' AND m.ordinal > ? ORDER BY m.ordinal`,
69
+ )
70
+ .all(ordinal) as MessageRow[]
71
+ ).map(projectMessage);
72
+ }
73
+
74
+ latestTurn(): { id: string; status: string; error?: string } | undefined {
75
+ const row = this.database
76
+ .query(
77
+ "SELECT turn_id, status, terminal_detail_json FROM turns ORDER BY turn_number DESC LIMIT 1",
78
+ )
79
+ .get() as {
80
+ turn_id: string;
81
+ status: string;
82
+ terminal_detail_json: string | null;
83
+ } | null;
84
+ if (!row) return undefined;
85
+ const detail = row.terminal_detail_json
86
+ ? (JSON.parse(row.terminal_detail_json) as { error?: string })
87
+ : undefined;
88
+ return {
89
+ id: row.turn_id,
90
+ status: row.status,
91
+ ...(detail?.error ? { error: detail.error } : {}),
92
+ };
93
+ }
94
+
95
+ turnStatus(turnId: string): string | undefined {
96
+ return (
97
+ this.database.query("SELECT status FROM turns WHERE turn_id = ?").get(turnId) as {
98
+ status: string;
99
+ } | null
100
+ )?.status;
101
+ }
102
+
103
+ close(): void {
104
+ this.database.close();
105
+ }
106
+ }
107
+
108
+ const MESSAGE_SELECT =
109
+ "SELECT m.*, t.status AS turn_status FROM messages m JOIN turns t ON t.turn_id = m.turn_id";
110
+ type MessageRow = {
111
+ message_id: string;
112
+ ordinal: number;
113
+ role: RemoteMessage["role"];
114
+ content: string | null;
115
+ turn_id: string;
116
+ turn_status: string;
117
+ created_at: string;
118
+ name: string | null;
119
+ tool_call_id: string | null;
120
+ tool_calls_json: string | null;
121
+ };
122
+ function projectMessage(row: MessageRow): RemoteMessage {
123
+ return {
124
+ id: row.message_id,
125
+ ordinal: row.ordinal,
126
+ role: row.role,
127
+ text: row.content ?? "",
128
+ turnId: row.turn_id,
129
+ turnStatus: row.turn_status,
130
+ createdAt: row.created_at,
131
+ ...(row.name ? { name: row.name } : {}),
132
+ ...(row.tool_call_id ? { toolCallId: row.tool_call_id } : {}),
133
+ ...(row.tool_calls_json
134
+ ? {
135
+ toolCalls: decodeStoredToolCalls(row.tool_calls_json).map((call) => ({
136
+ id: call.toolCallId,
137
+ name: call.name,
138
+ arguments: JSON.stringify(call.args),
139
+ })),
140
+ }
141
+ : {}),
142
+ };
143
+ }
@@ -0,0 +1,27 @@
1
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
2
+
3
+ /**
4
+ * Own every query statement until a short-lived connection closes. Bun only
5
+ * caches its first 20 queries; later statements otherwise survive close() until
6
+ * GC, leaving a zombie SQLite connection behind. Do not use this for resident
7
+ * stores: retaining every query is intentionally bounded by the operation.
8
+ */
9
+ export class ScopedQueryDatabase extends Database {
10
+ private readonly queries = new Set<{ finalize(): void }>();
11
+
12
+ override query<Result, Params extends SQLQueryBindings | SQLQueryBindings[]>(
13
+ sql: string,
14
+ ) {
15
+ const statement = super.query<Result, Params>(sql);
16
+ this.queries.add(statement);
17
+ return statement;
18
+ }
19
+
20
+ override close(): void {
21
+ for (const statement of this.queries) statement.finalize();
22
+ this.queries.clear();
23
+ // Also finalizes Bun's transaction statements and rejects any other live
24
+ // resources instead of silently deferring the underlying connection close.
25
+ super.close(true);
26
+ }
27
+ }
@@ -1,4 +1,5 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "bun:sqlite";
2
+ import { ScopedQueryDatabase } from "./scoped-query-database";
2
3
  import { lstat, readdir, realpath } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { throwIfTurnCancelled } from "../agent/turn-cancellation";
@@ -67,7 +68,7 @@ export function createSessionHistoryAccess(input: {
67
68
  throwIfTurnCancelled(signal);
68
69
  await validateHistoryFiles(location.databasePath, sessionId);
69
70
  throwIfTurnCancelled(signal);
70
- const database = new Database(location.databasePath, {
71
+ const database = new ScopedQueryDatabase(location.databasePath, {
71
72
  readonly: true,
72
73
  strict: true,
73
74
  safeIntegers: true,
@@ -100,7 +101,7 @@ export function createSessionHistoryAccess(input: {
100
101
  throwIfTurnCancelled(signal);
101
102
  return result;
102
103
  } finally {
103
- database.close(true);
104
+ database.close();
104
105
  }
105
106
  } catch (error) {
106
107
  throwIfTurnCancelled(signal);
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { ScopedQueryDatabase } from "./scoped-query-database";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { chmod, mkdir, open, readdir, rename, rmdir } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -1327,7 +1328,7 @@ export class SessionStore implements SessionLedgerCommitter {
1327
1328
  await chmod(stagingDatabasePath, 0o600);
1328
1329
  input.faultInjector?.("after_snapshot");
1329
1330
 
1330
- stagingDatabase = openWritableDatabase(stagingDatabasePath);
1331
+ stagingDatabase = openWritableDatabase(stagingDatabasePath, ScopedQueryDatabase);
1331
1332
  verifySessionSchema(stagingDatabase, this.sessionId);
1332
1333
  dropSessionCloneTriggers(stagingDatabase);
1333
1334
  input.faultInjector?.("after_trigger_drop");
@@ -1542,8 +1543,11 @@ export async function resolveSessionDatabasePath(
1542
1543
  );
1543
1544
  }
1544
1545
 
1545
- function openWritableDatabase(databasePath: string): Database {
1546
- const database = new Database(databasePath, {
1546
+ function openWritableDatabase(
1547
+ databasePath: string,
1548
+ DatabaseType: typeof Database = Database,
1549
+ ): Database {
1550
+ const database = new DatabaseType(databasePath, {
1547
1551
  create: false,
1548
1552
  readwrite: true,
1549
1553
  strict: true,
@@ -15,12 +15,7 @@ import {
15
15
  } from "./shell-process";
16
16
  import { TaskOutput, type TaskOutputSnapshot } from "./task-output";
17
17
  import type { TaskOutputRangeRequest } from "./task-output-range";
18
- import {
19
- createTerminalScreen,
20
- TERMINAL_SCREEN_COLUMNS,
21
- TERMINAL_SCREEN_ROWS,
22
- type TerminalScreen,
23
- } from "./terminal-screen";
18
+ import { createTerminalScreen, type TerminalScreen } from "./terminal-screen";
24
19
  import { resolveWorkspaceStorageRoot } from "../session/workspace-storage";
25
20
 
26
21
  export type ShellTaskStatus =
@@ -146,6 +141,8 @@ export class ShellTaskManager {
146
141
  description: string;
147
142
  origin: ShellTaskOrigin;
148
143
  tty: boolean;
144
+ cols?: number;
145
+ rows?: number;
149
146
  }): Promise<ShellTaskHandle> {
150
147
  if (!this.acceptingTasks) {
151
148
  throw new Error("Cannot start a Bash task after task manager shutdown.");
@@ -164,7 +161,7 @@ export class ShellTaskManager {
164
161
  throw new Error("Cannot start a Bash task after task manager shutdown.");
165
162
  }
166
163
 
167
- const terminalScreen = input.tty ? createTerminalScreen() : undefined;
164
+ const terminalScreen = input.tty ? createTerminalScreen(input) : undefined;
168
165
  let shellProcess: ShellProcessHandle;
169
166
  try {
170
167
  shellProcess = await spawnShellProcess({
@@ -172,6 +169,8 @@ export class ShellTaskManager {
172
169
  command: input.command,
173
170
  cwd: this.options.cwdState.cwd,
174
171
  cwdFilePath,
172
+ cols: terminalScreen?.columns,
173
+ rows: terminalScreen?.rows,
175
174
  onOutput(bytes) {
176
175
  output.write(Buffer.from(bytes));
177
176
  if (terminalScreen !== undefined) {
@@ -417,8 +416,15 @@ export class ShellTaskManager {
417
416
  if (!(await completesWithin(task.completion, this.stopGraceMs))) {
418
417
  this.synchronizeTerminalState(task);
419
418
  if (!isTerminalStatus(task.status)) {
420
- signalProcessGroup(task, "SIGKILL");
421
- escalated = true;
419
+ escalated = signalProcessGroup(task, "SIGKILL");
420
+ if (
421
+ !(await completesWithin(task.completion, this.stopGraceMs)) &&
422
+ !task.process.outputClosed
423
+ ) {
424
+ task.error =
425
+ "Task output remained open after forced termination; closed local output streams. Descendant processes may still be running.";
426
+ task.process.close();
427
+ }
422
428
  }
423
429
  }
424
430
 
@@ -474,8 +480,8 @@ export class ShellTaskManager {
474
480
  private async monitorTask(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
475
481
  const result = await task.process.wait();
476
482
 
477
- this.applyTermination(task, result);
478
483
  await task.process.waitForOutputClose();
484
+ this.applyTermination(task, result);
479
485
  await task.output.end();
480
486
  if (task.terminalScreen !== undefined) {
481
487
  await task.terminalScreen.flush();
@@ -505,9 +511,9 @@ export class ShellTaskManager {
505
511
  }
506
512
 
507
513
  task.endedAt ??= new Date().toISOString();
508
- if (result.error !== undefined) {
514
+ if (result.error !== undefined || task.error !== undefined) {
509
515
  task.status = "failed";
510
- task.error = result.error;
516
+ task.error ??= result.error;
511
517
  return;
512
518
  }
513
519
 
@@ -522,7 +528,9 @@ export class ShellTaskManager {
522
528
  }
523
529
 
524
530
  private synchronizeTerminalState(task: ManagedShellTask): void {
525
- if (isTerminalStatus(task.status)) {
531
+ // An exited wrapper can leave children holding its output descriptors open.
532
+ // Keep the task stoppable until both process exit and output closure occur.
533
+ if (isTerminalStatus(task.status) || !task.process.outputClosed) {
526
534
  return;
527
535
  }
528
536
 
@@ -576,8 +584,8 @@ export class ShellTaskManager {
576
584
  ...(screen === undefined
577
585
  ? {}
578
586
  : {
579
- screenRows: TERMINAL_SCREEN_ROWS,
580
- screenColumns: TERMINAL_SCREEN_COLUMNS,
587
+ screenRows: task.terminalScreen?.rows,
588
+ screenColumns: task.terminalScreen?.columns,
581
589
  screen,
582
590
  }),
583
591
  };
@@ -607,13 +615,15 @@ export class ShellTaskManager {
607
615
  function signalProcessGroup(
608
616
  task: ManagedShellTask,
609
617
  signal: "SIGTERM" | "SIGKILL",
610
- ): void {
618
+ ): boolean {
611
619
  try {
612
620
  process.kill(-task.processGroupId, signal);
621
+ return true;
613
622
  } catch (error) {
614
623
  if (!isNoSuchProcess(error)) {
615
624
  throw error;
616
625
  }
626
+ return false;
617
627
  }
618
628
  }
619
629