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.
Files changed (52) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +15 -7
  3. package/package.json +8 -7
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/runner-dependencies.ts +4 -1
  14. package/src/cli/tui-runner.tsx +17 -2
  15. package/src/events/bash-result-detail.ts +13 -6
  16. package/src/events/observation-text-log.ts +26 -1
  17. package/src/events/stdout-event-printer.ts +18 -2
  18. package/src/events/types.ts +14 -2
  19. package/src/model/fake-model-client.ts +177 -0
  20. package/src/model/model-client.ts +3 -0
  21. package/src/model/openai-chat-model-client.ts +54 -15
  22. package/src/model/openai-chat-stream.ts +95 -72
  23. package/src/observation/observation-builder.ts +54 -6
  24. package/src/session/session-catalog.ts +17 -11
  25. package/src/session/session-store.ts +2 -0
  26. package/src/tools/bash-guard.ts +131 -0
  27. package/src/tools/bash-task.ts +129 -90
  28. package/src/tools/bash.ts +75 -13
  29. package/src/tools/delete.ts +182 -0
  30. package/src/tools/edit.ts +68 -9
  31. package/src/tools/registry.ts +49 -3
  32. package/src/tools/shell-process.ts +296 -0
  33. package/src/tools/task-input.ts +229 -0
  34. package/src/tools/task-output-tool.ts +4 -1
  35. package/src/tools/terminal-screen.ts +105 -0
  36. package/src/tools/turn-undo-manager.ts +794 -0
  37. package/src/tools/types.ts +45 -0
  38. package/src/tools/write.ts +65 -14
  39. package/src/tui/app.tsx +161 -45
  40. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  41. package/src/tui/components/background-tasks.tsx +3 -2
  42. package/src/tui/components/bash-confirmation.tsx +27 -0
  43. package/src/tui/components/context-status.tsx +11 -1
  44. package/src/tui/components/footer.tsx +8 -5
  45. package/src/tui/components/prompt-input.tsx +13 -1
  46. package/src/tui/components/resume-session-picker.tsx +292 -46
  47. package/src/tui/components/timeline.tsx +10 -0
  48. package/src/tui/context-format.ts +17 -0
  49. package/src/tui/event-store.ts +76 -4
  50. package/src/tui/slash-commands.ts +28 -0
  51. package/src/tui/tui-projection-store.ts +246 -7
  52. 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 function accumulateOpenAIChatCompletionChunks(
18
- chunks: readonly unknown[],
19
- options: ProviderContext,
20
- ): Record<string, unknown> {
21
- if (chunks.length === 0) {
22
- throw providerStreamError(options, "chunks", "must not be empty");
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
- let role: "assistant" | undefined;
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
- chunks.forEach((chunk, chunkIndex) => {
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
- content = appendOptionalText(
94
- content,
92
+ const contentDelta = appendOptionalText(
93
+ undefined,
95
94
  delta.content,
96
95
  `${deltaPath}.content`,
97
- options,
96
+ this.options,
98
97
  );
99
- reasoningContent = appendOptionalText(
100
- reasoningContent,
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
- // Only fields the provider actually streamed are emitted; the strict
127
- // non-streaming mapper rejects a missing role, id, type, or name.
128
- const message: Record<string, unknown> = {
129
- ...(role === undefined ? {} : { role }),
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
- return {
147
- object: "chat.completion",
148
- choices: [
149
- {
150
- index: 0,
151
- message,
152
- finish_reason: finishReason ?? null,
153
- },
154
- ],
155
- ...(usage === undefined ? {} : { usage }),
156
- ...(resolvedModel === undefined ? {} : { model: resolvedModel }),
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
- "Use Read on outputFilePath to inspect current output.",
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
- "Use Read on outputFilePath to inspect current output.",
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
- "preview:",
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
- "preview:",
390
- raw.preview ?? "",
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 Object.freeze(
67
- summaries
68
- .filter(
69
- (summary) =>
70
- summary.turnCount > 0 ||
71
- summary.status === "incomplete" ||
72
- summary.status === "unavailable",
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
+ }