tinker-agent 1.5.1 → 1.7.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 +52 -1
- package/README.md +15 -7
- package/package.json +8 -7
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/runner-dependencies.ts +4 -1
- package/src/cli/tui-runner.tsx +17 -2
- package/src/events/bash-result-detail.ts +13 -6
- package/src/events/observation-text-log.ts +26 -1
- package/src/events/stdout-event-printer.ts +18 -2
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +177 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +54 -6
- package/src/session/session-catalog.ts +17 -11
- package/src/session/session-store.ts +2 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash-task.ts +129 -90
- package/src/tools/bash.ts +75 -13
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +49 -3
- package/src/tools/shell-process.ts +296 -0
- package/src/tools/task-input.ts +229 -0
- package/src/tools/task-output-tool.ts +4 -1
- package/src/tools/terminal-screen.ts +105 -0
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +45 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/background-tasks.tsx +3 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +292 -46
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +76 -4
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +19 -1
|
@@ -14,62 +14,61 @@ type ToolCallAccumulator = {
|
|
|
14
14
|
* chat.completion-shaped object so the result can be validated and mapped by
|
|
15
15
|
* fromOpenAIChatCompletion exactly like a non-streaming response.
|
|
16
16
|
*/
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
export class OpenAIChatCompletionStreamAccumulator {
|
|
18
|
+
private chunkCount = 0;
|
|
19
|
+
private role: "assistant" | undefined;
|
|
20
|
+
private content: string | undefined;
|
|
21
|
+
private reasoningContent: string | undefined;
|
|
22
|
+
private finishReason: string | undefined;
|
|
23
|
+
private resolvedModel: string | undefined;
|
|
24
|
+
private usage: Record<string, unknown> | undefined;
|
|
25
|
+
private readonly toolCalls: ToolCallAccumulator[] = [];
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
let content: string | undefined;
|
|
27
|
-
let reasoningContent: string | undefined;
|
|
28
|
-
let finishReason: string | undefined;
|
|
29
|
-
let resolvedModel: string | undefined;
|
|
30
|
-
let usage: Record<string, unknown> | undefined;
|
|
31
|
-
const toolCalls: ToolCallAccumulator[] = [];
|
|
27
|
+
constructor(private readonly options: ProviderContext) {}
|
|
32
28
|
|
|
33
|
-
|
|
29
|
+
push(chunk: unknown): string | undefined {
|
|
30
|
+
const chunkIndex = this.chunkCount;
|
|
31
|
+
this.chunkCount += 1;
|
|
34
32
|
const path = `chunk[${chunkIndex}]`;
|
|
35
|
-
const record = requireRecord(chunk, path, options);
|
|
33
|
+
const record = requireRecord(chunk, path, this.options);
|
|
34
|
+
let chunkContent: string | undefined;
|
|
36
35
|
|
|
37
36
|
if (record.model !== undefined && record.model !== null) {
|
|
38
37
|
if (typeof record.model !== "string" || record.model.trim() === "") {
|
|
39
|
-
throw providerStreamError(options, `${path}.model`, "must be a string");
|
|
38
|
+
throw providerStreamError(this.options, `${path}.model`, "must be a string");
|
|
40
39
|
}
|
|
41
|
-
if (resolvedModel !== undefined && resolvedModel !== record.model) {
|
|
40
|
+
if (this.resolvedModel !== undefined && this.resolvedModel !== record.model) {
|
|
42
41
|
throw providerStreamError(
|
|
43
|
-
options,
|
|
42
|
+
this.options,
|
|
44
43
|
`${path}.model`,
|
|
45
|
-
`conflicts with previously streamed model ${JSON.stringify(resolvedModel)}`,
|
|
44
|
+
`conflicts with previously streamed model ${JSON.stringify(this.resolvedModel)}`,
|
|
46
45
|
);
|
|
47
46
|
}
|
|
48
|
-
resolvedModel = record.model;
|
|
47
|
+
this.resolvedModel = record.model;
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
if (record.usage !== undefined && record.usage !== null) {
|
|
52
|
-
usage = requireRecord(record.usage, `${path}.usage`, options);
|
|
51
|
+
this.usage = requireRecord(record.usage, `${path}.usage`, this.options);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
if (record.choices === undefined || record.choices === null) {
|
|
56
|
-
return;
|
|
55
|
+
return chunkContent;
|
|
57
56
|
}
|
|
58
57
|
if (!Array.isArray(record.choices)) {
|
|
59
|
-
throw providerStreamError(options, `${path}.choices`, "must be an array");
|
|
58
|
+
throw providerStreamError(this.options, `${path}.choices`, "must be an array");
|
|
60
59
|
}
|
|
61
60
|
// The usage-only final chunk from stream_options.include_usage has empty choices.
|
|
62
61
|
for (const [choiceIndex, rawChoice] of record.choices.entries()) {
|
|
63
62
|
const choicePath = `${path}.choices[${choiceIndex}]`;
|
|
64
|
-
const choice = requireRecord(rawChoice, choicePath, options);
|
|
63
|
+
const choice = requireRecord(rawChoice, choicePath, this.options);
|
|
65
64
|
if (choice.index !== 0) {
|
|
66
|
-
throw providerStreamError(options, `${choicePath}.index`, "must be 0");
|
|
65
|
+
throw providerStreamError(this.options, `${choicePath}.index`, "must be 0");
|
|
67
66
|
}
|
|
68
67
|
if (typeof choice.finish_reason === "string") {
|
|
69
|
-
finishReason = choice.finish_reason;
|
|
68
|
+
this.finishReason = choice.finish_reason;
|
|
70
69
|
} else if (choice.finish_reason !== undefined && choice.finish_reason !== null) {
|
|
71
70
|
throw providerStreamError(
|
|
72
|
-
options,
|
|
71
|
+
this.options,
|
|
73
72
|
`${choicePath}.finish_reason`,
|
|
74
73
|
"must be a string or null",
|
|
75
74
|
);
|
|
@@ -79,82 +78,106 @@ export function accumulateOpenAIChatCompletionChunks(
|
|
|
79
78
|
continue;
|
|
80
79
|
}
|
|
81
80
|
const deltaPath = `${choicePath}.delta`;
|
|
82
|
-
const delta = asRecord(choice.delta, deltaPath, options);
|
|
81
|
+
const delta = asRecord(choice.delta, deltaPath, this.options);
|
|
83
82
|
if (delta.role !== undefined && delta.role !== null) {
|
|
84
83
|
if (delta.role !== "assistant") {
|
|
85
84
|
throw providerStreamError(
|
|
86
|
-
options,
|
|
85
|
+
this.options,
|
|
87
86
|
`${deltaPath}.role`,
|
|
88
87
|
'must be "assistant"',
|
|
89
88
|
);
|
|
90
89
|
}
|
|
91
|
-
role = delta.role;
|
|
90
|
+
this.role = delta.role;
|
|
92
91
|
}
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const contentDelta = appendOptionalText(
|
|
93
|
+
undefined,
|
|
95
94
|
delta.content,
|
|
96
95
|
`${deltaPath}.content`,
|
|
97
|
-
options,
|
|
96
|
+
this.options,
|
|
98
97
|
);
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (contentDelta !== undefined) {
|
|
99
|
+
this.content = (this.content ?? "") + contentDelta;
|
|
100
|
+
chunkContent = (chunkContent ?? "") + contentDelta;
|
|
101
|
+
}
|
|
102
|
+
this.reasoningContent = appendOptionalText(
|
|
103
|
+
this.reasoningContent,
|
|
101
104
|
delta.reasoning_content,
|
|
102
105
|
`${deltaPath}.reasoning_content`,
|
|
103
|
-
options,
|
|
106
|
+
this.options,
|
|
104
107
|
);
|
|
105
108
|
if (delta.tool_calls === undefined || delta.tool_calls === null) {
|
|
106
109
|
continue;
|
|
107
110
|
}
|
|
108
111
|
if (!Array.isArray(delta.tool_calls)) {
|
|
109
112
|
throw providerStreamError(
|
|
110
|
-
options,
|
|
113
|
+
this.options,
|
|
111
114
|
`${deltaPath}.tool_calls`,
|
|
112
115
|
"must be an array",
|
|
113
116
|
);
|
|
114
117
|
}
|
|
115
118
|
for (const [fragmentIndex, rawFragment] of delta.tool_calls.entries()) {
|
|
116
119
|
mergeToolCallFragment(
|
|
117
|
-
toolCalls,
|
|
120
|
+
this.toolCalls,
|
|
118
121
|
rawFragment,
|
|
119
122
|
`${deltaPath}.tool_calls[${fragmentIndex}]`,
|
|
120
|
-
options,
|
|
123
|
+
this.options,
|
|
121
124
|
);
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
|
-
|
|
127
|
+
return chunkContent;
|
|
128
|
+
}
|
|
125
129
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
content: content ?? null,
|
|
131
|
-
...(reasoningContent === undefined ? {} : { reasoning_content: reasoningContent }),
|
|
132
|
-
...(toolCalls.length === 0
|
|
133
|
-
? {}
|
|
134
|
-
: {
|
|
135
|
-
tool_calls: toolCalls.map((call) => ({
|
|
136
|
-
...(call.id === undefined ? {} : { id: call.id }),
|
|
137
|
-
...(call.type === undefined ? {} : { type: call.type }),
|
|
138
|
-
function: {
|
|
139
|
-
...(call.name === undefined ? {} : { name: call.name }),
|
|
140
|
-
arguments: call.arguments,
|
|
141
|
-
},
|
|
142
|
-
})),
|
|
143
|
-
}),
|
|
144
|
-
};
|
|
130
|
+
finish(): Record<string, unknown> {
|
|
131
|
+
if (this.chunkCount === 0) {
|
|
132
|
+
throw providerStreamError(this.options, "chunks", "must not be empty");
|
|
133
|
+
}
|
|
145
134
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
{
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
135
|
+
// Only fields the provider actually streamed are emitted; the strict
|
|
136
|
+
// non-streaming mapper rejects a missing role, id, type, or name.
|
|
137
|
+
const message: Record<string, unknown> = {
|
|
138
|
+
...(this.role === undefined ? {} : { role: this.role }),
|
|
139
|
+
content: this.content ?? null,
|
|
140
|
+
...(this.reasoningContent === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: { reasoning_content: this.reasoningContent }),
|
|
143
|
+
...(this.toolCalls.length === 0
|
|
144
|
+
? {}
|
|
145
|
+
: {
|
|
146
|
+
tool_calls: this.toolCalls.map((call) => ({
|
|
147
|
+
...(call.id === undefined ? {} : { id: call.id }),
|
|
148
|
+
...(call.type === undefined ? {} : { type: call.type }),
|
|
149
|
+
function: {
|
|
150
|
+
...(call.name === undefined ? {} : { name: call.name }),
|
|
151
|
+
arguments: call.arguments,
|
|
152
|
+
},
|
|
153
|
+
})),
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
object: "chat.completion",
|
|
159
|
+
choices: [
|
|
160
|
+
{
|
|
161
|
+
index: 0,
|
|
162
|
+
message,
|
|
163
|
+
finish_reason: this.finishReason ?? null,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
...(this.usage === undefined ? {} : { usage: this.usage }),
|
|
167
|
+
...(this.resolvedModel === undefined ? {} : { model: this.resolvedModel }),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function accumulateOpenAIChatCompletionChunks(
|
|
173
|
+
chunks: readonly unknown[],
|
|
174
|
+
options: ProviderContext,
|
|
175
|
+
): Record<string, unknown> {
|
|
176
|
+
const accumulator = new OpenAIChatCompletionStreamAccumulator(options);
|
|
177
|
+
for (const chunk of chunks) {
|
|
178
|
+
accumulator.push(chunk);
|
|
179
|
+
}
|
|
180
|
+
return accumulator.finish();
|
|
158
181
|
}
|
|
159
182
|
|
|
160
183
|
function mergeToolCallFragment(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolCall } from "../agent/types";
|
|
2
2
|
import type {
|
|
3
3
|
BashRawResult,
|
|
4
|
+
DeleteFileRawResult,
|
|
4
5
|
EditFileRawResult,
|
|
5
6
|
GenericToolRawResult,
|
|
6
7
|
GlobRawResult,
|
|
@@ -10,6 +11,7 @@ import type {
|
|
|
10
11
|
ReadFileRawResult,
|
|
11
12
|
RecallRawResult,
|
|
12
13
|
SkillRawResult,
|
|
14
|
+
TaskInputRawResult,
|
|
13
15
|
TaskListRawResult,
|
|
14
16
|
TaskOutputRawResult,
|
|
15
17
|
TaskStopRawResult,
|
|
@@ -42,12 +44,16 @@ export class ObservationBuilder {
|
|
|
42
44
|
return { content: renderWriteObservation(input.raw) };
|
|
43
45
|
case "edit":
|
|
44
46
|
return { content: renderEditObservation(input.raw) };
|
|
47
|
+
case "delete":
|
|
48
|
+
return { content: renderDeleteObservation(input.raw) };
|
|
45
49
|
case "bash":
|
|
46
50
|
return { content: renderBashObservation(input.raw) };
|
|
47
51
|
case "task_list":
|
|
48
52
|
return { content: renderTaskListObservation(input.raw) };
|
|
49
53
|
case "task_output":
|
|
50
54
|
return { content: renderTaskOutputObservation(input.raw) };
|
|
55
|
+
case "task_input":
|
|
56
|
+
return { content: renderTaskInputObservation(input.raw) };
|
|
51
57
|
case "task_stop":
|
|
52
58
|
return { content: renderTaskStopObservation(input.raw) };
|
|
53
59
|
case "web_search":
|
|
@@ -308,6 +314,14 @@ function renderEditObservation(raw: EditFileRawResult): string {
|
|
|
308
314
|
].join("\n");
|
|
309
315
|
}
|
|
310
316
|
|
|
317
|
+
function renderDeleteObservation(raw: DeleteFileRawResult): string {
|
|
318
|
+
if (!raw.ok) {
|
|
319
|
+
return `Delete failed for ${raw.filePath || "(unknown path)"}: ${raw.error ?? "Unknown error."}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return `Delete succeeded for ${raw.filePath}.`;
|
|
323
|
+
}
|
|
324
|
+
|
|
311
325
|
function renderBashObservation(raw: BashRawResult): string {
|
|
312
326
|
if (raw.taskId === "" && !raw.ok) {
|
|
313
327
|
return `Bash failed: ${raw.error ?? "Unknown error."}`;
|
|
@@ -320,8 +334,11 @@ function renderBashObservation(raw: BashRawResult): string {
|
|
|
320
334
|
`timeoutMs=${raw.timeoutMs ?? 0}`,
|
|
321
335
|
`command=${raw.command}`,
|
|
322
336
|
`cwd=${raw.cwd}`,
|
|
337
|
+
`tty=${raw.tty}`,
|
|
323
338
|
`outputFilePath=${raw.outputFilePath}`,
|
|
324
|
-
|
|
339
|
+
raw.tty
|
|
340
|
+
? "Use TaskOutput to inspect the current terminal screen and TaskInput to interact."
|
|
341
|
+
: "Use TaskOutput to inspect current output.",
|
|
325
342
|
].join("\n");
|
|
326
343
|
}
|
|
327
344
|
|
|
@@ -331,8 +348,11 @@ function renderBashObservation(raw: BashRawResult): string {
|
|
|
331
348
|
`taskId=${raw.taskId}`,
|
|
332
349
|
`command=${raw.command}`,
|
|
333
350
|
`cwd=${raw.cwd}`,
|
|
351
|
+
`tty=${raw.tty}`,
|
|
334
352
|
`outputFilePath=${raw.outputFilePath}`,
|
|
335
|
-
|
|
353
|
+
raw.tty
|
|
354
|
+
? "Use TaskOutput to inspect the current terminal screen and TaskInput to interact."
|
|
355
|
+
: "Use TaskOutput to inspect current output.",
|
|
336
356
|
].join("\n");
|
|
337
357
|
}
|
|
338
358
|
|
|
@@ -342,6 +362,7 @@ function renderBashObservation(raw: BashRawResult): string {
|
|
|
342
362
|
`exitCode=${raw.exitCode ?? "null"}`,
|
|
343
363
|
`status=${raw.status}`,
|
|
344
364
|
`cwd=${raw.cwd}`,
|
|
365
|
+
`tty=${raw.tty}`,
|
|
345
366
|
`outputFilePath=${raw.outputFilePath}`,
|
|
346
367
|
`outputBytes=${raw.outputBytes}`,
|
|
347
368
|
`outputLines=${raw.outputLines}`,
|
|
@@ -351,8 +372,9 @@ function renderBashObservation(raw: BashRawResult): string {
|
|
|
351
372
|
? undefined
|
|
352
373
|
: `returnCodeInterpretation=${raw.returnCodeInterpretation}`,
|
|
353
374
|
raw.error === undefined ? undefined : `error=${raw.error}`,
|
|
354
|
-
|
|
355
|
-
raw.preview,
|
|
375
|
+
raw.tty ? `screen=${raw.screenColumns ?? 80}x${raw.screenRows ?? 24}` : undefined,
|
|
376
|
+
raw.tty ? "current screen:" : "preview:",
|
|
377
|
+
raw.tty ? (raw.screen ?? "") : raw.preview,
|
|
356
378
|
]
|
|
357
379
|
.filter((line): line is string => line !== undefined)
|
|
358
380
|
.join("\n");
|
|
@@ -376,23 +398,48 @@ function renderTaskOutputObservation(raw: TaskOutputRawResult): string {
|
|
|
376
398
|
return `TaskOutput failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error ?? "Unknown error."}`;
|
|
377
399
|
}
|
|
378
400
|
|
|
401
|
+
const terminalScreen = raw.task.tty;
|
|
379
402
|
return [
|
|
380
403
|
"Task output retrieved.",
|
|
381
404
|
`taskId=${raw.taskId}`,
|
|
382
405
|
`status=${raw.task.status}`,
|
|
383
406
|
`command=${raw.task.command}`,
|
|
407
|
+
`tty=${terminalScreen}`,
|
|
384
408
|
`outputFilePath=${raw.outputFilePath}`,
|
|
385
409
|
`outputBytes=${raw.outputBytes ?? 0}`,
|
|
386
410
|
`outputLines=${raw.outputLines ?? 0}`,
|
|
387
411
|
`truncated=${raw.truncated ?? false}`,
|
|
388
412
|
raw.omittedLines === undefined ? undefined : `omittedLines=${raw.omittedLines}`,
|
|
389
|
-
|
|
390
|
-
|
|
413
|
+
terminalScreen
|
|
414
|
+
? `screen=${raw.screenColumns ?? 80}x${raw.screenRows ?? 24}`
|
|
415
|
+
: undefined,
|
|
416
|
+
terminalScreen ? "current screen:" : "preview:",
|
|
417
|
+
terminalScreen ? (raw.screen ?? "") : (raw.preview ?? ""),
|
|
391
418
|
]
|
|
392
419
|
.filter((line): line is string => line !== undefined)
|
|
393
420
|
.join("\n");
|
|
394
421
|
}
|
|
395
422
|
|
|
423
|
+
function renderTaskInputObservation(raw: TaskInputRawResult): string {
|
|
424
|
+
if (!raw.ok) {
|
|
425
|
+
return `TaskInput failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return [
|
|
429
|
+
"Terminal input sent.",
|
|
430
|
+
`taskId=${raw.taskId}`,
|
|
431
|
+
`status=${raw.status}`,
|
|
432
|
+
`writtenBytes=${raw.writtenBytes}`,
|
|
433
|
+
`waitedMs=${raw.waitedMs}`,
|
|
434
|
+
`screen=${raw.screenColumns}x${raw.screenRows}`,
|
|
435
|
+
`outputFilePath=${raw.outputFilePath}`,
|
|
436
|
+
`outputBytes=${raw.outputBytes}`,
|
|
437
|
+
`outputLines=${raw.outputLines}`,
|
|
438
|
+
"current screen:",
|
|
439
|
+
raw.screen,
|
|
440
|
+
].join("\n");
|
|
441
|
+
}
|
|
442
|
+
|
|
396
443
|
function renderTaskStopObservation(raw: TaskStopRawResult): string {
|
|
397
444
|
if (!raw.ok || raw.task === undefined) {
|
|
398
445
|
return `TaskStop failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error ?? "Unknown error."}`;
|
|
@@ -417,6 +464,7 @@ function renderTaskSummary(task: TaskListRawResult["tasks"][number]): string {
|
|
|
417
464
|
`taskId=${task.taskId}`,
|
|
418
465
|
`description=${task.description}`,
|
|
419
466
|
`status=${task.status}`,
|
|
467
|
+
`tty=${task.tty}`,
|
|
420
468
|
`startedAt=${task.startedAt}`,
|
|
421
469
|
task.endedAt === undefined ? undefined : `endedAt=${task.endedAt}`,
|
|
422
470
|
task.exitCode === undefined ? undefined : `exitCode=${task.exitCode}`,
|
|
@@ -34,6 +34,15 @@ export class SessionCatalog {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
async list(currentSessionId?: SessionId): Promise<readonly SessionSummary[]> {
|
|
37
|
+
const summaries = await this.scan(currentSessionId);
|
|
38
|
+
return Object.freeze(summaries.slice(0, this.input.limit ?? 20));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async listAll(currentSessionId?: SessionId): Promise<readonly SessionSummary[]> {
|
|
42
|
+
return Object.freeze(await this.scan(currentSessionId));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async scan(currentSessionId?: SessionId): Promise<SessionSummary[]> {
|
|
37
46
|
const workspaceRoot = await this.workspaceRootPromise;
|
|
38
47
|
const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
|
|
39
48
|
let entries;
|
|
@@ -63,17 +72,14 @@ export class SessionCatalog {
|
|
|
63
72
|
);
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
75
|
-
.slice(0, this.input.limit ?? 20),
|
|
76
|
-
);
|
|
75
|
+
return summaries
|
|
76
|
+
.filter(
|
|
77
|
+
(summary) =>
|
|
78
|
+
summary.turnCount > 0 ||
|
|
79
|
+
summary.status === "incomplete" ||
|
|
80
|
+
summary.status === "unavailable",
|
|
81
|
+
)
|
|
82
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
async get(
|
|
@@ -4838,11 +4838,13 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
|
|
|
4838
4838
|
"read",
|
|
4839
4839
|
"write",
|
|
4840
4840
|
"edit",
|
|
4841
|
+
"delete",
|
|
4841
4842
|
"glob",
|
|
4842
4843
|
"grep",
|
|
4843
4844
|
"bash",
|
|
4844
4845
|
"task_list",
|
|
4845
4846
|
"task_output",
|
|
4847
|
+
"task_input",
|
|
4846
4848
|
"task_stop",
|
|
4847
4849
|
"web_search",
|
|
4848
4850
|
"web_fetch",
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export type BashRisk =
|
|
4
|
+
| { readonly dangerous: false }
|
|
5
|
+
| { readonly dangerous: true; readonly reason: string };
|
|
6
|
+
|
|
7
|
+
export type BashRiskContext = {
|
|
8
|
+
readonly workspaceRoot?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const SAFE: BashRisk = Object.freeze({ dangerous: false });
|
|
12
|
+
|
|
13
|
+
export function classifyBashRisk(
|
|
14
|
+
command: string,
|
|
15
|
+
context: BashRiskContext = {},
|
|
16
|
+
): BashRisk {
|
|
17
|
+
const normalized = command.trim();
|
|
18
|
+
if (normalized === "") {
|
|
19
|
+
return SAFE;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(normalized)) {
|
|
23
|
+
return dangerous("fork bomb");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const segment of shellSegments(normalized)) {
|
|
27
|
+
const words = shellWords(segment);
|
|
28
|
+
const commandIndex = commandWordIndex(words);
|
|
29
|
+
if (commandIndex === -1) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const name = basename(words[commandIndex] ?? "");
|
|
33
|
+
const args = words.slice(commandIndex + 1);
|
|
34
|
+
|
|
35
|
+
if (["shutdown", "reboot", "halt", "poweroff"].includes(name)) {
|
|
36
|
+
return dangerous(`system power command ${name}`);
|
|
37
|
+
}
|
|
38
|
+
if (name === "wipefs" || name.startsWith("mkfs.")) {
|
|
39
|
+
return dangerous(`block-device command ${name}`);
|
|
40
|
+
}
|
|
41
|
+
if (name === "dd" && args.some((word) => /^of=\/dev\/[^/]/.test(word))) {
|
|
42
|
+
return dangerous("dd writes directly to a device");
|
|
43
|
+
}
|
|
44
|
+
if ((name === "chmod" || name === "chown") && hasRecursiveFlag(args)) {
|
|
45
|
+
const operands = args.filter((word) => !word.startsWith("-"));
|
|
46
|
+
if (operands.at(-1) === "/") {
|
|
47
|
+
return dangerous(`${name} recursively targets the filesystem root`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (name === "rm" && hasRecursiveFlag(args) && hasForceFlag(args)) {
|
|
51
|
+
const operands = args.filter((word) => !word.startsWith("-"));
|
|
52
|
+
if (
|
|
53
|
+
operands.some((target) => isDestructiveRmTarget(target, context.workspaceRoot))
|
|
54
|
+
) {
|
|
55
|
+
return dangerous("recursive forced removal targets a protected root");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return SAFE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dangerous(reason: string): BashRisk {
|
|
64
|
+
return Object.freeze({ dangerous: true, reason });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function shellSegments(command: string): string[] {
|
|
68
|
+
return command.split(/(?:&&|\|\||[;|\n])/);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function shellWords(segment: string): string[] {
|
|
72
|
+
return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g)?.map(unquote) ?? [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unquote(word: string): string {
|
|
76
|
+
if (
|
|
77
|
+
(word.startsWith('"') && word.endsWith('"')) ||
|
|
78
|
+
(word.startsWith("'") && word.endsWith("'"))
|
|
79
|
+
) {
|
|
80
|
+
return word.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
return word;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function commandWordIndex(words: readonly string[]): number {
|
|
86
|
+
let index = 0;
|
|
87
|
+
while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
|
|
88
|
+
index += 1;
|
|
89
|
+
}
|
|
90
|
+
if (basename(words[index] ?? "") === "sudo") {
|
|
91
|
+
index += 1;
|
|
92
|
+
while ((words[index] ?? "").startsWith("-")) {
|
|
93
|
+
index += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return index < words.length ? index : -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function basename(word: string): string {
|
|
100
|
+
return word.slice(word.lastIndexOf("/") + 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasRecursiveFlag(args: readonly string[]): boolean {
|
|
104
|
+
return args.some((word) => /^-[^-]*[rR]/.test(word) || word === "--recursive");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hasForceFlag(args: readonly string[]): boolean {
|
|
108
|
+
return args.some((word) => /^-[^-]*f/.test(word) || word === "--force");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isDestructiveRmTarget(
|
|
112
|
+
target: string,
|
|
113
|
+
workspaceRoot: string | undefined,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (target === "/" || target === "/*" || target === "~" || target === "~/*") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
target === "$HOME" ||
|
|
120
|
+
target === "${HOME}" ||
|
|
121
|
+
target === "$HOME/*" ||
|
|
122
|
+
target === "${HOME}/*"
|
|
123
|
+
) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (workspaceRoot === undefined || !path.isAbsolute(target)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const normalizedTarget = path.resolve(target.replace(/\/\*$/, ""));
|
|
130
|
+
return normalizedTarget === path.resolve(workspaceRoot);
|
|
131
|
+
}
|