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,126 @@
1
+ import { readPromptHistory, appendPromptHistory } from "./prompt-history";
2
+ import { loadProjectSlashCommands } from "../tui/project-slash-commands";
3
+ import { readCurrentGitBranch } from "../tui/git-branch";
4
+ import { listMemoryFiles } from "../memory/memory-files";
5
+ import path from "node:path";
6
+ import { realpath } from "node:fs/promises";
7
+ import type { HostedSession } from "../agent/runtime-hosted-session";
8
+ import { requireObject, requireText, RemoteError } from "./protocol";
9
+ import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
10
+ import { validateImageAssetRef, type ImageAssetRef } from "../image/image-types";
11
+ import { encodeFailure } from "./failures";
12
+ import { createWorkspaceFileLister } from "../tui/workspace-file-search";
13
+ import { loadViewFile } from "../tui/view-file";
14
+ import { readLastAssistantResponse } from "../session/session-last-response-reader";
15
+
16
+ export async function sessionPost(
17
+ session: HostedSession,
18
+ route: string,
19
+ request: Request,
20
+ homeRoot?: string,
21
+ ): Promise<Response | undefined> {
22
+ if (!["images", "verify-images", "prompt-history"].includes(route)) return undefined;
23
+ try {
24
+ if (route === "prompt-history") {
25
+ await appendPromptHistory(
26
+ request,
27
+ session.tuiSnapshot().history.workspaceRoot,
28
+ homeRoot,
29
+ );
30
+ return Response.json({ saved: true });
31
+ }
32
+ const input = requireObject(await request.json());
33
+ const value = await session.exclusive(async (runtime) => {
34
+ if (route === "verify-images") {
35
+ if (
36
+ !Array.isArray(input.assets) ||
37
+ input.assets.length > IMAGE_INPUT_POLICY.maxImagesPerMessage
38
+ )
39
+ throw new Error("Invalid image asset list.");
40
+ for (const asset of input.assets) validateImageAssetRef(asset as ImageAssetRef);
41
+ await runtime.verifyImageAssets(
42
+ input.assets as ImageAssetRef[],
43
+ request.signal,
44
+ );
45
+ return { verified: true };
46
+ }
47
+ const count = input.count;
48
+ if (
49
+ typeof count !== "number" ||
50
+ !Number.isInteger(count) ||
51
+ count < 1 ||
52
+ count > IMAGE_INPUT_POLICY.maxImagesPerMessage
53
+ )
54
+ throw new Error("Invalid image count.");
55
+ if (input.sourcePath !== undefined) {
56
+ const source = requireText(input.sourcePath, "sourcePath", 4096);
57
+ if (path.isAbsolute(source))
58
+ throw new Error("Workspace image paths must be relative.");
59
+ return runtime.importImage(source, request.signal, count);
60
+ }
61
+ const encoded = requireText(
62
+ input.bytes,
63
+ "bytes",
64
+ Math.ceil(IMAGE_INPUT_POLICY.maxBytesPerImage / 3) * 4,
65
+ );
66
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 !== 0)
67
+ throw new Error("Invalid base64 image.");
68
+ return runtime.importImageBytes(
69
+ Buffer.from(encoded, "base64"),
70
+ requireText(input.originalName, "originalName", 255),
71
+ request.signal,
72
+ count,
73
+ );
74
+ });
75
+ return Response.json(value, { headers: { "Cache-Control": "no-store" } });
76
+ } catch (error) {
77
+ return Response.json(
78
+ {
79
+ error: { message: error instanceof Error ? error.message : String(error) },
80
+ failure: encodeFailure(error),
81
+ },
82
+ { status: error instanceof RemoteError ? error.status : 422 },
83
+ );
84
+ }
85
+ }
86
+ export async function sessionRead(
87
+ session: HostedSession,
88
+ route: string,
89
+ url: URL,
90
+ signal: AbortSignal,
91
+ homeRoot?: string,
92
+ ): Promise<unknown> {
93
+ const snapshot = session.tuiSnapshot();
94
+ const workspaceRoot = snapshot.history.workspaceRoot;
95
+ if (route === "prompt-history") return readPromptHistory(workspaceRoot, homeRoot);
96
+ if (route === "project-commands") return loadProjectSlashCommands(workspaceRoot);
97
+ if (route === "git-branch")
98
+ return { branch: await readCurrentGitBranch(workspaceRoot) };
99
+ if (route === "memories") return listMemoryFiles(homeRoot);
100
+ if (route === "files") return createWorkspaceFileLister()(workspaceRoot, signal);
101
+ if (route === "last-response")
102
+ return {
103
+ text: await readLastAssistantResponse({
104
+ workspaceRoot,
105
+ sessionId: session.id,
106
+ homeRoot,
107
+ }),
108
+ };
109
+ if (route === "view-file") {
110
+ const requested = requireText(url.searchParams.get("path"), "path", 4096);
111
+ const actual = await realpath(path.resolve(workspaceRoot, requested));
112
+ const relative = path.relative(workspaceRoot, actual);
113
+ if (
114
+ relative === ".." ||
115
+ relative.startsWith(`..${path.sep}`) ||
116
+ path.isAbsolute(relative)
117
+ )
118
+ throw new RemoteError(
119
+ 403,
120
+ "OUTSIDE_WORKSPACE",
121
+ "File is outside this workspace.",
122
+ );
123
+ return loadViewFile(workspaceRoot, relative);
124
+ }
125
+ throw new RemoteError(404, "NOT_FOUND", "Unknown session read route.");
126
+ }
@@ -89,6 +89,8 @@ export class RemoteSyncHub {
89
89
  }
