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,3 +1,4 @@
1
+ import { decodeFailure, type RemoteFailure } from "./failures";
1
2
  import { readFile, chmod, writeFile, rename, mkdir } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { randomUUID } from "node:crypto";
@@ -56,6 +57,7 @@ type ClientState = {
56
57
  failures: { requestId: string; message: string }[];
57
58
  };
58
59
  export type ClientSnapshot = {
60
+ cursor?: { epoch: string; sequence: number };
59
61
  connection: "connecting" | "online" | "offline" | "closed";
60
62
  view?: RemoteView;
61
63
  pending: number;
@@ -112,8 +114,12 @@ export class RemoteClient {
112
114
  private diskTail: Promise<void> = Promise.resolve();
113
115
  private snapshot: ClientSnapshot = { connection: "connecting", pending: 0 };
114
116
  private readonly listeners = new Set<() => void>();
115
- constructor(readonly config: RemoteClientConfig) {}
117
+ constructor(
118
+ readonly config: RemoteClientConfig,
119
+ private readonly persistent = true,
120
+ ) {}
116
121
  async initialize(): Promise<void> {
122
+ if (!this.persistent) return;
117
123
  try {
118
124
  this.state = JSON.parse(
119
125
  await readFile(this.config.statePath, "utf8"),
@@ -139,6 +145,14 @@ export class RemoteClient {
139
145
  workspaces(): Promise<{ workspaces: { id: string; name: string }[] }> {
140
146
  return this.request("/v1/workspaces");
141
147
  }
148
+ async resolveWorkspace(
149
+ directory: string,
150
+ ): Promise<{ id: string; name: string; path: string }> {
151
+ const result = await this.request<{
152
+ workspace: { id: string; name: string; path: string };
153
+ }>(`/v1/workspaces/resolve?${new URLSearchParams({ directory })}`);
154
+ return result.workspace;
155
+ }
142
156
  sessions(workspaceId: string): Promise<{ sessions: RemoteSessionInfo[] }> {
143
157
  return this.request(`/v1/workspaces/${workspaceId}/sessions`);
144
158
  }
@@ -259,6 +273,7 @@ export class RemoteClient {
259
273
  );
260
274
  this.retryDelay = 500;
261
275
  this.emit({
276
+ cursor: { epoch: this.frame.epoch, sequence: this.frame.sequence },
262
277
  connection: "online",
263
278
  view: this.frame.type === "snapshot" ? this.frame.view : undefined,
264
279
  error: undefined,
@@ -292,7 +307,7 @@ export class RemoteClient {
292
307
  }, this.retryDelay);
293
308
  this.retryDelay = Math.min(this.retryDelay * 2, 10000);
294
309
  }
295
- async request<T>(route: string, input?: unknown): Promise<T> {
310
+ async request<T>(route: string, input?: unknown, signal?: AbortSignal): Promise<T> {
296
311
  const response = await fetch(new URL(route, this.config.url), {
297
312
  method: input === undefined ? "GET" : "POST",
298
313
  redirect: "error",
@@ -304,9 +319,15 @@ export class RemoteClient {
304
319
  ...(this.config.ca
305
320
  ? { tls: { ca: this.config.ca, rejectUnauthorized: true } }
306
321
  : {}),
307
- signal: AbortSignal.timeout(15000),
322
+ signal: signal
323
+ ? AbortSignal.any([signal, AbortSignal.timeout(15000)])
324
+ : AbortSignal.timeout(15000),
308
325
  });
309
- const result = (await response.json()) as T & { error?: { message: string } };
326
+ const result = (await response.json()) as T & {
327
+ error?: { message: string };
328
+ failure?: RemoteFailure;
329
+ };
330
+ if (!response.ok && result.failure) throw decodeFailure(result.failure);
310
331
  if (!response.ok)
311
332
  throw new ClientHttpError(
312
333
  response.status,
@@ -315,6 +336,7 @@ export class RemoteClient {
315
336
  return result;
316
337
  }
317
338
  private persist(): Promise<void> {
339
+ if (!this.persistent) return Promise.resolve();
318
340
  const data = JSON.stringify(this.state);
319
341
  this.diskTail = this.diskTail.then(async () => {
320
342
  await mkdir(path.dirname(this.config.statePath), {
@@ -340,7 +362,7 @@ export class RemoteClient {
340
362
  this.emit({ connection: "closed" });
341
363
  }
342
364
  }
343
- class ClientHttpError extends Error {
365
+ export class ClientHttpError extends Error {
344
366
  constructor(
345
367
  readonly status: number,
346
368
  message: string,
@@ -1,3 +1,4 @@
1
+ import { parseResidentPolicy, type ResidentPolicy } from "./resident-policy";
1
2
  import { createHash, timingSafeEqual } from "node:crypto";
2
3
  import { readFile, realpath, stat } from "node:fs/promises";
3
4
  import path from "node:path";
@@ -10,6 +11,7 @@ export type RemoteWorkspaceConfig = {
10
11
  profile?: string;
11
12
  };
12
13
  export type RemoteServiceConfig = {
14
+ resident?: ResidentPolicy;
13
15
  stateDirectory: string;
14
16
  hostname: string;
15
17
  port: number;
@@ -27,12 +29,11 @@ export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfi
27
29
  path.resolve(path.dirname(absolute), requireText(value, name, 4096));
28
30
  const tls = requireObject(raw.tls);
29
31
  const port = raw.port ?? 9443;
30
- if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)
32
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535)
31
33
  throw new Error("Invalid service port.");
32
34
  if (!Array.isArray(raw.devices) || raw.devices.length === 0)
33
35
  throw new Error("At least one paired device is required.");
34
- if (!Array.isArray(raw.workspaces) || raw.workspaces.length === 0)
35
- throw new Error("At least one workspace is required.");
36
+ if (!Array.isArray(raw.workspaces)) throw new Error("Workspaces must be an array.");
36
37
  const devices = raw.devices.map((entry) => {
37
38
  const device = requireObject(entry);
38
39
  const tokenSha256 = requireText(device.tokenSha256, "tokenSha256", 64);
@@ -69,7 +70,10 @@ export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfi
69
70
  if (hostname !== "127.0.0.1" && hostname !== "::1")
70
71
  throw new Error("Bind the service to loopback; expose only the relay TCP port.");
71
72
  return {
72
- stateDirectory: resolve(raw.stateDirectory, "stateDirectory"),
73
+ resident: parseResidentPolicy(raw.resident),
74
+ stateDirectory: await canonicalDirectory(
75
+ resolve(raw.stateDirectory ?? "./state", "stateDirectory"),
76
+ ),
73
77
  hostname,
74
78
  port,
75
79
  tls: {
@@ -81,6 +85,17 @@ export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfi
81
85
  };
82
86
  }
83
87
 
88
+ async function canonicalDirectory(directory: string): Promise<string> {
89
+ try {
90
+ return await realpath(directory);
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
93
+ const parent = path.dirname(directory);
94
+ if (parent === directory) throw error;
95
+ return path.join(await canonicalDirectory(parent), path.basename(directory));
96
+ }
97
+ }
98
+
84
99
  export function authenticateDevice(
85
100
  header: string | null,
86
101
  devices: RemoteServiceConfig["devices"],
@@ -0,0 +1,68 @@
1
+ import { ContextManagerError } from "../context/context-manager";
2
+ import { ContextBudgetExceededError } from "../model/model-request-preflight";
3
+ import { ModelRequestMediaAggregateError } from "../model/model-client";
4
+ import { ImageNotRecognizedError } from "../image/image-probe";
5
+
6
+ export type RemoteFailure = {
7
+ name: string;
8
+ message: string;
9
+ maintenance?: Pick<ContextManagerError, "stage" | "code" | "fatal" | "committed">;
10
+ context?: Pick<
11
+ ContextBudgetExceededError,
12
+ "projectedInputTokens" | "inputBudgetTokens" | "triggerTokens" | "source"
13
+ >;
14
+ };
15
+ export function encodeFailure(error: unknown): RemoteFailure {
16
+ return {
17
+ name: error instanceof Error ? error.name : "Error",
18
+ message: error instanceof Error ? error.message : String(error),
19
+ ...(error instanceof ContextManagerError
20
+ ? {
21
+ maintenance: {
22
+ stage: error.stage,
23
+ code: error.code,
24
+ fatal: error.fatal,
25
+ committed: error.committed,
26
+ },
27
+ }
28
+ : {}),
29
+ ...(error instanceof ContextBudgetExceededError
30
+ ? {
31
+ context: {
32
+ projectedInputTokens: error.projectedInputTokens,
33
+ inputBudgetTokens: error.inputBudgetTokens,
34
+ triggerTokens: error.triggerTokens,
35
+ source: error.source,
36
+ },
37
+ }
38
+ : {}),
39
+ ...(error instanceof ModelRequestMediaAggregateError
40
+ ? { name: "ModelRequestMediaAggregateError" }
41
+ : {}),
42
+ ...(error instanceof ImageNotRecognizedError
43
+ ? { name: "ImageNotRecognizedError" }
44
+ : {}),
45
+ };
46
+ }
47
+ export function decodeFailure(failure: RemoteFailure): Error {
48
+ if (failure.name === "ContextManagerError" && failure.maintenance) {
49
+ const m = failure.maintenance;
50
+ return new ContextManagerError(
51
+ m.stage,
52
+ m.code,
53
+ m.fatal,
54
+ m.committed,
55
+ failure.message,
56
+ );
57
+ }
58
+ if (failure.name === "ContextBudgetExceededError" && failure.context) {
59
+ const error = new Error(failure.message);
60
+ Object.setPrototypeOf(error, ContextBudgetExceededError.prototype);
61
+ return Object.assign(error, { name: failure.name }, failure.context);
62
+ }
63
+ if (failure.name === "ModelRequestMediaAggregateError")
64
+ return new ModelRequestMediaAggregateError(failure.message);
65
+ if (failure.name === "ImageNotRecognizedError")
66
+ return new ImageNotRecognizedError(failure.message);
67
+ return new Error(failure.message);
68
+ }
@@ -1,3 +1,6 @@
1
+ import { sessionPost, sessionRead } from "./session-reads";
2
+ import { resolveServiceWorkspace } from "./workspace-resolution";
3
+ import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
1
4
  import type { ServerWebSocket } from "bun";
2
5
  import { authenticateDevice, type RemoteServiceConfig } from "./config";
3
6
  import {
@@ -14,6 +17,7 @@ type SocketData = {
14
17
  session: HostedSession;
15
18
  cursor?: RemoteCursor;
16
19
  unsubscribe?: () => void;
20
+ release?: () => void;
17
21
  device: string;
18
22
  };
19
23
  const SOCKET_BUFFER_LIMIT = 2 * 1024 * 1024;
@@ -30,9 +34,10 @@ export function startRemoteHttpServer(
30
34
  cert: Bun.file(config.tls.certFile),
31
35
  key: Bun.file(config.tls.keyFile),
32
36
  },
33
- maxRequestBodySize: 72 * 1024,
37
+ maxRequestBodySize: Math.ceil(IMAGE_INPUT_POLICY.maxBytesPerImage / 3) * 4 + 4096,
34
38
  idleTimeout: 30,
35
39
  async fetch(request, server) {
40
+ let releaseRequest: (() => void) | undefined;
36
41
  try {
37
42
  // Native clients authenticate in headers. Browser-origin requests are not supported.
38
43
  if (request.headers.has("origin"))
@@ -55,6 +60,28 @@ export function startRemoteHttpServer(
55
60
  const parts = url.pathname.split("/").filter(Boolean);
56
61
  if (parts[0] !== "v1")
57
62
  throw new RemoteError(404, "NOT_FOUND", "Unknown API version or route.");
63
+ if (request.method === "GET" && url.pathname === "/v1/service")
64
+ return json({
65
+ version: 1,
66
+ instanceId: service.epoch,
67
+ ...service.residentStatus(),
68
+ });
69
+ if (request.method === "GET" && url.pathname === "/v1/workspaces/resolve") {
70
+ const directory = url.searchParams.get("directory");
71
+ if (!directory || directory.length > 4096)
72
+ throw new RemoteError(
73
+ 400,
74
+ "INVALID_DIRECTORY",
75
+ "Provide a workspace directory of at most 4096 characters.",
76
+ );
77
+ const workspace = await resolveServiceWorkspace(
78
+ service.workspaces,
79
+ directory,
80
+ );
81
+ return json({
82
+ workspace: { id: workspace.id, name: workspace.name, path: workspace.path },
83
+ });
84
+ }
58
85
  if (request.method === "GET" && url.pathname === "/v1/workspaces") {
59
86
  return json({
60
87
  version: 1,
@@ -64,6 +91,19 @@ export function startRemoteHttpServer(
64
91
  })),
65
92
  });
66
93
  }
94
+ if (
95
+ request.method === "GET" &&
96
+ parts.length === 5 &&
97
+ parts[1] === "workspaces" &&
98
+ parts[3] === "tui-sessions"
99
+ ) {
100
+ return json({
101
+ session: await service.getTuiSession(
102
+ requireId(parts[2], "workspaceId"),
103
+ requireId(parts[4], "sessionId", true),
104
+ ),
105
+ });
106
+ }
67
107
  if (
68
108
  request.method === "GET" &&
69
109
  parts.length === 4 &&
@@ -74,20 +114,83 @@ export function startRemoteHttpServer(
74
114
  sessions: await service.listSessions(requireId(parts[2], "workspaceId")),
75
115
  });
76
116
  }
117
+ if (
118
+ request.method === "GET" &&
119
+ parts.length === 4 &&
120
+ parts[1] === "workspaces" &&
121
+ parts[3] === "tui-sessions"
122
+ ) {
123
+ return json({
124
+ sessions: await service.listTuiSessions(requireId(parts[2], "workspaceId")),
125
+ });
126
+ }
77
127
  if (parts[1] === "operations") {
78
128
  if (request.method === "POST" && parts.length === 2) {
79
129
  if (!request.headers.get("content-type")?.startsWith("application/json"))
80
130
  throw new RemoteError(415, "JSON_REQUIRED", "Send application/json.");
81
- const input = parseOperation(await request.json());
131
+ const body = await request.text();
132
+ if (Buffer.byteLength(body) > 72 * 1024)
133
+ throw new RemoteError(
134
+ 413,
135
+ "BODY_TOO_LARGE",
136
+ "Operation body is too large.",
137
+ );
138
+ const input = parseOperation(JSON.parse(body));
82
139
  // Never use request.signal as a runtime cancellation signal.
83
140
  return json(await service.submit(input, device), 202);
84
141
  }
85
142
  if (request.method === "GET" && parts.length === 3)
86
143
  return json(service.store.get(requireId(parts[2], "requestId", true)));
87
144
  }
145
+ if (
146
+ request.method === "POST" &&
147
+ parts.length === 4 &&
148
+ parts[1] === "sessions"
149
+ ) {
150
+ if (service.residentStatus().phase !== "ready")
151
+ throw new RemoteError(
152
+ 503,
153
+ "SERVICE_DRAINING",
154
+ "Service is draining; new mutations are not accepted.",
155
+ );
156
+ const session = service.session(requireId(parts[2], "sessionId", true));
157
+ releaseRequest = session.attach(true);
158
+ await session.open();
159
+ session.assertAvailable();
160
+ const response = await sessionPost(
161
+ session,
162
+ parts[3],
163
+ request,
164
+ service.homeRoot,
165
+ );
166
+ if (response) return response;
167
+ }
88
168
  if (request.method === "GET" && parts.length === 4 && parts[1] === "sessions") {
89
169
  const session = service.session(requireId(parts[2], "sessionId", true));
170
+ releaseRequest = session.attach(true);
90
171
  await session.open();
172
+ session.assertAvailable();
173
+ if (
174
+ [
175
+ "files",
176
+ "last-response",
177
+ "view-file",
178
+ "memories",
179
+ "prompt-history",
180
+ "project-commands",
181
+ "git-branch",
182
+ ].includes(parts[3])
183
+ )
184
+ return json(
185
+ await sessionRead(
186
+ session,
187
+ parts[3],
188
+ url,
189
+ request.signal,
190
+ service.homeRoot,
191
+ ),
192
+ );
193
+ if (parts[3] === "tui-snapshot") return json(session.tuiSnapshot());
91
194
  if (parts[3] === "snapshot") return json(session.hub.snapshot());
92
195
  if (parts[3] === "history") {
93
196
  return json(
@@ -116,7 +219,17 @@ export function startRemoteHttpServer(
116
219
  );
117
220
  const cursor =
118
221
  epoch && sequence !== undefined ? { epoch, sequence } : undefined;
119
- if (server.upgrade(request, { data: { session, cursor, device } })) return;
222
+ const release = session.attach();
223
+ try {
224
+ if (
225
+ server.upgrade(request, { data: { session, cursor, device, release } })
226
+ )
227
+ return;
228
+ } catch (error) {
229
+ release();
230
+ throw error;
231
+ }
232
+ release();
120
233
  throw new RemoteError(
121
234
  400,
122
235
  "WEBSOCKET_REQUIRED",
@@ -151,6 +264,8 @@ export function startRemoteHttpServer(
151
264
  },
152
265
  500,
153
266
  );
267
+ } finally {
268
+ releaseRequest?.();
154
269
  }
155
270
  },
156
271
  websocket: {
@@ -185,6 +300,7 @@ export function startRemoteHttpServer(
185
300
  close(socket) {
186
301
  sockets.delete(socket);
187
302
  socket.data.unsubscribe?.();
303
+ socket.data.release?.();
188
304
  // Detach only. The hosted runtime and its AbortController stay alive.
189
305
  },
190
306
  },
@@ -194,6 +310,7 @@ export function startRemoteHttpServer(
194
310
  async stopTransport() {
195
311
  for (const socket of sockets) {
196
312
  socket.data.unsubscribe?.();
313
+ socket.data.release?.();
197
314
  socket.terminate();
198
315
  }
199
316
  // Bun 1.3.14 can leave stop's promise pending after a closing TLS socket