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.
- package/CHANGELOG.md +69 -1
- package/README.md +30 -1
- package/package.json +5 -3
- package/src/agent/loop.ts +50 -13
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-provider-retry.ts +115 -0
- package/src/agent/runtime-session-contracts.ts +11 -0
- package/src/agent/runtime-session.ts +29 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/cli/tui-runner.tsx +1 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +8 -0
- package/src/image/abortable-file-open.ts +54 -0
- package/src/image/image-asset-store.ts +7 -1
- package/src/model/fake-model-client.ts +20 -1
- package/src/model/openai-model-utils.ts +45 -1
- package/src/model/openai-responses-mapping.ts +11 -0
- package/src/model/openai-responses-stream.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/session/scoped-query-database.ts +27 -0
- package/src/session/session-history-access.ts +4 -3
- package/src/session/session-store.ts +7 -3
- package/src/tools/bash-task.ts +26 -16
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +148 -155
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/app.tsx +45 -3
- package/src/tui/components/ask-user.tsx +15 -8
- package/src/tui/components/prompt-input.tsx +14 -6
- package/src/tui/components/timeline.tsx +17 -9
- package/src/tui/event-store.ts +25 -2
- package/src/tui/file-mention.ts +29 -5
- package/src/tui/remote-app.tsx +210 -0
- package/src/tui/tui-projection-store.ts +5 -2
- package/src/tui/tui-session-controller.ts +8 -0
- 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
|
|
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(
|
|
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(
|
|
1546
|
-
|
|
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,
|
package/src/tools/bash-task.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
580
|
-
screenColumns:
|
|
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
|
-
):
|
|
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
|
|