tinker-agent 2.9.0 → 2.10.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 (38) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +17 -1
  3. package/package.json +2 -1
  4. package/src/agent/runtime-hosted-session.ts +443 -0
  5. package/src/cli/command-line.ts +26 -2
  6. package/src/cli/connect-runner.tsx +26 -0
  7. package/src/cli/main.ts +26 -0
  8. package/src/cli/output.ts +1 -1
  9. package/src/cli/public-cli-contract.ts +18 -0
  10. package/src/cli/public-config-contract.ts +1 -1
  11. package/src/cli/serve-runner.ts +45 -0
  12. package/src/cli/serve-runtime.ts +100 -0
  13. package/src/context/context-swap-renderer.ts +14 -0
  14. package/src/observation/observation-builder.ts +87 -37
  15. package/src/remote/client.ts +350 -0
  16. package/src/remote/config.ts +95 -0
  17. package/src/remote/http-server.ts +240 -0
  18. package/src/remote/protocol.ts +228 -0
  19. package/src/remote/service-store.ts +175 -0
  20. package/src/remote/service.ts +219 -0
  21. package/src/remote/sync-hub.ts +95 -0
  22. package/src/session/remote-history-reader.ts +143 -0
  23. package/src/tools/bash-task.ts +26 -16
  24. package/src/tools/bash.ts +44 -2
  25. package/src/tools/glob.ts +107 -19
  26. package/src/tools/grep-output.ts +130 -0
  27. package/src/tools/grep-pagination.ts +73 -0
  28. package/src/tools/grep-path.ts +11 -0
  29. package/src/tools/grep-snippets.ts +111 -0
  30. package/src/tools/grep.ts +139 -154
  31. package/src/tools/read.ts +0 -9
  32. package/src/tools/ripgrep.ts +19 -26
  33. package/src/tools/shell-process.ts +30 -4
  34. package/src/tools/task-stop.ts +2 -1
  35. package/src/tools/terminal-screen.ts +11 -2
  36. package/src/tools/types.ts +30 -2
  37. package/src/tui/event-store.ts +15 -2
  38. package/src/tui/remote-app.tsx +210 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.10.0] - 2026-09-06
9
+
10
+ ### Added
11
+
12
+ - Add remote access for paired clients. `tinker serve --config <path>` runs the
13
+ local daemon that paired remote clients connect to, and
14
+ `tinker connect --config <path>` attaches a terminal client to a running
15
+ service; exiting the client detaches without stopping the service. See
16
+ `docs/remote-access.md`. A native iOS client under `client/Tinker` is included
17
+ for acceptance.
18
+ - Add `offset` and `head_limit` pagination to `Glob` results. Long result sets
19
+ now report whether more matches remain instead of being silently capped.
20
+ - Add `cols` and `rows` options to `Bash` for custom initial PTY dimensions when
21
+ `tty=true`, so terminal applications that require a specific size can run in
22
+ an appropriately sized pseudo-terminal.
23
+
24
+ ### Changed
25
+
26
+ - Run `Grep` directory searches with ripgrep's working directory set to the search
27
+ directory and `.` as the path argument. Directory globs such as `sub/**` now
28
+ match from that directory; globs that relied on absolute search paths may need
29
+ updating. Explicit file searches keep their existing invocation.
30
+ - Increase the default `Bash` foreground wait before a command continues in the
31
+ background, and clarify how `TaskOutput` and `TaskInput` poll PTY sessions.
32
+ - Omit internal file hashes from tool observations; the model sees the same
33
+ content without opaque bookkeeping tokens.
34
+
35
+ ### Fixed
36
+
37
+ - Stop `TaskStop` from hanging when a shell's child processes survive after the
38
+ shell itself exits; stopping a task now reliably reports its final state.
39
+ - Preserve `Grep` match context across paginated results, keep long-line matches
40
+ instead of dropping them, and report counts and paginated records
41
+ consistently, including a `count-matches` mode with explicit counting units.
42
+ - Resolve symbolic-link search roots in `Glob` while preserving result paths,
43
+ and exclude directory and broken symbolic links from results.
44
+ - Accept pagination arguments in `Read` when reading empty files instead of
45
+ rejecting the call.
46
+
8
47
  ## [2.9.0] - 2026-09-05