90
90
  }
91
91
  close(): void {
92
+ this.ring.length = 0;
93
+ this.ringBytes = 0;
92
94
  this.listeners.clear();
93
95
  this.pending = [];
94
96
  }
@@ -0,0 +1,32 @@
1
+ import type { ClientModelCatalog } from "../client/model-catalog";
2
+ import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
3
+ import type { ProviderRetrySnapshot } from "../agent/runtime-provider-retry";
4
+ import type { AskUserSnapshot } from "../agent/runtime-session";
5
+ import type { PromptSchedulerSnapshot } from "../agent/runtime-session-contracts";
6
+ import type { TuiTimelineLog } from "../tui/tui-projection-store";
7
+ import type {
8
+ BashGuardSnapshot,
9
+ RuntimeSkillsSnapshot,
10
+ } from "../agent/runtime-session";
11
+ import type { McpInventorySnapshot } from "../mcp/mcp-manager";
12
+ import type { TuiProjectionState } from "../tui/event-store";
13
+ import type { RemoteActivity, RemoteCursor } from "./protocol";
14
+
15
+ /** Additive v1 read model for the full terminal renderer; no raw runtime events. */
16
+ export type RemoteTuiSnapshot = {
17
+ version: 1;
18
+ profileName?: string;
19
+ modelCatalog?: ClientModelCatalog;
20
+ reasoningEffort?: ReasoningEffortSnapshot;
21
+ supportsImageInput: boolean;
22
+ cursor: RemoteCursor;
23
+ activity: RemoteActivity;
24
+ history: TuiProjectionState;
25
+ timeline: TuiTimelineLog;
26
+ promptScheduler: PromptSchedulerSnapshot;
27
+ bashGuard: BashGuardSnapshot & { interactionId?: string };
28
+ askUser: AskUserSnapshot & { interactionId?: string };
29
+ providerRetry: ProviderRetrySnapshot;
30
+ skills: RuntimeSkillsSnapshot;
31
+ mcp: McpInventorySnapshot;
32
+ };
@@ -0,0 +1,82 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import type { RemoteWorkspaceConfig } from "./config";
5
+ import { RemoteError } from "./protocol";
6
+
7
+ /** Only resolves configured roots; selecting a child directory does not change execution scope. */
8
+ export async function resolveServiceWorkspace(
9
+ workspaces: readonly RemoteWorkspaceConfig[],
10
+ directory: string,
11
+ ): Promise<RemoteWorkspaceConfig> {
12
+ if (!path.isAbsolute(directory))
13
+ throw new RemoteError(
14
+ 400,
15
+ "INVALID_DIRECTORY",
16
+ "Workspace directory must be an absolute path on the service host.",
17
+ );
18
+ let canonical: string;
19
+ try {
20
+ canonical = await realpath(directory);
21
+ if (!(await stat(canonical)).isDirectory()) throw new Error("Not a directory.");
22
+ } catch {
23
+ throw new RemoteError(
24
+ 404,
25
+ "DIRECTORY_NOT_FOUND",
26
+ "Workspace directory does not exist on the service host.",
27
+ );
28
+ }
29
+ const match = findContainingWorkspace(workspaces, canonical);
30
+ if (!match)
31
+ throw new RemoteError(
32
+ 404,
33
+ "WORKSPACE_NOT_CONFIGURED",
34
+ "Current directory is outside configured workspaces. Use --service-config <path> from a local terminal to register it, or select --workspace <id> explicitly.",
35
+ );
36
+ return match;
37
+ }
38
+
39
+ export function findContainingWorkspace(
40
+ workspaces: readonly RemoteWorkspaceConfig[],
41
+ canonical: string,
42
+ ): RemoteWorkspaceConfig | undefined {
43
+ const matches = workspaces
44
+ .filter((workspace) => {
45
+ const relative = path.relative(workspace.path, canonical);
46
+ return (
47
+ relative === "" ||
48
+ (!path.isAbsolute(relative) &&
49
+ relative !== ".." &&
50
+ !relative.startsWith(`..${path.sep}`))
51
+ );
52
+ })
53
+ .sort((a, b) => b.path.length - a.path.length);
54
+ return matches[0];
55
+ }
56
+
57
+ export function localWorkspaceRecord(canonical: string): RemoteWorkspaceConfig {
58
+ return {
59
+ id: `local-${createHash("sha256").update(canonical).digest("hex").slice(0, 32)}`,
60
+ name: path.basename(canonical) || canonical,
61
+ path: canonical,
62
+ };
63
+ }
64
+
65
+ export function mergeWorkspaces(
66
+ configured: readonly RemoteWorkspaceConfig[],
67
+ registered: readonly RemoteWorkspaceConfig[],
68
+ ): RemoteWorkspaceConfig[] {
69
+ const result = [...configured];
70
+ for (const workspace of registered) {
71
+ const match = result.find(
72
+ (entry) => entry.id === workspace.id || entry.path === workspace.path,
73
+ );
74
+ if (match) {
75
+ if (match.id !== workspace.id || match.path !== workspace.path)
76
+ throw new Error(
77
+ "Configured workspace conflicts with a locally registered workspace identity. Preserve its ID and canonical path.",
78
+ );
79
+ } else result.push(workspace);
80
+ }
81
+ return result;
82
+ }
@@ -60,6 +60,22 @@ export class ResumeProjectionReader {
60
60
  input.sessionId,
61
61
  input.homeRoot,
62
62
  );
