tinker-agent 1.7.0 → 1.9.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 +29 -1
- package/README.md +7 -0
- package/package.json +3 -1
- package/src/agent/runtime-session.ts +1 -0
- package/src/cli/command-line.ts +24 -2
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +4 -4
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/runner-dependencies.ts +7 -6
- package/src/cli/update-runner.ts +308 -0
- package/src/events/stdout-event-printer.ts +17 -0
- package/src/mcp/mcp-manager.ts +34 -3
- package/src/observation/observation-builder.ts +7 -0
- package/src/session/session-store.ts +1 -0
- package/src/tools/bash-task.ts +1 -5
- package/src/tools/registry.ts +2 -0
- package/src/tools/types.ts +19 -0
- package/src/tools/update-plan.ts +166 -0
- package/src/tui/components/plan-view.tsx +43 -0
- package/src/tui/components/timeline.tsx +7 -0
- package/src/tui/event-store.ts +28 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.9.0] - 2026-08-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Add an UpdatePlan tool that lets the agent maintain a visible, ordered task
|
|
13
|
+
plan for multi-phase work, rendered as a plan view in the TUI timeline and
|
|
14
|
+
printed by the one-shot CLI. Plans are validated (at most 12 steps, at most
|
|
15
|
+
one step in progress), persisted in canonical session history, and fully
|
|
16
|
+
restored when resuming a session.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Trim the runtime system prompt to keep core tool-usage guidance compact.
|
|
21
|
+
|
|
22
|
+
## [1.8.0] - 2026-08-02
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Add `tinker update` for manually upgrading a direct npm global installation to
|
|
27
|
+
the latest stable release from the official npm registry.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Wait for completed background-task output to finish flushing before returning
|
|
32
|
+
it through `TaskOutput`.
|
|
33
|
+
|
|
8
34
|
## [1.7.0] - 2026-08-01
|
|
9
35
|
|
|
10
36
|
### Added
|
|
@@ -149,7 +175,9 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
149
175
|
- First formal npm release under the `tinker-agent` package name with the `tinker`
|
|
150
176
|
executable.
|
|
151
177
|
|
|
152
|
-
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.
|
|
178
|
+
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.9.0...HEAD
|
|
179
|
+
[1.9.0]: https://github.com/ishowshao/tinker/releases/tag/v1.9.0
|
|
180
|
+
[1.8.0]: https://github.com/ishowshao/tinker/releases/tag/v1.8.0
|
|
153
181
|
[1.7.0]: https://github.com/ishowshao/tinker/releases/tag/v1.7.0
|
|
154
182
|
[1.6.0]: https://github.com/ishowshao/tinker/releases/tag/v1.6.0
|
|
155
183
|
[1.5.1]: https://github.com/ishowshao/tinker/releases/tag/v1.5.1
|
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://gith
|
|
|
13
13
|
- `Read` / `Write` / `Edit` — File I/O with content hashing and concurrent-modification protection
|
|
14
14
|
- `Delete` — Delete one existing regular file without directory or symlink support
|
|
15
15
|
- `Bash` — Run foreground, background, and PTY shell commands with per-task working directories
|
|
16
|
+
- `UpdatePlan` — Track a complete ordered task plan and its progress
|
|
16
17
|
- `TaskList` / `TaskOutput` / `TaskInput` / `TaskStop` — Inspect, interact with, and stop long-running shell tasks
|
|
17
18
|
- `WebSearch` — Search the web via Exa API
|
|
18
19
|
- `WebFetch` — Fetch and refine web page content (local, browser, or Exa backend)
|
|
@@ -80,11 +81,17 @@ The installed package exposes this public CLI:
|
|
|
80
81
|
| `tinker run [--profile <profile-name>] [--yolo] <prompt>` | Submit one shell-quoted prompt argument. |
|
|
81
82
|
| `tinker run [--profile <profile-name>] [--yolo] --stdin` | Read the prompt from standard input until EOF. |
|
|
82
83
|
| `tinker run [--profile <profile-name>] [--yolo] --file <path>` | Read the prompt from a UTF-8 text file. |
|
|
84
|
+
| `tinker update` | Update the global npm installation from the official npm registry. |
|
|
83
85
|
| `tinker --help` | Show top-level CLI help. |
|
|
84
86
|
| `tinker help run` | Show one-shot command help. |
|
|
87
|
+
| `tinker help update` | Show update command help. |
|
|
85
88
|
| `tinker --version` | Print the installed package version. |
|
|
86
89
|
<!-- END GENERATED: PUBLIC CLI COMMANDS -->
|
|
87
90
|
|
|
91
|
+
`tinker update` is available only to direct npm global installations. It checks
|
|
92
|
+
the `latest` stable version on the official npm registry, updates that same global
|
|
93
|
+
prefix, and exits without loading model configuration or starting a session.
|
|
94
|
+
|
|
88
95
|
Repository development commands are separate from the installed CLI:
|
|
89
96
|
|
|
90
97
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tinker-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"chrome:diagnose": "bun packages/tinker-chrome/src/cli.ts diagnose",
|
|
60
60
|
"chrome:install-host": "bun packages/tinker-chrome/src/cli.ts install-host",
|
|
61
61
|
"chrome:mcp": "bun packages/tinker-chrome/src/cli.ts mcp",
|
|
62
|
+
"chrome:smoke": "bun packages/tinker-chrome/scripts/live-smoke.ts",
|
|
62
63
|
"docs:generate": "bun scripts/render-public-contract-docs.ts --write",
|
|
63
64
|
"docs:check": "bun scripts/render-public-contract-docs.ts --check",
|
|
64
65
|
"tinker": "bun src/cli/index.ts",
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
"markdansi": "0.3.2",
|
|
94
95
|
"marked": "^18.0.5",
|
|
95
96
|
"openai": "^7.1.0",
|
|
97
|
+
"puppeteer-core": "25.4.0",
|
|
96
98
|
"react": "^19.2.7",
|
|
97
99
|
"sharp": "^0.35.3",
|
|
98
100
|
"shiki": "^4.3.1",
|
|
@@ -635,6 +635,7 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
635
635
|
if (mcpConfig !== undefined) {
|
|
636
636
|
session.mcpManager = await dependencies.createMcpManager({
|
|
637
637
|
config: mcpConfig,
|
|
638
|
+
workspaceRoot: input.workspaceRoot,
|
|
638
639
|
runtimeSession: session.context,
|
|
639
640
|
timeoutMs: input.toolingConfig?.mcpTimeoutMs,
|
|
640
641
|
maxObservationChars: input.toolingConfig?.mcpMaxObservationChars,
|
package/src/cli/command-line.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { CliUsageError, type CliCommandScope } from "./output";
|
|
|
5
5
|
|
|
6
6
|
export type CliCommand =
|
|
7
7
|
| { readonly type: "tui"; readonly profileName?: string }
|
|
8
|
+
| { readonly type: "update" }
|
|
8
9
|
| {
|
|
9
10
|
readonly type: "run";
|
|
10
11
|
readonly profileName?: string;
|
|
@@ -129,6 +130,17 @@ export async function parseCommandLine(
|
|
|
129
130
|
},
|
|
130
131
|
);
|
|
131
132
|
|
|
133
|
+
program
|
|
134
|
+
.command(contract.update.command)
|
|
135
|
+
.description(contract.update.description)
|
|
136
|
+
.showHelpAfterError('Run "tinker update --help" for usage.')
|
|
137
|
+
.showSuggestionAfterError(false)
|
|
138
|
+
.allowExcessArguments(false)
|
|
139
|
+
.exitOverride()
|
|
140
|
+
.action(() => {
|
|
141
|
+
selectedCommand = Object.freeze({ type: "update" });
|
|
142
|
+
});
|
|
143
|
+
|
|
132
144
|
try {
|
|
133
145
|
await program.parseAsync([...args], { from: "user" });
|
|
134
146
|
} catch (error) {
|
|
@@ -154,6 +166,12 @@ export async function parseCommandLine(
|
|
|
154
166
|
"run",
|
|
155
167
|
);
|
|
156
168
|
}
|
|
169
|
+
if (selectedCommand.type === "update" && topLevelProfile !== undefined) {
|
|
170
|
+
throw new CliUsageError(
|
|
171
|
+
"The top-level --profile option only applies to the TUI.",
|
|
172
|
+
"update",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
157
175
|
return Object.freeze({ type: "command", command: selectedCommand });
|
|
158
176
|
}
|
|
159
177
|
|
|
@@ -173,7 +191,7 @@ function preflightArgv(args: readonly string[]): CliCommandScope {
|
|
|
173
191
|
|
|
174
192
|
if (
|
|
175
193
|
(!rootBlocked && scope === "root" && isRootTerminalOption(token)) ||
|
|
176
|
-
(scope
|
|
194
|
+
(scope !== "root" && isSubcommandTerminalOption(token))
|
|
177
195
|
) {
|
|
178
196
|
return scope;
|
|
179
197
|
}
|
|
@@ -202,6 +220,10 @@ function preflightArgv(args: readonly string[]): CliCommandScope {
|
|
|
202
220
|
scope = "run";
|
|
203
221
|
continue;
|
|
204
222
|
}
|
|
223
|
+
if (token === "update") {
|
|
224
|
+
scope = "update";
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
205
227
|
if (token === "help") {
|
|
206
228
|
return "root";
|
|
207
229
|
}
|
|
@@ -290,7 +312,7 @@ function isRootTerminalOption(token: string): boolean {
|
|
|
290
312
|
);
|
|
291
313
|
}
|
|
292
314
|
|
|
293
|
-
function
|
|
315
|
+
function isSubcommandTerminalOption(token: string): boolean {
|
|
294
316
|
return token === "--help" || token === "-h";
|
|
295
317
|
}
|
|
296
318
|
|
package/src/cli/main.ts
CHANGED
|
@@ -67,6 +67,14 @@ type OneShotRunner = {
|
|
|
67
67
|
) => Promise<number>;
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
type UpdateRunner = {
|
|
71
|
+
readonly runUpdate: (options: {
|
|
72
|
+
readonly metadata: PackageMetadata;
|
|
73
|
+
readonly stdout: CliOutputWriter;
|
|
74
|
+
readonly env: NodeJS.ProcessEnv;
|
|
75
|
+
}) => Promise<number>;
|
|
76
|
+
};
|
|
77
|
+
|
|
70
78
|
export type MainDependencies = {
|
|
71
79
|
readonly loadPackageMetadata: () => Promise<PackageMetadata>;
|
|
72
80
|
readonly parseCommandLine: (
|
|
@@ -81,6 +89,7 @@ export type MainDependencies = {
|
|
|
81
89
|
) => Promise<ResolvedPrompt>;
|
|
82
90
|
readonly loadTuiRunner: () => Promise<TuiRunner>;
|
|
83
91
|
readonly loadOneShotRunner: () => Promise<OneShotRunner>;
|
|
92
|
+
readonly loadUpdateRunner: () => Promise<UpdateRunner>;
|
|
84
93
|
};
|
|
85
94
|
|
|
86
95
|
const DEFAULT_DEPENDENCIES: MainDependencies = {
|
|
@@ -91,6 +100,7 @@ const DEFAULT_DEPENDENCIES: MainDependencies = {
|
|
|
91
100
|
resolvePromptSource,
|
|
92
101
|
loadTuiRunner: () => import("./tui-runner"),
|
|
93
102
|
loadOneShotRunner: () => import("./run-runner"),
|
|
103
|
+
loadUpdateRunner: () => import("./update-runner"),
|
|
94
104
|
};
|
|
95
105
|
|
|
96
106
|
export async function main(
|
|
@@ -133,6 +143,22 @@ export async function main(
|
|
|
133
143
|
return finish(0);
|
|
134
144
|
}
|
|
135
145
|
|
|
146
|
+
if (parsed.command.type === "update") {
|
|
147
|
+
try {
|
|
148
|
+
const runner = await dependencies.loadUpdateRunner();
|
|
149
|
+
return finish(
|
|
150
|
+
await runner.runUpdate({
|
|
151
|
+
metadata,
|
|
152
|
+
stdout: input.stdout,
|
|
153
|
+
env,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
await writeCliOutput(input.stderr, renderCliFailure("Update failed", error));
|
|
158
|
+
return finish(1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
let configBoundary: ConfigBoundary;
|
|
137
163
|
let publicConfig: ResolvedPublicConfig;
|
|
138
164
|
let runnerConfig: RunnerConfig;
|
package/src/cli/output.ts
CHANGED
|
@@ -4,7 +4,7 @@ const TRUNCATION_MARKER = "...[truncated]";
|
|
|
4
4
|
const ESCAPE = String.fromCharCode(27);
|
|
5
5
|
const ANSI_CSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|
6
6
|
|
|
7
|
-
export type CliCommandScope = "root" | "run";
|
|
7
|
+
export type CliCommandScope = "root" | "run" | "update";
|
|
8
8
|
|
|
9
9
|
export interface CliOutputWriter {
|
|
10
10
|
write(chunk: string): boolean | void;
|
|
@@ -24,9 +24,9 @@ export class CliUsageError extends Error {
|
|
|
24
24
|
|
|
25
25
|
export function renderUsageError(error: CliUsageError): string {
|
|
26
26
|
const hint =
|
|
27
|
-
error.scope === "
|
|
28
|
-
? 'Run "tinker
|
|
29
|
-
:
|
|
27
|
+
error.scope === "root"
|
|
28
|
+
? 'Run "tinker --help" for usage.'
|
|
29
|
+
: `Run "tinker ${error.scope} --help" for usage.`;
|
|
30
30
|
return `error: ${sanitizeDiagnosticDetail(error.message)}\n${hint}\n`;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -68,6 +68,10 @@ export const PUBLIC_CLI_CONTRACT = Object.freeze({
|
|
|
68
68
|
helpAfter:
|
|
69
69
|
"Use exactly one prompt source. For complex or sensitive prompts, prefer --stdin or --file.",
|
|
70
70
|
}),
|
|
71
|
+
update: Object.freeze({
|
|
72
|
+
command: "update",
|
|
73
|
+
description: "Update the global npm installation from the official npm registry.",
|
|
74
|
+
}),
|
|
71
75
|
});
|
|
72
76
|
|
|
73
77
|
export type PublicCliContract = typeof PUBLIC_CLI_CONTRACT;
|
|
@@ -7,8 +7,7 @@ import type { RunnerConfig } from "./config";
|
|
|
7
7
|
|
|
8
8
|
export const RUNTIME_INSTRUCTIONS = (
|
|
9
9
|
workspaceRoot: string,
|
|
10
|
-
): string => `You are a coding agent
|
|
11
|
-
Your name is Tinker.
|
|
10
|
+
): string => `You are a coding agent. Your name is Tinker.
|
|
12
11
|
|
|
13
12
|
Current workspace:
|
|
14
13
|
${workspaceRoot}
|
|
@@ -17,7 +16,7 @@ Use this path as the root for relative file paths. Absolute file paths may point
|
|
|
17
16
|
|
|
18
17
|
You can use tools to find, read, edit, write files, and run shell commands.
|
|
19
18
|
Use Glob to find files by name or path pattern.
|
|
20
|
-
Use Grep to search file contents.
|
|
19
|
+
Use Grep to search file contents.
|
|
21
20
|
With Grep, start with output_mode="files_with_matches" to narrow scope, then use output_mode="content" when you need matching lines.
|
|
22
21
|
Use head_limit and offset to page through large Grep result sets instead of requesting unlimited output.
|
|
23
22
|
Use Read to open specific files returned by Grep.
|
|
@@ -27,8 +26,7 @@ Write creates missing parent directories when creating a file.
|
|
|
27
26
|
Write may fail if the runtime has no known version or the file changed after it was last observed. If that happens, call Read again and retry with the updated content.
|
|
28
27
|
Use Read before an exact-string Edit when this runtime has not already established the current version through Read, Write, or Edit. A successful paginated Read is sufficient. Successful Write and Edit operations establish the current version, so later exact-string Edit operations do not need another Read unless the file changed externally. Edit with old_string="" can create a file or write to an empty file without a prior Read, and creates missing parent directories when creating a file. Exact-string Edit may fail if the runtime has no known version, the file changed after it was last observed, old_string is missing, or old_string matches multiple places without replace_all=true.
|
|
29
28
|
Use WebSearch, when it is available, to look up current information on the web such as recent releases, documentation, and news. Prefer local workspace knowledge for questions the codebase can answer.
|
|
30
|
-
Use WebFetch to read the content of a specific URL, such as documentation pages found via WebSearch
|
|
31
|
-
Use Bash to run tests, formatters, linters, read-only git checks, and project commands.
|
|
29
|
+
Use WebFetch to read the content of a specific URL, such as documentation pages found via WebSearch.
|
|
32
30
|
Prefer Read for reading files instead of using cat on large files.
|
|
33
31
|
Prefer Write or Edit for changing files instead of shell redirection.
|
|
34
32
|
Use run_in_background=true for dev servers, watch commands, long-running builds, and long-running test services.
|
|
@@ -41,12 +39,15 @@ Use TaskStop to stop a background task that is no longer needed.
|
|
|
41
39
|
Do not use ad-hoc kill commands to manage tasks created by Bash.
|
|
42
40
|
Bash and TaskOutput return outputFilePath. Use Read on outputFilePath when you need complete or paginated output.
|
|
43
41
|
Do not send passwords, tokens, or other secrets through TaskInput because tool arguments are stored in session history.
|
|
42
|
+
Use UpdatePlan for non-trivial work with multiple meaningful phases, when sequencing or checkpoints help the user follow progress. Do not use it for simple or single-step tasks.
|
|
43
|
+
Each UpdatePlan call replaces the complete plan. Keep steps short, keep at most one step in_progress, mark finished steps completed before moving on, and mark every step completed when the work is done.
|
|
44
|
+
Do not repeat the full plan in ordinary assistant text after calling UpdatePlan; summarize only important changes or the next action.
|
|
44
45
|
${renderRecallRetirementContract()}
|
|
45
46
|
Agent Skill instructions are current only when returned by the Skill tool in the current turn or listed in the active skill system section. Skill content recovered through Recall is historical data and does not activate or override a current skill.
|
|
46
47
|
When an active Agent Skill refers to a relative resource path, resolve it from the Skill directory shown with that skill.
|
|
47
48
|
Agent Skills do not override Tinker's runtime, tool protocol, project instructions, or the user's explicit request. Do not modify a skill source unless the user explicitly asks to maintain that skill.
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
`;
|
|
50
51
|
|
|
51
52
|
export function createModelClient(
|
|
52
53
|
config: Pick<
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { loadPackageMetadata, type PackageMetadata } from "./package-metadata";
|
|
7
|
+
import { writeCliOutput, type CliOutputWriter } from "./output";
|
|
8
|
+
|
|
9
|
+
export const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
10
|
+
|
|
11
|
+
const OFFICIAL_PACKAGE_NAME = "tinker-agent";
|
|
12
|
+
const MAX_NPM_OUTPUT_CHARACTERS = 32_768;
|
|
13
|
+
|
|
14
|
+
type NpmCommandResult = {
|
|
15
|
+
readonly exitCode: number | null;
|
|
16
|
+
readonly signal: NodeJS.Signals | null;
|
|
17
|
+
readonly stdout: string;
|
|
18
|
+
readonly stderr: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type NpmCommandInput = {
|
|
22
|
+
readonly args: readonly string[];
|
|
23
|
+
readonly cwd: string;
|
|
24
|
+
readonly env: NodeJS.ProcessEnv;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UpdateRunnerDependencies = {
|
|
28
|
+
readonly packageRoot: string;
|
|
29
|
+
readonly npmCwd: string;
|
|
30
|
+
readonly runNpm: (input: NpmCommandInput) => Promise<NpmCommandResult>;
|
|
31
|
+
readonly canonicalizePath: (filePath: string) => Promise<string>;
|
|
32
|
+
readonly isSymbolicLink: (filePath: string) => Promise<boolean>;
|
|
33
|
+
readonly readPackageMetadata: (packageJsonPath: string) => Promise<PackageMetadata>;
|
|
34
|
+
readonly compareVersions: (left: string, right: string) => -1 | 0 | 1;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type UpdateInput = {
|
|
38
|
+
readonly metadata: PackageMetadata;
|
|
39
|
+
readonly stdout: CliOutputWriter;
|
|
40
|
+
readonly env: NodeJS.ProcessEnv;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DEFAULT_DEPENDENCIES: UpdateRunnerDependencies = {
|
|
44
|
+
packageRoot: path.resolve(fileURLToPath(new URL("../../", import.meta.url))),
|
|
45
|
+
npmCwd: tmpdir(),
|
|
46
|
+
runNpm,
|
|
47
|
+
canonicalizePath: realpath,
|
|
48
|
+
isSymbolicLink: async (filePath) => (await lstat(filePath)).isSymbolicLink(),
|
|
49
|
+
readPackageMetadata: (packageJsonPath) =>
|
|
50
|
+
loadPackageMetadata(pathToFileURL(packageJsonPath)),
|
|
51
|
+
compareVersions: (left, right) => Bun.semver.order(left, right),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export async function runUpdate(
|
|
55
|
+
input: UpdateInput,
|
|
56
|
+
injected: Partial<UpdateRunnerDependencies> = {},
|
|
57
|
+
): Promise<number> {
|
|
58
|
+
const dependencies = { ...DEFAULT_DEPENDENCIES, ...injected };
|
|
59
|
+
if (input.metadata.name !== OFFICIAL_PACKAGE_NAME) {
|
|
60
|
+
throw new Error("The installed package is not tinker-agent.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await writeCliOutput(input.stdout, `Current version: ${input.metadata.version}\n`);
|
|
64
|
+
await writeCliOutput(
|
|
65
|
+
input.stdout,
|
|
66
|
+
"Checking npm official registry for the latest version...\n",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const globalRoot = await readGlobalPath(
|
|
70
|
+
dependencies,
|
|
71
|
+
input.env,
|
|
72
|
+
["root", "--global"],
|
|
73
|
+
"npm global package root",
|
|
74
|
+
);
|
|
75
|
+
const globalPrefix = await readGlobalPath(
|
|
76
|
+
dependencies,
|
|
77
|
+
input.env,
|
|
78
|
+
["prefix", "--global"],
|
|
79
|
+
"npm global prefix",
|
|
80
|
+
);
|
|
81
|
+
const installedRoot = path.join(globalRoot, input.metadata.name);
|
|
82
|
+
await assertGlobalInstallation(dependencies, installedRoot);
|
|
83
|
+
|
|
84
|
+
const latestResult = await runNpmChecked(
|
|
85
|
+
dependencies,
|
|
86
|
+
{
|
|
87
|
+
args: [
|
|
88
|
+
"view",
|
|
89
|
+
`${input.metadata.name}@latest`,
|
|
90
|
+
"version",
|
|
91
|
+
"--json",
|
|
92
|
+
"--registry",
|
|
93
|
+
OFFICIAL_NPM_REGISTRY,
|
|
94
|
+
"--prefer-online",
|
|
95
|
+
],
|
|
96
|
+
cwd: dependencies.npmCwd,
|
|
97
|
+
env: input.env,
|
|
98
|
+
},
|
|
99
|
+
"Could not query the npm official registry",
|
|
100
|
+
);
|
|
101
|
+
const latestVersion = parseLatestVersion(latestResult.stdout);
|
|
102
|
+
const order = compareVersions(dependencies, input.metadata.version, latestVersion);
|
|
103
|
+
|
|
104
|
+
if (order === 0) {
|
|
105
|
+
await writeCliOutput(
|
|
106
|
+
input.stdout,
|
|
107
|
+
`Already up to date: ${input.metadata.version}\n`,
|
|
108
|
+
);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (order > 0) {
|
|
112
|
+
await writeCliOutput(
|
|
113
|
+
input.stdout,
|
|
114
|
+
`Installed version ${input.metadata.version} is newer than npm latest ${latestVersion}; no changes made.\n`,
|
|
115
|
+
);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await writeCliOutput(input.stdout, `Updating to ${latestVersion}...\n`);
|
|
120
|
+
await runNpmChecked(
|
|
121
|
+
dependencies,
|
|
122
|
+
{
|
|
123
|
+
args: [
|
|
124
|
+
"install",
|
|
125
|
+
"--global",
|
|
126
|
+
"--prefix",
|
|
127
|
+
globalPrefix,
|
|
128
|
+
`${input.metadata.name}@${latestVersion}`,
|
|
129
|
+
"--registry",
|
|
130
|
+
OFFICIAL_NPM_REGISTRY,
|
|
131
|
+
"--prefer-online",
|
|
132
|
+
"--no-audit",
|
|
133
|
+
"--no-fund",
|
|
134
|
+
"--loglevel",
|
|
135
|
+
"error",
|
|
136
|
+
],
|
|
137
|
+
cwd: dependencies.npmCwd,
|
|
138
|
+
env: input.env,
|
|
139
|
+
},
|
|
140
|
+
`npm could not install ${input.metadata.name}@${latestVersion}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
let installedMetadata: PackageMetadata;
|
|
144
|
+
try {
|
|
145
|
+
installedMetadata = await dependencies.readPackageMetadata(
|
|
146
|
+
path.join(installedRoot, "package.json"),
|
|
147
|
+
);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error("The updated package metadata could not be verified.");
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
installedMetadata.name !== input.metadata.name ||
|
|
153
|
+
installedMetadata.version !== latestVersion
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`npm completed, but the active global installation is not version ${latestVersion}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await writeCliOutput(
|
|
161
|
+
input.stdout,
|
|
162
|
+
`Successfully updated from ${input.metadata.version} to version ${latestVersion}\n`,
|
|
163
|
+
);
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readGlobalPath(
|
|
168
|
+
dependencies: UpdateRunnerDependencies,
|
|
169
|
+
env: NodeJS.ProcessEnv,
|
|
170
|
+
args: readonly string[],
|
|
171
|
+
label: string,
|
|
172
|
+
): Promise<string> {
|
|
173
|
+
const result = await runNpmChecked(
|
|
174
|
+
dependencies,
|
|
175
|
+
{ args, cwd: dependencies.npmCwd, env },
|
|
176
|
+
`Could not resolve the ${label}`,
|
|
177
|
+
);
|
|
178
|
+
const value = result.stdout.trim();
|
|
179
|
+
if (value === "" || !path.isAbsolute(value)) {
|
|
180
|
+
throw new Error(`npm returned an invalid ${label}.`);
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function assertGlobalInstallation(
|
|
186
|
+
dependencies: UpdateRunnerDependencies,
|
|
187
|
+
installedRoot: string,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
try {
|
|
190
|
+
const [packageRoot, expectedRoot, linked] = await Promise.all([
|
|
191
|
+
dependencies.canonicalizePath(dependencies.packageRoot),
|
|
192
|
+
dependencies.canonicalizePath(installedRoot),
|
|
193
|
+
dependencies.isSymbolicLink(installedRoot),
|
|
194
|
+
]);
|
|
195
|
+
if (linked || packageRoot !== expectedRoot) {
|
|
196
|
+
throw new Error("not a direct global installation");
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"This Tinker installation is not managed by the active npm global prefix.",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseLatestVersion(stdout: string): string {
|
|
206
|
+
let parsed: unknown;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(stdout);
|
|
209
|
+
} catch {
|
|
210
|
+
throw new Error("npm returned invalid update metadata.");
|
|
211
|
+
}
|
|
212
|
+
if (typeof parsed !== "string" || parsed.trim() === "") {
|
|
213
|
+
throw new Error("npm returned invalid update metadata.");
|
|
214
|
+
}
|
|
215
|
+
return parsed.trim();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function compareVersions(
|
|
219
|
+
dependencies: UpdateRunnerDependencies,
|
|
220
|
+
currentVersion: string,
|
|
221
|
+
latestVersion: string,
|
|
222
|
+
): -1 | 0 | 1 {
|
|
223
|
+
try {
|
|
224
|
+
return dependencies.compareVersions(currentVersion, latestVersion);
|
|
225
|
+
} catch {
|
|
226
|
+
throw new Error("Tinker or npm returned an invalid semantic version.");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runNpmChecked(
|
|
231
|
+
dependencies: UpdateRunnerDependencies,
|
|
232
|
+
input: NpmCommandInput,
|
|
233
|
+
failureMessage: string,
|
|
234
|
+
): Promise<NpmCommandResult> {
|
|
235
|
+
const result = await dependencies.runNpm(input);
|
|
236
|
+
if (result.exitCode === 0) {
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
const diagnostic = result.stderr.trim() || result.stdout.trim();
|
|
240
|
+
const status =
|
|
241
|
+
result.signal === null
|
|
242
|
+
? `npm exited with code ${String(result.exitCode)}`
|
|
243
|
+
: `npm exited after ${result.signal}`;
|
|
244
|
+
throw new Error(
|
|
245
|
+
diagnostic === ""
|
|
246
|
+
? `${failureMessage}: ${status}.`
|
|
247
|
+
: `${failureMessage}: ${diagnostic}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function runNpm(input: NpmCommandInput): Promise<NpmCommandResult> {
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
let child;
|
|
254
|
+
try {
|
|
255
|
+
child = spawn("npm", [...input.args], {
|
|
256
|
+
cwd: input.cwd,
|
|
257
|
+
env: {
|
|
258
|
+
...input.env,
|
|
259
|
+
NPM_CONFIG_UPDATE_NOTIFIER: "false",
|
|
260
|
+
},
|
|
261
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
262
|
+
});
|
|
263
|
+
} catch {
|
|
264
|
+
reject(new Error("npm could not be started."));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let stdout = "";
|
|
269
|
+
let stderr = "";
|
|
270
|
+
let settled = false;
|
|
271
|
+
child.stdout.setEncoding("utf8");
|
|
272
|
+
child.stderr.setEncoding("utf8");
|
|
273
|
+
child.stdout.on("data", (chunk: string) => {
|
|
274
|
+
stdout = appendBounded(stdout, chunk);
|
|
275
|
+
});
|
|
276
|
+
child.stderr.on("data", (chunk: string) => {
|
|
277
|
+
stderr = appendBounded(stderr, chunk);
|
|
278
|
+
});
|
|
279
|
+
child.once("error", (error) => {
|
|
280
|
+
if (settled) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
settled = true;
|
|
284
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
285
|
+
reject(
|
|
286
|
+
new Error(
|
|
287
|
+
code === "ENOENT"
|
|
288
|
+
? "npm was not found on PATH."
|
|
289
|
+
: "npm could not be started.",
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
child.once("close", (exitCode, signal) => {
|
|
294
|
+
if (settled) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
settled = true;
|
|
298
|
+
resolve({ exitCode, signal, stdout, stderr });
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function appendBounded(current: string, chunk: string): string {
|
|
304
|
+
if (current.length >= MAX_NPM_OUTPUT_CHARACTERS) {
|
|
305
|
+
return current;
|
|
306
|
+
}
|
|
307
|
+
return (current + chunk).slice(0, MAX_NPM_OUTPUT_CHARACTERS);
|
|
308
|
+
}
|
|
@@ -207,6 +207,8 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
|
|
|
207
207
|
case "task_list":
|
|
208
208
|
case "task_stop":
|
|
209
209
|
return optionalLine(formatTaskResult(call, raw));
|
|
210
|
+
case "update_plan":
|
|
211
|
+
return raw.ok ? formatPlanResult(raw) : [];
|
|
210
212
|
case "skill":
|
|
211
213
|
return [formatSkillResult(raw)];
|
|
212
214
|
case "read":
|
|
@@ -225,6 +227,21 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
|
|
|
225
227
|
}
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
function formatPlanResult(
|
|
231
|
+
raw: Extract<ToolRawResult, { kind: "update_plan"; ok: true }>,
|
|
232
|
+
): string[] {
|
|
233
|
+
const lines: string[] = [];
|
|
234
|
+
if (raw.explanation !== undefined) {
|
|
235
|
+
lines.push(`${raw.explanation}\n`);
|
|
236
|
+
}
|
|
237
|
+
for (const step of raw.plan) {
|
|
238
|
+
const symbol =
|
|
239
|
+
step.status === "completed" ? "✓" : step.status === "in_progress" ? "→" : "•";
|
|
240
|
+
lines.push(` ${symbol} ${step.step}\n`);
|
|
241
|
+
}
|
|
242
|
+
return lines;
|
|
243
|
+
}
|
|
244
|
+
|
|
228
245
|
function formatSkillResult(raw: Extract<ToolRawResult, { kind: "skill" }>): string {
|
|
229
246
|
if (!raw.ok) {
|
|
230
247
|
return `skill ${raw.name || "(unknown)"} failed -> ${boundedToolError(raw.error)}\n`;
|
package/src/mcp/mcp-manager.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
2
6
|
import {
|
|
3
7
|
StdioClientTransport,
|
|
4
8
|
getDefaultEnvironment,
|
|
5
9
|
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
10
|
import type { RuntimeSessionContext } from "../agent/runtime-session";
|
|
11
|
+
import { ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
12
|
import type { ToolExecutor } from "../tools/types";
|
|
8
13
|
import type { McpConfig, McpServerConfig } from "./mcp-config";
|
|
9
14
|
import { createMcpToolExecutor } from "./mcp-tool-executor";
|
|
10
15
|
|
|
11
16
|
const STDERR_TAIL_MAX_CHARS = 2_000;
|
|
12
17
|
|
|
18
|
+
function temporaryDirectoryEnvironment(): Record<string, string> {
|
|
19
|
+
return process.platform === "win32" ? { TEMP: tmpdir() } : { TMPDIR: tmpdir() };
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
export type McpClientConnection = {
|
|
14
23
|
client: Client;
|
|
15
24
|
close(): Promise<void>;
|
|
@@ -18,6 +27,7 @@ export type McpClientConnection = {
|
|
|
18
27
|
export type McpClientFactory = (
|
|
19
28
|
serverName: string,
|
|
20
29
|
serverConfig: McpServerConfig,
|
|
30
|
+
workspaceRoot: string,
|
|
21
31
|
) => Promise<McpClientConnection>;
|
|
22
32
|
|
|
23
33
|
export type McpManager = {
|
|
@@ -37,6 +47,7 @@ export type McpServerInventory = {
|
|
|
37
47
|
|
|
38
48
|
export type CreateMcpManagerOptions = {
|
|
39
49
|
config: McpConfig;
|
|
50
|
+
workspaceRoot: string;
|
|
40
51
|
runtimeSession: RuntimeSessionContext;
|
|
41
52
|
clientFactory?: McpClientFactory;
|
|
42
53
|
timeoutMs?: number;
|
|
@@ -70,7 +81,11 @@ export async function createMcpManager(
|
|
|
70
81
|
let tools;
|
|
71
82
|
|
|
72
83
|
try {
|
|
73
|
-
connection = await clientFactory(
|
|
84
|
+
connection = await clientFactory(
|
|
85
|
+
serverName,
|
|
86
|
+
serverConfig,
|
|
87
|
+
options.workspaceRoot,
|
|
88
|
+
);
|
|
74
89
|
} catch (error) {
|
|
75
90
|
await options.runtimeSession.append({
|
|
76
91
|
type: "mcp.server.failed",
|
|
@@ -227,11 +242,16 @@ async function closeConnections(
|
|
|
227
242
|
async function stdioClientFactory(
|
|
228
243
|
serverName: string,
|
|
229
244
|
serverConfig: McpServerConfig,
|
|
245
|
+
workspaceRoot: string,
|
|
230
246
|
): Promise<McpClientConnection> {
|
|
231
247
|
const transport = new StdioClientTransport({
|
|
232
248
|
command: serverConfig.command,
|
|
233
249
|
args: serverConfig.args,
|
|
234
|
-
env: {
|
|
250
|
+
env: {
|
|
251
|
+
...getDefaultEnvironment(),
|
|
252
|
+
...temporaryDirectoryEnvironment(),
|
|
253
|
+
...serverConfig.env,
|
|
254
|
+
},
|
|
235
255
|
cwd: serverConfig.cwd,
|
|
236
256
|
stderr: "pipe",
|
|
237
257
|
});
|
|
@@ -241,7 +261,18 @@ async function stdioClientFactory(
|
|
|
241
261
|
stderrTail = (stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX_CHARS);
|
|
242
262
|
});
|
|
243
263
|
|
|
244
|
-
const client = new Client(
|
|
264
|
+
const client = new Client(
|
|
265
|
+
{ name: "tinker", version: "0.1.0" },
|
|
266
|
+
{ capabilities: { roots: { listChanged: false } } },
|
|
267
|
+
);
|
|
268
|
+
client.setRequestHandler(ListRootsRequestSchema, () => ({
|
|
269
|
+
roots: [
|
|
270
|
+
{
|
|
271
|
+
uri: pathToFileURL(workspaceRoot).href,
|
|
272
|
+
name: path.basename(workspaceRoot),
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
}));
|
|
245
276
|
|
|
246
277
|
try {
|
|
247
278
|
await client.connect(transport);
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
TaskOutputRawResult,
|
|
17
17
|
TaskStopRawResult,
|
|
18
18
|
ToolRawResult,
|
|
19
|
+
UpdatePlanRawResult,
|
|
19
20
|
WebFetchRawResult,
|
|
20
21
|
WebSearchRawResult,
|
|
21
22
|
WriteFileRawResult,
|
|
@@ -48,6 +49,8 @@ export class ObservationBuilder {
|
|
|
48
49
|
return { content: renderDeleteObservation(input.raw) };
|
|
49
50
|
case "bash":
|
|
50
51
|
return { content: renderBashObservation(input.raw) };
|
|
52
|
+
case "update_plan":
|
|
53
|
+
return { content: renderUpdatePlanObservation(input.raw) };
|
|
51
54
|
case "task_list":
|
|
52
55
|
return { content: renderTaskListObservation(input.raw) };
|
|
53
56
|
case "task_output":
|
|
@@ -70,6 +73,10 @@ export class ObservationBuilder {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
function renderUpdatePlanObservation(raw: UpdatePlanRawResult): string {
|
|
77
|
+
return raw.ok ? "Plan updated." : `UpdatePlan failed: ${raw.error}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
function assertNever(value: never): never {
|
|
74
81
|
throw new Error(`Unhandled tool raw result: ${JSON.stringify(value)}`);
|
|
75
82
|
}
|
package/src/tools/bash-task.ts
CHANGED
|
@@ -264,11 +264,7 @@ export class ShellTaskManager {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
this.synchronizeTerminalState(task);
|
|
267
|
-
if (
|
|
268
|
-
task.mode === "pty" &&
|
|
269
|
-
isTerminalStatus(task.status) &&
|
|
270
|
-
task.finalScreen === undefined
|
|
271
|
-
) {
|
|
267
|
+
if (isTerminalStatus(task.status)) {
|
|
272
268
|
await task.completion;
|
|
273
269
|
} else {
|
|
274
270
|
await task.terminalScreen?.flush();
|
package/src/tools/registry.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createTaskListToolExecutor } from "./task-list";
|
|
|
11
11
|
import { createTaskInputToolExecutor } from "./task-input";
|
|
12
12
|
import { createTaskOutputToolExecutor } from "./task-output-tool";
|
|
13
13
|
import { createTaskStopToolExecutor } from "./task-stop";
|
|
14
|
+
import { createUpdatePlanToolExecutor } from "./update-plan";
|
|
14
15
|
import { createWebFetchToolExecutor } from "./web-fetch";
|
|
15
16
|
import type { Refiner } from "./web-fetch/refiner";
|
|
16
17
|
import { createWebSearchToolExecutor } from "./web-search";
|
|
@@ -255,6 +256,7 @@ export function createDefaultTooling(options: {
|
|
|
255
256
|
maxTimeoutMs: toolingConfig.bashMaxTimeoutMs,
|
|
256
257
|
}),
|
|
257
258
|
);
|
|
259
|
+
registry.register(createUpdatePlanToolExecutor());
|
|
258
260
|
registry.register(createTaskListToolExecutor({ taskManager }));
|
|
259
261
|
registry.register(createTaskOutputToolExecutor({ taskManager }));
|
|
260
262
|
registry.register(createTaskInputToolExecutor({ taskManager }));
|
package/src/tools/types.ts
CHANGED
|
@@ -144,6 +144,24 @@ export type TaskListRawResult = {
|
|
|
144
144
|
error?: string;
|
|
145
145
|
};
|
|
146
146
|
|
|
147
|
+
export type PlanStepStatus = "pending" | "in_progress" | "completed";
|
|
148
|
+
|
|
149
|
+
export type PlanStep = {
|
|
150
|
+
step: string;
|
|
151
|
+
status: PlanStepStatus;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type UpdatePlanRawResult =
|
|
155
|
+
| {
|
|
156
|
+
ok: true;
|
|
157
|
+
explanation?: string;
|
|
158
|
+
plan: PlanStep[];
|
|
159
|
+
}
|
|
160
|
+
| {
|
|
161
|
+
ok: false;
|
|
162
|
+
error: string;
|
|
163
|
+
};
|
|
164
|
+
|
|
147
165
|
export type TaskOutputRawResult = {
|
|
148
166
|
ok: boolean;
|
|
149
167
|
taskId: string;
|
|
@@ -355,6 +373,7 @@ export type ToolRawResultByKind = {
|
|
|
355
373
|
glob: GlobRawResult;
|
|
356
374
|
grep: GrepRawResult;
|
|
357
375
|
bash: BashRawResult;
|
|
376
|
+
update_plan: UpdatePlanRawResult;
|
|
358
377
|
task_list: TaskListRawResult;
|
|
359
378
|
task_output: TaskOutputRawResult;
|
|
360
379
|
task_input: TaskInputRawResult;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
2
|
+
import {
|
|
3
|
+
defineToolExecutor,
|
|
4
|
+
type PlanStep,
|
|
5
|
+
type ToolExecutionContext,
|
|
6
|
+
type ToolExecutor,
|
|
7
|
+
type UpdatePlanRawResult,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
const MAX_PLAN_ITEMS = 12;
|
|
11
|
+
const MAX_STEP_LENGTH = 200;
|
|
12
|
+
const MAX_EXPLANATION_LENGTH = 500;
|
|
13
|
+
const STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
14
|
+
|
|
15
|
+
export function createUpdatePlanToolExecutor(): ToolExecutor {
|
|
16
|
+
return defineToolExecutor("update_plan", {
|
|
17
|
+
definition: {
|
|
18
|
+
name: "UpdatePlan",
|
|
19
|
+
description:
|
|
20
|
+
"Replace the current task plan with a complete ordered list of steps and their progress. Use an optional explanation when revising the approach. At most one step may be in_progress.",
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object",
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
properties: {
|
|
25
|
+
explanation: {
|
|
26
|
+
type: "string",
|
|
27
|
+
maxLength: MAX_EXPLANATION_LENGTH,
|
|
28
|
+
description: "Optional explanation for this plan update.",
|
|
29
|
+
},
|
|
30
|
+
plan: {
|
|
31
|
+
type: "array",
|
|
32
|
+
maxItems: MAX_PLAN_ITEMS,
|
|
33
|
+
description: "The complete ordered task plan.",
|
|
34
|
+
items: {
|
|
35
|
+
type: "object",
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
step: {
|
|
39
|
+
type: "string",
|
|
40
|
+
minLength: 1,
|
|
41
|
+
maxLength: MAX_STEP_LENGTH,
|
|
42
|
+
description: "Task step text.",
|
|
43
|
+
},
|
|
44
|
+
status: {
|
|
45
|
+
type: "string",
|
|
46
|
+
enum: ["pending", "in_progress", "completed"],
|
|
47
|
+
description: "Step status.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ["step", "status"],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
required: ["plan"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
async execute(
|
|
58
|
+
args,
|
|
59
|
+
_call,
|
|
60
|
+
context: ToolExecutionContext,
|
|
61
|
+
): Promise<UpdatePlanRawResult> {
|
|
62
|
+
throwIfTurnCancelled(context.signal);
|
|
63
|
+
const parsed = parseUpdatePlanArgs(args);
|
|
64
|
+
if (!parsed.ok) {
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
throwIfTurnCancelled(context.signal);
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
...(parsed.explanation === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { explanation: parsed.explanation }),
|
|
73
|
+
plan: parsed.plan,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type ParsedUpdatePlanArgs =
|
|
80
|
+
| { ok: true; explanation?: string; plan: PlanStep[] }
|
|
81
|
+
| { ok: false; error: string };
|
|
82
|
+
|
|
83
|
+
function parseUpdatePlanArgs(args: unknown): ParsedUpdatePlanArgs {
|
|
84
|
+
if (!isRecord(args)) {
|
|
85
|
+
return failure("UpdatePlan arguments must be an object.");
|
|
86
|
+
}
|
|
87
|
+
const unexpected = Object.keys(args).find(
|
|
88
|
+
(key) => key !== "explanation" && key !== "plan",
|
|
89
|
+
);
|
|
90
|
+
if (unexpected !== undefined) {
|
|
91
|
+
return failure(`UpdatePlan received unexpected argument: ${unexpected}.`);
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(args.plan)) {
|
|
94
|
+
return failure("UpdatePlan plan must be an array.");
|
|
95
|
+
}
|
|
96
|
+
if (args.plan.length > MAX_PLAN_ITEMS) {
|
|
97
|
+
return failure(`UpdatePlan accepts at most ${MAX_PLAN_ITEMS} steps.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let explanation: string | undefined;
|
|
101
|
+
if (args.explanation !== undefined) {
|
|
102
|
+
if (typeof args.explanation !== "string") {
|
|
103
|
+
return failure("UpdatePlan explanation must be a string.");
|
|
104
|
+
}
|
|
105
|
+
explanation = args.explanation.trim();
|
|
106
|
+
if (explanation.length > MAX_EXPLANATION_LENGTH) {
|
|
107
|
+
return failure(
|
|
108
|
+
`UpdatePlan explanation must be at most ${MAX_EXPLANATION_LENGTH} characters.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (explanation === "") {
|
|
112
|
+
explanation = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const plan: PlanStep[] = [];
|
|
117
|
+
let inProgressCount = 0;
|
|
118
|
+
for (const [index, item] of args.plan.entries()) {
|
|
119
|
+
if (!isRecord(item)) {
|
|
120
|
+
return failure(`UpdatePlan plan[${index}] must be an object.`);
|
|
121
|
+
}
|
|
122
|
+
const unexpectedItemKey = Object.keys(item).find(
|
|
123
|
+
(key) => key !== "step" && key !== "status",
|
|
124
|
+
);
|
|
125
|
+
if (unexpectedItemKey !== undefined) {
|
|
126
|
+
return failure(
|
|
127
|
+
`UpdatePlan plan[${index}] received unexpected field: ${unexpectedItemKey}.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (typeof item.step !== "string" || item.step.trim() === "") {
|
|
131
|
+
return failure(`UpdatePlan plan[${index}].step must be a non-empty string.`);
|
|
132
|
+
}
|
|
133
|
+
const step = item.step.trim();
|
|
134
|
+
if (step.length > MAX_STEP_LENGTH) {
|
|
135
|
+
return failure(
|
|
136
|
+
`UpdatePlan plan[${index}].step must be at most ${MAX_STEP_LENGTH} characters.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (typeof item.status !== "string" || !STATUSES.has(item.status)) {
|
|
140
|
+
return failure(
|
|
141
|
+
`UpdatePlan plan[${index}].status must be pending, in_progress, or completed.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (item.status === "in_progress") {
|
|
145
|
+
inProgressCount += 1;
|
|
146
|
+
}
|
|
147
|
+
plan.push({ step, status: item.status as PlanStep["status"] });
|
|
148
|
+
}
|
|
149
|
+
if (inProgressCount > 1) {
|
|
150
|
+
return failure("UpdatePlan allows at most one in_progress step.");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
...(explanation === undefined ? {} : { explanation }),
|
|
156
|
+
plan,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function failure(error: string): ParsedUpdatePlanArgs {
|
|
161
|
+
return { ok: false, error };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
165
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
166
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { TimelineItem } from "../event-store";
|
|
3
|
+
|
|
4
|
+
export function PlanView(props: { plan: NonNullable<TimelineItem["plan"]> }) {
|
|
5
|
+
return (
|
|
6
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
7
|
+
{props.plan.explanation === undefined ? null : (
|
|
8
|
+
<Text dimColor italic>
|
|
9
|
+
{props.plan.explanation}
|
|
10
|
+
</Text>
|
|
11
|
+
)}
|
|
12
|
+
{props.plan.steps.length === 0 ? (
|
|
13
|
+
<Text dimColor italic>
|
|
14
|
+
(no steps)
|
|
15
|
+
</Text>
|
|
16
|
+
) : (
|
|
17
|
+
props.plan.steps.map((step, index) => (
|
|
18
|
+
<PlanStepRow key={`${index}:${step.step}`} step={step} />
|
|
19
|
+
))
|
|
20
|
+
)}
|
|
21
|
+
</Box>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function PlanStepRow(props: {
|
|
26
|
+
step: NonNullable<TimelineItem["plan"]>["steps"][number];
|
|
27
|
+
}) {
|
|
28
|
+
if (props.step.status === "completed") {
|
|
29
|
+
return (
|
|
30
|
+
<Text dimColor strikethrough>
|
|
31
|
+
✓ {props.step.step}
|
|
32
|
+
</Text>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (props.step.status === "in_progress") {
|
|
36
|
+
return (
|
|
37
|
+
<Text color="cyan" bold>
|
|
38
|
+
→ {props.step.step}
|
|
39
|
+
</Text>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return <Text dimColor>• {props.step.step}</Text>;
|
|
43
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { AssistantStreamSectionItem } from "../tui-projection-store";
|
|
|
5
5
|
import { AssistantMarkdown } from "./assistant-markdown";
|
|
6
6
|
import { BashResultView } from "./bash-result-view";
|
|
7
7
|
import { DiffView } from "./diff-view";
|
|
8
|
+
import { PlanView } from "./plan-view";
|
|
8
9
|
|
|
9
10
|
export type TimelineProps = {
|
|
10
11
|
items: readonly TimelineItem[];
|
|
@@ -42,6 +43,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
|
|
|
42
43
|
)}
|
|
43
44
|
{renderItemBash(item)}
|
|
44
45
|
{renderItemDiff(item)}
|
|
46
|
+
{renderItemPlan(item)}
|
|
45
47
|
</Fragment>
|
|
46
48
|
);
|
|
47
49
|
}
|
|
@@ -51,6 +53,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
|
|
|
51
53
|
<Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
|
|
52
54
|
{renderItemBash(item)}
|
|
53
55
|
{renderItemDiff(item)}
|
|
56
|
+
{renderItemPlan(item)}
|
|
54
57
|
</Fragment>
|
|
55
58
|
);
|
|
56
59
|
}
|
|
@@ -105,6 +108,10 @@ function renderItemBash(item: TimelineItem) {
|
|
|
105
108
|
return <BashResultView detail={item.bash} />;
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
function renderItemPlan(item: TimelineItem) {
|
|
112
|
+
return item.plan === undefined ? null : <PlanView plan={item.plan} />;
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
function formatTimelineItem(item: TimelineItem): string {
|
|
109
116
|
if (item.status === "text") {
|
|
110
117
|
return item.text;
|
package/src/tui/event-store.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from "../model/model-context-profile";
|
|
12
12
|
import type { ShellTaskSnapshot, ShellTaskStatus } from "../tools/bash-task";
|
|
13
13
|
import { countPatchChanges } from "../tools/file-diff";
|
|
14
|
-
import type { DiffHunk, ToolRawResult } from "../tools/types";
|
|
14
|
+
import type { DiffHunk, PlanStep, ToolRawResult } from "../tools/types";
|
|
15
15
|
import {
|
|
16
16
|
defaultTuiProjectionPolicy,
|
|
17
17
|
type TuiProjectionPolicy,
|
|
@@ -33,6 +33,10 @@ export type TimelineItem = {
|
|
|
33
33
|
diffTruncated?: boolean;
|
|
34
34
|
bash?: BashDisplayDetail;
|
|
35
35
|
userPrompt?: UserPromptProjection;
|
|
36
|
+
plan?: {
|
|
37
|
+
explanation?: string;
|
|
38
|
+
steps: readonly PlanStep[];
|
|
39
|
+
};
|
|
36
40
|
};
|
|
37
41
|
|
|
38
42
|
export type TuiTurnProjection = {
|
|
@@ -747,11 +751,12 @@ export function toolCallStartedProjection(input: {
|
|
|
747
751
|
export function toolRawResultProjection(
|
|
748
752
|
input: { name: string; args: unknown },
|
|
749
753
|
raw: ToolRawResult,
|
|
750
|
-
): Pick<TimelineItem, "text" | "bash" | "diff" | "diffTruncated"> {
|
|
754
|
+
): Pick<TimelineItem, "text" | "bash" | "diff" | "diffTruncated" | "plan"> {
|
|
751
755
|
return {
|
|
752
756
|
text: toolRawResultSummary(input.name, input.args, raw),
|
|
753
757
|
...toolRawResultDiff(raw),
|
|
754
758
|
...toolRawResultBashDetail(raw),
|
|
759
|
+
...toolRawResultPlanDetail(raw),
|
|
755
760
|
};
|
|
756
761
|
}
|
|
757
762
|
|
|
@@ -888,6 +893,13 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
888
893
|
: `${base} -> running ${raw.outputFilePath}`;
|
|
889
894
|
}
|
|
890
895
|
return raw.exitCode === undefined ? base : `${base} -> exit ${raw.exitCode}`;
|
|
896
|
+
case "update_plan": {
|
|
897
|
+
if (!raw.ok) {
|
|
898
|
+
return base;
|
|
899
|
+
}
|
|
900
|
+
const completed = raw.plan.filter((step) => step.status === "completed").length;
|
|
901
|
+
return `${base} -> ${completed}/${raw.plan.length} completed`;
|
|
902
|
+
}
|
|
891
903
|
case "mcp":
|
|
892
904
|
case "generic":
|
|
893
905
|
return base;
|
|
@@ -929,6 +941,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
|
|
|
929
941
|
case "delete":
|
|
930
942
|
case "glob":
|
|
931
943
|
case "grep":
|
|
944
|
+
case "update_plan":
|
|
932
945
|
case "task_list":
|
|
933
946
|
case "task_stop":
|
|
934
947
|
case "web_search":
|
|
@@ -964,6 +977,7 @@ function toolRawResultDiff(
|
|
|
964
977
|
case "glob":
|
|
965
978
|
case "grep":
|
|
966
979
|
case "bash":
|
|
980
|
+
case "update_plan":
|
|
967
981
|
case "task_list":
|
|
968
982
|
case "task_output":
|
|
969
983
|
case "task_input":
|
|
@@ -981,6 +995,18 @@ function toolRawResultDiff(
|
|
|
981
995
|
}
|
|
982
996
|
}
|
|
983
997
|
|
|
998
|
+
function toolRawResultPlanDetail(raw: ToolRawResult): Pick<TimelineItem, "plan"> {
|
|
999
|
+
if (raw.kind !== "update_plan" || !raw.ok) {
|
|
1000
|
+
return {};
|
|
1001
|
+
}
|
|
1002
|
+
return {
|
|
1003
|
+
plan: {
|
|
1004
|
+
...(raw.explanation === undefined ? {} : { explanation: raw.explanation }),
|
|
1005
|
+
steps: raw.plan.map((step) => ({ ...step })),
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
984
1010
|
function upsertBackgroundTask(
|
|
985
1011
|
tasks: ShellTaskSnapshot[],
|
|
986
1012
|
task: ShellTaskSnapshot,
|