9
48
 
10
49
  ### Added
@@ -398,7 +437,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
398
437
  - First formal npm release under the `tinker-agent` package name with the `tinker`
399
438
  executable.
400
439
 
401
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.9.0...HEAD
440
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.10.0...HEAD
441
+ [2.10.0]: https://github.com/ishowshao/tinker/releases/tag/v2.10.0
402
442
  [2.9.0]: https://github.com/ishowshao/tinker/releases/tag/v2.9.0
403
443
  [2.8.0]: https://github.com/ishowshao/tinker/releases/tag/v2.8.0
404
444
  [2.7.0]: https://github.com/ishowshao/tinker/releases/tag/v2.7.0
package/README.md CHANGED
@@ -34,6 +34,20 @@ This does not pretend that any model has infinite tokens or guarantee that it wi
34
34
  explicit model and context limits. Actual provider support must be established
35
35
  by a qualification matrix; transport compatibility alone is not a guarantee.
36
36
 
37
+ ### PTY screen size
38
+
39
+ `Bash` accepts optional `cols` and `rows` for the initial PTY screen size:
40
+
41
+ ```json
42
+ {"command":"python3 -q","tty":true,"cols":160,"rows":48}
43
+ ```
44
+
45
+ Each omitted dimension keeps its default: 80 columns and 24 rows. Columns accept
46
+ integers from 2 to 1000; rows accept integers from 1 to 1000. When `tty` is omitted
47
+ or `false`, dimensions are ignored and execution continues through ordinary pipes.
48
+ `TaskOutput` and `TaskInput` report the actual screen size. Dimensions are set at
49
+ creation; running tasks cannot be resized through these tools.
50
+
37
51
  ### Reading another session's history
38
52
 
39
53
  Both Recall tools accept an optional `sessionId` (a canonical session UUID).
@@ -158,6 +172,8 @@ The installed package exposes this public CLI:
158
172
  | `tinker run [--profile <profile-name>] [--yolo] --stdin` | Read the prompt from standard input until EOF. |
159
173
  | `tinker run [--profile <profile-name>] [--yolo] --file <path>` | Read the prompt from a UTF-8 text file. |
160
174
  | `tinker update` | Update the global npm installation from the official npm registry. |
175
+ | `tinker serve --config <path>` | Run the local daemon for paired remote clients. |
176
+ | `tinker connect --config <path>` | Attach a terminal client to a service; exiting detaches only. |
161
177
  | `tinker --help` | Show top-level CLI help. |
162
178
  | `tinker help run` | Show one-shot command help. |
163
179
  | `tinker help update` | Show update command help. |
@@ -229,7 +245,7 @@ are required. Boolean environment values accept case-insensitive `true/false`,
229
245
  | `EXA_API_KEY` | Tooling | All modes | No | Non-empty string | — | Yes | Enables WebSearch and the Exa WebFetch backend when set. |
230
246
  | `TINKER_MCP_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `60000` | No | MCP tool-call timeout in milliseconds. |
231
247
  | `TINKER_MCP_MAX_OBSERVATION_CHARS` | Tooling | All modes | No | Positive integer | `40000` | No | Maximum model-visible characters in one MCP result. |
232
- | `TINKER_BASH_DEFAULT_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `5000` | No | Default Bash foreground timeout in milliseconds. |
248
+ | `TINKER_BASH_DEFAULT_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `60000` | No | Default Bash foreground timeout in milliseconds. |
233
249
  | `TINKER_BASH_MAX_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `600000` | No | Maximum Bash foreground timeout in milliseconds. |