63
+ return this.readDatabase({ ...input, workspaceRoot, databasePath, policy });
64
+ }
65
+
66
+ /** Read completed history while a service-owned session may have an open tail. */
67
+ static readDatabase(input: {
68
+ workspaceRoot: string;
69
+ sessionId: SessionId;
70
+ modelName: string;
71
+ databasePath: string;
72
+ policy?: TuiProjectionPolicy;
73
+ closedTurnsOnly?: boolean;
74
+ }): TuiProjectionState {
75
+ const { workspaceRoot, databasePath } = input;
76
+ const policy = validateTuiProjectionPolicy(
77
+ input.policy ?? defaultTuiProjectionPolicy,
78
+ );
63
79
  const database = new Database(databasePath, {
64
80
  readonly: true,
65
81
  strict: true,
@@ -84,11 +100,12 @@ export class ResumeProjectionReader {
84
100
  { sessionId: input.sessionId },
85
101
  );
86
102
  }
103
+ const filter = input.closedTurnsOnly ? " WHERE status <> 'open'" : "";
87
104
  const totalTurns = count(
88
- database.query("SELECT COUNT(*) AS count FROM turns").get(),
105
+ database.query(`SELECT COUNT(*) AS count FROM turns${filter}`).get(),
89
106
  );
90
107
  const turns = database
91
- .query(`SELECT * FROM turns ORDER BY turn_number DESC LIMIT ?`)
108
+ .query(`SELECT * FROM turns${filter} ORDER BY turn_number DESC LIMIT ?`)
92
109
  .all(policy.recentTurnLimit)
93
110
  .reverse() as Array<Record<string, unknown>>;
94
111
  const recentTurns = turns.map((turn) => projectTurn(database, turn, policy));
@@ -46,6 +46,8 @@ const defaultDependencies: SessionLeaseDependencies = {
46
46
 
47
47
  export class SessionLease {
48
48
  private released = false;
49
+ private retained = false;
50
+ private writerActive = true;
49
51
  private lockPath: string;
50
52
 
51
53
  private constructor(
@@ -167,7 +169,37 @@ export class SessionLease {
167
169
  this.lockPath = path.join(sessionDirectory, "active.lock");
168
170
  }
169
171
 
172
+ /** Keep the canonical process lease while its current SQLite writer is unloaded. */
173
+ retainOwnership(): this {
174
+ this.requireActive();
175
+ this.retained = true;
176
+ return this;
177
+ }
178
+ claim(sessionDirectory: string, sessionId: SessionId): this {
179
+ this.requireActive();
180
+ if (
181
+ !this.retained ||
182
+ this.writerActive ||
183
+ sessionId !== this.record.sessionId ||
184
+ path.join(sessionDirectory, "active.lock") !== this.lockPath
185
+ )
186
+ throw new Error("Retained session lease is not available for this writer.");
187
+ this.writerActive = true;
188
+ return this;
189
+ }
190
+ async releaseOwnership(): Promise<void> {
191
+ if (this.released) return;
192
+ if (this.retained && this.writerActive)
193
+ throw new Error("Close the session writer before releasing service ownership.");
194
+ this.retained = false;
195
+ await this.release();
196
+ }
197
+
170
198
  async release(): Promise<void> {
199
+ if (this.retained && !this.released) {
200
+ this.writerActive = false;
201
+ return;
202
+ }
171
203
  if (this.released) {
172
204
  return;
173
205
  }
@@ -1,3 +1,4 @@
1
+ import type { SessionLease } from "./session-lock";
1
2
  import type {
2
3
  ContextRevisionId,
3
4
  IterationId,
@@ -269,6 +270,7 @@ export type CreateNewSessionStoreInput = {
269
270
  };
270
271
 
271
272
  export type OpenSessionStoreInput = {
273
+ lease?: SessionLease;
272
274
  workspaceRoot: string;
273
275
  sessionId: SessionId;
274
276
  clock?: () => string;
@@ -374,10 +374,9 @@ export class SessionStore implements SessionLedgerCommitter {
374
374
  await validateSecureOptionalFile(optionalFile, input.sessionId);
375
375
  }
376
376
 
377
- const lease = await SessionLease.acquire({
378
- sessionDirectory,
379
- sessionId: input.sessionId,
380
- });
377
+ const lease = input.lease
378
+ ? input.lease.claim(sessionDirectory, input.sessionId)
379
+ : await SessionLease.acquire({ sessionDirectory, sessionId: input.sessionId });
381
380
  let database: Database | undefined;
382
381
  try {
383
382
  database = openWritableDatabase(databasePath);
@@ -1317,6 +1316,10 @@ export class SessionStore implements SessionLedgerCommitter {
1317
1316
  }
1318
1317
  }
1319
1318
 
1319
+ retainLease(): SessionLease {
1320
+ return this.lease.retainOwnership();
1321
+ }
1322
+
1320
1323
  async close(reason: SessionCloseReason): Promise<void> {
1321
1324
  if (this.closed) {
1322
1325
  return;
package/src/tools/bash.ts CHANGED
@@ -48,7 +48,15 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
48
48
  definition: {
49
49
  name: "Bash",
50
50
  description:
51
- "Run a shell command locally. If the foreground timeout expires while the command is still running, it continues as a background task and returns a task ID; it is not killed. Use TaskOutput to inspect progress, then decide whether to keep waiting or stop it with TaskStop.",
51
+ "Run a shell command locally. " +
52
+ "If the foreground timeout expires while the command is still running, " +
53
+ "it continues as a background task and returns a task ID; it is not killed. " +
54
+ "Use TaskOutput to inspect progress, then decide whether to keep waiting or stop it with TaskStop. " +
55
+ "Use run_in_background=true for persistent processes such as dev servers and watch commands, " +
56
+ "or when you have independent work to do while a command runs. " +
57
+ "For finite commands whose result is needed next, such as builds, tests, and checks, " +
58
+ "prefer foreground execution when no independent work remains. " +
59
+ "Set a sufficient foreground timeout; the call returns as soon as the command finishes.",
52
60
  parameters: {
53
61
  type: "object",
54
62
  additionalProperties: false,
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
 
3
- export const MAX_PREVIEW_LINES = 200;
4
- export const PREVIEW_EDGE_LINES = 100;
3
+ export const MAX_PREVIEW_LINES = 50;
4
+ export const PREVIEW_EDGE_LINES = 25;
5
5
  export const MAX_PREVIEW_BYTES = 32 * 1024;
6
6
  export const MAX_PREVIEW_LINE_BYTES = 8 * 1024;
7
7
 
package/src/tui/app.tsx CHANGED
@@ -20,7 +20,6 @@ import { ContextBudgetExceededError } from "../model/model-request-preflight";
20
20
  import { ModelRequestMediaAggregateError } from "../model/model-client";
21
21
  import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
22
22
  import type { SessionId } from "../ids/runtime-id";
23
- import { readLastAssistantResponse } from "../session/session-last-response-reader";
24
23
  import type { PromptHistory } from "./prompt-history";
25
24
  import { Footer } from "./components/footer";
26
25
  import { AssistantMarkdownProvider } from "./components/assistant-markdown";
@@ -56,8 +55,8 @@ import {
56
55
  type ProjectSlashCommand,
57
56
  } from "./project-slash-commands";
58
57
  import type { TuiSessionController } from "./tui-session-controller";
59
- import type { SessionSummary } from "../session/session-catalog";
60
- import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
58
+ import type { ClientSessionSummary as SessionSummary } from "../client/session-client";
59
+ import type { ClientModelProfile, ClientModelProfiles } from "../client/model-catalog";
61
60
  import { loadViewFile, type ViewFile } from "./view-file";
62
61
  import { writeClipboardText } from "./clipboard";
63
62
  import type { WorkspaceFileLister } from "./workspace-file-search";
@@ -74,7 +73,7 @@ export type AppProps = {
74
73
  history?: PromptHistory;
75
74
  projectSlashCommands?: readonly ProjectSlashCommand[];
76
75
  fileLister?: WorkspaceFileLister;
77
- profiles?: ModelProfiles;
76
+ profiles?: ClientModelProfiles;
78
77
  persistDefaultProfile?: (profileName: string) => Promise<void>;
79
78
  readViewFile?: (workspaceRoot: string, filePath: string) => Promise<ViewFile>;
80
79
  readLastResponse?: (
@@ -203,16 +202,34 @@ export function App(props: AppProps) {
203
202
  write(clearTerminal);
204
203
  setStaticRenderEpoch((current) => current + 1);
205
204
  }, [write]);
205
+ const presentationRevision = binding.projectionStore.getPresentationRevision?.();
206
+ const renderedPresentation = useRef({
207
+ sessionId: binding.sessionId,
208
+ revision: presentationRevision,
209
+ });
210
+ useEffect(() => {
211
+ const previous = renderedPresentation.current;
212
+ renderedPresentation.current = {
213
+ sessionId: binding.sessionId,
214
+ revision: presentationRevision,
215
+ };
216
+ if (
217
+ previous.sessionId === binding.sessionId &&
218
+ previous.revision !== presentationRevision
219
+ )
220
+ restoreStaticViewport();
221
+ }, [binding.sessionId, presentationRevision, restoreStaticViewport]);
206
222
  const staticItems = useMemo<Array<typeof STATIC_HEADER | TuiCommittedItem>>(
207
223
  () => [STATIC_HEADER, ...log.committed],
208
224
  [log.committed],
209
225
  );
210
226
 
227
+ const profiles = props.profiles ?? binding.modelProfiles?.();
211
228
  const canSwitchModel =
212
229
  state.recentTurns.length === 0 &&
213
230
  state.activeTurn === undefined &&
214
- props.profiles !== undefined &&
215
- props.profiles.profiles.size > 1;
231
+ profiles !== undefined &&
232
+ profiles.profiles.size > 1;
216
233
  const reasoningEffort = binding.reasoningEffort?.();
217
234
  const hasReasoningEffort = reasoningEffort !== undefined;
218
235
  const activeResumePicker =
@@ -225,8 +242,8 @@ export function App(props: AppProps) {
225
242
  );
226
243
  const availableCommands = [...builtInCommands, ...(props.projectSlashCommands ?? [])];
227
244
 
228
- const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
229
- const cycleReasoningEffort = () => {
245
+ const profileList = profiles ? [...profiles.profiles.values()] : [];
246
+ const cycleReasoningEffort = async () => {
230
247
  const current = binding.reasoningEffort?.();
231
248
  if (current === undefined) {
232
249
  return;
@@ -239,7 +256,7 @@ export function App(props: AppProps) {
239
256
  return;
240
257
  }
241
258
  try {
242
- const updated = binding.setReasoningEffort?.(nextEffort);
259
+ const updated = await binding.setReasoningEffort?.(nextEffort);
243
260
  if (updated === undefined) {
244
261
  throw new Error("Reasoning effort control is unavailable.");
245
262
  }
@@ -282,6 +299,24 @@ export function App(props: AppProps) {
282
299
  return;
283
300
  }
284
301
 
302
+ if (binding.stopTurn !== undefined) {
303
+ if (isCancelling) return;
304
+ setIsCancelling(true);
305
+ setNotice("Cancelling current turn...");
306
+ void binding.stopTurn().then(
307
+ () => {
308
+ setIsCancelling(false);
309
+ setNotice((current) =>
310
+ current === "Cancelling current turn..." ? undefined : current,
311
+ );
312
+ },
313
+ (error: unknown) => {
314
+ setIsCancelling(false);
315
+ setNotice(`Stop failed: ${errorMessage(error)}`);
316
+ },
317
+ );
318
+ return;
319
+ }
285
320
  const controller = activeController.current;
286
321
  if (controller === undefined || controller.signal.aborted) {
287
322
  return;
@@ -384,7 +419,7 @@ export function App(props: AppProps) {
384
419
  restoreStaticViewport();
385
420
  };
386
421
 
387
- const doSwitchModel = (profile: ModelProfile) => {
422
+ const doSwitchModel = (profile: ClientModelProfile) => {
388
423
  setShowModelPicker(false);
389
424
  setModelPickerState(undefined);
390
425
  setNotice(undefined);
@@ -394,7 +429,9 @@ export function App(props: AppProps) {
394
429
  .then(async () => {
395
430
  setGitBranchRefresh((current) => current + 1);
396
431
  try {
397
- await props.persistDefaultProfile?.(profile.name);
432
+ await (
433
+ props.persistDefaultProfile ?? props.sessionController.persistDefaultProfile
434
+ )?.(profile.name);
398
435
  setNotice(`Switched to model profile "${profile.name}" (${profile.model}).`);
399
436
  } catch (error) {
400
437
  setNotice(
@@ -462,10 +499,7 @@ export function App(props: AppProps) {
462
499
  void Promise.resolve()
463
500
  .then(() =>
464
501
  props.readLastResponse === undefined
465
- ? readLastAssistantResponse({
466
- workspaceRoot,
467
- sessionId: binding.sessionId,
468
- })
502
+ ? binding.readLastResponse()
469
503
  : props.readLastResponse(workspaceRoot, binding.sessionId),
470
504
  )
471
505
  .then(async (markdown) => {
@@ -496,7 +530,7 @@ export function App(props: AppProps) {
496
530
  return false;
497
531
  }
498
532
  try {
499
- const queued = binding.queueFollowUp?.(submission.userMessage);
533
+ const queued = await binding.queueFollowUp?.(submission.userMessage);
500
534
  if (queued === undefined) {
501
535
  throw new Error("TUI session binding does not support follow-up queuing.");
502
536
  }
@@ -605,10 +639,10 @@ export function App(props: AppProps) {
605
639
  }
606
640
  };
607
641
 
608
- const onSubmit = (
642
+ const onSubmit = async (
609
643
  submission: PromptSubmission,
610
644
  signal: AbortSignal,
611
- ): PromptSubmissionOutcome | Promise<PromptSubmissionOutcome> => {
645
+ ): Promise<PromptSubmissionOutcome> => {
612
646
  const { userMessage } = submission;
613
647
  const trimmed = userMessage.content.trim();
614
648
  const shouldRestoreViewport = showStatus || showSkills || showMcp;
@@ -664,7 +698,7 @@ export function App(props: AppProps) {
664
698
  return true;
665
699
  }
666
700
  if (command.type === "yolo") {
667
- binding.setYoloMode(command.enabled);
701
+ await binding.setYoloMode(command.enabled);
668
702
  setNotice(
669
703
  command.enabled
670
704
  ? "YOLO enabled for this session; dangerous Bash commands will run without confirmation."
@@ -770,7 +804,7 @@ export function App(props: AppProps) {
770
804
  if (command.type === "reasoning_status") {
771
805
  setNotice(formatReasoningEffortStatus(current));
772
806
  } else if (command.type === "reasoning_reset") {
773
- const reset = binding.resetReasoningEffort?.();
807
+ const reset = await binding.resetReasoningEffort?.();
774
808
  if (reset === undefined) {
775
809
  throw new Error("Reasoning effort control is unavailable.");
776
810
  }
@@ -778,7 +812,7 @@ export function App(props: AppProps) {
778
812
  `Reasoning effort reset to profile default ${JSON.stringify(reset.defaultEffort)}.`,
779
813
  );
780
814
  } else {
781
- const updated = binding.setReasoningEffort?.(command.effort);
815
+ const updated = await binding.setReasoningEffort?.(command.effort);
782
816
  if (updated === undefined) {
783
817
  throw new Error("Reasoning effort control is unavailable.");
784
818
  }
@@ -800,7 +834,7 @@ export function App(props: AppProps) {
800
834
  return false;
801
835
  }
802
836
  if (command.type === "model_switch") {
803
- const targetProfile = props.profiles?.profiles.get(command.profileName);
837
+ const targetProfile = profiles?.profiles.get(command.profileName);
804
838
  if (targetProfile === undefined) {
805
839
  setNotice(`Unknown model profile: ${command.profileName}`);
806
840
  return false;
@@ -969,18 +1003,22 @@ export function App(props: AppProps) {
969
1003
  />
970
1004
  ) : askUser.pending !== undefined ? (
971
1005
  <AskUser
1006
+ key={askUser.interactionId}
972
1007
  question={askUser.pending.question}
973
1008
  options={askUser.pending.options}
974
1009
  onSelect={(selectedIndex) => {
975
1010
  void binding
976
- .resolveAskUser({ outcome: "selected", selectedIndex })
1011
+ .resolveAskUser(
1012
+ { outcome: "selected", selectedIndex },
1013
+ askUser.interactionId,
1014
+ )
977
1015
  .catch((error: unknown) =>
978
1016
  setNotice(`Answer failed: ${errorMessage(error)}`),
979
1017
  );
980
1018
  }}
981
1019
  onDismiss={() => {
982
1020
  void binding
983
- .resolveAskUser({ outcome: "dismissed" })
1021
+ .resolveAskUser({ outcome: "dismissed" }, askUser.interactionId)
984
1022
  .catch((error: unknown) =>
985
1023
  setNotice(`Dismiss failed: ${errorMessage(error)}`),
986
1024
  );
@@ -992,7 +1030,7 @@ export function App(props: AppProps) {
992
1030
  reason={bashGuard.pending.reason}
993
1031
  onDecision={(decision) => {
994
1032
  void binding
995
- .resolveBashConfirmation(decision)
1033
+ .resolveBashConfirmation(decision, bashGuard.interactionId)
996
1034
  .catch((error: unknown) =>
997
1035
  setNotice(`Bash confirmation failed: ${errorMessage(error)}`),
998
1036
  );
@@ -1014,6 +1052,7 @@ export function App(props: AppProps) {
1014
1052
  version={props.version}
1015
1053
  workspaceRoot={binding.workspaceRoot}
1016
1054
  gitBranch={gitBranch}
1055
+ serviceStatus={binding.projectionStore.getServiceStatus?.()}
1017
1056
  contextUsage={state.contextUsage}
1018
1057
  isDisabled={
1019
1058
  isSessionOperation ||
@@ -1034,7 +1073,9 @@ export function App(props: AppProps) {
1034
1073
  verifyImageAssets={binding.verifyImageAssets}
1035
1074
  onCycleReasoningEffort={
1036
1075
  hasReasoningEffort && promptScheduler.state !== "running"
1037
- ? cycleReasoningEffort
1076
+ ? () => {
1077
+ void cycleReasoningEffort();
1078
+ }
1038
1079
  : undefined
1039
1080
  }
1040
1081
  onSubmit={onSubmit}