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.
Files changed (61) hide show
  1. package/CHANGELOG.md +66 -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/runner-dependencies.ts +35 -35
  19. package/src/cli/serve-runner.ts +155 -21
  20. package/src/cli/serve-runtime.ts +47 -46
  21. package/src/cli/service-control.ts +27 -0
  22. package/src/cli/service-supervisor.ts +170 -0
  23. package/src/cli/service-upgrade.ts +106 -0
  24. package/src/cli/tui-runner.tsx +9 -261
  25. package/src/cli/update-runner.ts +4 -0
  26. package/src/client/local-workspace-client.ts +238 -0
  27. package/src/client/model-catalog.ts +15 -0
  28. package/src/client/remote-images.ts +80 -0
  29. package/src/client/remote-prompt-history.ts +22 -0
  30. package/src/client/remote-tasks.ts +231 -0
  31. package/src/client/remote-tui-view.ts +69 -0
  32. package/src/client/remote-workspace-client.ts +528 -0
  33. package/src/client/session-client.ts +109 -0
  34. package/src/image/image-asset-store.ts +17 -0
  35. package/src/remote/client.ts +27 -5
  36. package/src/remote/config.ts +19 -4
  37. package/src/remote/failures.ts +68 -0
  38. package/src/remote/http-server.ts +120 -3
  39. package/src/remote/local-service-discovery.ts +361 -0
  40. package/src/remote/prompt-history.ts +36 -0
  41. package/src/remote/protocol.ts +93 -2
  42. package/src/remote/resident-policy.ts +96 -0
  43. package/src/remote/service-store.ts +28 -6
  44. package/src/remote/service.ts +479 -60
  45. package/src/remote/session-operations.ts +66 -0
  46. package/src/remote/session-reads.ts +126 -0
  47. package/src/remote/sync-hub.ts +2 -0
  48. package/src/remote/tui-protocol.ts +32 -0
  49. package/src/remote/workspace-resolution.ts +82 -0
  50. package/src/session/resume-projection.ts +19 -2
  51. package/src/session/session-lock.ts +32 -0
  52. package/src/session/session-store-contracts.ts +2 -0
  53. package/src/session/session-store.ts +7 -4
  54. package/src/tools/bash.ts +9 -1
  55. package/src/tools/bounded-output-preview.ts +2 -2
  56. package/src/tui/app.tsx +67 -26
  57. package/src/tui/components/model-picker.tsx +5 -5
  58. package/src/tui/components/prompt-input.tsx +16 -1
  59. package/src/tui/components/resume-session-picker.tsx +6 -2
  60. package/src/tui/tui-projection-store.ts +25 -0
  61. package/src/tui/tui-session-controller.ts +12 -282
@@ -0,0 +1,106 @@
1
+ import { createConnection } from "node:net";
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, readdir, realpath, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import {
6
+ localServicePaths,
7
+ type LocalServiceInstance,
8
+ } from "../remote/local-service-discovery";
9
+
10
+ /** Check every live service using this installation, including explicit non-default state directories. */
11
+ export async function assertServiceUpgradeSafe(packageRoot: string): Promise<void> {
12
+ if (process.platform === "win32") return;
13
+ const root = path.dirname(localServicePaths("").socket);
14
+ let entries: string[];
15
+ try {
16
+ const info = await lstat(root);
17
+ if (
18
+ !info.isDirectory() ||
19
+ info.isSymbolicLink() ||
20
+ info.uid !== process.getuid?.() ||
21
+ info.mode & 0o077
22
+ )
23
+ throw new Error(
24
+ "Unsafe local service discovery directory; cannot verify upgrade safety.",
25
+ );
26
+ entries = await readdir(root);
27
+ } catch (error) {
28
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
29
+ throw error;
30
+ }
31
+ const canonical = await realpath(packageRoot);
32
+ for (const entry of entries.filter((name) =>
33
+ /^[a-f0-9]{32}\.sock\.json$/.test(name),
34
+ )) {
35
+ const instance = JSON.parse(
36
+ await readFile(path.join(root, entry), "utf8"),
37
+ ) as LocalServiceInstance;
38
+ if (
39
+ instance.packageRoot === canonical &&
40
+ (await Bun.file(
41
+ path.join(instance.stateDirectory, "supervisor.enabled"),
42
+ ).exists())
43
+ )
44
+ throw new Error(
45
+ `Disable the supervised service before upgrading: tinker serve --config ${instance.configPath} --stop.`,
46
+ );
47
+ }
48
+ for (const entry of entries.filter((name) => /^[a-f0-9]{32}\.sock$/.test(name))) {
49
+ const instance = await probe(path.join(root, entry));
50
+ if (instance && (!instance.packageRoot || instance.packageRoot === canonical))
51
+ throw new Error(
52
+ `Stop the running service before upgrading: tinker serve --config ${instance.configPath} --stop. Upgrade does not interrupt work or replace a live runtime.`,
53
+ );
54
+ }
55
+ }
56
+ function probe(socketPath: string): Promise<LocalServiceInstance | undefined> {
57
+ return new Promise((resolve, reject) => {
58
+ const socket = createConnection(socketPath);
59
+ const nonce = randomUUID();
60
+ let content = "";
61
+ let finished = false;
62
+ const done = (instance?: LocalServiceInstance, error?: Error) => {
63
+ if (finished) return;
64
+ finished = true;
65
+ socket.destroy();
66
+ if (error) reject(error);
67
+ else resolve(instance);
68
+ };
69
+ socket.setEncoding("utf8");
70
+ socket.setTimeout(1000, () =>
71
+ done(
72
+ undefined,
73
+ new Error(
74
+ "A local service is unresponsive; stop it explicitly before upgrading.",
75
+ ),
76
+ ),
77
+ );
78
+ socket.once("connect", () => socket.write(`${nonce}\n`));
79
+ socket.once("error", (error: NodeJS.ErrnoException) =>
80
+ ["ENOENT", "ECONNREFUSED"].includes(error.code ?? "")
81
+ ? done()
82
+ : done(undefined, error),
83
+ );
84
+ socket.once("end", () => done());
85
+ socket.on("data", (chunk: string) => {
86
+ content += chunk;
87
+ if (content.length > 16384)
88
+ return done(undefined, new Error("Invalid service upgrade probe response."));
89
+ if (!content.includes("\n")) return;
90
+ try {
91
+ const response = JSON.parse(content.trim()) as {
92
+ nonce: string;
93
+ instance: LocalServiceInstance;
94
+ };
95
+ if (response.nonce !== nonce || response.instance?.version !== 1)
96
+ throw new Error("Invalid service upgrade probe response.");
97
+ done(response.instance);
98
+ } catch (error) {
99
+ done(
100
+ undefined,
101
+ error instanceof Error ? error : new Error("Invalid service response."),
102
+ );
103
+ }
104
+ });
105
+ });
106
+ }
@@ -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
+ }