234
250
  | `TINKER_YOLO` | Tooling | All modes | No | Boolean | `false` | No | Allow high-confidence destructive Bash commands without confirmation. |
235
251
  | `TINKER_GREP_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `20000` | No | Bundled ripgrep invocation timeout in milliseconds. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "src/memory",
24
24
  "src/model",
25
25
  "src/observation",
26
+ "src/remote",
26
27
  "src/session",
27
28
  "src/skills",
28
29
  "src/tools",
@@ -0,0 +1,443 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { RuntimeSession } from "./runtime-session";
3
+ import type {
4
+ AssistantTextDeltaSink,
5
+ AssistantTextDeltaUpdate,
6
+ } from "./assistant-text-delta";
7
+ import type { AgentEvent } from "../events/types";
8
+ import type { EventSink } from "../events/event-sink";
9
+ import {
10
+ RemoteHistoryReader,
11
+ type RemoteHistoryPage,
12
+ } from "../session/remote-history-reader";
13
+ import { parseSessionId } from "../ids/runtime-id";
14
+ import {
15
+ RemoteError,
16
+ type RemoteActivity,
17
+ type RemoteOperationInput,
18
+ type OperationReceipt,
19
+ type RemoteView,
20
+ } from "../remote/protocol";
21
+ import { type ManagedSessionRecord, RemoteServiceStore } from "../remote/service-store";
22
+ import { RemoteSyncHub } from "../remote/sync-hub";
23
+
24
+ export type HostedRuntimeFactory = (input: {
25
+ record: ManagedSessionRecord;
26
+ sink: EventSink & AssistantTextDeltaSink;
27
+ }) => Promise<{
28
+ runtime: RuntimeSession;
29
+ databasePath: string;
30
+ modelName: string;
31
+ }>;
32
+
33
+ export class HostedSession implements EventSink, AssistantTextDeltaSink {
34
+ readonly name = "remote-view";
35
+ readonly hub: RemoteSyncHub;
36
+ private runtime?: RuntimeSession;
37
+ private reader?: RemoteHistoryReader;
38
+ private opening?: Promise<void>;
39
+ private readonly queue: OperationReceipt[] = [];
40
+ private active?: {
41
+ receipt: OperationReceipt;
42
+ controller: AbortController;
43
+ completion?: Promise<void>;
44
+ };
45
+ private activity: Omit<RemoteActivity, "session" | "operations"> = {
46
+ status: "idle",
47
+ tools: [],
48
+ };
49
+ private lastOrdinal = 0;
50
+ private streamTimer?: ReturnType<typeof setTimeout>;
51
+ private stopping = false;
52
+ private unsubscribers: (() => void)[] = [];
53
+ private controlTail: Promise<void> = Promise.resolve();
54
+
55
+ constructor(
56
+ private record: ManagedSessionRecord,
57
+ private readonly store: RemoteServiceStore,
58
+ epoch: string,
59
+ private readonly factory: HostedRuntimeFactory,
60
+ ) {
61
+ this.hub = new RemoteSyncHub(epoch, () => this.view());
62
+ }
63
+
64
+ open(): Promise<void> {
65
+ return (this.opening ??= this.initialize());
66
+ }
67
+ private async initialize(): Promise<void> {
68
+ try {
69
+ const opened = await this.factory({ record: this.record, sink: this });
70
+ this.runtime = opened.runtime;
71
+ this.reader = new RemoteHistoryReader(
72
+ opened.databasePath,
73
+ parseSessionId(this.record.id),
74
+ this.record.workspacePath,
75
+ );
76
+ this.record = {
77
+ ...this.record,
78
+ initialized: true,
79
+ modelName: opened.modelName,
80
+ status: "idle",
81
+ };
82
+ this.store.saveSession(this.record);
83
+ const latest = this.reader.latestTurn();
84
+ if (latest && latest.status !== "open") {
85
+ this.activity.status = latest.status as RemoteActivity["status"];
86
+ this.activity.error = latest.error;
87
+ }
88
+ // Canonical terminal status wins over a crash between turn commit and receipt update.
89
+ for (const receipt of this.store.operations(this.record.id)) {
90
+ const status = receipt.turnId
91
+ ? this.reader.turnStatus(receipt.turnId)
92
+ : undefined;
93
+ if (receipt.kind === "prompt" && status && status !== "open") {
94
+ this.store.update({
95
+ ...receipt,
96
+ status: status as OperationReceipt["status"],
97
+ error: status === "interrupted" ? receipt.error : undefined,
98
+ });
99
+ }
100
+ }
101
+ const last = this.store
102
+ .operations(this.record.id)
103
+ .filter((op) => op.kind === "prompt")
104
+ .at(-1);
105
+ if (last?.status === "interrupted") {
106
+ this.activity.status = "interrupted";
107
+ this.activity.error = last.error;
108
+ }
109
+ this.unsubscribers = [
110
+ this.runtime.subscribeAskUser(() => this.updateInteraction()),
111
+ this.runtime.subscribeBashGuard(() => this.updateInteraction()),
112
+ ];
113
+ this.publish();
114
+ } catch (error) {
115
+ this.activity.status = "failed";
116
+ this.activity.error = errorMessage(error);
117
+ if (this.runtime)
118
+ await this.runtime
119
+ .dispose({ type: "runner_failed", error: errorMessage(error) })
120
+ .catch(() => undefined);
121
+ this.publish();
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ view(): RemoteView {
127
+ return { ...this.readActivity(), history: this.history() };
128
+ }
129
+ history(before?: number, limit?: number): RemoteHistoryPage {
130
+ return this.reader?.page(before, limit) ?? { messages: [], hasMore: false };
131
+ }
132
+ private readActivity(): RemoteActivity {
133
+ const { id, workspaceId, title, modelName, owner, updatedAt } = this.record;
134
+ const session = { id, workspaceId, title, modelName, owner, updatedAt };
135
+ return structuredClone({
136
+ ...this.activity,
137
+ session: { ...session, status: this.activity.status },
138
+ operations: this.store.operations(this.record.id),
139
+ });
140
+ }
141
+
142
+ validate(input: RemoteOperationInput): void {
143
+ if (this.stopping)
144
+ throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
145
+ if (input.kind === "prompt" && this.queue.length >= 8)
146
+ throw new RemoteError(
147
+ 409,
148
+ "QUEUE_FULL",
149
+ "This session already has eight queued requests.",
150
+ );
151
+ if (input.kind === "answer" || input.kind === "confirm") {
152
+ const pending = this.activity.interaction;
153
+ if (
154
+ !pending ||
155
+ pending.id !== input.interactionId ||
156
+ pending.kind !== (input.kind === "answer" ? "question" : "confirmation")
157
+ )
158
+ throw new RemoteError(
159
+ 409,
160
+ "STALE_INTERACTION",
161
+ "This question or confirmation is no longer pending.",
162
+ );
163
+ if (
164
+ input.kind === "answer" &&
165
+ input.selectedIndex !== null &&
166
+ pending.kind === "question" &&
167
+ !pending.options[input.selectedIndex]
168
+ )
169
+ throw new RemoteError(400, "INVALID_ANSWER", "Answer index is out of range.");
170
+ }
171
+ if (input.kind === "stop") {
172
+ const target = this.store.get(input.targetRequestId);
173
+ if (target.sessionId !== this.record.id || target.kind !== "prompt")
174
+ throw new RemoteError(
175
+ 409,
176
+ "INVALID_STOP_TARGET",
177
+ "Stop must target a prompt in this session.",
178
+ );
179
+ }
180
+ }
181
+
182
+ enqueue(receipt: OperationReceipt): void {
183
+ this.queue.push(receipt);
184
+ if (!this.active) this.activity.status = "accepted";
185
+ if (this.record.title === "New session" && receipt.prompt) {
186
+ this.record = {
187
+ ...this.record,
188
+ title: receipt.prompt.replace(/\s+/g, " ").slice(0, 100),
189
+ };
190
+ this.store.saveSession(this.record);
191
+ }
192
+ this.publish();
193
+ this.pump();
194
+ }
195
+ private pump(): void {
196
+ if (this.active || this.stopping) return;
197
+ const receipt = this.queue.shift();
198
+ if (!receipt) return;
199
+ const active = {
200
+ receipt,
201
+ controller: new AbortController(),
202
+ completion: undefined as Promise<void> | undefined,
203
+ };
204
+ this.active = active;
205
+ active.completion = this.execute(active);
206
+ }
207
+ private async execute(active: NonNullable<HostedSession["active"]>): Promise<void> {
208
+ try {
209
+ await this.open();
210
+ if (active.controller.signal.aborted) {
211
+ this.updateReceipt(active.receipt, { status: "cancelled" });
212
+ return;
213
+ }
214
+ this.activity = {
215
+ status: "running",
216
+ activeRequestId: active.receipt.requestId,
217
+ tools: [],
218
+ };
219
+ this.updateReceipt(active.receipt, { status: "running" });
220
+ this.publish();
221
+ const accepted = await this.runtime!.admitTurn({
222
+ userMessage: { role: "user", content: active.receipt.prompt! },
223
+ signal: active.controller.signal,
224
+ });
225
+ active.receipt = this.updateReceipt(active.receipt, {
226
+ status: "running",
227
+ turnId: accepted.turnId,
228
+ });
229
+ this.activity.activeTurnId = accepted.turnId;
230
+ this.publish();
231
+ const result = await accepted.completion;
232
+ this.updateReceipt(active.receipt, {
233
+ status: result.status,
234
+ ...(result.status === "failed" ? { error: result.error } : {}),
235
+ });
236
+ this.activity.status = result.status;
237
+ if (result.status === "failed") this.activity.error = result.error;
238
+ } catch (error) {
239
+ const status = active.controller.signal.aborted ? "cancelled" : "failed";
240
+ this.updateReceipt(active.receipt, {
241
+ status,
242
+ error: errorMessage(error),
243
+ });
244
+ this.activity.status = status;
245
+ this.activity.error = errorMessage(error);
246
+ } finally {
247
+ this.activity.streaming = undefined;
248
+ this.activity.interaction = undefined;
249
+ this.activity.activeRequestId = undefined;
250
+ this.activity.activeTurnId = undefined;
251
+ for (const tool of this.activity.tools) {
252
+ if (tool.status === "running")
253
+ tool.status = this.activity.status === "cancelled" ? "cancelled" : "failed";
254
+ }
255
+ this.active = undefined;
256
+ this.publish(true);
257
+ this.pump();
258
+ }
259
+ }
260
+
261
+ control(input: RemoteOperationInput, receipt: OperationReceipt): void {
262
+ // Separate from prompt execution: answering a wait cannot wait for that turn.
263
+ this.controlTail = this.controlTail.then(async () => {
264
+ try {
265
+ await this.open();
266
+ this.validate(input);
267
+ if (input.kind === "stop") {
268
+ if (this.active?.receipt.requestId === input.targetRequestId)
269
+ this.active.controller.abort();
270
+ const index = this.queue.findIndex(
271
+ (op) => op.requestId === input.targetRequestId,
272
+ );
273
+ if (index !== -1)
274
+ this.updateReceipt(this.queue.splice(index, 1)[0], {
275
+ status: "cancelled",
276
+ });
277
+ } else if (input.kind === "answer") {
278
+ await this.runtime!.resolveAskUser(
279
+ input.selectedIndex === null
280
+ ? { outcome: "dismissed" }
281
+ : { outcome: "selected", selectedIndex: input.selectedIndex },
282
+ );
283
+ } else if (input.kind === "confirm") {
284
+ await this.runtime!.resolveBashConfirmation(input.decision);
285
+ }
286
+ this.updateReceipt(receipt, { status: "completed" });
287
+ } catch (error) {
288
+ this.updateReceipt(receipt, {
289
+ status: "failed",
290
+ error: errorMessage(error),
291
+ });
292
+ }
293
+ this.publish();
294
+ });
295
+ }
296
+
297
+ private updateReceipt(
298
+ receipt: OperationReceipt,
299
+ patch: Partial<OperationReceipt>,
300
+ ): OperationReceipt {
301
+ const next = this.store.update({
302
+ ...this.store.get(receipt.requestId),
303
+ ...patch,
304
+ });
305
+ this.record = { ...this.record, updatedAt: next.updatedAt };
306
+ this.store.saveSession(this.record);
307
+ if (this.active?.receipt.requestId === receipt.requestId)
308
+ this.active.receipt = next;
309
+ return next;
310
+ }
311
+
312
+ private updateInteraction(): void {
313
+ const question = this.runtime?.askUser().pending;
314
+ const confirmation = this.runtime?.bashGuard().pending;
315
+ if (question || confirmation) {
316
+ if (!this.activity.interaction) {
317
+ this.activity.interaction = question
318
+ ? { id: randomUUID(), kind: "question", ...question }
319
+ : { id: randomUUID(), kind: "confirmation", ...confirmation! };
320
+ }
321
+ this.activity.status = "waiting_input";
322
+ } else {
323
+ this.activity.interaction = undefined;
324
+ if (this.active) this.activity.status = "running";
325
+ }
326
+ if (this.active)
327
+ this.updateReceipt(this.active.receipt, {
328
+ status: this.activity.status as OperationReceipt["status"],
329
+ });
330
+ this.publish();
331
+ }
332
+
333
+ async append(event: AgentEvent): Promise<void> {
334
+ if (!this.reader) return;
335
+ switch (event.type) {
336
+ case "turn.started":
337
+ this.activity.activeTurnId = event.turnId;
338
+ if (this.active)
339
+ this.updateReceipt(this.active.receipt, { turnId: event.turnId });
340
+ break;
341
+ case "model.request.started":
342
+ this.activity.streaming = undefined;
343
+ break;
344
+ case "model.request.finished":
345
+ this.activity.streaming = undefined;
346
+ break;
347
+ case "tool.started":
348
+ this.activity.tools.push({
349
+ id: event.data.call.toolCallId,
350
+ name: event.data.call.name,
351
+ arguments: JSON.stringify(event.data.call.args),
352
+ status: "running",
353
+ });
354
+ this.activity.tools = this.activity.tools.slice(-100);
355
+ break;
356
+ case "tool.finished": {
357
+ const tool = this.activity.tools.find((tool) => tool.id === event.toolCallId);
358
+ if (tool) tool.status = event.data.ok ? "completed" : "failed";
359
+ break;
360
+ }
361
+ case "tool.observation": {
362
+ const tool = this.activity.tools.find((tool) => tool.id === event.toolCallId);
363
+ if (tool) tool.detail = event.data.observation.displayText;
364
+ break;
365
+ }
366
+ case "turn.finished":
367
+ case "turn.failed":
368
+ case "turn.cancelled":
369
+ case "agent.iteration.finished":
370
+ case "turn.steering.applied":
371
+ break;
372
+ default:
373
+ return;
374
+ }
375
+ this.publish();
376
+ }
377
+ updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
378
+ const previous = this.activity.streaming;
379
+ this.activity.streaming = {
380
+ iterationId: update.iterationId,
381
+ attempt: update.attemptNumber,
382
+ text:
383
+ previous?.iterationId === update.iterationId &&
384
+ previous.attempt === update.attemptNumber
385
+ ? previous.text + update.content
386
+ : update.content,
387
+ };
388
+ if (!this.streamTimer)
389
+ this.streamTimer = setTimeout(() => {
390
+ this.streamTimer = undefined;
391
+ this.publish();
392
+ }, 50);
393
+ }
394
+ private publish(refreshTurn = false): void {
395
+ if (this.streamTimer) {
396
+ clearTimeout(this.streamTimer);
397
+ this.streamTimer = undefined;
398
+ }
399
+ const messages = this.reader?.after(this.lastOrdinal) ?? [];
400
+ if (messages.length) this.lastOrdinal = messages.at(-1)!.ordinal;
401
+ if (refreshTurn) {
402
+ const latest = this.reader?.latestTurn();
403
+ messages.push(
404
+ ...(this.reader?.page().messages.filter((m) => m.turnId === latest?.id) ?? []),
405
+ );
406
+ }
407
+ this.hub.publish({ activity: this.readActivity(), messages });
408
+ }
409
+
410
+ async close(): Promise<void> {
411
+ this.stopping = true;
412
+ for (const receipt of this.queue.splice(0))
413
+ this.updateReceipt(receipt, {
414
+ status: "interrupted",
415
+ error: "The service was stopped before this queued request began.",
416
+ });
417
+ if (this.opening) await this.opening.catch(() => undefined);
418
+ if (this.runtime)
419
+ await this.runtime.dispose({
420
+ type: "runner_failed",
421
+ error: "Remote service shutdown.",
422
+ });
423
+ await this.active?.completion;
424
+ await this.controlTail;
425
+ for (const unsubscribe of this.unsubscribers) unsubscribe();
426
+ if (this.streamTimer) clearTimeout(this.streamTimer);
427
+ this.hub.close();
428
+ this.reader?.close();
429
+ }
430
+ get pendingCount(): number {
431
+ return this.queue.length + (this.active ? 1 : 0);
432
+ }
433
+ receiptChanged(): void {
434
+ this.publish();
435
+ }
436
+ get initialized(): boolean {
437
+ return this.runtime !== undefined;
438
+ }
439
+ }
440
+
441
+ function errorMessage(error: unknown): string {
442
+ return error instanceof Error ? error.message : String(error);
443
+ }
@@ -6,6 +6,8 @@ import { CliUsageError, type CliCommandScope } from "./output";
6
6
  export type CliCommand =
7
7
  | { readonly type: "tui"; readonly profileName?: string }
8
8
  | { readonly type: "update" }
9
+ | { readonly type: "serve"; readonly configPath: string }
10
+ | { readonly type: "connect"; readonly configPath: string }
9
11
  | {
10
12
  readonly type: "run";
11
13
  readonly profileName?: string;
@@ -130,6 +132,21 @@ export async function parseCommandLine(
130
132
  },
131
133
  );
132
134
 
135
+ for (const type of ["serve", "connect"] as const) {
136
+ const command = contract[type];
137
+ program
138
+ .command(command.command)
139
+ .description(command.description)
140
+ .requiredOption(command.configOption.flags, command.configOption.description)
141
+ .allowExcessArguments(false)
142
+ .exitOverride()
143
+ .action((options: { config: string }) => {
144
+ if (!options.config.trim())
145
+ throw new CliUsageError("--config requires a non-empty path.", type);
146
+ selectedCommand = Object.freeze({ type, configPath: options.config });
147
+ });
148
+ }
149
+
133
150
  program
134
151
  .command(contract.update.command)
135
152
  .description(contract.update.description)
@@ -166,10 +183,13 @@ export async function parseCommandLine(
166
183
  "run",
167
184
  );
168
185
  }
169
- if (selectedCommand.type === "update" && topLevelProfile !== undefined) {
186
+ if (
187
+ ["update", "serve", "connect"].includes(selectedCommand.type) &&
188
+ topLevelProfile !== undefined
189
+ ) {
170
190
  throw new CliUsageError(
171
191
  "The top-level --profile option only applies to the TUI.",
172
- "update",
192
+ selectedCommand.type as CliCommandScope,
173
193
  );
174
194
  }
175
195
  return Object.freeze({ type: "command", command: selectedCommand });
@@ -224,6 +244,10 @@ function preflightArgv(args: readonly string[]): CliCommandScope {
224
244
  scope = "update";
225
245
  continue;
226
246
  }
247
+ if (token === "serve" || token === "connect") {
248
+ scope = token;
249
+ continue;
250
+ }
227
251
  if (token === "help") {
228
252
  return "root";
229
253
  }
@@ -0,0 +1,26 @@
1
+ import { render } from "ink";
2
+ import { RemoteClient, loadRemoteClientConfig } from "../remote/client";
3
+ import { RemoteApp } from "../tui/remote-app";
4
+ import type { CliOutputWriter } from "./output";
5
+
6
+ export async function runConnect(input: {
7
+ configPath: string;
8
+ env: NodeJS.ProcessEnv;
9
+ stdout: CliOutputWriter;
10
+ }): Promise<number> {
11
+ if (!process.stdin.isTTY)
12
+ throw new Error("tinker connect requires an interactive terminal.");
13
+ const client = new RemoteClient(await loadRemoteClientConfig(input.configPath));
14
+ let instance: ReturnType<typeof render> | undefined;
15
+ try {
16
+ await client.initialize();
17
+ instance = render(<RemoteApp client={client} />, { incrementalRendering: true });
18
+ await instance.waitUntilExit();
19
+ return 0;
20
+ } finally {
21
+ instance?.unmount();
22
+ await client.close();
23
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
24
+ process.stdin.pause();
25
+ }
26
+ }
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SessionId } from "../ids/runtime-id";
2
+ import path from "node:path";
2
3
  import { createUuidV7 } from "../ids/uuid-v7";
3
4
  import { parseCommandLine, type CommandLineResult } from "./command-line";
4
5
  import type {
@@ -91,6 +92,8 @@ export type MainDependencies = {
91
92
  readonly loadTuiRunner: () => Promise<TuiRunner>;
92
93
  readonly loadOneShotRunner: () => Promise<OneShotRunner>;
93
94
  readonly loadUpdateRunner: () => Promise<UpdateRunner>;
95
+ readonly loadServeRunner: () => Promise<typeof import("./serve-runner")>;
96
+ readonly loadConnectRunner: () => Promise<typeof import("./connect-runner")>;
94
97
  };
95
98
 
96
99
  const DEFAULT_DEPENDENCIES: MainDependencies = {
@@ -102,6 +105,8 @@ const DEFAULT_DEPENDENCIES: MainDependencies = {
102
105
  loadTuiRunner: () => import("./tui-runner"),
103
106
  loadOneShotRunner: () => import("./run-runner"),
104
107
  loadUpdateRunner: () => import("./update-runner"),
108
+ loadServeRunner: () => import("./serve-runner"),
109
+ loadConnectRunner: () => import("./connect-runner"),
105
110
  };
106
111
 
107
112
  export async function main(
@@ -160,6 +165,27 @@ export async function main(
160
165
  }
161
166
  }
162
167
 
168
+ if (parsed.command.type === "serve" || parsed.command.type === "connect") {
169
+ const options = {
170
+ configPath: path.resolve(cwd, parsed.command.configPath),
171
+ env,
172
+ stdout: input.stdout,
173
+ };
174
+ try {
175
+ const exitCode =
176
+ parsed.command.type === "serve"
177
+ ? await (await dependencies.loadServeRunner()).runServe(options)
178
+ : await (await dependencies.loadConnectRunner()).runConnect(options);
179
+ return finish(exitCode);
180
+ } catch (error) {
181
+ await writeCliOutput(
182
+ input.stderr,
183
+ renderCliFailure("Remote operation failed", error),
184
+ );
185
+ return finish(1);
186
+ }
187
+ }
188
+
163
189
  let configBoundary: ConfigBoundary;
164
190
  let publicConfig: ResolvedPublicConfig;
165
191
  let runnerConfig: RunnerConfig;