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/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" | "update";
7
+ export type CliCommandScope = "root" | "run" | "update" | "serve" | "connect";
8
8
 
9
9
  export interface CliOutputWriter {
10
10
  write(chunk: string): boolean | void;
@@ -47,6 +47,24 @@ export const PUBLIC_CLI_CONTRACT = Object.freeze({
47
47
  description: "Start the interactive terminal interface.",
48
48
  profileOption: PROFILE_OPTION,
49
49
  }),
50
+ serve: Object.freeze({
51
+ command: "serve",
52
+ description: "Run the local daemon for paired remote clients.",
53
+ configOption: Object.freeze({
54
+ flags: "--config <path>",
55
+ description: "Read the service JSON configuration.",
56
+ valueName: "path",
57
+ } satisfies PublicCliOption),
58
+ }),
59
+ connect: Object.freeze({
60
+ command: "connect",
61
+ description: "Attach a terminal client to a service; exiting detaches only.",
62
+ configOption: Object.freeze({
63
+ flags: "--config <path>",
64
+ description: "Read the paired client JSON configuration.",
65
+ valueName: "path",
66
+ } satisfies PublicCliOption),
67
+ }),
50
68
  run: Object.freeze({
51
69
  command: "run [prompt]",
52
70
  description: "Run one prompt non-interactively.",
@@ -174,7 +174,7 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
174
174
  valueKind: "positive-integer",
175
175
  requiredIn: "never",
176
176
  appliesIn: "always",
177
- defaultValue: 5_000,
177
+ defaultValue: 60_000,
178
178
  secret: false,
179
179
  section: "tooling",
180
180
  description: "Default Bash foreground timeout in milliseconds.",
@@ -0,0 +1,45 @@
1
+ import { loadRemoteConfig } from "../remote/config";
2
+ import { startRemoteHttpServer } from "../remote/http-server";
3
+ import { RemoteService } from "../remote/service";
4
+ import { RemoteServiceStore } from "../remote/service-store";
5
+ import { defaultHomeRoot } from "../session/workspace-storage";
6
+ import { createHostedRuntimeFactory } from "./serve-runtime";
7
+ import { writeCliOutput, type CliOutputWriter } from "./output";
8
+
9
+ export async function runServe(input: {
10
+ configPath: string;
11
+ env: NodeJS.ProcessEnv;
12
+ stdout: CliOutputWriter;
13
+ }): Promise<number> {
14
+ const config = await loadRemoteConfig(input.configPath);
15
+ const homeRoot = defaultHomeRoot(input.env);
16
+ const store = await RemoteServiceStore.open(config.stateDirectory);
17
+ const service = new RemoteService(
18
+ store,
19
+ config.workspaces,
20
+ createHostedRuntimeFactory(config.workspaces, input.env, homeRoot),
21
+ homeRoot,
22
+ );
23
+ let transport: ReturnType<typeof startRemoteHttpServer> | undefined;
24
+ try {
25
+ await service.initialize();
26
+ transport = startRemoteHttpServer(service, config);
27
+ await writeCliOutput(
28
+ input.stdout,
29
+ `Tinker service listening on https://${config.hostname}:${transport.port}; ${config.workspaces.length} workspace(s).\nClient disconnects detach only. Stop the process to shut down hosted sessions.\n`,
30
+ );
31
+ await new Promise<void>((resolve) => {
32
+ const stop = () => {
33
+ process.off("SIGINT", stop);
34
+ process.off("SIGTERM", stop);
35
+ resolve();
36
+ };
37
+ process.once("SIGINT", stop);
38
+ process.once("SIGTERM", stop);
39
+ });
40
+ return 0;
41
+ } finally {
42
+ await transport?.stopTransport();
43
+ await service.close();
44
+ }
45
+ }
@@ -0,0 +1,100 @@
1
+ import { createRuntimeSession } from "../agent/runtime-session";
2
+ import { parseSessionId } from "../ids/runtime-id";
3
+ import {
4
+ buildSystemPrompt,
5
+ loadProjectInstructions,
6
+ projectInstructionManifest,
7
+ } from "../instructions/project-instructions";
8
+ import { createReasoningEffortController } from "../model/reasoning-effort";
9
+ import { resolveSessionDatabasePath } from "../session/session-store";
10
+ import { SessionCatalog } from "../session/session-catalog";
11
+ import { loadSkillCatalog } from "../skills/skill-loader";
12
+ import type { RemoteWorkspaceConfig } from "../remote/config";
13
+ import type { HostedRuntimeFactory } from "../agent/runtime-hosted-session";
14
+ import { deriveRunnerConfig, resolvePublicConfig } from "./config";
15
+ import { resolveSessionProfileName } from "./model-profiles";
16
+ import {
17
+ createRunnerModelClient,
18
+ createWebFetchRefiner,
19
+ RUNTIME_INSTRUCTIONS,
20
+ } from "./runner-dependencies";
21
+
22
+ /** Service composition reuses the existing configuration/provider/runtime contracts. */
23
+ export function createHostedRuntimeFactory(
24
+ workspaces: readonly RemoteWorkspaceConfig[],
25
+ env: NodeJS.ProcessEnv,
26
+ homeRoot?: string,
27
+ ): HostedRuntimeFactory {
28
+ return async ({ record, sink }) => {
29
+ const workspace = workspaces.find((entry) => entry.id === record.workspaceId);
30
+ if (!workspace || workspace.path !== record.workspacePath)
31
+ throw new Error("Managed workspace configuration changed.");
32
+ const sessionId = parseSessionId(record.id);
33
+ const publicConfig = await resolvePublicConfig({
34
+ env: { ...env, TINKER_WORKSPACE: workspace.path },
35
+ cwd: workspace.path,
36
+ });
37
+ let profileName = workspace.profile;
38
+ if (record.initialized && publicConfig.mode === "profile") {
39
+ const summary = await new SessionCatalog({
40
+ workspaceRoot: workspace.path,
41
+ homeRoot,
42
+ }).get(sessionId);
43
+ profileName = resolveSessionProfileName(publicConfig.profiles, summary);
44
+ }
45
+ const config = deriveRunnerConfig(publicConfig, {
46
+ sessionId,
47
+ ...(profileName ? { profileName } : {}),
48
+ });
49
+ const reasoning = createReasoningEffortController(config.reasoning);
50
+ const projectInstructions = await loadProjectInstructions(workspace.path);
51
+ const runtime = await createRuntimeSession({
52
+ workspaceRoot: workspace.path,
53
+ ...(homeRoot === undefined ? {} : { homeRoot }),
54
+ ...(record.initialized
55
+ ? { selection: { mode: "resume" as const, sessionId } }
56
+ : { selection: { mode: "new" as const, sessionId } }),
57
+ modelName: config.modelName,
58
+ profileName: config.profileName,
59
+ maxIterations: config.maxIterations,
60
+ includeReasoningContent: config.includeReasoningContent,
61
+ contextProfile: config.contextProfile,
62
+ contextBudget: config.contextBudget,
63
+ modelClient: createRunnerModelClient(config, undefined, env, reasoning),
64
+ systemPrompt: buildSystemPrompt({
65
+ workspaceRoot: workspace.path,
66
+ runtimeInstructions: RUNTIME_INSTRUCTIONS(workspace.path),
67
+ projectInstructions,
68
+ }),
69
+ projectInstruction: projectInstructionManifest(projectInstructions),
70
+ skillCatalog: await loadSkillCatalog({ workspaceRoot: workspace.path }),
71
+ presentationSinks: [sink],
72
+ assistantTextDeltaSink: sink,
73
+ toolingConfig: publicConfig.tooling,
74
+ webFetchRefiner: createWebFetchRefiner(config, env, reasoning),
75
+ enableAskUser: true,
76
+ bashGuard: {
77
+ mode: config.bashGuardMode,
78
+ source: config.bashGuardSource,
79
+ surface: "tui",
80
+ },
81
+ });
82
+ try {
83
+ return {
84
+ runtime,
85
+ databasePath: await resolveSessionDatabasePath(
86
+ workspace.path,
87
+ sessionId,
88
+ homeRoot,
89
+ ),
90
+ modelName: config.modelName,
91
+ };
92
+ } catch (error) {
93
+ await runtime.dispose({
94
+ type: "initialization_failed",
95
+ error: "Cannot open remote history reader.",
96
+ });
97
+ throw error;
98
+ }
99
+ };
100
+ }
@@ -193,6 +193,11 @@ function metadataEntries(
193
193
  ["pattern", raw.pattern],
194
194
  ["searchPath", raw.searchPath],
195
195
  ["matchCount", raw.matchCount],
196
+ ["totalMatches", raw.totalMatches],
197
+ ["returnedCount", raw.returnedCount],
198
+ ["appliedOffset", raw.appliedOffset],
199
+ ["hasMore", raw.hasMore],
200
+ ["nextOffset", raw.nextOffset],
196
201
  ];
197
202
  case "grep":
198
203
  return [
@@ -200,6 +205,15 @@ function metadataEntries(
200
205
  ["searchPath", raw.searchPath],
201
206
  ["mode", raw.mode],
202
207
  ["numMatches", raw.numMatches],
208
+ ["numLines", raw.numLines],
209
+ ["paginationUnit", raw.paginationUnit],
210
+ ["totalResults", raw.totalResults],
211
+ ["returnedResults", raw.returnedResults],
212
+ ["appliedOffset", raw.appliedOffset],
213
+ ["hasMore", raw.hasMore],
214
+ ["nextOffset", raw.nextOffset],
215
+ ["searchIncomplete", raw.searchIncomplete],
216
+ ["contextMayBeIncomplete", raw.contextMayBeIncomplete],
203
217
  ["truncated", raw.truncated],
204
218
  ];
205
219
  case "bash":
@@ -35,6 +35,7 @@ import type {
35
35
  WriteFileRawResult,
36
36
  } from "../tools/types";
37
37
  import { MAX_MEMORY_TEXT_BYTES, truncateUtf8 } from "../memory/contracts";
38
+ import { formatGrepPath } from "../tools/grep-path";
38
39
 
39
40
  export type ToolObservation = {
40
41
  readonly content: readonly ToolResultContent[];
@@ -181,18 +182,30 @@ function assertNever(value: never): never {
181
182
 
182
183
  function renderGlobObservation(raw: GlobRawResult): string {
183
184
  if (!raw.ok) {
184
- return `Glob failed for pattern=${JSON.stringify(raw.pattern)}: ${raw.error ?? "Unknown error."}`;
185
+ const pattern =
186
+ raw.pattern === undefined ? "(missing or invalid)" : JSON.stringify(raw.pattern);
187
+ return `Glob failed for pattern=${pattern}, searchPath=${JSON.stringify(raw.searchPath)}: ${raw.error ?? "Unknown error."}`;
185
188
  }
186
189
 
187
190
  const matches = raw.matches ?? [];
191
+ const totalMatches = raw.totalMatches ?? raw.matchCount ?? matches.length;
188
192
 
189
193
  return [
190
194
  `Glob succeeded for pattern=${JSON.stringify(raw.pattern)}.`,
191
195
  `searchPath=${raw.searchPath}`,
192
- `matchCount=${raw.matchCount ?? matches.length}`,
196
+ `totalMatches=${totalMatches}`,
197
+ `returnedCount=${raw.returnedCount ?? matches.length}`,
198
+ `hasMore=${raw.hasMore ?? false}`,
199
+ ...(raw.hasMore && raw.nextOffset !== undefined
200
+ ? [`nextOffset=${raw.nextOffset}`]
201
+ : []),
193
202
  `ignored=${(raw.ignored ?? []).join(",")}`,
194
203
  "matches:",
195
- matches.length === 0 ? "(no matches)" : matches.join("\n"),
204
+ matches.length > 0
205
+ ? matches.join("\n")
206
+ : totalMatches === 0
207
+ ? "(no matches)"
208
+ : `(no results on this page at offset ${raw.appliedOffset ?? 0})`,
196
209
  ].join("\n");
197
210
  }
198
211
 
@@ -202,31 +215,51 @@ function renderGrepObservation(raw: GrepRawResult): string {
202
215
  }
203
216
 
204
217
  const sections: string[] = [];
218
+ const paginated = raw.appliedLimit !== undefined || (raw.appliedOffset ?? 0) > 0;
219
+ const incomplete = grepSearchIncomplete(raw);
220
+ const empty = raw.mode === "content" ? !raw.content : raw.numFiles === 0;
205
221
 
206
- if (raw.mode === "files_with_matches") {
207
- sections.push(
208
- raw.numFiles === 0
209
- ? "No files found"
210
- : [
211
- `Found ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}`,
212
- ...raw.filenames,
213
- ].join("\n"),
214
- );
215
- } else if (raw.mode === "count") {
216
- if (raw.numFiles === 0) {
222
+ if (empty) {
223
+ if (raw.totalResults === 0 && !incomplete) {
217
224
  sections.push("No matches found");
225
+ } else if ((raw.appliedOffset ?? 0) > 0) {
226
+ sections.push(`No results on this page at offset ${raw.appliedOffset}.`);
218
227
  } else {
219
- sections.push(raw.content ?? "");
220
228
  sections.push(
221
- `Found ${raw.numMatches ?? 0} total occurrence${(raw.numMatches ?? 0) === 1 ? "" : "s"} across ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}.`,
229
+ incomplete
230
+ ? "No results available in this partial output."
231
+ : "No matches found",
222
232
  );
223
233
  }
224
- } else {
234
+ } else if (raw.mode === "files_with_matches") {
225
235
  sections.push(
226
- raw.content === undefined || raw.content === ""
227
- ? "No matches found"
228
- : raw.content,
236
+ [
237
+ `${paginated || incomplete ? "Showing" : "Found"} ${raw.numFiles} matching file${raw.numFiles === 1 ? "" : "s"}${paginated ? " on this page" : ""}`,
238
+ ...raw.filenames.map(formatGrepPath),
239
+ ].join("\n"),
229
240
  );
241
+ } else if (raw.mode === "count" || raw.mode === "count-matches") {
242
+ const mode = raw.mode;
243
+ sections.push(
244
+ raw.counts !== undefined
245
+ ? raw.counts
246
+ .map(
247
+ (entry) =>
248
+ `${formatGrepPath(entry.filePath)}: ${grepCountLabel(mode, entry.count)}`,
249
+ )
250
+ .join("\n")
251
+ : // Legacy stored results have only display text. Never use it to compute totals.
252
+ (raw.content ?? "").replace(
253
+ /:(\d+)$/gm,
254
+ (_suffix, count: string) => `: ${grepCountLabel(mode, Number(count))}`,
255
+ ),
256
+ );
257
+ const scope = paginated ? "This page" : incomplete ? "Results shown" : "Total";
258
+ sections.push(
259
+ `${scope}: ${grepCountLabel(mode, raw.numMatches ?? 0)} across ${raw.numFiles} matching file${raw.numFiles === 1 ? "" : "s"}.`,
260
+ );
261
+ } else {
262
+ sections.push(raw.content ?? "");
230
263
  }
231
264
 
232
265
  const pagination = renderGrepPagination(raw);
@@ -234,27 +267,49 @@ function renderGrepObservation(raw: GrepRawResult): string {
234
267
  sections.push(pagination);
235
268
  }
236
269
 
237
- if (raw.truncated === true && raw.error !== undefined) {
238
- sections.push(`Warning: results are incomplete. ${raw.error}`);
270
+ if (incomplete) {
271
+ sections.push(
272
+ `Warning: results are incomplete. ${raw.error ?? "Search did not finish."}`,
273
+ );
274
+ }
275
+ if (raw.contextMayBeIncomplete === true) {
276
+ sections.push(
277
+ "Warning: requested context may be incomplete because the search stopped early. Narrow the search and retry.",
278
+ );
239
279
  }
240
280
 
241
281
  return sections.join("\n\n");
242
282
  }
243
283
 
244
- function renderGrepPagination(raw: GrepRawResult): string | undefined {
245
- const parts: string[] = [];
284
+ function grepCountLabel(mode: "count" | "count-matches", count: number): string {
285
+ return mode === "count"
286
+ ? `${count} matching line${count === 1 ? "" : "s"}`
287
+ : `${count} match${count === 1 ? "" : "es"}`;
288
+ }
246
289
 
247
- if (raw.appliedLimit !== undefined) {
248
- parts.push(`limit: ${raw.appliedLimit}`);
290
+ function renderGrepPagination(raw: GrepRawResult): string | undefined {
291
+ const incomplete = grepSearchIncomplete(raw);
292
+ const hasMore = raw.hasMore ?? raw.appliedLimit !== undefined;
293
+ const nextOffset =
294
+ raw.nextOffset ??
295
+ (raw.appliedLimit === undefined
296
+ ? undefined
297
+ : (raw.appliedOffset ?? 0) + raw.appliedLimit);
298
+ if (hasMore && nextOffset !== undefined) {
299
+ return `More ${incomplete ? "collected " : ""}results available; nextOffset=${nextOffset}.`;
249
300
  }
250
301
 
251
- if (raw.appliedOffset !== undefined) {
252
- parts.push(`offset: ${raw.appliedOffset}`);
302
+ if ((raw.appliedOffset ?? 0) > 0 && raw.totalResults !== 0) {
303
+ return incomplete
304
+ ? "End of collected results; search is incomplete."
305
+ : "End of results.";
253
306
  }
254
307
 
255
- return parts.length === 0
256
- ? undefined
257
- : `[Showing results with pagination = ${parts.join(", ")}]`;
308
+ return undefined;
309
+ }
310
+
311
+ function grepSearchIncomplete(raw: GrepRawResult): boolean {
312
+ return raw.searchIncomplete ?? (raw.truncated === true && raw.error !== undefined);
258
313
  }
259
314
 
260
315
  function renderReadObservation(raw: ReadFileRawResult): string {
@@ -269,7 +324,6 @@ function renderReadObservation(raw: ReadFileRawResult): string {
269
324
 
270
325
  return [
271
326
  `Read succeeded for ${raw.filePath}.`,
272
- `sha256=${raw.sha256}`,
273
327
  `sizeBytes=${raw.sizeBytes ?? 0}`,
274
328
  `contentBytes=${raw.contentBytes ?? 0}`,
275
329
  `totalLines=${raw.totalLines ?? 0}`,
@@ -489,8 +543,6 @@ function renderWriteObservation(raw: WriteFileRawResult): string {
489
543
  return [
490
544
  `Write succeeded for ${raw.filePath}.`,
491
545
  `bytesWritten=${raw.bytesWritten ?? 0}`,
492
- `oldSha256=${raw.oldSha256 ?? "null"}`,
493
- `newSha256=${raw.newSha256}`,
494
546
  ].join("\n");
495
547
  }
496
548
 
@@ -508,8 +560,6 @@ function renderEditObservation(raw: EditFileRawResult): string {
508
560
  `replacementCount=${raw.replacementCount ?? 0}`,
509
561
  `replaceAll=${raw.replaceAll ?? false}`,
510
562
  `created=${raw.created ?? false}`,
511
- `oldSha256=${raw.oldSha256 ?? "null"}`,
512
- `newSha256=${raw.newSha256}`,
513
563
  ].join("\n");
514
564
  }
515
565
 
@@ -644,7 +694,7 @@ function renderTaskInputObservation(raw: TaskInputRawResult): string {
644
694
  }
645
695
 
646
696
  return [
647
- "Terminal input sent.",
697
+ raw.writtenBytes === 0 ? "Terminal screen polled." : "Terminal input sent.",
648
698
  `taskId=${raw.taskId}`,
649
699
  `status=${raw.status}`,
650
700
  `writtenBytes=${raw.writtenBytes}`,