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.
Files changed (58) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +25 -4
  3. package/package.json +2 -1
  4. package/src/agent/runtime-hosted-session.ts +505 -81
  5. package/src/agent/runtime-session-contracts.ts +9 -0
  6. package/src/agent/runtime-session.ts +29 -7
  7. package/src/cli/command-line.ts +159 -11
  8. package/src/cli/connect-runner.tsx +63 -0
  9. package/src/cli/connect-workspace.ts +33 -0
  10. package/src/cli/default-service-config.ts +163 -0
  11. package/src/cli/default-tui-runner.ts +23 -0
  12. package/src/cli/interactive-runtime.ts +87 -0
  13. package/src/cli/local-service-start.ts +131 -0
  14. package/src/cli/local-tui-client.ts +234 -0
  15. package/src/cli/main.ts +46 -3
  16. package/src/cli/public-cli-contract.ts +65 -2
  17. package/src/cli/public-config-contract.ts +1 -1
  18. package/src/cli/serve-runner.ts +155 -21
  19. package/src/cli/serve-runtime.ts +47 -46
  20. package/src/cli/service-control.ts +27 -0
  21. package/src/cli/service-supervisor.ts +170 -0
  22. package/src/cli/service-upgrade.ts +106 -0
  23. package/src/cli/tui-runner.tsx +9 -261
  24. package/src/cli/update-runner.ts +4 -0
  25. package/src/client/local-workspace-client.ts +238 -0
  26. package/src/client/model-catalog.ts +15 -0
  27. package/src/client/remote-images.ts +80 -0
  28. package/src/client/remote-prompt-history.ts +22 -0
  29. package/src/client/remote-tasks.ts +231 -0
  30. package/src/client/remote-tui-view.ts +69 -0
  31. package/src/client/remote-workspace-client.ts +528 -0
  32. package/src/client/session-client.ts +109 -0
  33. package/src/image/image-asset-store.ts +17 -0
  34. package/src/remote/client.ts +27 -5
  35. package/src/remote/config.ts +19 -4
  36. package/src/remote/failures.ts +68 -0
  37. package/src/remote/http-server.ts +120 -3
  38. package/src/remote/local-service-discovery.ts +361 -0
  39. package/src/remote/prompt-history.ts +36 -0
  40. package/src/remote/protocol.ts +93 -2
  41. package/src/remote/resident-policy.ts +96 -0
  42. package/src/remote/service-store.ts +28 -6
  43. package/src/remote/service.ts +479 -60
  44. package/src/remote/session-operations.ts +66 -0
  45. package/src/remote/session-reads.ts +126 -0
  46. package/src/remote/sync-hub.ts +2 -0
  47. package/src/remote/tui-protocol.ts +32 -0
  48. package/src/remote/workspace-resolution.ts +82 -0
  49. package/src/session/resume-projection.ts +19 -2
  50. package/src/session/session-lock.ts +32 -0
  51. package/src/session/session-store-contracts.ts +2 -0
  52. package/src/session/session-store.ts +7 -4
  53. package/src/tui/app.tsx +67 -26
  54. package/src/tui/components/model-picker.tsx +5 -5
  55. package/src/tui/components/prompt-input.tsx +16 -1
  56. package/src/tui/components/resume-session-picker.tsx +6 -2
  57. package/src/tui/tui-projection-store.ts +25 -0
  58. package/src/tui/tui-session-controller.ts +12 -282
@@ -1,54 +1,20 @@
1
1
  import { render } from "ink";
2
2
  import { realpath } from "node:fs/promises";
3
- import {
4
- createRuntimeSession,
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 controller: DefaultTuiSessionController | undefined;
32
+ let connection: Awaited<ReturnType<typeof createLocalTuiClient>> | undefined;
67
33
  let instance: ReturnType<typeof render> | undefined;
68
- let disposeReason: SessionDisposeReason = { type: "tui_exit" };
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
- const createSessionForConfig = async (
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={controller}
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
- disposeReason = { type: "runner_failed", error: errorMessage(error) };
75
+ closeReason = { type: "client_failed", error: errorMessage(error) };
299
76
  } finally {
300
77
  instance?.unmount();
301
78
  restoreStdin();
302
- if (controller !== undefined) {
79
+ if (connection !== undefined) {
303
80
  try {
304
- await controller.dispose(disposeReason);
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);
@@ -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
+ }