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
@@ -1,14 +1,14 @@
1
1
  import { Box, Text, useInput } from "ink";
2
2
  import { useState } from "react";
3
- import type { ModelProfile } from "../../cli/model-profiles";
3
+ import type { ClientModelProfile } from "../../client/model-catalog";
4
4
 
5
5
  export type ModelPickerProps = {
6
- profiles: readonly ModelProfile[];
6
+ profiles: readonly ClientModelProfile[];
7
7
  currentProfileName?: string;
8
8
  isSwitching?: boolean;
9
9
  error?: string;
10
10
  onCancel: () => void;
11
- onSelect: (profile: ModelProfile) => void;
11
+ onSelect: (profile: ClientModelProfile) => void;
12
12
  };
13
13
 
14
14
  export function ModelPicker(props: ModelPickerProps) {
@@ -89,7 +89,7 @@ function ModelPickerContent(props: ModelPickerProps) {
89
89
  }
90
90
 
91
91
  function ProfileOption(props: {
92
- profile: ModelProfile;
92
+ profile: ClientModelProfile;
93
93
  isSelected: boolean;
94
94
  isCurrent: boolean;
95
95
  }) {
@@ -117,7 +117,7 @@ function ProfileOption(props: {
117
117
  }
118
118
 
119
119
  function initialSelectedIndex(
120
- profiles: readonly ModelProfile[],
120
+ profiles: readonly ClientModelProfile[],
121
121
  currentProfileName?: string,
122
122
  ): number {
123
123
  if (currentProfileName === undefined) {
@@ -1,3 +1,5 @@
1
+ import type { TuiServiceStatus } from "../tui-session-controller";
2
+ import { TurnCancelledError } from "../../agent/turn-cancellation";
1
3
  import os from "node:os";
2
4
  import path from "node:path";
3
5
  import { Box, Text, useInput, usePaste } from "ink";
@@ -68,6 +70,7 @@ export type PromptInputProps = {
68
70
  version?: string;
69
71
  workspaceRoot: string;
70
72
  gitBranch?: string;
73
+ serviceStatus?: TuiServiceStatus;
71
74
  contextUsage?: ContextUsageSnapshot;
72
75
  isDisabled?: boolean;
73
76
  placeholder?: string;
@@ -150,7 +153,7 @@ export function PromptInput(props: PromptInputProps) {
150
153
 
151
154
  useEffect(
152
155
  () => () => {
153
- operation.current?.abort();
156
+ operation.current?.abort(new TurnCancelledError("session_dispose"));
154
157
  },
155
158
  [],
156
159
  );
@@ -785,6 +788,18 @@ export function PromptInput(props: PromptInputProps) {
785
788
  <Text color={FOOTER_COLORS.cacheRate}>{cacheRate}</Text>
786
789
  </>
787
790
  )}
791
+ {props.serviceStatus === undefined ? null : (
792
+ <Text
793
+ dimColor={!props.serviceStatus.error}
794
+ color={props.serviceStatus.error ? "red" : undefined}
795
+ >
796
+ {" · "}
797
+ {props.serviceStatus.connection}
798
+ {" · "}
799
+ {props.serviceStatus.activity}
800
+ {props.serviceStatus.error ? ` · ${props.serviceStatus.error}` : ""}
801
+ </Text>
802
+ )}
788
803
  {props.version === undefined ? null : (
789
804
  <Text dimColor> · tinker {props.version}</Text>
790
805
  )}
@@ -1,6 +1,6 @@
1
1
  import { Box, Text, useInput, usePaste, useWindowSize } from "ink";
2
2
  import { useReducer, useRef } from "react";
3
- import type { SessionSummary } from "../../session/session-catalog";
3
+ import type { ClientSessionSummary as SessionSummary } from "../../client/session-client";
4
4
  import {
5
5
  backspace,
6
6
  createLineEditorState,
@@ -470,7 +470,11 @@ function SessionCell(props: {
470
470
  }
471
471
 
472
472
  export function isSessionSelectable(session: SessionSummary): boolean {
473
- return session.status === "resumable" || session.status === "interrupted";
473
+ return (
474
+ session.canConnect === true ||
475
+ session.status === "resumable" ||
476
+ session.status === "interrupted"
477
+ );
474
478
  }
475
479
 
476
480
  export function formatRelativeTime(updatedAt: string, now: Date): string {
@@ -26,6 +26,8 @@ export type TuiProjectionStoreInput = {
26
26
  workspaceRoot: string;
27
27
  policy?: TuiProjectionPolicy;
28
28
  initialSnapshot?: TuiProjectionState;
29
+ /** Optional retained transport tail; local terminal scrollback stays unlimited. */
30
+ committedItemLimit?: number;
29
31
  };
30
32
 
31
33
  export type AssistantStreamSectionItem = Readonly<{
@@ -66,6 +68,7 @@ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
66
68
  readonly name = "tui-projection-store";
67
69
  private readonly listeners = new Set<() => void>();
68
70
  private readonly policy: TuiProjectionPolicy;
71
+ private readonly committedItemLimit?: number;
69
72
  private readonly printed = new Set<string>();
70
73
  private readonly physicallyAdoptedIterations = new Map<string, string>();
71
74
  private snapshot: TuiProjectionState;
@@ -73,6 +76,12 @@ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
73
76
  private assistantStreamAttempt?: AssistantStreamAttempt;
74
77
 
75
78
  constructor(input: TuiProjectionStoreInput) {
79
+ if (
80
+ input.committedItemLimit !== undefined &&
81
+ (!Number.isSafeInteger(input.committedItemLimit) || input.committedItemLimit < 1)
82
+ )
83
+ throw new Error("Committed item limit must be a positive integer.");
84
+ this.committedItemLimit = input.committedItemLimit;
76
85
  this.policy = validateTuiProjectionPolicy(
77
86
  input.policy ?? defaultTuiProjectionPolicy,
78
87
  );
@@ -82,6 +91,7 @@ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
82
91
  : validateInitialSnapshot(input, input.initialSnapshot, this.policy);
83
92
  if (input.initialSnapshot !== undefined) {
84
93
  this.refreshLog(visibleTimelineItems(this.snapshot));
94
+ this.retainLog();
85
95
  }
86
96
  }
87
97
 
@@ -279,11 +289,26 @@ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
279
289
  }
280
290
 
281
291
  private notifyListeners(): void {
292
+ this.retainLog();
282
293
  for (const listener of this.listeners) {
283
294
  listener();
284
295
  }
285
296
  }
286
297
 
298
+ private retainLog(): void {
299
+ if (this.committedItemLimit === undefined) return;
300
+ this.log = {
301
+ ...this.log,
302
+ committed: this.log.committed.slice(-this.committedItemLimit),
303
+ };
304
+ // Only IDs still reachable from the bounded projection or transport tail can reappear.
305
+ const retained = new Set([
306
+ ...timelineStreamItems(this.snapshot).map((item) => item.id),
307
+ ...this.log.committed.map((item) => item.id),
308
+ ]);
309
+ for (const id of this.printed) if (!retained.has(id)) this.printed.delete(id);
310
+ }
311
+
287
312
  private refreshLog(stream = timelineStreamItems(this.snapshot)): void {
288
313
  const settledEnd = firstRunningIndex(stream);
289
314
  const pending = stream
@@ -1,286 +1,16 @@
1
- import type {
2
- ContextCompactionResult,
3
- ContextRetirementResult,
4
- } from "../context/context-manager";
5
- import type {
6
- ExecuteTurnInput,
7
- AcceptedTurn,
8
- AskUserSnapshot,
9
- AskUserResolution,
10
- BashGuardSnapshot,
11
- RuntimeSession,
12
- RuntimeSkillsSnapshot,
13
- SessionDisposeReason,
14
- } from "../agent/runtime-session";
15
- import type { RunAgentResult, UserMessage } from "../agent/types";
16
- import type { TurnUndoResult } from "../tools/turn-undo-manager";
17
- import type { ImageAssetRef } from "../image/image-types";
18
- import type { ImportedImageAsset } from "../image/image-asset-store";
19
- import type { SessionId } from "../ids/runtime-id";
20
- import { createUuidV7 } from "../ids/uuid-v7";
21
- import type { ModelProfile } from "../cli/model-profiles";
22
- import type { McpInventorySnapshot } from "../mcp/mcp-manager";
23
- import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
24
- import { SessionCatalog, type SessionSummary } from "../session/session-catalog";
1
+ import type { SessionClient, WorkspaceClient } from "../client/session-client";
25
2
  import type { TuiProjectionStore } from "./tui-projection-store";
26
3
 
27
- export type TuiSessionBinding = {
28
- sessionId: SessionId;
29
- modelName: string;
30
- workspaceRoot: string;
31
- profileName?: string;
32
- projectionStore: TuiProjectionStore;
33
- skills(): RuntimeSkillsSnapshot;
34
- mcp(): McpInventorySnapshot;
35
- reasoningEffort?: () => ReasoningEffortSnapshot | undefined;
36
- setReasoningEffort?: (effort: string) => ReasoningEffortSnapshot;
37
- resetReasoningEffort?: () => ReasoningEffortSnapshot;
38
- supportsImageInput?: () => boolean;
39
- importImage?: (
40
- sourcePath: string,
41
- signal: AbortSignal,
42
- prospectiveMessageImageCount: number,
43
- ) => Promise<ImportedImageAsset>;
44
- verifyImageAssets?: (
45
- assets: readonly ImageAssetRef[],
46
- signal: AbortSignal,
47
- ) => Promise<void>;
48
- admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
49
- executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
50
- promptScheduler?: RuntimeSession["promptScheduler"];
51
- subscribePromptScheduler?: RuntimeSession["subscribePromptScheduler"];
52
- queueFollowUp?: RuntimeSession["queueFollowUp"];
53
- bashGuard(): BashGuardSnapshot;
54
- subscribeBashGuard(listener: () => void): () => void;
55
- setYoloMode(enabled: boolean): void;
56
- resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
57
- providerRetry?: RuntimeSession["providerRetry"];
58
- subscribeProviderRetry?: RuntimeSession["subscribeProviderRetry"];
59
- resolveProviderRetry?: RuntimeSession["resolveProviderRetry"];
60
- askUser(): AskUserSnapshot;
61
- subscribeAskUser(listener: () => void): () => void;
62
- resolveAskUser(response: AskUserResolution): Promise<void>;
63
- };
64
-
65
- export type TuiSessionController = {
66
- getBinding: () => TuiSessionBinding;
67
- subscribe: (listener: () => void) => () => void;
68
- listSessions: () => Promise<readonly SessionSummary[]>;
69
- compact: () => Promise<ContextCompactionResult>;
70
- retire: () => Promise<ContextRetirementResult>;
71
- undo: () => Promise<TurnUndoResult>;
72
- fork: (beforeCommit?: () => void) => Promise<SessionId>;
73
- clear: (beforeCommit?: () => void) => Promise<void>;
74
- resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
75
- delete: (sessionId: SessionId) => Promise<void>;
76
- switchModel: (profile: ModelProfile, beforeCommit?: () => void) => Promise<void>;
77
- };
4
+ export type TuiServiceStatus = { connection: string; activity: string; error?: string };
78
5
 
79
- export type ManagedTuiSessionBinding = TuiSessionBinding & {
80
- runtimeSession: RuntimeSession;
6
+ /** Read-only presentation surface; the client adapter owns event ingestion. */
7
+ export type TuiSessionView = Pick<
8
+ TuiProjectionStore,
9
+ "getSnapshot" | "getLogSnapshot" | "subscribe"
10
+ > & {
11
+ getServiceStatus?: () => TuiServiceStatus;
12
+ /** Changes when canonical recovery replaces previously printed rows. */
13
+ getPresentationRevision?: () => string | undefined;
81
14
  };
82
-
83
- export class DefaultTuiSessionController implements TuiSessionController {
84
- private readonly listeners = new Set<() => void>();
85
- private binding: ManagedTuiSessionBinding;
86
- private operation?: Promise<unknown>;
87
-
88
- constructor(
89
- initial: ManagedTuiSessionBinding,
90
- private readonly catalog: SessionCatalog,
91
- private readonly openSession: (
92
- sessionId: SessionId,
93
- ) => Promise<ManagedTuiSessionBinding>,
94
- private readonly createSessionWithProfile: (
95
- profile: ModelProfile,
96
- ) => Promise<ManagedTuiSessionBinding>,
97
- private readonly createFreshSession: (
98
- current: TuiSessionBinding,
99
- ) => Promise<ManagedTuiSessionBinding>,
100
- ) {
101
- this.binding = initial;
102
- }
103
-
104
- readonly getBinding = (): TuiSessionBinding => this.binding;
105
-
106
- readonly subscribe = (listener: () => void): (() => void) => {
107
- this.listeners.add(listener);
108
- return () => {
109
- this.listeners.delete(listener);
110
- };
111
- };
112
-
113
- listSessions(): Promise<readonly SessionSummary[]> {
114
- return this.catalog.listAll(this.binding.sessionId);
115
- }
116
-
117
- compact(): Promise<ContextCompactionResult> {
118
- return this.serialize(() => this.binding.runtimeSession.compactContext());
119
- }
120
-
121
- retire(): Promise<ContextRetirementResult> {
122
- return this.serialize(() => this.binding.runtimeSession.retireContext());
123
- }
124
-
125
- undo(): Promise<TurnUndoResult> {
126
- return this.serialize(() =>
127
- this.binding.runtimeSession.undoLatestFileMutationTurn(),
128
- );
129
- }
130
-
131
- fork(beforeCommit?: () => void): Promise<SessionId> {
132
- return this.serialize(async () => {
133
- const targetSessionId = createUuidV7() as SessionId;
134
- await this.replaceSession(
135
- "Cannot clone the session while a turn, context operation, or background task is active.",
136
- async (current) => {
137
- await current.runtimeSession.cloneSession(targetSessionId);
138
- return this.openSession(targetSessionId);
139
- },
140
- beforeCommit,
141
- );
142
- return targetSessionId;
143
- });
144
- }
145
-
146
- clear(beforeCommit?: () => void): Promise<void> {
147
- return this.serialize(() =>
148
- this.replaceSession(
149
- "Cannot clear the session while a turn, context operation, or background task is active.",
150
- (current) => this.createFreshSession(current),
151
- beforeCommit,
152
- ),
153
- );
154
- }
155
-
156
- resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
157
- return this.serialize(async () => {
158
- if (sessionId === this.binding.sessionId) {
159
- throw new Error(`Session ${sessionId} is already current.`);
160
- }
161
- await this.replaceSession(
162
- "Cannot switch sessions while a turn or background task is active.",
163
- () => this.openSession(sessionId),
164
- beforeCommit,
165
- );
166
- });
167
- }
168
-
169
- delete(sessionId: SessionId): Promise<void> {
170
- return this.serialize(() => this.catalog.delete(sessionId, this.binding.sessionId));
171
- }
172
-
173
- switchModel(profile: ModelProfile, beforeCommit?: () => void): Promise<void> {
174
- return this.serialize(() =>
175
- this.replaceSession(
176
- "Cannot switch models while a turn or background task is active.",
177
- () => this.createSessionWithProfile(profile),
178
- beforeCommit,
179
- ),
180
- );
181
- }
182
-
183
- dispose(reason: SessionDisposeReason): Promise<void> {
184
- return this.binding.runtimeSession.dispose(reason);
185
- }
186
-
187
- private async replaceSession(
188
- unavailableMessage: string,
189
- createTarget: (
190
- current: ManagedTuiSessionBinding,
191
- ) => Promise<ManagedTuiSessionBinding>,
192
- beforeCommit?: () => void,
193
- ): Promise<void> {
194
- const current = this.binding;
195
- if (!current.runtimeSession.canSwitchSession()) {
196
- throw new Error(unavailableMessage);
197
- }
198
-
199
- const target = await createTarget(current);
200
- try {
201
- await current.runtimeSession.dispose({ type: "session_switch" });
202
- } catch (error) {
203
- await target.runtimeSession
204
- .dispose({ type: "runner_failed", error: errorMessage(error) })
205
- .catch(() => undefined);
206
- throw error;
207
- }
208
- beforeCommit?.();
209
- this.binding = target;
210
- for (const listener of this.listeners) {
211
- listener();
212
- }
213
- }
214
-
215
- private serialize<T>(operation: () => Promise<T>): Promise<T> {
216
- if (this.operation !== undefined) {
217
- return Promise.reject(new Error("Another session operation is already running."));
218
- }
219
- const pending = operation().finally(() => {
220
- if (this.operation === pending) {
221
- this.operation = undefined;
222
- }
223
- });
224
- this.operation = pending;
225
- return pending;
226
- }
227
- }
228
-
229
- export function managedTuiBinding(input: {
230
- runtimeSession: RuntimeSession;
231
- modelName: string;
232
- workspaceRoot: string;
233
- profileName?: string;
234
- projectionStore: TuiProjectionStore;
235
- }): ManagedTuiSessionBinding {
236
- return {
237
- sessionId: input.runtimeSession.sessionId,
238
- modelName: input.modelName,
239
- workspaceRoot: input.workspaceRoot,
240
- profileName: input.profileName,
241
- projectionStore: input.projectionStore,
242
- runtimeSession: input.runtimeSession,
243
- skills: () => input.runtimeSession.skills(),
244
- mcp: () => input.runtimeSession.mcp(),
245
- reasoningEffort: () => input.runtimeSession.reasoningEffort(),
246
- setReasoningEffort: (effort) => input.runtimeSession.setReasoningEffort(effort),
247
- resetReasoningEffort: () => input.runtimeSession.resetReasoningEffort(),
248
- supportsImageInput: () => input.runtimeSession.supportsImageInput(),
249
- importImage: (sourcePath, signal, prospectiveMessageImageCount) =>
250
- input.runtimeSession.importImage(
251
- sourcePath,
252
- signal,
253
- prospectiveMessageImageCount,
254
- ),
255
- verifyImageAssets: (assets, signal) =>
256
- input.runtimeSession.verifyImageAssets(assets, signal),
257
- admitTurn: (userMessage, signal) =>
258
- input.runtimeSession.admitTurn({ userMessage, signal }),
259
- executeTurn: (userMessage, signal) =>
260
- input.runtimeSession.executeTurn({
261
- userMessage,
262
- signal,
263
- } satisfies ExecuteTurnInput),
264
- promptScheduler: () => input.runtimeSession.promptScheduler(),
265
- subscribePromptScheduler: (listener) =>
266
- input.runtimeSession.subscribePromptScheduler(listener),
267
- queueFollowUp: (userMessage) => input.runtimeSession.queueFollowUp(userMessage),
268
- bashGuard: () => input.runtimeSession.bashGuard(),
269
- subscribeBashGuard: (listener) => input.runtimeSession.subscribeBashGuard(listener),
270
- setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
271
- resolveBashConfirmation: (decision) =>
272
- input.runtimeSession.resolveBashConfirmation(decision),
273
- providerRetry: () => input.runtimeSession.providerRetry(),
274
- subscribeProviderRetry: (listener) =>
275
- input.runtimeSession.subscribeProviderRetry(listener),
276
- resolveProviderRetry: (requestId, decision) =>
277
- input.runtimeSession.resolveProviderRetry(requestId, decision),
278
- askUser: () => input.runtimeSession.askUser(),
279
- subscribeAskUser: (listener) => input.runtimeSession.subscribeAskUser(listener),
280
- resolveAskUser: (response) => input.runtimeSession.resolveAskUser(response),
281
- };
282
- }
283
-
284
- function errorMessage(error: unknown): string {
285
- return error instanceof Error ? error.message : String(error);
286
- }
15
+ export type TuiSessionBinding = SessionClient<TuiSessionView>;
16
+ export type TuiSessionController = WorkspaceClient<TuiSessionView>;