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.
- package/CHANGELOG.md +41 -1
- package/README.md +17 -1
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/tools/bash-task.ts +26 -16
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +139 -154
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/event-store.ts +15 -2
- 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.
|
|
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 | `
|
|
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.
|
|
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
|
+
}
|
package/src/cli/command-line.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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;
|