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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { RemoteChange, RemoteCursor, RemoteFrame, RemoteView } from "./protocol";
|
|
2
|
+
|
|
3
|
+
/** Synchronous cursor allocation; transport delivery is always outside the runtime. */
|
|
4
|
+
export class RemoteSyncHub {
|
|
5
|
+
private sequence = 0;
|
|
6
|
+
private readonly ring: { frame: RemoteFrame; bytes: number }[] = [];
|
|
7
|
+
private ringBytes = 0;
|
|
8
|
+
private readonly listeners = new Set<(frame: RemoteFrame) => void>();
|
|
9
|
+
private pending: RemoteFrame[] = [];
|
|
10
|
+
private scheduled = false;
|
|
11
|
+
constructor(
|
|
12
|
+
readonly epoch: string,
|
|
13
|
+
private readonly readView: () => RemoteView,
|
|
14
|
+
private readonly capacity = 256,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
snapshot(): RemoteFrame {
|
|
18
|
+
return {
|
|
19
|
+
version: 1,
|
|
20
|
+
type: "snapshot",
|
|
21
|
+
epoch: this.epoch,
|
|
22
|
+
sequence: this.sequence,
|
|
23
|
+
view: this.readView(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
publish(change: RemoteChange): void {
|
|
27
|
+
const frame: RemoteFrame = {
|
|
28
|
+
version: 1,
|
|
29
|
+
type: "event",
|
|
30
|
+
epoch: this.epoch,
|
|
31
|
+
sequence: ++this.sequence,
|
|
32
|
+
change,
|
|
33
|
+
};
|
|
34
|
+
const bytes = Buffer.byteLength(JSON.stringify(frame));
|
|
35
|
+
this.ring.push({ frame, bytes });
|
|
36
|
+
this.ringBytes += bytes;
|
|
37
|
+
while (this.ring.length > this.capacity || this.ringBytes > 8 * 1024 * 1024)
|
|
38
|
+
this.ringBytes -= this.ring.shift()!.bytes;
|
|
39
|
+
// A single scheduled delivery per tick; no subscriber can hold up append().
|
|
40
|
+
this.pending.push(frame);
|
|
41
|
+
if (this.pending.length > this.capacity) this.pending = [this.snapshot()];
|
|
42
|
+
if (!this.scheduled) {
|
|
43
|
+
this.scheduled = true;
|
|
44
|
+
setTimeout(() => this.deliver(), 0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
subscribe(
|
|
48
|
+
cursor: RemoteCursor | undefined,
|
|
49
|
+
listener: (frame: RemoteFrame) => void,
|
|
50
|
+
): () => void {
|
|
51
|
+
// No await between reading the cursor/view and installing the subscription.
|
|
52
|
+
let last = this.sequence;
|
|
53
|
+
const oldest = this.ring[0]?.frame.sequence ?? this.sequence + 1;
|
|
54
|
+
const replay =
|
|
55
|
+
cursor?.epoch === this.epoch &&
|
|
56
|
+
cursor.sequence >= oldest - 1 &&
|
|
57
|
+
cursor.sequence <= this.sequence
|
|
58
|
+
? this.ring
|
|
59
|
+
.filter(({ frame }) => frame.sequence > cursor.sequence)
|
|
60
|
+
.map(({ frame }) => frame)
|
|
61
|
+
: [this.snapshot()];
|
|
62
|
+
// Same-cursor reconnect still needs a handshake to mark the link synchronized.
|
|
63
|
+
if (replay.length === 0) replay.push(this.snapshot());
|
|
64
|
+
const guarded = (frame: RemoteFrame) => {
|
|
65
|
+
if (frame.sequence <= last) return;
|
|
66
|
+
last = frame.sequence;
|
|
67
|
+
listener(frame);
|
|
68
|
+
};
|
|
69
|
+
this.listeners.add(guarded);
|
|
70
|
+
try {
|
|
71
|
+
for (const frame of replay) listener(frame);
|
|
72
|
+
} catch {
|
|
73
|
+
this.listeners.delete(guarded);
|
|
74
|
+
}
|
|
75
|
+
return () => this.listeners.delete(guarded);
|
|
76
|
+
}
|
|
77
|
+
private deliver(): void {
|
|
78
|
+
this.scheduled = false;
|
|
79
|
+
const pending = this.pending;
|
|
80
|
+
this.pending = [];
|
|
81
|
+
for (const frame of pending) {
|
|
82
|
+
for (const listener of this.listeners) {
|
|
83
|
+
try {
|
|
84
|
+
listener(frame);
|
|
85
|
+
} catch {
|
|
86
|
+
this.listeners.delete(listener);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
close(): void {
|
|
92
|
+
this.listeners.clear();
|
|
93
|
+
this.pending = [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { SessionId } from "../ids/runtime-id";
|
|
3
|
+
import { verifyReadableSessionSchema } from "./session-schema";
|
|
4
|
+
import { decodeStoredToolCalls } from "./session-store-record-codecs";
|
|
5
|
+
|
|
6
|
+
export type RemoteMessage = {
|
|
7
|
+
id: string;
|
|
8
|
+
ordinal: number;
|
|
9
|
+
role: "user" | "assistant" | "tool";
|
|
10
|
+
text: string;
|
|
11
|
+
turnId: string;
|
|
12
|
+
turnStatus: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
toolCallId?: string;
|
|
16
|
+
toolCalls?: { id: string; name: string; arguments: string }[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RemoteHistoryPage = {
|
|
20
|
+
messages: RemoteMessage[];
|
|
21
|
+
hasMore: boolean;
|
|
22
|
+
beforeOrdinal?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A read-only canonical projection; open tails are legal and never synthesized. */
|
|
26
|
+
export class RemoteHistoryReader {
|
|
27
|
+
private readonly database: Database;
|
|
28
|
+
constructor(databasePath: string, sessionId: SessionId, workspaceRoot: string) {
|
|
29
|
+
this.database = new Database(databasePath, { readonly: true, strict: true });
|
|
30
|
+
try {
|
|
31
|
+
verifyReadableSessionSchema(this.database, sessionId);
|
|
32
|
+
const identity = this.database
|
|
33
|
+
.query("SELECT session_id, workspace_root FROM session_meta")
|
|
34
|
+
.get() as { session_id: string; workspace_root: string } | null;
|
|
35
|
+
if (
|
|
36
|
+
identity?.session_id !== sessionId ||
|
|
37
|
+
identity.workspace_root !== workspaceRoot
|
|
38
|
+
) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Remote history identity does not match its workspace/session.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.database.close();
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
page(before = Number.MAX_SAFE_INTEGER, limit = 80): RemoteHistoryPage {
|
|
50
|
+
const rows = this.database
|
|
51
|
+
.query(
|
|
52
|
+
`${MESSAGE_SELECT} WHERE m.role <> 'system' AND m.ordinal < ? ORDER BY m.ordinal DESC LIMIT ?`,
|
|
53
|
+
)
|
|
54
|
+
.all(before, limit + 1) as MessageRow[];
|
|
55
|
+
const hasMore = rows.length > limit;
|
|
56
|
+
const messages = rows.slice(0, limit).reverse().map(projectMessage);
|
|
57
|
+
return {
|
|
58
|
+
messages,
|
|
59
|
+
hasMore,
|
|
60
|
+
...(messages[0] ? { beforeOrdinal: messages[0].ordinal } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
after(ordinal: number): RemoteMessage[] {
|
|
65
|
+
return (
|
|
66
|
+
this.database
|
|
67
|
+
.query(
|
|
68
|
+
`${MESSAGE_SELECT} WHERE m.role <> 'system' AND m.ordinal > ? ORDER BY m.ordinal`,
|
|
69
|
+
)
|
|
70
|
+
.all(ordinal) as MessageRow[]
|
|
71
|
+
).map(projectMessage);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
latestTurn(): { id: string; status: string; error?: string } | undefined {
|
|
75
|
+
const row = this.database
|
|
76
|
+
.query(
|
|
77
|
+
"SELECT turn_id, status, terminal_detail_json FROM turns ORDER BY turn_number DESC LIMIT 1",
|
|
78
|
+
)
|
|
79
|
+
.get() as {
|
|
80
|
+
turn_id: string;
|
|
81
|
+
status: string;
|
|
82
|
+
terminal_detail_json: string | null;
|
|
83
|
+
} | null;
|
|
84
|
+
if (!row) return undefined;
|
|
85
|
+
const detail = row.terminal_detail_json
|
|
86
|
+
? (JSON.parse(row.terminal_detail_json) as { error?: string })
|
|
87
|
+
: undefined;
|
|
88
|
+
return {
|
|
89
|
+
id: row.turn_id,
|
|
90
|
+
status: row.status,
|
|
91
|
+
...(detail?.error ? { error: detail.error } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
turnStatus(turnId: string): string | undefined {
|
|
96
|
+
return (
|
|
97
|
+
this.database.query("SELECT status FROM turns WHERE turn_id = ?").get(turnId) as {
|
|
98
|
+
status: string;
|
|
99
|
+
} | null
|
|
100
|
+
)?.status;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
close(): void {
|
|
104
|
+
this.database.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const MESSAGE_SELECT =
|
|
109
|
+
"SELECT m.*, t.status AS turn_status FROM messages m JOIN turns t ON t.turn_id = m.turn_id";
|
|
110
|
+
type MessageRow = {
|
|
111
|
+
message_id: string;
|
|
112
|
+
ordinal: number;
|
|
113
|
+
role: RemoteMessage["role"];
|
|
114
|
+
content: string | null;
|
|
115
|
+
turn_id: string;
|
|
116
|
+
turn_status: string;
|
|
117
|
+
created_at: string;
|
|
118
|
+
name: string | null;
|
|
119
|
+
tool_call_id: string | null;
|
|
120
|
+
tool_calls_json: string | null;
|
|
121
|
+
};
|
|
122
|
+
function projectMessage(row: MessageRow): RemoteMessage {
|
|
123
|
+
return {
|
|
124
|
+
id: row.message_id,
|
|
125
|
+
ordinal: row.ordinal,
|
|
126
|
+
role: row.role,
|
|
127
|
+
text: row.content ?? "",
|
|
128
|
+
turnId: row.turn_id,
|
|
129
|
+
turnStatus: row.turn_status,
|
|
130
|
+
createdAt: row.created_at,
|
|
131
|
+
...(row.name ? { name: row.name } : {}),
|
|
132
|
+
...(row.tool_call_id ? { toolCallId: row.tool_call_id } : {}),
|
|
133
|
+
...(row.tool_calls_json
|
|
134
|
+
? {
|
|
135
|
+
toolCalls: decodeStoredToolCalls(row.tool_calls_json).map((call) => ({
|
|
136
|
+
id: call.toolCallId,
|
|
137
|
+
name: call.name,
|
|
138
|
+
arguments: JSON.stringify(call.args),
|
|
139
|
+
})),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/tools/bash-task.ts
CHANGED
|
@@ -15,12 +15,7 @@ import {
|
|
|
15
15
|
} from "./shell-process";
|
|
16
16
|
import { TaskOutput, type TaskOutputSnapshot } from "./task-output";
|
|
17
17
|
import type { TaskOutputRangeRequest } from "./task-output-range";
|
|
18
|
-
import {
|
|
19
|
-
createTerminalScreen,
|
|
20
|
-
TERMINAL_SCREEN_COLUMNS,
|
|
21
|
-
TERMINAL_SCREEN_ROWS,
|
|
22
|
-
type TerminalScreen,
|
|
23
|
-
} from "./terminal-screen";
|
|
18
|
+
import { createTerminalScreen, type TerminalScreen } from "./terminal-screen";
|
|
24
19
|
import { resolveWorkspaceStorageRoot } from "../session/workspace-storage";
|
|
25
20
|
|
|
26
21
|
export type ShellTaskStatus =
|
|
@@ -146,6 +141,8 @@ export class ShellTaskManager {
|
|
|
146
141
|
description: string;
|
|
147
142
|
origin: ShellTaskOrigin;
|
|
148
143
|
tty: boolean;
|
|
144
|
+
cols?: number;
|
|
145
|
+
rows?: number;
|
|
149
146
|
}): Promise<ShellTaskHandle> {
|
|
150
147
|
if (!this.acceptingTasks) {
|
|
151
148
|
throw new Error("Cannot start a Bash task after task manager shutdown.");
|
|
@@ -164,7 +161,7 @@ export class ShellTaskManager {
|
|
|
164
161
|
throw new Error("Cannot start a Bash task after task manager shutdown.");
|
|
165
162
|
}
|
|
166
163
|
|
|
167
|
-
const terminalScreen = input.tty ? createTerminalScreen() : undefined;
|
|
164
|
+
const terminalScreen = input.tty ? createTerminalScreen(input) : undefined;
|
|
168
165
|
let shellProcess: ShellProcessHandle;
|
|
169
166
|
try {
|
|
170
167
|
shellProcess = await spawnShellProcess({
|
|
@@ -172,6 +169,8 @@ export class ShellTaskManager {
|
|
|
172
169
|
command: input.command,
|
|
173
170
|
cwd: this.options.cwdState.cwd,
|
|
174
171
|
cwdFilePath,
|
|
172
|
+
cols: terminalScreen?.columns,
|
|
173
|
+
rows: terminalScreen?.rows,
|
|
175
174
|
onOutput(bytes) {
|
|
176
175
|
output.write(Buffer.from(bytes));
|
|
177
176
|
if (terminalScreen !== undefined) {
|
|
@@ -417,8 +416,15 @@ export class ShellTaskManager {
|
|
|
417
416
|
if (!(await completesWithin(task.completion, this.stopGraceMs))) {
|
|
418
417
|
this.synchronizeTerminalState(task);
|
|
419
418
|
if (!isTerminalStatus(task.status)) {
|
|
420
|
-
signalProcessGroup(task, "SIGKILL");
|
|
421
|
-
|
|
419
|
+
escalated = signalProcessGroup(task, "SIGKILL");
|
|
420
|
+
if (
|
|
421
|
+
!(await completesWithin(task.completion, this.stopGraceMs)) &&
|
|
422
|
+
!task.process.outputClosed
|
|
423
|
+
) {
|
|
424
|
+
task.error =
|
|
425
|
+
"Task output remained open after forced termination; closed local output streams. Descendant processes may still be running.";
|
|
426
|
+
task.process.close();
|
|
427
|
+
}
|
|
422
428
|
}
|
|
423
429
|
}
|
|
424
430
|
|
|
@@ -474,8 +480,8 @@ export class ShellTaskManager {
|
|
|
474
480
|
private async monitorTask(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
|
|
475
481
|
const result = await task.process.wait();
|
|
476
482
|
|
|
477
|
-
this.applyTermination(task, result);
|
|
478
483
|
await task.process.waitForOutputClose();
|
|
484
|
+
this.applyTermination(task, result);
|
|
479
485
|
await task.output.end();
|
|
480
486
|
if (task.terminalScreen !== undefined) {
|
|
481
487
|
await task.terminalScreen.flush();
|
|
@@ -505,9 +511,9 @@ export class ShellTaskManager {
|
|
|
505
511
|
}
|
|
506
512
|
|
|
507
513
|
task.endedAt ??= new Date().toISOString();
|
|
508
|
-
if (result.error !== undefined) {
|
|
514
|
+
if (result.error !== undefined || task.error !== undefined) {
|
|
509
515
|
task.status = "failed";
|
|
510
|
-
task.error
|
|
516
|
+
task.error ??= result.error;
|
|
511
517
|
return;
|
|
512
518
|
}
|
|
513
519
|
|
|
@@ -522,7 +528,9 @@ export class ShellTaskManager {
|
|
|
522
528
|
}
|
|
523
529
|
|
|
524
530
|
private synchronizeTerminalState(task: ManagedShellTask): void {
|
|
525
|
-
|
|
531
|
+
// An exited wrapper can leave children holding its output descriptors open.
|
|
532
|
+
// Keep the task stoppable until both process exit and output closure occur.
|
|
533
|
+
if (isTerminalStatus(task.status) || !task.process.outputClosed) {
|
|
526
534
|
return;
|
|
527
535
|
}
|
|
528
536
|
|
|
@@ -576,8 +584,8 @@ export class ShellTaskManager {
|
|
|
576
584
|
...(screen === undefined
|
|
577
585
|
? {}
|
|
578
586
|
: {
|
|
579
|
-
screenRows:
|
|
580
|
-
screenColumns:
|
|
587
|
+
screenRows: task.terminalScreen?.rows,
|
|
588
|
+
screenColumns: task.terminalScreen?.columns,
|
|
581
589
|
screen,
|
|
582
590
|
}),
|
|
583
591
|
};
|
|
@@ -607,13 +615,15 @@ export class ShellTaskManager {
|
|
|
607
615
|
function signalProcessGroup(
|
|
608
616
|
task: ManagedShellTask,
|
|
609
617
|
signal: "SIGTERM" | "SIGKILL",
|
|
610
|
-
):
|
|
618
|
+
): boolean {
|
|
611
619
|
try {
|
|
612
620
|
process.kill(-task.processGroupId, signal);
|
|
621
|
+
return true;
|
|
613
622
|
} catch (error) {
|
|
614
623
|
if (!isNoSuchProcess(error)) {
|
|
615
624
|
throw error;
|
|
616
625
|
}
|
|
626
|
+
return false;
|
|
617
627
|
}
|
|
618
628
|
}
|
|
619
629
|
|
package/src/tools/bash.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { TaskOutputSnapshot } from "./task-output";
|
|
|
13
13
|
import type { BashRawResult, ToolExecutionContext, ToolExecutor } from "./types";
|
|
14
14
|
import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
|
|
15
15
|
import { classifyBashRisk } from "./bash-guard";
|
|
16
|
+
import { MAX_TERMINAL_DIMENSION, MIN_TERMINAL_COLUMNS } from "./terminal-screen";
|
|
16
17
|
|
|
17
18
|
type BashArgs = {
|
|
18
19
|
command: string;
|
|
@@ -20,6 +21,8 @@ type BashArgs = {
|
|
|
20
21
|
description?: string;
|
|
21
22
|
run_in_background?: boolean;
|
|
22
23
|
tty?: boolean;
|
|
24
|
+
cols?: number;
|
|
25
|
+
rows?: number;
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
export type BashToolOptions = {
|
|
@@ -44,7 +47,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
44
47
|
return defineToolExecutor("bash", {
|
|
45
48
|
definition: {
|
|
46
49
|
name: "Bash",
|
|
47
|
-
description:
|
|
50
|
+
description:
|
|
51
|
+
"Run a shell command locally. If the foreground timeout expires while the command is still running, it continues as a background task and returns a task ID; it is not killed. Use TaskOutput to inspect progress, then decide whether to keep waiting or stop it with TaskStop.",
|
|
48
52
|
parameters: {
|
|
49
53
|
type: "object",
|
|
50
54
|
additionalProperties: false,
|
|
@@ -57,7 +61,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
57
61
|
type: "integer",
|
|
58
62
|
minimum: 1,
|
|
59
63
|
maximum: maxTimeoutMs,
|
|
60
|
-
description:
|
|
64
|
+
description:
|
|
65
|
+
"Optional foreground wait duration in milliseconds. On timeout, a still-running command continues in the background instead of being killed.",
|
|
61
66
|
},
|
|
62
67
|
description: {
|
|
63
68
|
type: "string",
|
|
@@ -72,6 +77,20 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
72
77
|
description:
|
|
73
78
|
"Run the command in a pseudo-terminal so it can receive interactive input.",
|
|
74
79
|
},
|
|
80
|
+
cols: {
|
|
81
|
+
type: "integer",
|
|
82
|
+
minimum: MIN_TERMINAL_COLUMNS,
|
|
83
|
+
maximum: MAX_TERMINAL_DIMENSION,
|
|
84
|
+
description:
|
|
85
|
+
"Initial PTY width in columns. Defaults to 80. Ignored unless tty=true.",
|
|
86
|
+
},
|
|
87
|
+
rows: {
|
|
88
|
+
type: "integer",
|
|
89
|
+
minimum: 1,
|
|
90
|
+
maximum: MAX_TERMINAL_DIMENSION,
|
|
91
|
+
description:
|
|
92
|
+
"Initial PTY height in rows. Defaults to 24. Ignored unless tty=true.",
|
|
93
|
+
},
|
|
75
94
|
},
|
|
76
95
|
required: ["command"],
|
|
77
96
|
},
|
|
@@ -137,6 +156,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
137
156
|
description: input.description ?? input.command,
|
|
138
157
|
origin: call,
|
|
139
158
|
tty: input.tty === true,
|
|
159
|
+
cols: input.cols,
|
|
160
|
+
rows: input.rows,
|
|
140
161
|
});
|
|
141
162
|
|
|
142
163
|
if (input.run_in_background === true) {
|
|
@@ -232,6 +253,25 @@ export function parseBashArgs(
|
|
|
232
253
|
return { ok: false, error: "Bash.tty must be a boolean." };
|
|
233
254
|
}
|
|
234
255
|
|
|
256
|
+
if (args.tty === true) {
|
|
257
|
+
for (const name of ["cols", "rows"] as const) {
|
|
258
|
+
const value = args[name];
|
|
259
|
+
const minimum = name === "cols" ? MIN_TERMINAL_COLUMNS : 1;
|
|
260
|
+
if (
|
|
261
|
+
value !== undefined &&
|
|
262
|
+
(typeof value !== "number" ||
|
|
263
|
+
!Number.isInteger(value) ||
|
|
264
|
+
value < minimum ||
|
|
265
|
+
value > MAX_TERMINAL_DIMENSION)
|
|
266
|
+
) {
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: `Bash.${name} must be an integer between ${minimum} and ${MAX_TERMINAL_DIMENSION}.`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
235
275
|
return {
|
|
236
276
|
ok: true,
|
|
237
277
|
value: {
|
|
@@ -243,6 +283,8 @@ export function parseBashArgs(
|
|
|
243
283
|
: args.description,
|
|
244
284
|
run_in_background: args.run_in_background,
|
|
245
285
|
tty: args.tty,
|
|
286
|
+
cols: args.tty === true ? (args.cols as number | undefined) : undefined,
|
|
287
|
+
rows: args.tty === true ? (args.rows as number | undefined) : undefined,
|
|
246
288
|
},
|
|
247
289
|
};
|
|
248
290
|
}
|
package/src/tools/glob.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { stat } from "node:fs/promises";
|
|
3
|
-
import { glob } from "glob";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { glob, type Path } from "glob";
|
|
4
4
|
import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
5
5
|
import { resolveWorkspacePath, toDisplayPath } from "./path-safety";
|
|
6
6
|
import { defineToolExecutor } from "./types";
|
|
@@ -9,6 +9,8 @@ import type { GlobRawResult, ToolExecutionContext, ToolExecutor } from "./types"
|
|
|
9
9
|
type GlobArgs = {
|
|
10
10
|
pattern: string;
|
|
11
11
|
path?: string;
|
|
12
|
+
head_limit: number;
|
|
13
|
+
offset: number;
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
export type GlobToolOptions = {
|
|
@@ -16,12 +18,15 @@ export type GlobToolOptions = {
|
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
const ignoredDirectories = ["node_modules", ".git"];
|
|
21
|
+
const defaultHeadLimit = 200;
|
|
22
|
+
const maxHeadLimit = 500;
|
|
19
23
|
|
|
20
24
|
export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
21
25
|
return defineToolExecutor("glob", {
|
|
22
26
|
definition: {
|
|
23
27
|
name: "Glob",
|
|
24
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
"Find regular files by glob pattern, including symbolic links to regular files. Case sensitivity follows platform defaults. Directory links and broken links are excluded. node_modules and .git are skipped during traversal; to search inside either, set path directly to that directory.",
|
|
25
30
|
parameters: {
|
|
26
31
|
type: "object",
|
|
27
32
|
additionalProperties: false,
|
|
@@ -33,7 +38,21 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
|
33
38
|
path: {
|
|
34
39
|
type: "string",
|
|
35
40
|
description:
|
|
36
|
-
"Optional workspace-relative or absolute search directory. Defaults to the workspace root.",
|
|
41
|
+
"Optional workspace-relative or absolute search directory. Defaults to the workspace root. A directory symbolic link is allowed as the search root.",
|
|
42
|
+
},
|
|
43
|
+
head_limit: {
|
|
44
|
+
type: "integer",
|
|
45
|
+
minimum: 1,
|
|
46
|
+
maximum: maxHeadLimit,
|
|
47
|
+
description:
|
|
48
|
+
"Maximum paths to return. Defaults to 200; must be between 1 and 500.",
|
|
49
|
+
},
|
|
50
|
+
offset: {
|
|
51
|
+
type: "integer",
|
|
52
|
+
minimum: 0,
|
|
53
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
54
|
+
description:
|
|
55
|
+
"Skip the first N sorted matches. Defaults to 0. To continue, pass nextOffset from the previous result with the same pattern and path.",
|
|
37
56
|
},
|
|
38
57
|
},
|
|
39
58
|
required: ["pattern"],
|
|
@@ -46,8 +65,16 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
|
46
65
|
if (!parsed.ok) {
|
|
47
66
|
return {
|
|
48
67
|
ok: false,
|
|
49
|
-
pattern:
|
|
50
|
-
|
|
68
|
+
pattern:
|
|
69
|
+
isRecord(args) && typeof args.pattern === "string"
|
|
70
|
+
? args.pattern
|
|
71
|
+
: undefined,
|
|
72
|
+
searchPath:
|
|
73
|
+
isRecord(args) && args.path !== undefined
|
|
74
|
+
? typeof args.path === "string"
|
|
75
|
+
? args.path
|
|
76
|
+
: "(invalid path)"
|
|
77
|
+
: ".",
|
|
51
78
|
ignored: ignoredDirectories,
|
|
52
79
|
error: parsed.error,
|
|
53
80
|
};
|
|
@@ -87,27 +114,43 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
try {
|
|
117
|
+
// Resolve the root only; keep the traversal policy for links below it.
|
|
118
|
+
const realSearchPath = await realpath(absoluteSearchPath);
|
|
119
|
+
throwIfTurnCancelled(context.signal);
|
|
90
120
|
const matches = await glob(input.pattern, {
|
|
91
|
-
cwd:
|
|
121
|
+
cwd: realSearchPath,
|
|
92
122
|
nodir: true,
|
|
93
123
|
dot: true,
|
|
94
124
|
follow: false,
|
|
125
|
+
withFileTypes: true,
|
|
95
126
|
signal: context.signal,
|
|
96
127
|
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
97
128
|
});
|
|
98
|
-
const displayMatches = toDisplayMatches({
|
|
129
|
+
const displayMatches = await toDisplayMatches({
|
|
99
130
|
workspaceRoot: options.workspaceRoot,
|
|
100
131
|
absoluteSearchPath,
|
|
132
|
+
realSearchPath,
|
|
101
133
|
matches,
|
|
134
|
+
signal: context.signal,
|
|
102
135
|
});
|
|
136
|
+
const page = displayMatches.slice(
|
|
137
|
+
input.offset,
|
|
138
|
+
input.offset + input.head_limit,
|
|
139
|
+
);
|
|
140
|
+
const hasMore = input.offset + page.length < displayMatches.length;
|
|
103
141
|
|
|
104
142
|
return {
|
|
105
143
|
ok: true,
|
|
106
144
|
pattern: input.pattern,
|
|
107
145
|
searchPath,
|
|
108
146
|
absoluteSearchPath,
|
|
109
|
-
matches:
|
|
110
|
-
matchCount:
|
|
147
|
+
matches: page,
|
|
148
|
+
matchCount: page.length,
|
|
149
|
+
totalMatches: displayMatches.length,
|
|
150
|
+
returnedCount: page.length,
|
|
151
|
+
appliedOffset: input.offset,
|
|
152
|
+
hasMore,
|
|
153
|
+
...(hasMore ? { nextOffset: input.offset + page.length } : {}),
|
|
111
154
|
ignored: ignoredDirectories,
|
|
112
155
|
};
|
|
113
156
|
} catch (error) {
|
|
@@ -151,11 +194,35 @@ function parseGlobArgs(
|
|
|
151
194
|
return { ok: false, error: "Glob.path must be a string." };
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
if (
|
|
198
|
+
args.head_limit !== undefined &&
|
|
199
|
+
(typeof args.head_limit !== "number" ||
|
|
200
|
+
!Number.isSafeInteger(args.head_limit) ||
|
|
201
|
+
args.head_limit < 1 ||
|
|
202
|
+
args.head_limit > maxHeadLimit)
|
|
203
|
+
) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: "Glob.head_limit must be an integer between 1 and 500.",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
args.offset !== undefined &&
|
|
212
|
+
(typeof args.offset !== "number" ||
|
|
213
|
+
!Number.isSafeInteger(args.offset) ||
|
|
214
|
+
args.offset < 0)
|
|
215
|
+
) {
|
|
216
|
+
return { ok: false, error: "Glob.offset must be a non-negative safe integer." };
|
|
217
|
+
}
|
|
218
|
+
|
|
154
219
|
return {
|
|
155
220
|
ok: true,
|
|
156
221
|
value: {
|
|
157
222
|
pattern: args.pattern,
|
|
158
223
|
path: args.path,
|
|
224
|
+
head_limit: args.head_limit ?? defaultHeadLimit,
|
|
225
|
+
offset: args.offset ?? 0,
|
|
159
226
|
},
|
|
160
227
|
};
|
|
161
228
|
}
|
|
@@ -177,22 +244,43 @@ async function ensureDirectory(
|
|
|
177
244
|
}
|
|
178
245
|
}
|
|
179
246
|
|
|
180
|
-
function toDisplayMatches(input: {
|
|
247
|
+
async function toDisplayMatches(input: {
|
|
181
248
|
workspaceRoot: string;
|
|
182
249
|
absoluteSearchPath: string;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
250
|
+
realSearchPath: string;
|
|
251
|
+
matches: Path[];
|
|
252
|
+
signal: AbortSignal;
|
|
253
|
+
}): Promise<string[]> {
|
|
254
|
+
const normalized: string[] = [];
|
|
255
|
+
for (const match of input.matches) {
|
|
256
|
+
throwIfTurnCancelled(input.signal);
|
|
257
|
+
if (!(await isRegularFileMatch(match))) continue;
|
|
258
|
+
// Preserve the caller's root spelling, including directory links and aliases.
|
|
259
|
+
const absolutePath = path.resolve(
|
|
260
|
+
input.absoluteSearchPath,
|
|
261
|
+
path.relative(input.realSearchPath, match.fullpath()),
|
|
189
262
|
);
|
|
190
|
-
|
|
191
|
-
}
|
|
263
|
+
normalized.push(toDisplayPath(input.workspaceRoot, absolutePath));
|
|
264
|
+
}
|
|
265
|
+
throwIfTurnCancelled(input.signal);
|
|
192
266
|
|
|
193
267
|
return [...new Set(normalized)].sort((left, right) => left.localeCompare(right));
|
|
194
268
|
}
|
|
195
269
|
|
|
270
|
+
async function isRegularFileMatch(match: Path): Promise<boolean> {
|
|
271
|
+
if (!match.isSymbolicLink()) return match.isFile();
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
// Resolve only the type; keep the link's path in the returned matches.
|
|
275
|
+
return (await stat(match.fullpath())).isFile();
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
196
284
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
197
285
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
198
286
|
}
|