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,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
+ }
@@ -0,0 +1,22 @@
1
+ import { PromptHistory, type LoadedPromptHistoryRecord } from "../tui/prompt-history";
2
+ import type { PromptDraft } from "../tui/prompt-draft";
3
+
4
+ /** Draft navigation stays synchronous; persistence belongs to the service workspace. */
5
+ export class RemotePromptHistory extends PromptHistory {
6
+ private pending: Promise<void> = Promise.resolve();
7
+ constructor(
8
+ records: LoadedPromptHistoryRecord[],
9
+ private readonly persist: (prompt: string | PromptDraft) => Promise<void>,
10
+ ) {
11
+ super({ records });
12
+ }
13
+ override async append(prompt: string | PromptDraft): Promise<void> {
14
+ await super.append(prompt);
15
+ const next = this.pending.catch(() => undefined).then(() => this.persist(prompt));
16
+ this.pending = next;
17
+ await next;
18
+ }
19
+ async flush(): Promise<void> {
20
+ await this.pending.catch(() => undefined);
21
+ }
22
+ }
@@ -0,0 +1,231 @@
1
+ import { decodeFailure } from "../remote/failures";
2
+ import type {
3
+ SessionOperation,
4
+ SessionOperationResult,
5
+ } from "../remote/session-operations";
6
+ import { TurnCancelledError } from "../agent/turn-cancellation";
7
+ import { randomUUID } from "node:crypto";
8
+ import { ClientHttpError, type RemoteClient } from "../remote/client";
9
+ import {
10
+ isTerminal,
11
+ type OperationReceipt,
12
+ type RemoteOperationInput,
13
+ } from "../remote/protocol";
14
+ import type { RemoteTuiSnapshot } from "../remote/tui-protocol";
15
+ import type {
16
+ AcceptedTurn,
17
+ QueueFollowUpResult,
18
+ } from "../agent/runtime-session-contracts";
19
+ import type { RunAgentResult, UserMessage } from "../agent/types";
20
+ import type { TurnId } from "../ids/runtime-id";
21
+ import { validateUserMessage } from "../image/image-types";
22
+
23
+ /** Commands retain their UUID across uncertain HTTP responses. Detach only stops observation. */
24
+ export class RemoteTasks {
25
+ constructor(
26
+ private readonly transport: RemoteClient,
27
+ private readonly sessionId: string,
28
+ private readonly snapshot: () => RemoteTuiSnapshot,
29
+ private readonly lifetime: AbortSignal,
30
+ private readonly reportError: (message: string) => void,
31
+ ) {}
32
+
33
+ async admitTurn(
34
+ userMessage: UserMessage,
35
+ signal: AbortSignal,
36
+ ): Promise<AcceptedTurn> {
37
+ validateUserMessage(userMessage);
38
+ signal.throwIfAborted();
39
+ const requestId = randomUUID();
40
+ const stop = () => {
41
+ if (
42
+ signal.reason instanceof TurnCancelledError &&
43
+ signal.reason.source === "session_dispose"
44
+ )
45
+ return;
46
+ if (!this.lifetime.aborted)
47
+ void this.stop(requestId).catch((error: unknown) => {
48
+ if (!this.lifetime.aborted)
49
+ this.reportError(`Stop failed: ${message(error)}`);
50
+ });
51
+ };
52
+ // Wait for acceptance before sending stop; a lost response is retried with the same ID.
53
+ let receipt = await this.submit({
54
+ kind: "prompt",
55
+ requestId,
56
+ sessionId: this.sessionId,
57
+ prompt: userMessage.content,
58
+ ...(userMessage.attachments ? { attachments: userMessage.attachments } : {}),
59
+ });
60
+ signal.addEventListener("abort", stop, { once: true });
61
+ if (signal.aborted) stop();
62
+ try {
63
+ while (!receipt.turnId && !isTerminal(receipt.status)) {
64
+ await pause(100, this.lifetime);
65
+ receipt = await this.read(requestId);
66
+ }
67
+ if (!receipt.turnId && receipt.failure) throw decodeFailure(receipt.failure);
68
+ if (!receipt.turnId)
69
+ throw new Error(
70
+ receipt.error ?? `Task ${receipt.status} before turn admission.`,
71
+ );
72
+ const completion = this.complete(receipt).finally(() =>
73
+ signal.removeEventListener("abort", stop),
74
+ );
75
+ return { turnId: receipt.turnId as TurnId, userMessage, completion };
76
+ } catch (error) {
77
+ signal.removeEventListener("abort", stop);
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ async queueFollowUp(userMessage: UserMessage): Promise<QueueFollowUpResult> {
83
+ plainText(userMessage);
84
+ const targetRequestId = this.snapshot().activity.activeRequestId;
85
+ if (!targetRequestId) throw new Error("No execution is running in this session.");
86
+ const receipt = await this.settled(
87
+ await this.submit({
88
+ kind: "follow_up",
89
+ requestId: randomUUID(),
90
+ sessionId: this.sessionId,
91
+ targetRequestId,
92
+ prompt: userMessage.content,
93
+ }),
94
+ );
95
+ if (receipt.status !== "completed" || !receipt.followUp)
96
+ throw new Error(receipt.error ?? `Follow-up ${receipt.status}.`);
97
+ return receipt.followUp;
98
+ }
99
+
100
+ async deleteSession(targetSessionId: string): Promise<void> {
101
+ const receipt = await this.settled(
102
+ await this.submit({
103
+ kind: "delete_session",
104
+ sessionId: this.sessionId,
105
+ targetSessionId,
106
+ requestId: randomUUID(),
107
+ }),
108
+ );
109
+ if (receipt.status !== "completed")
110
+ throw new Error(receipt.error ?? `Session deletion ${receipt.status}.`);
111
+ }
112
+
113
+ async operate(
114
+ input: Omit<SessionOperation, "sessionId"> & Record<string, unknown>,
115
+ ): Promise<SessionOperationResult> {
116
+ const receipt = await this.settled(
117
+ await this.submit({
118
+ ...input,
119
+ sessionId: this.sessionId,
120
+ requestId: randomUUID(),
121
+ } as RemoteOperationInput),
122
+ );
123
+ if (receipt.failure) throw decodeFailure(receipt.failure);
124
+ if (receipt.status !== "completed" || !receipt.sessionResult)
125
+ throw new Error(receipt.error ?? `Session operation ${receipt.status}.`);
126
+ return receipt.sessionResult;
127
+ }
128
+
129
+ async respond(
130
+ input:
131
+ | { kind: "answer"; interactionId: string; selectedIndex: number | null }
132
+ | { kind: "confirm"; interactionId: string; decision: "allow" | "deny" }
133
+ | { kind: "provider_retry"; interactionId: string; decision: "retry" | "stop" },
134
+ ): Promise<void> {
135
+ const receipt = await this.settled(
136
+ await this.submit({
137
+ ...input,
138
+ requestId: randomUUID(),
139
+ sessionId: this.sessionId,
140
+ }),
141
+ );
142
+ if (receipt.status !== "completed")
143
+ throw new Error(receipt.error ?? `Interaction ${receipt.status}.`);
144
+ }
145
+
146
+ async stopTurn(): Promise<void> {
147
+ const target = this.snapshot().activity.activeRequestId;
148
+ if (!target) throw new Error("No execution is running in this session.");
149
+ await this.stop(target);
150
+ }
151
+ private async stop(targetRequestId: string): Promise<void> {
152
+ const receipt = await this.settled(
153
+ await this.submit({
154
+ kind: "stop",
155
+ requestId: randomUUID(),
156
+ sessionId: this.sessionId,
157
+ targetRequestId,
158
+ }),
159
+ );
160
+ if (receipt.status !== "completed")
161
+ throw new Error(receipt.error ?? `Stop ${receipt.status}.`);
162
+ await this.settled(await this.read(targetRequestId));
163
+ }
164
+ private async complete(initial: OperationReceipt): Promise<RunAgentResult> {
165
+ const receipt = await this.settled(initial);
166
+ if (receipt.result) return receipt.result;
167
+ throw new Error(
168
+ receipt.error ?? `Task ${receipt.status}; its execution result is unavailable.`,
169
+ );
170
+ }
171
+ private async settled(initial: OperationReceipt): Promise<OperationReceipt> {
172
+ let receipt = initial;
173
+ while (!isTerminal(receipt.status)) {
174
+ await pause(100, this.lifetime);
175
+ receipt = await this.read(receipt.requestId);
176
+ }
177
+ return receipt;
178
+ }
179
+ private submit(input: RemoteOperationInput): Promise<OperationReceipt> {
180
+ return this.retry(() =>
181
+ this.transport.request("/v1/operations", input, this.lifetime),
182
+ );
183
+ }
184
+ private read(id: string): Promise<OperationReceipt> {
185
+ return this.retry(() =>
186
+ this.transport.request(`/v1/operations/${id}`, undefined, this.lifetime),
187
+ );
188
+ }
189
+ private async retry<T>(request: () => Promise<T>): Promise<T> {
190
+ for (;;) {
191
+ this.lifetime.throwIfAborted();
192
+ try {
193
+ return await request();
194
+ } catch (error) {
195
+ this.lifetime.throwIfAborted();
196
+ if (
197
+ error instanceof ClientHttpError &&
198
+ error.status < 500 &&
199
+ error.status !== 429
200
+ )
201
+ throw error;
202
+ this.reportError(`Waiting for service: ${message(error)}`);
203
+ await pause(500, this.lifetime);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ function plainText(userMessage: UserMessage): void {
209
+ validateUserMessage(userMessage);
210
+ if (userMessage.attachments !== undefined)
211
+ throw new Error("Service TUI image input is not enabled yet.");
212
+ }
213
+ function message(error: unknown): string {
214
+ return error instanceof Error ? error.message : String(error);
215
+ }
216
+ function pause(ms: number, signal: AbortSignal): Promise<void> {
217
+ signal.throwIfAborted();
218
+ return new Promise((resolve, reject) => {
219
+ const abort = () => {
220
+ clearTimeout(timer);
221
+ reject(
222
+ signal.reason instanceof Error ? signal.reason : new Error("Client closed."),
223
+ );
224
+ };
225
+ const timer = setTimeout(() => {
226
+ signal.removeEventListener("abort", abort);
227
+ resolve();
228
+ }, ms);
229
+ signal.addEventListener("abort", abort, { once: true });
230
+ });
231
+ }
@@ -0,0 +1,69 @@
1
+ import { type TuiProjectionState } from "../tui/event-store";
2
+ import type { TuiTimelineLog } from "../tui/tui-projection-store";
3
+ import type { RemoteTuiSnapshot } from "../remote/tui-protocol";
4
+
5
+ /** Retains printed history across refreshes; service status is separate from the timeline. */
6
+ export class RemoteTuiView {
7
+ private readonly listeners = new Set<() => void>();
8
+ private readonly printed = new Set<string>();
9
+ private state: TuiProjectionState;
10
+ private log: TuiTimelineLog = { committed: [], live: [] };
11
+ private connection = "connecting";
12
+ private error?: string;
13
+ private activity = "idle";
14
+ private epoch?: string;
15
+ private live: TuiTimelineLog["live"] = [];
16
+
17
+ constructor(snapshot: RemoteTuiSnapshot) {
18
+ this.state = snapshot.history;
19
+ this.update(snapshot);
20
+ }
21
+ readonly getServiceStatus = () => ({
22
+ connection: this.connection,
23
+ activity: this.activity,
24
+ error: this.error,
25
+ });
26
+ readonly getPresentationRevision = () => this.epoch;
27
+ readonly getSnapshot = () => this.state;
28
+ readonly getLogSnapshot = () => this.log;
29
+ readonly subscribe = (listener: () => void) => {
30
+ this.listeners.add(listener);
31
+ return () => {
32
+ this.listeners.delete(listener);
33
+ };
34
+ };
35
+ update(snapshot: RemoteTuiSnapshot): void {
36
+ // Runtime-generated row IDs are not stable across a service restart. Replace
37
+ // the old projection with canonical recovery and request a terminal redraw.
38
+ if (this.epoch !== snapshot.cursor.epoch) {
39
+ this.epoch = snapshot.cursor.epoch;
40
+ this.printed.clear();
41
+ this.log = { committed: [], live: [] };
42
+ }
43
+ this.error = undefined;
44
+ this.activity = snapshot.activity.status;
45
+ this.state = {
46
+ ...snapshot.history,
47
+ ...(snapshot.activity.activeRequestId ? { status: "running" as const } : {}),
48
+ };
49
+ this.live = snapshot.timeline.live;
50
+ const added = snapshot.timeline.committed.filter(
51
+ (item) => !this.printed.has(item.id),
52
+ );
53
+ for (const item of added) this.printed.add(item.id);
54
+ this.log = { committed: [...this.log.committed, ...added], live: [] };
55
+ this.refresh();
56
+ }
57
+ setConnection(connection: string, error?: string): void {
58
+ this.connection = connection;
59
+ this.error = error;
60
+ this.refresh();
61
+ }
62
+ private refresh(): void {
63
+ this.log = {
64
+ ...this.log,
65
+ live: this.live,
66
+ };
67
+ for (const listener of this.listeners) listener();
68
+ }
69
+ }