tinker-agent 2.12.0 → 3.0.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 +66 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/runner-dependencies.ts +35 -35
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tools/bash.ts +9 -1
- package/src/tools/bounded-output-preview.ts +2 -2
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- package/src/tui/tui-session-controller.ts +12 -282
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { RemotePromptHistory } from "./remote-prompt-history";
|
|
2
|
+
import type { LoadedPromptHistoryRecord } from "../tui/prompt-history";
|
|
3
|
+
import type { ProjectSlashCommand } from "../tui/project-slash-commands";
|
|
4
|
+
import type { ViewFile } from "../tui/view-file";
|
|
5
|
+
import { RemoteImages } from "./remote-images";
|
|
6
|
+
import type { ClientModelProfile } from "./model-catalog";
|
|
7
|
+
import type {
|
|
8
|
+
SessionOperation,
|
|
9
|
+
SessionOperationResult,
|
|
10
|
+
} from "../remote/session-operations";
|
|
11
|
+
import { RemoteTasks } from "./remote-tasks";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { parseSessionId, type SessionId } from "../ids/runtime-id";
|
|
14
|
+
import { RemoteClient, type RemoteClientConfig } from "../remote/client";
|
|
15
|
+
import type { OperationReceipt } from "../remote/protocol";
|
|
16
|
+
import type { RemoteTuiSnapshot } from "../remote/tui-protocol";
|
|
17
|
+
import type { TuiSessionView } from "../tui/tui-session-controller";
|
|
18
|
+
import type {
|
|
19
|
+
ClientConnection,
|
|
20
|
+
ClientSessionSummary,
|
|
21
|
+
SessionClient,
|
|
22
|
+
WorkspaceClient,
|
|
23
|
+
} from "./session-client";
|
|
24
|
+
import { RemoteTuiView } from "./remote-tui-view";
|
|
25
|
+
|
|
26
|
+
class RemoteTuiSession {
|
|
27
|
+
readonly view: RemoteTuiView;
|
|
28
|
+
readonly binding: SessionClient<TuiSessionView>;
|
|
29
|
+
readonly tasks: RemoteTasks;
|
|
30
|
+
private snapshot: RemoteTuiSnapshot;
|
|
31
|
+
private readonly abort = new AbortController();
|
|
32
|
+
private unsubscribe?: () => void;
|
|
33
|
+
private refreshing = false;
|
|
34
|
+
private dirty = false;
|
|
35
|
+
private lastKey = "";
|
|
36
|
+
private retryTimer?: ReturnType<typeof setTimeout>;
|
|
37
|
+
|
|
38
|
+
private constructor(
|
|
39
|
+
private readonly transport: RemoteClient,
|
|
40
|
+
snapshot: RemoteTuiSnapshot,
|
|
41
|
+
) {
|
|
42
|
+
this.snapshot = snapshot;
|
|
43
|
+
this.view = new RemoteTuiView(snapshot);
|
|
44
|
+
const tasks = (this.tasks = new RemoteTasks(
|
|
45
|
+
transport,
|
|
46
|
+
snapshot.activity.session.id,
|
|
47
|
+
() => this.snapshot,
|
|
48
|
+
this.abort.signal,
|
|
49
|
+
(error) => this.view.setConnection(transport.getSnapshot().connection, error),
|
|
50
|
+
));
|
|
51
|
+
const images = new RemoteImages(
|
|
52
|
+
transport,
|
|
53
|
+
snapshot.activity.session.id,
|
|
54
|
+
this.abort.signal,
|
|
55
|
+
);
|
|
56
|
+
this.binding = {
|
|
57
|
+
sessionId: parseSessionId(snapshot.history.sessionId),
|
|
58
|
+
workspaceRoot: snapshot.history.workspaceRoot,
|
|
59
|
+
modelName: snapshot.history.modelName,
|
|
60
|
+
profileName: snapshot.profileName,
|
|
61
|
+
modelProfiles: () =>
|
|
62
|
+
this.snapshot.modelCatalog
|
|
63
|
+
? {
|
|
64
|
+
defaultProfile: this.snapshot.modelCatalog.defaultProfile,
|
|
65
|
+
profiles: new Map(
|
|
66
|
+
this.snapshot.modelCatalog.profiles.map((p) => [p.name, p]),
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
: undefined,
|
|
70
|
+
reasoningEffort: () => this.snapshot.reasoningEffort,
|
|
71
|
+
setReasoningEffort: async (effort) => {
|
|
72
|
+
const result = await tasks.operate({ kind: "reasoning", effort });
|
|
73
|
+
await this.refreshNow();
|
|
74
|
+
if (result.kind !== "reasoning") throw new Error("Invalid reasoning response.");
|
|
75
|
+
return result.value;
|
|
76
|
+
},
|
|
77
|
+
resetReasoningEffort: async () => {
|
|
78
|
+
const result = await tasks.operate({ kind: "reasoning", effort: null });
|
|
79
|
+
await this.refreshNow();
|
|
80
|
+
if (result.kind !== "reasoning") throw new Error("Invalid reasoning response.");
|
|
81
|
+
return result.value;
|
|
82
|
+
},
|
|
83
|
+
supportsImageInput: () => this.snapshot.supportsImageInput,
|
|
84
|
+
projectionStore: this.view,
|
|
85
|
+
readLastResponse: async () =>
|
|
86
|
+
(
|
|
87
|
+
await transport.request<{ text?: string }>(
|
|
88
|
+
`/v1/sessions/${snapshot.activity.session.id}/last-response`,
|
|
89
|
+
undefined,
|
|
90
|
+
this.abort.signal,
|
|
91
|
+
)
|
|
92
|
+
).text,
|
|
93
|
+
importImage: (sourcePath, signal, count) =>
|
|
94
|
+
images.import(sourcePath, signal, count),
|
|
95
|
+
verifyImageAssets: (assets, signal) => images.verify(assets, signal),
|
|
96
|
+
skills: () => this.snapshot.skills,
|
|
97
|
+
mcp: () => this.snapshot.mcp,
|
|
98
|
+
bashGuard: () => this.snapshot.bashGuard,
|
|
99
|
+
subscribeBashGuard: this.view.subscribe,
|
|
100
|
+
askUser: () => this.snapshot.askUser,
|
|
101
|
+
subscribeAskUser: this.view.subscribe,
|
|
102
|
+
providerRetry: () => this.snapshot.providerRetry,
|
|
103
|
+
subscribeProviderRetry: this.view.subscribe,
|
|
104
|
+
resolveProviderRetry: (requestId, decision) =>
|
|
105
|
+
tasks.respond({ kind: "provider_retry", interactionId: requestId, decision }),
|
|
106
|
+
setYoloMode: async (enabled) => {
|
|
107
|
+
await tasks.operate({ kind: "yolo", enabled });
|
|
108
|
+
await this.refreshNow();
|
|
109
|
+
},
|
|
110
|
+
resolveAskUser: async (response, interactionId) => {
|
|
111
|
+
if (!interactionId) throw new Error("Question identity is required.");
|
|
112
|
+
await tasks.respond({
|
|
113
|
+
kind: "answer",
|
|
114
|
+
interactionId,
|
|
115
|
+
selectedIndex:
|
|
116
|
+
response.outcome === "selected" ? response.selectedIndex : null,
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
resolveBashConfirmation: async (decision, interactionId) => {
|
|
120
|
+
if (!interactionId) throw new Error("Confirmation identity is required.");
|
|
121
|
+
await tasks.respond({ kind: "confirm", interactionId, decision });
|
|
122
|
+
},
|
|
123
|
+
admitTurn: (message, signal) => tasks.admitTurn(message, signal),
|
|
124
|
+
executeTurn: async (message, signal) =>
|
|
125
|
+
(await tasks.admitTurn(message, signal)).completion,
|
|
126
|
+
queueFollowUp: (message) => tasks.queueFollowUp(message),
|
|
127
|
+
stopTurn: () => tasks.stopTurn(),
|
|
128
|
+
promptScheduler: () => this.snapshot.promptScheduler,
|
|
129
|
+
subscribePromptScheduler: this.view.subscribe,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static async open(
|
|
134
|
+
config: RemoteClientConfig,
|
|
135
|
+
workspaceId: string,
|
|
136
|
+
sessionId: string,
|
|
137
|
+
): Promise<RemoteTuiSession> {
|
|
138
|
+
const transport = new RemoteClient(config, false);
|
|
139
|
+
try {
|
|
140
|
+
const snapshot = await transport.request<RemoteTuiSnapshot>(
|
|
141
|
+
`/v1/sessions/${sessionId}/tui-snapshot`,
|
|
142
|
+
);
|
|
143
|
+
validate(snapshot, workspaceId, sessionId);
|
|
144
|
+
const session = new RemoteTuiSession(transport, snapshot);
|
|
145
|
+
session.unsubscribe = transport.subscribe(() => session.changed());
|
|
146
|
+
await transport.select(sessionId, workspaceId);
|
|
147
|
+
return session;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await transport.close();
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
private changed(): void {
|
|
154
|
+
if (this.abort.signal.aborted) return;
|
|
155
|
+
const state = this.transport.getSnapshot();
|
|
156
|
+
this.view.setConnection(state.connection, state.error);
|
|
157
|
+
const key = JSON.stringify([state.connection, state.cursor]);
|
|
158
|
+
if (key === this.lastKey) return;
|
|
159
|
+
this.lastKey = key;
|
|
160
|
+
if (state.connection !== "online") return;
|
|
161
|
+
this.dirty = true;
|
|
162
|
+
void this.refresh();
|
|
163
|
+
}
|
|
164
|
+
async refreshNow(): Promise<void> {
|
|
165
|
+
while (this.refreshing && !this.abort.signal.aborted)
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
167
|
+
const next = await this.transport.request<RemoteTuiSnapshot>(
|
|
168
|
+
`/v1/sessions/${this.binding.sessionId}/tui-snapshot`,
|
|
169
|
+
undefined,
|
|
170
|
+
this.abort.signal,
|
|
171
|
+
);
|
|
172
|
+
validate(next, this.snapshot.activity.session.workspaceId, this.binding.sessionId);
|
|
173
|
+
if (
|
|
174
|
+
next.cursor.epoch === this.snapshot.cursor.epoch &&
|
|
175
|
+
next.cursor.sequence < this.snapshot.cursor.sequence
|
|
176
|
+
)
|
|
177
|
+
return;
|
|
178
|
+
this.snapshot = next;
|
|
179
|
+
this.view.update(next);
|
|
180
|
+
}
|
|
181
|
+
private async refresh(): Promise<void> {
|
|
182
|
+
if (this.refreshing) return;
|
|
183
|
+
this.refreshing = true;
|
|
184
|
+
try {
|
|
185
|
+
while (this.dirty && !this.abort.signal.aborted) {
|
|
186
|
+
this.dirty = false;
|
|
187
|
+
const next = await this.transport.request<RemoteTuiSnapshot>(
|
|
188
|
+
`/v1/sessions/${this.binding.sessionId}/tui-snapshot`,
|
|
189
|
+
undefined,
|
|
190
|
+
this.abort.signal,
|
|
191
|
+
);
|
|
192
|
+
if (this.abort.signal.aborted) return;
|
|
193
|
+
validate(
|
|
194
|
+
next,
|
|
195
|
+
this.snapshot.activity.session.workspaceId,
|
|
196
|
+
this.binding.sessionId,
|
|
197
|
+
);
|
|
198
|
+
if (
|
|
199
|
+
next.cursor.epoch === this.snapshot.cursor.epoch &&
|
|
200
|
+
next.cursor.sequence < this.snapshot.cursor.sequence
|
|
201
|
+
)
|
|
202
|
+
continue;
|
|
203
|
+
this.snapshot = next;
|
|
204
|
+
this.view.update(next);
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!this.abort.signal.aborted) {
|
|
208
|
+
this.view.setConnection(
|
|
209
|
+
this.transport.getSnapshot().connection,
|
|
210
|
+
errorMessage(error),
|
|
211
|
+
);
|
|
212
|
+
this.retryTimer ??= setTimeout(() => {
|
|
213
|
+
this.retryTimer = undefined;
|
|
214
|
+
if (this.abort.signal.aborted) return;
|
|
215
|
+
this.dirty = true;
|
|
216
|
+
void this.refresh();
|
|
217
|
+
}, 1000);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
this.refreshing = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async close(): Promise<void> {
|
|
224
|
+
this.abort.abort();
|
|
225
|
+
this.unsubscribe?.();
|
|
226
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
227
|
+
await this.transport.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** No local runtime or local workspace reads. Each terminal owns only its subscriptions. */
|
|
232
|
+
export class RemoteWorkspaceClient implements WorkspaceClient<TuiSessionView> {
|
|
233
|
+
private current?: RemoteTuiSession;
|
|
234
|
+
private history?: RemotePromptHistory;
|
|
235
|
+
private readonly listeners = new Set<() => void>();
|
|
236
|
+
private readonly abort = new AbortController();
|
|
237
|
+
private pending?: Promise<void>;
|
|
238
|
+
private readonly transport: RemoteClient;
|
|
239
|
+
constructor(
|
|
240
|
+
private readonly config: RemoteClientConfig,
|
|
241
|
+
private readonly workspaceId: string,
|
|
242
|
+
) {
|
|
243
|
+
this.transport = new RemoteClient(config, false);
|
|
244
|
+
}
|
|
245
|
+
async initialize(sessionId?: string, profileName?: string): Promise<void> {
|
|
246
|
+
const workspaces = await this.transport.workspaces();
|
|
247
|
+
if (!workspaces.workspaces.some((w) => w.id === this.workspaceId))
|
|
248
|
+
throw new Error("Workspace is not configured on the service.");
|
|
249
|
+
// Check the additive full-TUI endpoint before creating any session.
|
|
250
|
+
await this.listSessions();
|
|
251
|
+
if (sessionId) await this.resume(parseSessionId(sessionId));
|
|
252
|
+
else
|
|
253
|
+
await this.replace(async () =>
|
|
254
|
+
this.operation({
|
|
255
|
+
kind: "create",
|
|
256
|
+
workspaceId: this.workspaceId,
|
|
257
|
+
...(profileName ? { profileName } : {}),
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
readonly getBinding = (): SessionClient<TuiSessionView> => {
|
|
262
|
+
if (!this.current) throw new Error("Service session is not connected.");
|
|
263
|
+
return this.current.binding;
|
|
264
|
+
};
|
|
265
|
+
readonly subscribe = (listener: () => void) => {
|
|
266
|
+
this.listeners.add(listener);
|
|
267
|
+
return () => {
|
|
268
|
+
this.listeners.delete(listener);
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
async listSessions(): Promise<readonly ClientSessionSummary[]> {
|
|
272
|
+
const result = await this.transport.request<{ sessions: ClientSessionSummary[] }>(
|
|
273
|
+
`/v1/workspaces/${this.workspaceId}/tui-sessions`,
|
|
274
|
+
undefined,
|
|
275
|
+
this.abort.signal,
|
|
276
|
+
);
|
|
277
|
+
return result.sessions.map((session) =>
|
|
278
|
+
session.sessionId === this.current?.binding.sessionId
|
|
279
|
+
? { ...session, status: "current", canConnect: false }
|
|
280
|
+
: session,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
clear(beforeCommit?: () => void): Promise<void> {
|
|
284
|
+
return this.replace(
|
|
285
|
+
async () =>
|
|
286
|
+
this.operation({
|
|
287
|
+
kind: "create",
|
|
288
|
+
workspaceId: this.workspaceId,
|
|
289
|
+
...(this.current?.binding.profileName
|
|
290
|
+
? { profileName: this.current.binding.profileName }
|
|
291
|
+
: {}),
|
|
292
|
+
}),
|
|
293
|
+
beforeCommit,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
|
|
297
|
+
return this.replace(async () => {
|
|
298
|
+
if (sessionId === this.current?.binding.sessionId)
|
|
299
|
+
throw new Error(`Session ${sessionId} is already current.`);
|
|
300
|
+
const { session } = await this.transport.request<{
|
|
301
|
+
session: ClientSessionSummary;
|
|
302
|
+
}>(
|
|
303
|
+
`/v1/workspaces/${this.workspaceId}/tui-sessions/${sessionId}`,
|
|
304
|
+
undefined,
|
|
305
|
+
this.abort.signal,
|
|
306
|
+
);
|
|
307
|
+
if (session.canConnect) return sessionId;
|
|
308
|
+
if (session.status !== "resumable" && session.status !== "interrupted")
|
|
309
|
+
throw new Error("Exit the local session before connecting it to the service.");
|
|
310
|
+
return this.operation({
|
|
311
|
+
kind: "adopt",
|
|
312
|
+
workspaceId: this.workspaceId,
|
|
313
|
+
sessionId,
|
|
314
|
+
});
|
|
315
|
+
}, beforeCommit);
|
|
316
|
+
}
|
|
317
|
+
private async operation(input: Record<string, string>): Promise<string> {
|
|
318
|
+
const request = { ...input, requestId: randomUUID() };
|
|
319
|
+
let receipt = await this.transport.request<OperationReceipt>(
|
|
320
|
+
"/v1/operations",
|
|
321
|
+
request,
|
|
322
|
+
this.abort.signal,
|
|
323
|
+
);
|
|
324
|
+
const deadline = Date.now() + 30000;
|
|
325
|
+
while (receipt.status === "accepted" || receipt.status === "running") {
|
|
326
|
+
if (Date.now() >= deadline)
|
|
327
|
+
throw new Error(`Session operation is still pending: ${receipt.requestId}.`);
|
|
328
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
329
|
+
receipt = await this.transport.request<OperationReceipt>(
|
|
330
|
+
`/v1/operations/${receipt.requestId}`,
|
|
331
|
+
undefined,
|
|
332
|
+
this.abort.signal,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (receipt.status !== "completed")
|
|
336
|
+
throw new Error(receipt.error ?? `Session operation ${receipt.status}.`);
|
|
337
|
+
return receipt.sessionId;
|
|
338
|
+
}
|
|
339
|
+
private replace(
|
|
340
|
+
prepare: () => Promise<string>,
|
|
341
|
+
beforeCommit?: () => void,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
if (this.abort.signal.aborted)
|
|
344
|
+
return Promise.reject(new Error("Client is closed."));
|
|
345
|
+
if (this.pending)
|
|
346
|
+
return Promise.reject(new Error("Another session operation is already running."));
|
|
347
|
+
const pending = (async () => {
|
|
348
|
+
const id = await prepare();
|
|
349
|
+
const target = await RemoteTuiSession.open(this.config, this.workspaceId, id);
|
|
350
|
+
if (this.abort.signal.aborted) {
|
|
351
|
+
await target.close();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const previous = this.current;
|
|
355
|
+
try {
|
|
356
|
+
beforeCommit?.();
|
|
357
|
+
} catch (error) {
|
|
358
|
+
await target.close();
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
this.current = target;
|
|
362
|
+
await previous?.close();
|
|
363
|
+
for (const listener of this.listeners) listener();
|
|
364
|
+
})().finally(() => {
|
|
365
|
+
if (this.pending === pending) this.pending = undefined;
|
|
366
|
+
});
|
|
367
|
+
this.pending = pending;
|
|
368
|
+
return pending;
|
|
369
|
+
}
|
|
370
|
+
private async maintain(
|
|
371
|
+
input: Omit<SessionOperation, "sessionId"> & Record<string, unknown>,
|
|
372
|
+
): Promise<SessionOperationResult> {
|
|
373
|
+
if (!this.current) throw new Error("Service session is not connected.");
|
|
374
|
+
const current = this.current;
|
|
375
|
+
const result = await current.tasks.operate(input);
|
|
376
|
+
await current.refreshNow();
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
async compact() {
|
|
380
|
+
const result = await this.maintain({ kind: "compact" });
|
|
381
|
+
if (result.kind !== "compact") throw new Error("Invalid compaction response.");
|
|
382
|
+
return result.value;
|
|
383
|
+
}
|
|
384
|
+
async retire() {
|
|
385
|
+
const result = await this.maintain({ kind: "retire" });
|
|
386
|
+
if (result.kind !== "retire") throw new Error("Invalid retirement response.");
|
|
387
|
+
return result.value;
|
|
388
|
+
}
|
|
389
|
+
async undo() {
|
|
390
|
+
const result = await this.maintain({ kind: "undo" });
|
|
391
|
+
if (result.kind !== "undo") throw new Error("Invalid undo response.");
|
|
392
|
+
return result.value;
|
|
393
|
+
}
|
|
394
|
+
async fork(beforeCommit?: () => void): Promise<SessionId> {
|
|
395
|
+
let id: string | undefined;
|
|
396
|
+
await this.replace(async () => {
|
|
397
|
+
const result = await this.maintain({ kind: "fork" });
|
|
398
|
+
if (result.kind !== "fork") throw new Error("Invalid clone response.");
|
|
399
|
+
id = result.value;
|
|
400
|
+
return id;
|
|
401
|
+
}, beforeCommit);
|
|
402
|
+
return parseSessionId(id!);
|
|
403
|
+
}
|
|
404
|
+
switchModel(profile: ClientModelProfile, beforeCommit?: () => void): Promise<void> {
|
|
405
|
+
return this.replace(async () => {
|
|
406
|
+
const result = await this.maintain({
|
|
407
|
+
kind: "switch_model",
|
|
408
|
+
profileName: profile.name,
|
|
409
|
+
});
|
|
410
|
+
if (result.kind !== "switch_model")
|
|
411
|
+
throw new Error("Invalid model switch response.");
|
|
412
|
+
return result.value;
|
|
413
|
+
}, beforeCommit);
|
|
414
|
+
}
|
|
415
|
+
persistDefaultProfile = async (profileName: string): Promise<void> => {
|
|
416
|
+
await this.maintain({ kind: "default_profile", profileName });
|
|
417
|
+
};
|
|
418
|
+
readonly listFiles = (
|
|
419
|
+
_root: string,
|
|
420
|
+
signal: AbortSignal,
|
|
421
|
+
): Promise<readonly string[]> =>
|
|
422
|
+
this.transport.request(
|
|
423
|
+
`/v1/sessions/${this.getBinding().sessionId}/files`,
|
|
424
|
+
undefined,
|
|
425
|
+
AbortSignal.any([signal, this.abort.signal]),
|
|
426
|
+
);
|
|
427
|
+
readonly readFile = (_root: string, filePath: string): Promise<ViewFile> =>
|
|
428
|
+
this.transport.request(
|
|
429
|
+
`/v1/sessions/${this.getBinding().sessionId}/view-file?path=${encodeURIComponent(filePath)}`,
|
|
430
|
+
undefined,
|
|
431
|
+
this.abort.signal,
|
|
432
|
+
);
|
|
433
|
+
readonly readGitBranch = async (): Promise<string | undefined> =>
|
|
434
|
+
(
|
|
435
|
+
await this.transport.request<{ branch?: string }>(
|
|
436
|
+
`/v1/sessions/${this.getBinding().sessionId}/git-branch`,
|
|
437
|
+
undefined,
|
|
438
|
+
this.abort.signal,
|
|
439
|
+
)
|
|
440
|
+
).branch;
|
|
441
|
+
readonly projectCommands = (): Promise<readonly ProjectSlashCommand[]> =>
|
|
442
|
+
this.transport.request(
|
|
443
|
+
`/v1/sessions/${this.getBinding().sessionId}/project-commands`,
|
|
444
|
+
undefined,
|
|
445
|
+
this.abort.signal,
|
|
446
|
+
);
|
|
447
|
+
async loadHistory(): Promise<RemotePromptHistory> {
|
|
448
|
+
if (this.history) return this.history;
|
|
449
|
+
const records = await this.transport.request<LoadedPromptHistoryRecord[]>(
|
|
450
|
+
`/v1/sessions/${this.getBinding().sessionId}/prompt-history`,
|
|
451
|
+
undefined,
|
|
452
|
+
this.abort.signal,
|
|
453
|
+
);
|
|
454
|
+
this.history = new RemotePromptHistory(records, async (prompt) => {
|
|
455
|
+
await this.transport.request(
|
|
456
|
+
`/v1/sessions/${this.getBinding().sessionId}/prompt-history`,
|
|
457
|
+
{ prompt },
|
|
458
|
+
this.abort.signal,
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
return this.history;
|
|
462
|
+
}
|
|
463
|
+
readonly listStoredMemories = (): Promise<
|
|
464
|
+
readonly import("../memory/contracts").StoredMemorySummary[]
|
|
465
|
+
> =>
|
|
466
|
+
this.transport.request(
|
|
467
|
+
`/v1/sessions/${this.getBinding().sessionId}/memories`,
|
|
468
|
+
undefined,
|
|
469
|
+
this.abort.signal,
|
|
470
|
+
);
|
|
471
|
+
async delete(sessionId: SessionId): Promise<void> {
|
|
472
|
+
if (sessionId === this.getBinding().sessionId)
|
|
473
|
+
throw new Error("Cannot delete the current session.");
|
|
474
|
+
if (this.pending) throw new Error("Another session operation is already running.");
|
|
475
|
+
const pending = this.current!.tasks.deleteSession(sessionId).finally(() => {
|
|
476
|
+
if (this.pending === pending) this.pending = undefined;
|
|
477
|
+
});
|
|
478
|
+
this.pending = pending;
|
|
479
|
+
await pending;
|
|
480
|
+
}
|
|
481
|
+
async close(): Promise<void> {
|
|
482
|
+
this.abort.abort();
|
|
483
|
+
await this.current?.close();
|
|
484
|
+
await this.pending?.catch(() => undefined);
|
|
485
|
+
await this.history?.flush();
|
|
486
|
+
await this.transport.close();
|
|
487
|
+
this.listeners.clear();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function createRemoteTuiClient(
|
|
492
|
+
config: RemoteClientConfig,
|
|
493
|
+
workspaceId: string,
|
|
494
|
+
sessionId?: string,
|
|
495
|
+
profileName?: string,
|
|
496
|
+
): Promise<ClientConnection<TuiSessionView> & { client: RemoteWorkspaceClient }> {
|
|
497
|
+
const client = new RemoteWorkspaceClient(config, workspaceId);
|
|
498
|
+
try {
|
|
499
|
+
await client.initialize(sessionId, profileName);
|
|
500
|
+
return { client, close: () => client.close() };
|
|
501
|
+
} catch (error) {
|
|
502
|
+
await client.close();
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function validate(
|
|
507
|
+
snapshot: RemoteTuiSnapshot,
|
|
508
|
+
workspaceId: string,
|
|
509
|
+
sessionId: string,
|
|
510
|
+
): void {
|
|
511
|
+
if (
|
|
512
|
+
!snapshot.timeline ||
|
|
513
|
+
!snapshot.promptScheduler ||
|
|
514
|
+
!snapshot.askUser ||
|
|
515
|
+
!snapshot.providerRetry
|
|
516
|
+
)
|
|
517
|
+
throw new Error("Update the service to enable full TUI interactions.");
|
|
518
|
+
if (
|
|
519
|
+
snapshot.version !== 1 ||
|
|
520
|
+
snapshot.activity.session.workspaceId !== workspaceId ||
|
|
521
|
+
snapshot.history.sessionId !== sessionId ||
|
|
522
|
+
snapshot.activity.session.id !== sessionId
|
|
523
|
+
)
|
|
524
|
+
throw new Error("Service snapshot identity or version does not match.");
|
|
525
|
+
}
|
|
526
|
+
function errorMessage(error: unknown): string {
|
|
527
|
+
return error instanceof Error ? error.message : String(error);
|
|
528
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Client-side contract. Reads use local snapshots; mutations are asynchronous.
|
|
2
|
+
* View is supplied by the presentation adapter, not by the execution owner.
|
|
3
|
+
* This is an in-process client API, not the public network serialization format.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
ContextCompactionResult,
|
|
7
|
+
ContextRetirementResult,
|
|
8
|
+
} from "../context/context-manager";
|
|
9
|
+
import type {
|
|
10
|
+
AcceptedTurn,
|
|
11
|
+
AskUserSnapshot,
|
|
12
|
+
AskUserResolution,
|
|
13
|
+
BashGuardSnapshot,
|
|
14
|
+
RuntimeSkillsSnapshot,
|
|
15
|
+
} from "../agent/runtime-session";
|
|
16
|
+
import type { RunAgentResult, UserMessage } from "../agent/types";
|
|
17
|
+
import type { TurnUndoResult } from "../tools/turn-undo-manager";
|
|
18
|
+
import type { ImageAssetRef } from "../image/image-types";
|
|
19
|
+
import type { ImportedImageAsset } from "../image/image-asset-store";
|
|
20
|
+
import type { SessionId } from "../ids/runtime-id";
|
|
21
|
+
import type { ClientModelProfile, ClientModelProfiles } from "./model-catalog";
|
|
22
|
+
import type { McpInventorySnapshot } from "../mcp/mcp-manager";
|
|
23
|
+
import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
|
|
24
|
+
import type { SessionSummary } from "../session/session-catalog";
|
|
25
|
+
import type {
|
|
26
|
+
PromptSchedulerSnapshot,
|
|
27
|
+
QueueFollowUpResult,
|
|
28
|
+
} from "../agent/runtime-session-contracts";
|
|
29
|
+
import type {
|
|
30
|
+
ProviderRetrySnapshot,
|
|
31
|
+
ProviderRetryDecision,
|
|
32
|
+
} from "../agent/runtime-provider-retry";
|
|
33
|
+
|
|
34
|
+
export type ClientSessionSummary = SessionSummary & { canConnect?: boolean };
|
|
35
|
+
|
|
36
|
+
export type SessionClient<View> = {
|
|
37
|
+
sessionId: SessionId;
|
|
38
|
+
modelName: string;
|
|
39
|
+
workspaceRoot: string;
|
|
40
|
+
profileName?: string;
|
|
41
|
+
modelProfiles?: () => ClientModelProfiles | undefined;
|
|
42
|
+
projectionStore: View;
|
|
43
|
+
readLastResponse(): Promise<string | undefined>;
|
|
44
|
+
skills(): RuntimeSkillsSnapshot;
|
|
45
|
+
mcp(): McpInventorySnapshot;
|
|
46
|
+
reasoningEffort?: () => ReasoningEffortSnapshot | undefined;
|
|
47
|
+
setReasoningEffort?: (effort: string) => Promise<ReasoningEffortSnapshot>;
|
|
48
|
+
resetReasoningEffort?: () => Promise<ReasoningEffortSnapshot>;
|
|
49
|
+
supportsImageInput?: () => boolean;
|
|
50
|
+
importImage?: (
|
|
51
|
+
sourcePath: string,
|
|
52
|
+
signal: AbortSignal,
|
|
53
|
+
prospectiveMessageImageCount: number,
|
|
54
|
+
) => Promise<ImportedImageAsset>;
|
|
55
|
+
verifyImageAssets?: (
|
|
56
|
+
assets: readonly ImageAssetRef[],
|
|
57
|
+
signal: AbortSignal,
|
|
58
|
+
) => Promise<void>;
|
|
59
|
+
admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
|
|
60
|
+
executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
|
|
61
|
+
stopTurn?: () => Promise<void>;
|
|
62
|
+
promptScheduler?: () => PromptSchedulerSnapshot;
|
|
63
|
+
subscribePromptScheduler?: (listener: () => void) => () => void;
|
|
64
|
+
queueFollowUp?: (message: UserMessage) => Promise<QueueFollowUpResult>;
|
|
65
|
+
bashGuard(): BashGuardSnapshot & { interactionId?: string };
|
|
66
|
+
subscribeBashGuard(listener: () => void): () => void;
|
|
67
|
+
setYoloMode(enabled: boolean): Promise<void>;
|
|
68
|
+
resolveBashConfirmation(
|
|
69
|
+
decision: "allow" | "deny",
|
|
70
|
+
interactionId?: string,
|
|
71
|
+
): Promise<void>;
|
|
72
|
+
providerRetry?: () => ProviderRetrySnapshot;
|
|
73
|
+
subscribeProviderRetry?: (listener: () => void) => () => void;
|
|
74
|
+
resolveProviderRetry?: (
|
|
75
|
+
requestId: string,
|
|
76
|
+
decision: ProviderRetryDecision,
|
|
77
|
+
) => Promise<void>;
|
|
78
|
+
askUser(): AskUserSnapshot & { interactionId?: string };
|
|
79
|
+
subscribeAskUser(listener: () => void): () => void;
|
|
80
|
+
resolveAskUser(response: AskUserResolution, interactionId?: string): Promise<void>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type WorkspaceClient<View> = {
|
|
84
|
+
getBinding: () => SessionClient<View>;
|
|
85
|
+
subscribe: (listener: () => void) => () => void;
|
|
86
|
+
listSessions: () => Promise<readonly ClientSessionSummary[]>;
|
|
87
|
+
persistDefaultProfile?: (profileName: string) => Promise<void>;
|
|
88
|
+
compact: () => Promise<ContextCompactionResult>;
|
|
89
|
+
retire: () => Promise<ContextRetirementResult>;
|
|
90
|
+
undo: () => Promise<TurnUndoResult>;
|
|
91
|
+
fork: (beforeCommit?: () => void) => Promise<SessionId>;
|
|
92
|
+
clear: (beforeCommit?: () => void) => Promise<void>;
|
|
93
|
+
resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
|
|
94
|
+
delete: (sessionId: SessionId) => Promise<void>;
|
|
95
|
+
switchModel: (
|
|
96
|
+
profile: ClientModelProfile,
|
|
97
|
+
beforeCommit?: () => void,
|
|
98
|
+
) => Promise<void>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Closing a connection is distinct from an explicit task cancellation. */
|
|
102
|
+
export type ClientCloseReason =
|
|
103
|
+
| { type: "client_exit" }
|
|
104
|
+
| { type: "client_failed"; error: string };
|
|
105
|
+
|
|
106
|
+
export type ClientConnection<View> = {
|
|
107
|
+
client: WorkspaceClient<View>;
|
|
108
|
+
close(reason: ClientCloseReason): Promise<void>;
|
|
109
|
+
};
|
|
@@ -90,6 +90,23 @@ export class ImageAssetStore {
|
|
|
90
90
|
return this.importFileInternal(sourcePath, false, options);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
async importBytes(
|
|
94
|
+
bytes: Buffer,
|
|
95
|
+
originalName: string,
|
|
96
|
+
options: {
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
accept?: (asset: ImageAssetRef) => void;
|
|
99
|
+
} = {},
|
|
100
|
+
): Promise<ImportedImageAsset> {
|
|
101
|
+
throwIfAborted(options.signal);
|
|
102
|
+
const name = normalizeOriginalImageName(originalName);
|
|
103
|
+
const asset = await probeImageBytes(bytes, { fullDecode: true, sourceName: name });
|
|
104
|
+
options.accept?.(asset);
|
|
105
|
+
throwIfAborted(options.signal);
|
|
106
|
+
await this.publish(asset, bytes, options.signal);
|
|
107
|
+
return Object.freeze({ asset, originalName: name });
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
private async importFileInternal(
|
|
94
111
|
sourcePath: string,
|
|
95
112
|
workspaceOnly: boolean,
|