tinker-agent 2.13.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 +53 -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/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/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
package/src/cli/tui-runner.tsx
CHANGED
|
@@ -1,54 +1,20 @@
|
|
|
1
1
|
import { render } from "ink";
|
|
2
2
|
import { realpath } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
type RuntimeSession,
|
|
6
|
-
type SessionDisposeReason,
|
|
7
|
-
} from "../agent/runtime-session";
|
|
8
|
-
import type { EventSink } from "../events/event-sink";
|
|
9
|
-
import type { AgentEvent } from "../events/types";
|
|
10
|
-
import type {
|
|
11
|
-
AssistantTextDeltaSink,
|
|
12
|
-
AssistantTextDeltaUpdate,
|
|
13
|
-
} from "../agent/assistant-text-delta";
|
|
14
|
-
import { createUuidV7 } from "../ids/uuid-v7";
|
|
15
|
-
import type { SessionId } from "../ids/runtime-id";
|
|
16
|
-
import {
|
|
17
|
-
buildSystemPrompt,
|
|
18
|
-
loadProjectInstructions,
|
|
19
|
-
projectInstructionManifest,
|
|
20
|
-
} from "../instructions/project-instructions";
|
|
21
|
-
import { ResumeProjectionReader } from "../session/resume-projection";
|
|
22
|
-
import { SessionCatalog } from "../session/session-catalog";
|
|
3
|
+
import type { ClientCloseReason } from "../client/session-client";
|
|
4
|
+
import { createLocalTuiClient } from "./local-tui-client";
|
|
23
5
|
import { App } from "../tui/app";
|
|
24
6
|
import { readCurrentGitBranch } from "../tui/git-branch";
|
|
25
7
|
import { PromptHistory } from "../tui/prompt-history";
|
|
26
|
-
import { TuiProjectionStore } from "../tui/tui-projection-store";
|
|
27
|
-
import {
|
|
28
|
-
DefaultTuiSessionController,
|
|
29
|
-
managedTuiBinding,
|
|
30
|
-
type ManagedTuiSessionBinding,
|
|
31
|
-
type TuiSessionBinding,
|
|
32
|
-
} from "../tui/tui-session-controller";
|
|
33
8
|
import {
|
|
34
9
|
promptHistoryPath,
|
|
35
|
-
deriveRunnerConfig,
|
|
36
10
|
type ResolvedPublicConfig,
|
|
37
11
|
type RunnerConfig,
|
|
38
12
|
} from "./config";
|
|
39
|
-
import {
|
|
40
|
-
createRunnerModelClient,
|
|
41
|
-
createWebFetchRefiner,
|
|
42
|
-
RUNTIME_INSTRUCTIONS,
|
|
43
|
-
} from "./runner-dependencies";
|
|
44
|
-
import { resolveSessionProfileName, type ModelProfile } from "./model-profiles";
|
|
45
|
-
import { loadSkillCatalog } from "../skills/skill-loader";
|
|
46
13
|
import { loadProjectSlashCommands } from "../tui/project-slash-commands";
|
|
47
14
|
import { createWorkspaceFileLister } from "../tui/workspace-file-search";
|
|
48
15
|
import { clipboardWriterForEnvironment } from "../tui/clipboard";
|
|
49
16
|
import { listMemoryFiles } from "../memory/memory-files";
|
|
50
17
|
import { prepareShikiHighlighter } from "../tui/shiki-highlighter";
|
|
51
|
-
import { createReasoningEffortController } from "../model/reasoning-effort";
|
|
52
18
|
|
|
53
19
|
export type RunTuiOptions = {
|
|
54
20
|
readonly publicConfig: ResolvedPublicConfig;
|
|
@@ -63,212 +29,23 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
|
|
|
63
29
|
options.publicConfig.mode === "profile" ? options.publicConfig.profiles : undefined;
|
|
64
30
|
const config = options.initialRunnerConfig;
|
|
65
31
|
const workspaceRoot = await realpath(config.workspaceRoot);
|
|
66
|
-
let
|
|
32
|
+
let connection: Awaited<ReturnType<typeof createLocalTuiClient>> | undefined;
|
|
67
33
|
let instance: ReturnType<typeof render> | undefined;
|
|
68
|
-
let
|
|
34
|
+
let closeReason: ClientCloseReason = { type: "client_exit" };
|
|
69
35
|
let primaryError: unknown;
|
|
70
36
|
let quitRequested = false;
|
|
71
37
|
|
|
72
38
|
try {
|
|
73
39
|
const projectSlashCommands = await loadProjectSlashCommands(workspaceRoot);
|
|
74
|
-
|
|
75
|
-
sessionConfig: RunnerConfig,
|
|
76
|
-
mode: "new" | "resume",
|
|
77
|
-
sessionId: SessionId,
|
|
78
|
-
sink: EventSink & AssistantTextDeltaSink,
|
|
79
|
-
): Promise<RuntimeSession> => {
|
|
80
|
-
const reasoningEffort = createReasoningEffortController(sessionConfig.reasoning);
|
|
81
|
-
const modelClient = createRunnerModelClient(
|
|
82
|
-
sessionConfig,
|
|
83
|
-
undefined,
|
|
84
|
-
options.env,
|
|
85
|
-
reasoningEffort,
|
|
86
|
-
);
|
|
87
|
-
const projectInstructions = await loadProjectInstructions(workspaceRoot);
|
|
88
|
-
const skillCatalog = await loadSkillCatalog({ workspaceRoot });
|
|
89
|
-
const common = {
|
|
90
|
-
workspaceRoot,
|
|
91
|
-
modelName: sessionConfig.modelName,
|
|
92
|
-
profileName: sessionConfig.profileName,
|
|
93
|
-
maxIterations: sessionConfig.maxIterations,
|
|
94
|
-
includeReasoningContent: sessionConfig.includeReasoningContent,
|
|
95
|
-
contextProfile: sessionConfig.contextProfile,
|
|
96
|
-
contextBudget: sessionConfig.contextBudget,
|
|
97
|
-
modelClient,
|
|
98
|
-
systemPrompt: buildSystemPrompt({
|
|
99
|
-
workspaceRoot,
|
|
100
|
-
runtimeInstructions: RUNTIME_INSTRUCTIONS(workspaceRoot),
|
|
101
|
-
projectInstructions,
|
|
102
|
-
}),
|
|
103
|
-
projectInstruction: projectInstructionManifest(projectInstructions),
|
|
104
|
-
skillCatalog,
|
|
105
|
-
presentationSinks: [sink],
|
|
106
|
-
assistantTextDeltaSink: sink,
|
|
107
|
-
webFetchRefiner: createWebFetchRefiner(
|
|
108
|
-
sessionConfig,
|
|
109
|
-
options.env,
|
|
110
|
-
reasoningEffort,
|
|
111
|
-
),
|
|
112
|
-
toolingConfig: options.publicConfig.tooling,
|
|
113
|
-
enableTurnUndo: true,
|
|
114
|
-
enableAskUser: true,
|
|
115
|
-
enableProviderRetryPrompt: true,
|
|
116
|
-
bashGuard: {
|
|
117
|
-
mode: sessionConfig.bashGuardMode,
|
|
118
|
-
source: sessionConfig.bashGuardSource,
|
|
119
|
-
surface: "tui" as const,
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
if (mode === "resume") {
|
|
123
|
-
return createRuntimeSession({
|
|
124
|
-
...common,
|
|
125
|
-
selection: { mode, sessionId },
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return createRuntimeSession({
|
|
130
|
-
...common,
|
|
131
|
-
selection: { mode, sessionId },
|
|
132
|
-
});
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const projectionStore = new TuiProjectionStore({
|
|
136
|
-
sessionId: config.sessionId,
|
|
137
|
-
modelName: config.modelName,
|
|
138
|
-
workspaceRoot,
|
|
139
|
-
});
|
|
140
|
-
const initialSession = await createSessionForConfig(
|
|
141
|
-
config,
|
|
142
|
-
"new",
|
|
143
|
-
config.sessionId,
|
|
144
|
-
projectionStore,
|
|
145
|
-
);
|
|
40
|
+
connection = await createLocalTuiClient({ ...options, workspaceRoot });
|
|
146
41
|
const promptHistory = await PromptHistory.load(
|
|
147
42
|
await promptHistoryPath(workspaceRoot),
|
|
148
43
|
);
|
|
149
|
-
const catalog = new SessionCatalog({ workspaceRoot });
|
|
150
|
-
|
|
151
|
-
const openStoredSession = async (
|
|
152
|
-
sessionId: SessionId,
|
|
153
|
-
): Promise<ManagedTuiSessionBinding> => {
|
|
154
|
-
const deferred = new DeferredProjectionSink();
|
|
155
|
-
const summary = await catalog.get(sessionId);
|
|
156
|
-
const resumeConfig = deriveRunnerConfig(options.publicConfig, {
|
|
157
|
-
sessionId,
|
|
158
|
-
...(profiles === undefined
|
|
159
|
-
? {}
|
|
160
|
-
: { profileName: resolveSessionProfileName(profiles, summary) }),
|
|
161
|
-
});
|
|
162
|
-
const runtimeSession = await createSessionForConfig(
|
|
163
|
-
resumeConfig,
|
|
164
|
-
"resume",
|
|
165
|
-
sessionId,
|
|
166
|
-
deferred,
|
|
167
|
-
);
|
|
168
|
-
const targetProjection = new TuiProjectionStore({
|
|
169
|
-
sessionId,
|
|
170
|
-
modelName: resumeConfig.modelName,
|
|
171
|
-
workspaceRoot,
|
|
172
|
-
});
|
|
173
|
-
try {
|
|
174
|
-
targetProjection.hydrate(
|
|
175
|
-
await ResumeProjectionReader.read({
|
|
176
|
-
workspaceRoot,
|
|
177
|
-
sessionId,
|
|
178
|
-
modelName: resumeConfig.modelName,
|
|
179
|
-
}),
|
|
180
|
-
);
|
|
181
|
-
await deferred.attach(targetProjection);
|
|
182
|
-
} catch (error) {
|
|
183
|
-
await runtimeSession
|
|
184
|
-
.dispose({ type: "runner_failed", error: errorMessage(error) })
|
|
185
|
-
.catch(() => undefined);
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
return managedTuiBinding({
|
|
189
|
-
runtimeSession,
|
|
190
|
-
modelName: resumeConfig.modelName,
|
|
191
|
-
workspaceRoot,
|
|
192
|
-
profileName: resumeConfig.profileName,
|
|
193
|
-
projectionStore: targetProjection,
|
|
194
|
-
});
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const createNewSessionBinding = async (
|
|
198
|
-
sessionConfig: RunnerConfig,
|
|
199
|
-
): Promise<ManagedTuiSessionBinding> => {
|
|
200
|
-
const freshProjectionStore = new TuiProjectionStore({
|
|
201
|
-
sessionId: sessionConfig.sessionId,
|
|
202
|
-
modelName: sessionConfig.modelName,
|
|
203
|
-
workspaceRoot,
|
|
204
|
-
});
|
|
205
|
-
const runtimeSession = await createSessionForConfig(
|
|
206
|
-
sessionConfig,
|
|
207
|
-
"new",
|
|
208
|
-
sessionConfig.sessionId,
|
|
209
|
-
freshProjectionStore,
|
|
210
|
-
);
|
|
211
|
-
return managedTuiBinding({
|
|
212
|
-
runtimeSession,
|
|
213
|
-
modelName: sessionConfig.modelName,
|
|
214
|
-
workspaceRoot,
|
|
215
|
-
profileName: sessionConfig.profileName,
|
|
216
|
-
projectionStore: freshProjectionStore,
|
|
217
|
-
});
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const createSessionWithProfile = async (
|
|
221
|
-
profile: ModelProfile,
|
|
222
|
-
): Promise<ManagedTuiSessionBinding> => {
|
|
223
|
-
if (profiles === undefined) {
|
|
224
|
-
throw new Error("Model profiles are not configured.");
|
|
225
|
-
}
|
|
226
|
-
return createNewSessionBinding(
|
|
227
|
-
deriveRunnerConfig(options.publicConfig, {
|
|
228
|
-
sessionId: createUuidV7() as SessionId,
|
|
229
|
-
profileName: profile.name,
|
|
230
|
-
}),
|
|
231
|
-
);
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const createFreshSession = async (
|
|
235
|
-
current: TuiSessionBinding,
|
|
236
|
-
): Promise<ManagedTuiSessionBinding> => {
|
|
237
|
-
const sessionId = createUuidV7() as SessionId;
|
|
238
|
-
if (profiles === undefined) {
|
|
239
|
-
return createNewSessionBinding(
|
|
240
|
-
deriveRunnerConfig(options.publicConfig, { sessionId }),
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
if (current.profileName === undefined) {
|
|
244
|
-
throw new Error("Current session does not have a model profile.");
|
|
245
|
-
}
|
|
246
|
-
return createNewSessionBinding(
|
|
247
|
-
deriveRunnerConfig(options.publicConfig, {
|
|
248
|
-
sessionId,
|
|
249
|
-
profileName: current.profileName,
|
|
250
|
-
}),
|
|
251
|
-
);
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
controller = new DefaultTuiSessionController(
|
|
255
|
-
managedTuiBinding({
|
|
256
|
-
runtimeSession: initialSession,
|
|
257
|
-
modelName: config.modelName,
|
|
258
|
-
workspaceRoot,
|
|
259
|
-
profileName: config.profileName,
|
|
260
|
-
projectionStore,
|
|
261
|
-
}),
|
|
262
|
-
catalog,
|
|
263
|
-
openStoredSession,
|
|
264
|
-
createSessionWithProfile,
|
|
265
|
-
createFreshSession,
|
|
266
|
-
);
|
|
267
44
|
|
|
268
45
|
await shikiPreparation;
|
|
269
46
|
instance = render(
|
|
270
47
|
<App
|
|
271
|
-
sessionController={
|
|
48
|
+
sessionController={connection.client}
|
|
272
49
|
version={options.version}
|
|
273
50
|
readGitBranch={readCurrentGitBranch}
|
|
274
51
|
history={promptHistory}
|
|
@@ -295,13 +72,13 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
|
|
|
295
72
|
await instance.waitUntilExit();
|
|
296
73
|
} catch (error) {
|
|
297
74
|
primaryError = error;
|
|
298
|
-
|
|
75
|
+
closeReason = { type: "client_failed", error: errorMessage(error) };
|
|
299
76
|
} finally {
|
|
300
77
|
instance?.unmount();
|
|
301
78
|
restoreStdin();
|
|
302
|
-
if (
|
|
79
|
+
if (connection !== undefined) {
|
|
303
80
|
try {
|
|
304
|
-
await
|
|
81
|
+
await connection.close(closeReason);
|
|
305
82
|
} catch (error) {
|
|
306
83
|
primaryError =
|
|
307
84
|
primaryError === undefined
|
|
@@ -322,35 +99,6 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
|
|
|
322
99
|
}
|
|
323
100
|
}
|
|
324
101
|
|
|
325
|
-
class DeferredProjectionSink implements EventSink, AssistantTextDeltaSink {
|
|
326
|
-
readonly name = "deferred-tui-projection";
|
|
327
|
-
private target?: TuiProjectionStore;
|
|
328
|
-
private readonly buffered: AgentEvent[] = [];
|
|
329
|
-
|
|
330
|
-
async append(event: AgentEvent): Promise<void> {
|
|
331
|
-
if (this.target === undefined) {
|
|
332
|
-
this.buffered.push(event);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
await this.target.append(event);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
|
|
339
|
-
this.target?.updateAssistantTextDelta(update);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async attach(target: TuiProjectionStore): Promise<void> {
|
|
343
|
-
if (this.target !== undefined) {
|
|
344
|
-
throw new Error("Deferred projection sink is already attached.");
|
|
345
|
-
}
|
|
346
|
-
this.target = target;
|
|
347
|
-
for (const event of this.buffered) {
|
|
348
|
-
await target.append(event);
|
|
349
|
-
}
|
|
350
|
-
this.buffered.length = 0;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
102
|
function restoreStdin(): void {
|
|
355
103
|
if (process.stdin.isTTY) {
|
|
356
104
|
process.stdin.setRawMode(false);
|
package/src/cli/update-runner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertServiceUpgradeSafe } from "./service-upgrade";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { lstat, realpath } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
@@ -26,6 +27,7 @@ type NpmCommandInput = {
|
|
|
26
27
|
|
|
27
28
|
export type UpdateRunnerDependencies = {
|
|
28
29
|
readonly packageRoot: string;
|
|
30
|
+
readonly assertServiceUpgradeSafe: (packageRoot: string) => Promise<void>;
|
|
29
31
|
readonly npmCwd: string;
|
|
30
32
|
readonly runNpm: (input: NpmCommandInput) => Promise<NpmCommandResult>;
|
|
31
33
|
readonly canonicalizePath: (filePath: string) => Promise<string>;
|
|
@@ -41,6 +43,7 @@ type UpdateInput = {
|
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
const DEFAULT_DEPENDENCIES: UpdateRunnerDependencies = {
|
|
46
|
+
assertServiceUpgradeSafe,
|
|
44
47
|
packageRoot: path.resolve(fileURLToPath(new URL("../../", import.meta.url))),
|
|
45
48
|
npmCwd: tmpdir(),
|
|
46
49
|
runNpm,
|
|
@@ -116,6 +119,7 @@ export async function runUpdate(
|
|
|
116
119
|
return 0;
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
await dependencies.assertServiceUpgradeSafe(dependencies.packageRoot);
|
|
119
123
|
await writeCliOutput(input.stdout, `Updating to ${latestVersion}...\n`);
|
|
120
124
|
await runNpmChecked(
|
|
121
125
|
dependencies,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RuntimeSession,
|
|
3
|
+
SessionDisposeReason,
|
|
4
|
+
ExecuteTurnInput,
|
|
5
|
+
} from "../agent/runtime-session";
|
|
6
|
+
import type { SessionId } from "../ids/runtime-id";
|
|
7
|
+
import { createUuidV7 } from "../ids/uuid-v7";
|
|
8
|
+
import type { ClientModelProfile } from "./model-catalog";
|
|
9
|
+
import type { SessionCatalog, SessionSummary } from "../session/session-catalog";
|
|
10
|
+
import { readLastAssistantResponse } from "../session/session-last-response-reader";
|
|
11
|
+
import type {
|
|
12
|
+
ContextCompactionResult,
|
|
13
|
+
ContextRetirementResult,
|
|
14
|
+
} from "../context/context-manager";
|
|
15
|
+
import type { TurnUndoResult } from "../tools/turn-undo-manager";
|
|
16
|
+
import type { SessionClient, WorkspaceClient } from "./session-client";
|
|
17
|
+
|
|
18
|
+
export type OwnedSessionBinding<View> = {
|
|
19
|
+
client: SessionClient<View>;
|
|
20
|
+
runtimeSession: RuntimeSession;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class LocalWorkspaceClient<View> implements WorkspaceClient<View> {
|
|
24
|
+
private readonly listeners = new Set<() => void>();
|
|
25
|
+
private binding: OwnedSessionBinding<View>;
|
|
26
|
+
private operation?: Promise<unknown>;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
initial: OwnedSessionBinding<View>,
|
|
30
|
+
private readonly catalog: SessionCatalog,
|
|
31
|
+
private readonly openSession: (
|
|
32
|
+
sessionId: SessionId,
|
|
33
|
+
) => Promise<OwnedSessionBinding<View>>,
|
|
34
|
+
private readonly createSessionWithProfile: (
|
|
35
|
+
profile: ClientModelProfile,
|
|
36
|
+
) => Promise<OwnedSessionBinding<View>>,
|
|
37
|
+
private readonly createFreshSession: (
|
|
38
|
+
current: SessionClient<View>,
|
|
39
|
+
) => Promise<OwnedSessionBinding<View>>,
|
|
40
|
+
) {
|
|
41
|
+
this.binding = initial;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
readonly getBinding = (): SessionClient<View> => this.binding.client;
|
|
45
|
+
|
|
46
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
47
|
+
this.listeners.add(listener);
|
|
48
|
+
return () => {
|
|
49
|
+
this.listeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
listSessions(): Promise<readonly SessionSummary[]> {
|
|
54
|
+
return this.catalog.listAll(this.binding.client.sessionId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
compact(): Promise<ContextCompactionResult> {
|
|
58
|
+
return this.serialize(() => this.binding.runtimeSession.compactContext());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
retire(): Promise<ContextRetirementResult> {
|
|
62
|
+
return this.serialize(() => this.binding.runtimeSession.retireContext());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
undo(): Promise<TurnUndoResult> {
|
|
66
|
+
return this.serialize(() =>
|
|
67
|
+
this.binding.runtimeSession.undoLatestFileMutationTurn(),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fork(beforeCommit?: () => void): Promise<SessionId> {
|
|
72
|
+
return this.serialize(async () => {
|
|
73
|
+
const targetSessionId = createUuidV7() as SessionId;
|
|
74
|
+
await this.replaceSession(
|
|
75
|
+
"Cannot clone the session while a turn, context operation, or background task is active.",
|
|
76
|
+
async (current) => {
|
|
77
|
+
await current.runtimeSession.cloneSession(targetSessionId);
|
|
78
|
+
return this.openSession(targetSessionId);
|
|
79
|
+
},
|
|
80
|
+
beforeCommit,
|
|
81
|
+
);
|
|
82
|
+
return targetSessionId;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clear(beforeCommit?: () => void): Promise<void> {
|
|
87
|
+
return this.serialize(() =>
|
|
88
|
+
this.replaceSession(
|
|
89
|
+
"Cannot clear the session while a turn, context operation, or background task is active.",
|
|
90
|
+
(current) => this.createFreshSession(current.client),
|
|
91
|
+
beforeCommit,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
|
|
97
|
+
return this.serialize(async () => {
|
|
98
|
+
if (sessionId === this.binding.client.sessionId) {
|
|
99
|
+
throw new Error(`Session ${sessionId} is already current.`);
|
|
100
|
+
}
|
|
101
|
+
await this.replaceSession(
|
|
102
|
+
"Cannot switch sessions while a turn or background task is active.",
|
|
103
|
+
() => this.openSession(sessionId),
|
|
104
|
+
beforeCommit,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
delete(sessionId: SessionId): Promise<void> {
|
|
110
|
+
return this.serialize(() =>
|
|
111
|
+
this.catalog.delete(sessionId, this.binding.client.sessionId),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
switchModel(profile: ClientModelProfile, beforeCommit?: () => void): Promise<void> {
|
|
116
|
+
return this.serialize(() =>
|
|
117
|
+
this.replaceSession(
|
|
118
|
+
"Cannot switch models while a turn or background task is active.",
|
|
119
|
+
() => this.createSessionWithProfile(profile),
|
|
120
|
+
beforeCommit,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
dispose(reason: SessionDisposeReason): Promise<void> {
|
|
126
|
+
return this.binding.runtimeSession.dispose(reason);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async replaceSession(
|
|
130
|
+
unavailableMessage: string,
|
|
131
|
+
createTarget: (
|
|
132
|
+
current: OwnedSessionBinding<View>,
|
|
133
|
+
) => Promise<OwnedSessionBinding<View>>,
|
|
134
|
+
beforeCommit?: () => void,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const current = this.binding;
|
|
137
|
+
if (!current.runtimeSession.canSwitchSession()) {
|
|
138
|
+
throw new Error(unavailableMessage);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const target = await createTarget(current);
|
|
142
|
+
try {
|
|
143
|
+
await current.runtimeSession.dispose({ type: "session_switch" });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
await target.runtimeSession
|
|
146
|
+
.dispose({ type: "runner_failed", error: errorMessage(error) })
|
|
147
|
+
.catch(() => undefined);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
beforeCommit?.();
|
|
151
|
+
this.binding = target;
|
|
152
|
+
for (const listener of this.listeners) {
|
|
153
|
+
listener();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private serialize<T>(operation: () => Promise<T>): Promise<T> {
|
|
158
|
+
if (this.operation !== undefined) {
|
|
159
|
+
return Promise.reject(new Error("Another session operation is already running."));
|
|
160
|
+
}
|
|
161
|
+
const pending = operation().finally(() => {
|
|
162
|
+
if (this.operation === pending) {
|
|
163
|
+
this.operation = undefined;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
this.operation = pending;
|
|
167
|
+
return pending;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createLocalSessionBinding<View>(input: {
|
|
172
|
+
runtimeSession: RuntimeSession;
|
|
173
|
+
modelName: string;
|
|
174
|
+
workspaceRoot: string;
|
|
175
|
+
profileName?: string;
|
|
176
|
+
projectionStore: View;
|
|
177
|
+
}): OwnedSessionBinding<View> {
|
|
178
|
+
return {
|
|
179
|
+
runtimeSession: input.runtimeSession,
|
|
180
|
+
client: {
|
|
181
|
+
sessionId: input.runtimeSession.sessionId,
|
|
182
|
+
modelName: input.modelName,
|
|
183
|
+
workspaceRoot: input.workspaceRoot,
|
|
184
|
+
profileName: input.profileName,
|
|
185
|
+
projectionStore: input.projectionStore,
|
|
186
|
+
readLastResponse: () =>
|
|
187
|
+
readLastAssistantResponse({
|
|
188
|
+
workspaceRoot: input.workspaceRoot,
|
|
189
|
+
sessionId: input.runtimeSession.sessionId,
|
|
190
|
+
}),
|
|
191
|
+
skills: () => input.runtimeSession.skills(),
|
|
192
|
+
mcp: () => input.runtimeSession.mcp(),
|
|
193
|
+
reasoningEffort: () => input.runtimeSession.reasoningEffort(),
|
|
194
|
+
setReasoningEffort: async (effort) =>
|
|
195
|
+
input.runtimeSession.setReasoningEffort(effort),
|
|
196
|
+
resetReasoningEffort: async () => input.runtimeSession.resetReasoningEffort(),
|
|
197
|
+
supportsImageInput: () => input.runtimeSession.supportsImageInput(),
|
|
198
|
+
importImage: (sourcePath, signal, prospectiveMessageImageCount) =>
|
|
199
|
+
input.runtimeSession.importImage(
|
|
200
|
+
sourcePath,
|
|
201
|
+
signal,
|
|
202
|
+
prospectiveMessageImageCount,
|
|
203
|
+
),
|
|
204
|
+
verifyImageAssets: (assets, signal) =>
|
|
205
|
+
input.runtimeSession.verifyImageAssets(assets, signal),
|
|
206
|
+
admitTurn: (userMessage, signal) =>
|
|
207
|
+
input.runtimeSession.admitTurn({ userMessage, signal }),
|
|
208
|
+
executeTurn: (userMessage, signal) =>
|
|
209
|
+
input.runtimeSession.executeTurn({
|
|
210
|
+
userMessage,
|
|
211
|
+
signal,
|
|
212
|
+
} satisfies ExecuteTurnInput),
|
|
213
|
+
promptScheduler: () => input.runtimeSession.promptScheduler(),
|
|
214
|
+
subscribePromptScheduler: (listener) =>
|
|
215
|
+
input.runtimeSession.subscribePromptScheduler(listener),
|
|
216
|
+
queueFollowUp: async (userMessage) =>
|
|
217
|
+
input.runtimeSession.queueFollowUp(userMessage),
|
|
218
|
+
bashGuard: () => input.runtimeSession.bashGuard(),
|
|
219
|
+
subscribeBashGuard: (listener) =>
|
|
220
|
+
input.runtimeSession.subscribeBashGuard(listener),
|
|
221
|
+
setYoloMode: async (enabled) => input.runtimeSession.setYoloMode(enabled),
|
|
222
|
+
resolveBashConfirmation: (decision) =>
|
|
223
|
+
input.runtimeSession.resolveBashConfirmation(decision),
|
|
224
|
+
providerRetry: () => input.runtimeSession.providerRetry(),
|
|
225
|
+
subscribeProviderRetry: (listener) =>
|
|
226
|
+
input.runtimeSession.subscribeProviderRetry(listener),
|
|
227
|
+
resolveProviderRetry: (requestId, decision) =>
|
|
228
|
+
input.runtimeSession.resolveProviderRetry(requestId, decision),
|
|
229
|
+
askUser: () => input.runtimeSession.askUser(),
|
|
230
|
+
subscribeAskUser: (listener) => input.runtimeSession.subscribeAskUser(listener),
|
|
231
|
+
resolveAskUser: (response) => input.runtimeSession.resolveAskUser(response),
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function errorMessage(error: unknown): string {
|
|
237
|
+
return error instanceof Error ? error.message : String(error);
|
|
238
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModelProfile } from "../cli/model-profiles";
|
|
2
|
+
|
|
3
|
+
/** Display data only. Provider endpoints and credentials never cross the client boundary. */
|
|
4
|
+
export type ClientModelProfile = Pick<
|
|
5
|
+
ModelProfile,
|
|
6
|
+
"name" | "model" | "contextWindowTokens" | "maxSupportedOutputTokens"
|
|
7
|
+
>;
|
|
8
|
+
export type ClientModelCatalog = {
|
|
9
|
+
defaultProfile: string;
|
|
10
|
+
profiles: readonly ClientModelProfile[];
|
|
11
|
+
};
|
|
12
|
+
export type ClientModelProfiles = {
|
|
13
|
+
defaultProfile: string;
|
|
14
|
+
profiles: ReadonlyMap<string, ClientModelProfile>;
|
|
15
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
|
|
5
|
+
import { probeImageBytes } from "../image/image-probe";
|
|
6
|
+
import type { ImportedImageAsset } from "../image/image-asset-store";
|
|
7
|
+
import type { ImageAssetRef } from "../image/image-types";
|
|
8
|
+
import type { RemoteClient } from "../remote/client";
|
|
9
|
+
|
|
10
|
+
/** Relative @-mentions name service workspace files; absolute paths name client files. */
|
|
11
|
+
export class RemoteImages {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly transport: RemoteClient,
|
|
14
|
+
private readonly sessionId: string,
|
|
15
|
+
private readonly lifetime: AbortSignal,
|
|
16
|
+
) {}
|
|
17
|
+
async import(
|
|
18
|
+
sourcePath: string,
|
|
19
|
+
signal: AbortSignal,
|
|
20
|
+
count: number,
|
|
21
|
+
): Promise<ImportedImageAsset> {
|
|
22
|
+
const combined = AbortSignal.any([signal, this.lifetime]);
|
|
23
|
+
combined.throwIfAborted();
|
|
24
|
+
if (!path.isAbsolute(sourcePath))
|
|
25
|
+
return this.transport.request(
|
|
26
|
+
`/v1/sessions/${this.sessionId}/images`,
|
|
27
|
+
{ sourcePath, count },
|
|
28
|
+
combined,
|
|
29
|
+
);
|
|
30
|
+
const stat = await lstat(sourcePath);
|
|
31
|
+
if (
|
|
32
|
+
!stat.isFile() ||
|
|
33
|
+
stat.isSymbolicLink() ||
|
|
34
|
+
stat.size < 1 ||
|
|
35
|
+
stat.size > IMAGE_INPUT_POLICY.maxBytesPerImage
|
|
36
|
+
)
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Image must be a regular non-symlink file within the image size limit.",
|
|
39
|
+
);
|
|
40
|
+
const file = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
41
|
+
let bytes: Buffer;
|
|
42
|
+
try {
|
|
43
|
+
const opened = await file.stat();
|
|
44
|
+
if (opened.dev !== stat.dev || opened.ino !== stat.ino || !opened.isFile())
|
|
45
|
+
throw new Error("Image file changed while opening.");
|
|
46
|
+
// A bounded read also protects against a file growing after stat().
|
|
47
|
+
bytes = Buffer.alloc(IMAGE_INPUT_POLICY.maxBytesPerImage + 1);
|
|
48
|
+
let used = 0;
|
|
49
|
+
while (used < bytes.length) {
|
|
50
|
+
combined.throwIfAborted();
|
|
51
|
+
const read = await file.read(bytes, used, bytes.length - used, used);
|
|
52
|
+
if (read.bytesRead === 0) break;
|
|
53
|
+
used += read.bytesRead;
|
|
54
|
+
}
|
|
55
|
+
bytes = bytes.subarray(0, used);
|
|
56
|
+
} finally {
|
|
57
|
+
await file.close();
|
|
58
|
+
}
|
|
59
|
+
await probeImageBytes(bytes, {
|
|
60
|
+
fullDecode: true,
|
|
61
|
+
sourceName: path.basename(sourcePath),
|
|
62
|
+
});
|
|
63
|
+
return this.transport.request(
|
|
64
|
+
`/v1/sessions/${this.sessionId}/images`,
|
|
65
|
+
{
|
|
66
|
+
bytes: bytes.toString("base64"),
|
|
67
|
+
originalName: path.basename(sourcePath),
|
|
68
|
+
count,
|
|
69
|
+
},
|
|
70
|
+
combined,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
async verify(assets: readonly ImageAssetRef[], signal: AbortSignal): Promise<void> {
|
|
74
|
+
await this.transport.request(
|
|
75
|
+
`/v1/sessions/${this.sessionId}/verify-images`,
|
|
76
|
+
{ assets },
|
|
77
|
+
AbortSignal.any([signal, this.lifetime]),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|