tinker-agent 1.5.1 → 1.6.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 (42) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.md +13 -5
  3. package/package.json +6 -5
  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/tui-runner.tsx +17 -2
  14. package/src/events/observation-text-log.ts +21 -0
  15. package/src/events/stdout-event-printer.ts +11 -0
  16. package/src/events/types.ts +14 -2
  17. package/src/model/fake-model-client.ts +93 -0
  18. package/src/model/model-client.ts +3 -0
  19. package/src/model/openai-chat-model-client.ts +54 -15
  20. package/src/model/openai-chat-stream.ts +95 -72
  21. package/src/observation/observation-builder.ts +11 -0
  22. package/src/session/session-store.ts +1 -0
  23. package/src/tools/bash-guard.ts +131 -0
  24. package/src/tools/bash.ts +31 -0
  25. package/src/tools/delete.ts +182 -0
  26. package/src/tools/edit.ts +68 -9
  27. package/src/tools/registry.ts +47 -3
  28. package/src/tools/turn-undo-manager.ts +794 -0
  29. package/src/tools/types.ts +13 -0
  30. package/src/tools/write.ts +65 -14
  31. package/src/tui/app.tsx +161 -45
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/bash-confirmation.tsx +27 -0
  34. package/src/tui/components/context-status.tsx +11 -1
  35. package/src/tui/components/footer.tsx +8 -5
  36. package/src/tui/components/prompt-input.tsx +13 -1
  37. package/src/tui/components/timeline.tsx +10 -0
  38. package/src/tui/context-format.ts +17 -0
  39. package/src/tui/event-store.ts +62 -3
  40. package/src/tui/slash-commands.ts +28 -0
  41. package/src/tui/tui-projection-store.ts +246 -7
  42. package/src/tui/tui-session-controller.ts +18 -0
@@ -72,6 +72,13 @@ export type EditFileRawResult = {
72
72
  error?: string;
73
73
  };
74
74
 
75
+ export type DeleteFileRawResult = {
76
+ ok: boolean;
77
+ filePath: string;
78
+ absolutePath?: string;
79
+ error?: string;
80
+ };
81
+
75
82
  export type GlobRawResult = {
76
83
  ok: boolean;
77
84
  pattern: string;
@@ -313,6 +320,7 @@ export type ToolRawResultByKind = {
313
320
  read: ReadFileRawResult;
314
321
  write: WriteFileRawResult;
315
322
  edit: EditFileRawResult;
323
+ delete: DeleteFileRawResult;
316
324
  glob: GlobRawResult;
317
325
  grep: GrepRawResult;
318
326
  bash: BashRawResult;
@@ -367,6 +375,11 @@ export function defineToolExecutor<TKind extends ToolRawResultKind>(
367
375
 
368
376
  export type ToolExecutionContext = {
369
377
  signal: AbortSignal;
378
+ confirmBashCommand?: (request: {
379
+ command: string;
380
+ reason: string;
381
+ }) => Promise<"allow" | "deny">;
382
+ bashGuardSurface?: "tui" | "one-shot";
370
383
  };
371
384
 
372
385
  export class ToolExecutionFatalError extends Error {
@@ -5,6 +5,7 @@ import { computeFilePatch } from "./file-diff";
5
5
  import { ensureParentDirectory } from "./ensure-parent-directory";
6
6
  import { sha256Bytes, sha256Text } from "./hash";
7
7
  import { resolveWorkspacePath } from "./path-safety";
8
+ import type { TurnUndoManager } from "./turn-undo-manager";
8
9
  import { defineToolExecutor } from "./types";
9
10
  import type {
10
11
  FileSnapshotStore,
@@ -21,6 +22,7 @@ type WriteArgs = {
21
22
  export type WriteToolOptions = {
22
23
  workspaceRoot: string;
23
24
  snapshots: FileSnapshotStore;
25
+ undoManager?: TurnUndoManager;
24
26
  };
25
27
 
26
28
  export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor {
@@ -47,7 +49,7 @@ export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor
47
49
  },
48
50
  async execute(
49
51
  args,
50
- _call,
52
+ call,
51
53
  context: ToolExecutionContext,
52
54
  ): Promise<WriteFileRawResult> {
53
55
  throwIfTurnCancelled(context.signal);
@@ -118,10 +120,24 @@ export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor
118
120
  oldContent = target.content;
119
121
  }
120
122
 
123
+ const undoCapture = await options.undoManager?.captureBeforeMutation({
124
+ turnId: call.turnId,
125
+ turnNumber: call.turnNumber,
126
+ absolutePath,
127
+ displayPath: input.file_path,
128
+ loadBefore: async () =>
129
+ target.exists
130
+ ? { state: "present", bytes: target.bytes }
131
+ : { state: "absent" },
132
+ });
133
+
121
134
  throwIfTurnCancelled(context.signal);
122
135
  try {
123
136
  await ensureParentDirectory(absolutePath);
124
137
  } catch (error) {
138
+ if (undoCapture !== undefined) {
139
+ await options.undoManager?.recordMutationFailure(undoCapture);
140
+ }
125
141
  return {
126
142
  ok: false,
127
143
  filePath: input.file_path,
@@ -129,15 +145,39 @@ export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor
129
145
  error: `Failed to create parent directory: ${errorMessage(error)}`,
130
146
  };
131
147
  }
132
- throwIfTurnCancelled(context.signal);
133
- await writeFile(absolutePath, input.content, "utf8");
134
- const newSha256 = sha256Text(input.content);
135
- const writtenInfo = await stat(absolutePath);
136
- options.snapshots.set(absolutePath, {
137
- sha256: newSha256,
138
- mtimeMs: writtenInfo.mtimeMs,
139
- source: "write",
140
- });
148
+ let newSha256: string;
149
+ try {
150
+ throwIfTurnCancelled(context.signal);
151
+ await writeFile(absolutePath, input.content, "utf8");
152
+ const expectedSha256 = sha256Text(input.content);
153
+ const written = await targetFileState(absolutePath);
154
+ if (!written.ok) {
155
+ throw new Error(`Failed to verify written file: ${written.error}`);
156
+ }
157
+ if (!written.exists) {
158
+ throw new Error("Failed to verify written file: File does not exist.");
159
+ }
160
+ if (written.sha256 !== expectedSha256) {
161
+ throw new Error("File changed while Write was being verified.");
162
+ }
163
+ newSha256 = written.sha256;
164
+ if (undoCapture !== undefined) {
165
+ options.undoManager?.recordMutationResult(undoCapture, {
166
+ state: "present",
167
+ sha256: newSha256,
168
+ });
169
+ }
170
+ options.snapshots.set(absolutePath, {
171
+ sha256: newSha256,
172
+ mtimeMs: written.mtimeMs,
173
+ source: "write",
174
+ });
175
+ } catch (error) {
176
+ if (undoCapture !== undefined) {
177
+ await options.undoManager?.recordMutationFailure(undoCapture);
178
+ }
179
+ throw error;
180
+ }
141
181
 
142
182
  const patch = computeFilePatch({
143
183
  filePath: input.file_path,
@@ -184,11 +224,16 @@ function parseWriteArgs(
184
224
  };
185
225
  }
186
226
 
187
- async function targetFileState(
188
- absolutePath: string,
189
- ): Promise<
227
+ async function targetFileState(absolutePath: string): Promise<
190
228
  | { ok: true; exists: false }
191
- | { ok: true; exists: true; sha256: string; content: string }
229
+ | {
230
+ ok: true;
231
+ exists: true;
232
+ sha256: string;
233
+ content: string;
234
+ bytes: Buffer;
235
+ mtimeMs: number;
236
+ }
192
237
  | { ok: false; error: string }
193
238
  > {
194
239
  try {
@@ -198,11 +243,17 @@ async function targetFileState(
198
243
  }
199
244
 
200
245
  const bytes = await readFile(absolutePath);
246
+ const currentInfo = await stat(absolutePath);
247
+ if (currentInfo.mtimeMs > info.mtimeMs) {
248
+ return { ok: false, error: "File changed while it was being read." };
249
+ }
201
250
  return {
202
251
  ok: true,
203
252
  exists: true,
204
253
  sha256: sha256Bytes(bytes),
205
254
  content: bytes.toString("utf8"),
255
+ bytes,
256
+ mtimeMs: currentInfo.mtimeMs,
206
257
  };
207
258
  } catch (error) {
208
259
  if (isNotFound(error)) {
package/src/tui/app.tsx CHANGED
@@ -19,12 +19,12 @@ import { ContextBudgetExceededError } from "../model/model-request-preflight";
19
19
  import { ModelRequestMediaAggregateError } from "../model/model-client";
20
20
  import type { SessionId } from "../ids/runtime-id";
21
21
  import { readLastAssistantResponse } from "../session/session-last-response-reader";
22
- import type { TimelineItem } from "./event-store";
23
22
  import type { PromptHistory } from "./prompt-history";
24
23
  import { Footer } from "./components/footer";
25
24
  import { AssistantMarkdownProvider } from "./components/assistant-markdown";
26
25
  import { ContextStatus } from "./components/context-status";
27
26
  import { BackgroundTasks } from "./components/background-tasks";
27
+ import { BashConfirmation } from "./components/bash-confirmation";
28
28
  import { Header } from "./components/header";
29
29
  import { ModelPicker } from "./components/model-picker";
30
30
  import { FileViewer, FileViewerLoading } from "./components/file-viewer";
@@ -42,7 +42,11 @@ import {
42
42
  ResumeSessionPicker,
43
43
  ResumeSessionPickerLoading,
44
44
  } from "./components/resume-session-picker";
45
- import { Timeline, TimelineRow } from "./components/timeline";
45
+ import {
46
+ AssistantStreamSectionRow,
47
+ Timeline,
48
+ TimelineRow,
49
+ } from "./components/timeline";
46
50
  import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands";
47
51
  import {
48
52
  resolveProjectSlashCommand,
@@ -54,6 +58,11 @@ import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
54
58
  import { loadViewFile, type ViewFile } from "./view-file";
55
59
  import { writeClipboardText } from "./clipboard";
56
60
  import type { WorkspaceFileLister } from "./workspace-file-search";
61
+ import type { TurnUndoBarrierReason, TurnUndoResult } from "../tools/turn-undo-manager";
62
+ import {
63
+ isAssistantStreamSectionItem,
64
+ type TuiCommittedItem,
65
+ } from "./tui-projection-store";
57
66
 
58
67
  export type AppProps = {
59
68
  sessionController: TuiSessionController;
@@ -122,6 +131,11 @@ export function App(props: AppProps) {
122
131
  binding.projectionStore.getLogSnapshot,
123
132
  binding.projectionStore.getLogSnapshot,
124
133
  );
134
+ const bashGuard = useSyncExternalStore(
135
+ (listener) => binding.subscribeBashGuard(listener),
136
+ () => binding.bashGuard(),
137
+ () => binding.bashGuard(),
138
+ );
125
139
  const [isRunning, setIsRunning] = useState(false);
126
140
  const [isSessionOperation, setIsSessionOperation] = useState(false);
127
141
  const [isCopying, setIsCopying] = useState(false);
@@ -156,7 +170,7 @@ export function App(props: AppProps) {
156
170
  write(clearTerminal);
157
171
  setStaticRenderEpoch((current) => current + 1);
158
172
  }, [write]);
159
- const staticItems = useMemo<Array<typeof STATIC_HEADER | TimelineItem>>(
173
+ const staticItems = useMemo<Array<typeof STATIC_HEADER | TuiCommittedItem>>(
160
174
  () => [STATIC_HEADER, ...log.committed],
161
175
  [log.committed],
162
176
  );
@@ -549,6 +563,19 @@ export function App(props: AppProps) {
549
563
  setShowStatus(true);
550
564
  return true;
551
565
  }
566
+ if (command.type === "yolo_status") {
567
+ setNotice(`Bash guard: ${bashGuard.mode} (source: ${bashGuard.source}).`);
568
+ return true;
569
+ }
570
+ if (command.type === "yolo") {
571
+ binding.setYoloMode(command.enabled);
572
+ setNotice(
573
+ command.enabled
574
+ ? "YOLO enabled for this session; dangerous Bash commands will run without confirmation."
575
+ : "YOLO disabled; dangerous Bash commands require confirmation.",
576
+ );
577
+ return true;
578
+ }
552
579
  if (command.type === "skills") {
553
580
  setShowSkills(true);
554
581
  return true;
@@ -579,6 +606,22 @@ export function App(props: AppProps) {
579
606
  .finally(() => setIsSessionOperation(false));
580
607
  return true;
581
608
  }
609
+ if (command.type === "undo") {
610
+ setIsSessionOperation(true);
611
+ void props.sessionController
612
+ .undo()
613
+ .then((result) => {
614
+ if (result.status === "restored" || result.status === "incomplete") {
615
+ setGitBranchRefresh((current) => current + 1);
616
+ }
617
+ setNotice(formatTurnUndoNotice(result));
618
+ })
619
+ .catch((error: unknown) => {
620
+ setNotice(errorMessage(error));
621
+ })
622
+ .finally(() => setIsSessionOperation(false));
623
+ return true;
624
+ }
582
625
  if (command.type === "clear") {
583
626
  setIsSessionOperation(true);
584
627
  void props.sessionController
@@ -676,6 +719,8 @@ export function App(props: AppProps) {
676
719
  workspaceRoot={binding.workspaceRoot}
677
720
  sessionId={binding.sessionId}
678
721
  />
722
+ ) : isAssistantStreamSectionItem(item) ? (
723
+ <AssistantStreamSectionRow key={item.id} item={item} />
679
724
  ) : (
680
725
  <TimelineRow key={item.id} item={item} />
681
726
  )
@@ -700,55 +745,71 @@ export function App(props: AppProps) {
700
745
  ) : (
701
746
  <Box
702
747
  flexDirection="column"
703
- maxHeight={
704
- state.status === "running" ? Math.max(1, windowSize.rows - 1) : undefined
705
- }
706
- overflow={state.status === "running" ? "hidden" : "visible"}
748
+ maxHeight={Math.max(1, windowSize.rows - 1)}
749
+ overflow="hidden"
707
750
  >
708
- <Box marginTop={1} flexDirection="column">
709
- <Box
710
- maxHeight={
711
- state.backgroundTasks.length === 0
712
- ? LIVE_TIMELINE_MAX_ROWS
713
- : LIVE_TIMELINE_WITH_TASKS_MAX_ROWS
714
- }
715
- overflow="hidden"
716
- >
717
- <Timeline items={log.live} />
751
+ <Box flexDirection="column" flexShrink={1} minHeight={0} overflow="hidden">
752
+ <Box flexDirection="column" flexShrink={0}>
753
+ <Box marginTop={1} flexDirection="column" flexShrink={0}>
754
+ <Box
755
+ maxHeight={
756
+ state.backgroundTasks.length === 0
757
+ ? LIVE_TIMELINE_MAX_ROWS
758
+ : LIVE_TIMELINE_WITH_TASKS_MAX_ROWS
759
+ }
760
+ overflow="hidden"
761
+ >
762
+ <Timeline items={log.live} />
763
+ </Box>
764
+ </Box>
765
+ {state.backgroundTasks.length === 0 ? null : (
766
+ <Box
767
+ marginTop={1}
768
+ maxHeight={BACKGROUND_TASKS_MAX_ROWS}
769
+ overflow="hidden"
770
+ flexShrink={0}
771
+ >
772
+ <BackgroundTasks tasks={state.backgroundTasks} />
773
+ </Box>
774
+ )}
775
+ {showStatus ? (
776
+ <Box marginTop={1} flexShrink={0}>
777
+ <ContextStatus state={state} bashGuard={bashGuard} />
778
+ </Box>
779
+ ) : null}
780
+ {showSkills ? (
781
+ <Box marginTop={1} flexShrink={0}>
782
+ <SkillsPanel snapshot={binding.skills()} />
783
+ </Box>
784
+ ) : null}
785
+ {showMcp ? (
786
+ <Box marginTop={1} flexShrink={0}>
787
+ <McpPanel snapshot={binding.mcp()} />
788
+ </Box>
789
+ ) : null}
718
790
  </Box>
719
791
  </Box>
720
- {state.backgroundTasks.length === 0 ? null : (
721
- <Box
722
- marginTop={1}
723
- maxHeight={BACKGROUND_TASKS_MAX_ROWS}
724
- overflow="hidden"
725
- >
726
- <BackgroundTasks tasks={state.backgroundTasks} />
727
- </Box>
728
- )}
729
- {showStatus ? (
730
- <Box marginTop={1}>
731
- <ContextStatus state={state} />
732
- </Box>
733
- ) : null}
734
- {showSkills ? (
735
- <Box marginTop={1}>
736
- <SkillsPanel snapshot={binding.skills()} />
737
- </Box>
738
- ) : null}
739
- {showMcp ? (
740
- <Box marginTop={1}>
741
- <McpPanel snapshot={binding.mcp()} />
742
- </Box>
743
- ) : null}
744
- <Box marginTop={1}>
792
+ <Box marginTop={1} flexShrink={0}>
745
793
  <Footer
746
794
  status={isCancelling ? "cancelling" : state.status}
747
795
  workedForMs={state.workedForMs}
796
+ yolo={bashGuard.mode === "yolo"}
748
797
  />
749
798
  </Box>
750
- <Box marginTop={1} flexDirection="column">
751
- {showModelPicker ? (
799
+ <Box marginTop={1} flexDirection="column" flexShrink={0}>
800
+ {bashGuard.pending !== undefined ? (
801
+ <BashConfirmation
802
+ command={bashGuard.pending.command}
803
+ reason={bashGuard.pending.reason}
804
+ onDecision={(decision) => {
805
+ void binding
806
+ .resolveBashConfirmation(decision)
807
+ .catch((error: unknown) =>
808
+ setNotice(`Bash confirmation failed: ${errorMessage(error)}`),
809
+ );
810
+ }}
811
+ />
812
+ ) : showModelPicker ? (
752
813
  <ModelPicker
753
814
  profiles={profileList}
754
815
  currentProfileName={binding.profileName}
@@ -763,7 +824,12 @@ export function App(props: AppProps) {
763
824
  workspaceRoot={binding.workspaceRoot}
764
825
  gitBranch={gitBranch}
765
826
  contextUsage={state.contextUsage}
766
- isDisabled={isRunning || isSessionOperation || isCopying}
827
+ isDisabled={
828
+ isRunning ||
829
+ isSessionOperation ||
830
+ isCopying ||
831
+ bashGuard.pending !== undefined
832
+ }
767
833
  history={props.history}
768
834
  commands={availableCommands}
769
835
  fileLister={props.fileLister}
@@ -811,6 +877,56 @@ export function formatContextCompactionNotice(result: ContextCompactionResult):
811
877
  return `Context compacted: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.addedOverrideCount} observations swapped, ${before} -> ${after} estimated tokens (-${reduction}%).`;
812
878
  }
813
879
 
880
+ export function formatTurnUndoNotice(result: TurnUndoResult): string {
881
+ if (result.status === "nothing") {
882
+ return "Nothing to undo in this active session.";
883
+ }
884
+ if (result.status === "unavailable") {
885
+ return `Cannot undo turn ${result.turnNumber}: ${formatUndoBarrierReason(result.reason)}`;
886
+ }
887
+ if (result.status === "refused") {
888
+ const count = result.conflicts.length;
889
+ return [
890
+ `Undo refused: ${count} ${count === 1 ? "file" : "files"} changed after turn ${result.turnNumber}.`,
891
+ ...result.conflicts.map(
892
+ (conflict) => `- ${conflict.displayPath}: ${conflict.detail}`,
893
+ ),
894
+ ].join("\n");
895
+ }
896
+ if (result.status === "incomplete") {
897
+ return `Undo incomplete for turn ${result.turnNumber}: ${formatPartialUndoCount(result)} before ${result.failedPath} failed: ${sentenceDetail(result.detail)}\nRun /undo again to retry.`;
898
+ }
899
+ return `Restored workspace to before turn ${result.turnNumber}: ${formatFileCount(result.restoredFileCount)} restored, ${formatFileCount(result.deletedFileCount)} deleted.`;
900
+ }
901
+
902
+ function formatUndoBarrierReason(reason: TurnUndoBarrierReason): string {
903
+ if (reason.kind === "file-too-large" || reason.kind === "turn-too-large") {
904
+ return "undo snapshot capacity was exceeded.";
905
+ }
906
+ return `undo snapshot could not be captured for ${reason.displayPath}: ${sentenceDetail(reason.detail)}`;
907
+ }
908
+
909
+ function formatPartialUndoCount(
910
+ result: Extract<TurnUndoResult, { status: "incomplete" }>,
911
+ ): string {
912
+ const completed: string[] = [];
913
+ if (result.restoredFileCount > 0) {
914
+ completed.push(`${formatFileCount(result.restoredFileCount)} restored`);
915
+ }
916
+ if (result.deletedFileCount > 0) {
917
+ completed.push(`${formatFileCount(result.deletedFileCount)} deleted`);
918
+ }
919
+ return completed.length === 0 ? "no files restored" : completed.join(" and ");
920
+ }
921
+
922
+ function formatFileCount(count: number): string {
923
+ return `${count} ${count === 1 ? "file" : "files"}`;
924
+ }
925
+
926
+ function sentenceDetail(detail: string): string {
927
+ return /[.!?]$/.test(detail) ? detail : `${detail}.`;
928
+ }
929
+
814
930
  export function formatContextCompactionFailureNotice(error: unknown): string {
815
931
  if (!(error instanceof ContextManagerError)) {
816
932
  return "Context compaction failed.";
@@ -0,0 +1,135 @@
1
+ import { lexer, walkTokens, type Token } from "marked";
2
+
3
+ export type MarkdownSectionFrame = Readonly<{
4
+ markdown: string;
5
+ start: number;
6
+ end: number;
7
+ }>;
8
+
9
+ export type MarkdownSectionFramerResult = Readonly<{
10
+ content: string;
11
+ tail: string;
12
+ sealedEnd: number;
13
+ framingStopped: boolean;
14
+ }>;
15
+
16
+ const ATX_HEADING_LINE = /^ {0,3}#{1,6}(?:[\t ]+|$)/u;
17
+
18
+ export class MarkdownSectionFramer {
19
+ private source = "";
20
+ private scanOffset = 0;
21
+ private sealedEnd = 0;
22
+ private framingStopped = false;
23
+
24
+ push(content: string): readonly MarkdownSectionFrame[] {
25
+ if (content === "") {
26
+ return [];
27
+ }
28
+ this.source += content;
29
+ if (this.framingStopped) {
30
+ return [];
31
+ }
32
+
33
+ const frames: MarkdownSectionFrame[] = [];
34
+ while (true) {
35
+ const newline = this.source.indexOf("\n", this.scanOffset);
36
+ if (newline === -1) {
37
+ break;
38
+ }
39
+ const lineStart = this.scanOffset;
40
+ const lineEnd = newline + 1;
41
+ this.scanOffset = lineEnd;
42
+ const line = this.source.slice(lineStart, newline).replace(/\r$/u, "");
43
+ if (!ATX_HEADING_LINE.test(line) || !this.isTopLevelHeading(lineStart, lineEnd)) {
44
+ continue;
45
+ }
46
+
47
+ const section = this.source.slice(this.sealedEnd, lineStart);
48
+ if (section.trim() === "") {
49
+ continue;
50
+ }
51
+ if (hasDocumentLevelMarkdownDependency(section)) {
52
+ this.framingStopped = true;
53
+ break;
54
+ }
55
+
56
+ frames.push(
57
+ Object.freeze({
58
+ markdown: section,
59
+ start: this.sealedEnd,
60
+ end: lineStart,
61
+ }),
62
+ );
63
+ this.sealedEnd = lineStart;
64
+ }
65
+ return frames;
66
+ }
67
+
68
+ finish(): MarkdownSectionFramerResult {
69
+ return Object.freeze({
70
+ content: this.source,
71
+ tail: this.source.slice(this.sealedEnd),
72
+ sealedEnd: this.sealedEnd,
73
+ framingStopped: this.framingStopped,
74
+ });
75
+ }
76
+
77
+ reset(): void {
78
+ this.source = "";
79
+ this.scanOffset = 0;
80
+ this.sealedEnd = 0;
81
+ this.framingStopped = false;
82
+ }
83
+
84
+ private isTopLevelHeading(lineStart: number, lineEnd: number): boolean {
85
+ const markdown = normalizeMarkedSource(this.source.slice(this.sealedEnd, lineEnd));
86
+ const candidateOffset = normalizeMarkedSource(
87
+ this.source.slice(this.sealedEnd, lineStart),
88
+ ).length;
89
+ let tokenOffset = 0;
90
+ for (const token of lexer(markdown, { gfm: true })) {
91
+ if (tokenOffset === candidateOffset && token.type === "heading") {
92
+ return true;
93
+ }
94
+ tokenOffset += token.raw.length;
95
+ }
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function normalizeMarkedSource(source: string): string {
101
+ return source.replace(/\r\n|\r/gu, "\n");
102
+ }
103
+
104
+ function hasDocumentLevelMarkdownDependency(markdown: string): boolean {
105
+ let found = false;
106
+ void walkTokens(lexer(markdown, { gfm: true }), (token: Token) => {
107
+ if (found) {
108
+ return;
109
+ }
110
+ if (token.type === "def") {
111
+ found = true;
112
+ return;
113
+ }
114
+ if (
115
+ (token.type === "text" || token.type === "link" || token.type === "image") &&
116
+ containsReferenceSyntax(token.raw)
117
+ ) {
118
+ found = true;
119
+ }
120
+ });
121
+ return found;
122
+ }
123
+
124
+ function containsReferenceSyntax(source: string): boolean {
125
+ const bracket = /!?\[(?:\\.|[^\]\\\n])+\](?:[\t ]*\[(?:\\.|[^\]\\\n])*\])?/gu;
126
+ for (const match of source.matchAll(bracket)) {
127
+ const value = match[0];
128
+ const end = (match.index ?? 0) + value.length;
129
+ const hasSecondLabel = /\][\t ]*\[/u.test(value);
130
+ if (hasSecondLabel || !/^[\t ]*\(/u.test(source.slice(end))) {
131
+ return true;
132
+ }
133
+ }
134
+ return false;
135
+ }
@@ -0,0 +1,27 @@
1
+ import { Box, Text, useInput } from "ink";
2
+
3
+ export type BashConfirmationProps = {
4
+ command: string;
5
+ reason: string;
6
+ onDecision(decision: "allow" | "deny"): void;
7
+ };
8
+
9
+ export function BashConfirmation(props: BashConfirmationProps) {
10
+ useInput((input) => {
11
+ const normalized = input.toLowerCase();
12
+ if (normalized === "y") {
13
+ props.onDecision("allow");
14
+ } else if (normalized === "n") {
15
+ props.onDecision("deny");
16
+ }
17
+ });
18
+
19
+ return (
20
+ <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
21
+ <Text color="yellow">Dangerous Bash command</Text>
22
+ <Text>{props.command}</Text>
23
+ <Text dimColor>{props.reason}</Text>
24
+ <Text>y allow / n deny / Esc cancel turn</Text>
25
+ </Box>
26
+ );
27
+ }
@@ -1,8 +1,12 @@
1
1
  import { Box, Text } from "ink";
2
+ import type { BashGuardSnapshot } from "../../agent/runtime-session";
2
3
  import type { TuiProjectionState } from "../event-store";
3
4
  import { formatContextUsageLine, formatTokenCount } from "../context-format";
4
5
 
5
- export function ContextStatus(props: { state: TuiProjectionState }) {
6
+ export function ContextStatus(props: {
7
+ state: TuiProjectionState;
8
+ bashGuard: BashGuardSnapshot;
9
+ }) {
6
10
  const usage = props.state.contextUsage;
7
11
  const profile = props.state.contextProfile;
8
12
  const budget = props.state.contextBudget;
@@ -14,6 +18,12 @@ export function ContextStatus(props: { state: TuiProjectionState }) {
14
18
  <Text> model: {props.state.modelName}</Text>
15
19
  <Text> workspace: {props.state.workspaceRoot}</Text>
16
20
  <Text> </Text>
21
+ <Text bold>Bash guard</Text>
22
+ <Text>
23
+ {" mode: "}
24
+ {props.bashGuard.mode} (source: {props.bashGuard.source})
25
+ </Text>
26
+ <Text> </Text>
17
27
  <Text bold>Context</Text>
18
28
  {usage === undefined || profile === undefined || budget === undefined ? (
19
29
  <Text color="yellow"> measurement unavailable</Text>