tinker-agent 1.5.0 → 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 (48) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +13 -5
  3. package/package.json +7 -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 +21 -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 +190 -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 +301 -134
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/assistant-markdown.tsx +27 -26
  34. package/src/tui/components/background-tasks.tsx +7 -2
  35. package/src/tui/components/bash-confirmation.tsx +27 -0
  36. package/src/tui/components/context-status.tsx +11 -1
  37. package/src/tui/components/file-viewer.tsx +2 -2
  38. package/src/tui/components/footer.tsx +9 -12
  39. package/src/tui/components/memory-browser.tsx +1 -1
  40. package/src/tui/components/prompt-input.tsx +13 -1
  41. package/src/tui/components/resume-session-picker.tsx +3 -1
  42. package/src/tui/components/timeline.tsx +19 -11
  43. package/src/tui/context-format.ts +17 -0
  44. package/src/tui/event-store.ts +75 -3
  45. package/src/tui/shiki-highlighter.ts +104 -0
  46. package/src/tui/slash-commands.ts +28 -0
  47. package/src/tui/tui-projection-store.ts +277 -5
  48. package/src/tui/tui-session-controller.ts +32 -8
@@ -73,8 +73,13 @@ import {
73
73
  } from "../context/compiled-context-hash";
74
74
  import { createDefaultTooling, type DefaultTooling } from "../tools/registry";
75
75
  import type { ToolExecutor } from "../tools/types";
76
+ import type { TurnUndoResult } from "../tools/turn-undo-manager";
76
77
  import type { Refiner } from "../tools/web-fetch/refiner";
77
78
  import type { ProjectInstructionManifest } from "../instructions/project-instructions";
79
+ import type {
80
+ AssistantTextDeltaSink,
81
+ AssistantTextDeltaUpdate,
82
+ } from "./assistant-text-delta";
78
83
  import {
79
84
  SessionStore,
80
85
  createSessionCompatibilityContract,
@@ -91,7 +96,7 @@ import {
91
96
  type AdmissionBaseToken,
92
97
  type SessionLedger,
93
98
  } from "./session-ledger";
94
- import { TurnCancelledError } from "./turn-cancellation";
99
+ import { cancellationError, TurnCancelledError } from "./turn-cancellation";
95
100
  import type { ToolCompletionInput } from "../context/protocol-frame";
96
101
  import type { BuiltContextRequest } from "../context/context-revision";
97
102
  import type { CommittedToolCompletion } from "./session-ledger";
@@ -159,11 +164,27 @@ export type RuntimeSession = {
159
164
  executeTurn(input: ExecuteTurnInput): Promise<RunAgentResult>;
160
165
  compactContext(): Promise<ContextCompactionResult>;
161
166
  retireContext(): Promise<ContextRetirementResult>;
167
+ undoLatestFileMutationTurn(): Promise<TurnUndoResult>;
162
168
  cloneSession(targetSessionId: SessionId): Promise<void>;
163
169
  canSwitchSession(): boolean;
170
+ bashGuard(): BashGuardSnapshot;
171
+ subscribeBashGuard(listener: () => void): () => void;
172
+ setYoloMode(enabled: boolean): void;
173
+ resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
164
174
  dispose(reason: SessionDisposeReason): Promise<void>;
165
175
  };
166
176
 
177
+ export type BashGuardSource = "default" | "environment" | "cli" | "session";
178
+
179
+ export type BashGuardSnapshot = {
180
+ readonly mode: "guard" | "yolo";
181
+ readonly source: BashGuardSource;
182
+ readonly pending?: {
183
+ readonly command: string;
184
+ readonly reason: string;
185
+ };
186
+ };
187
+
167
188
  const EMPTY_MCP_INVENTORY: McpInventorySnapshot = Object.freeze({
168
189
  servers: Object.freeze([]),
169
190
  });
@@ -187,6 +208,7 @@ export type RuntimeSessionContext = {
187
208
  ): ToolCallIdentity;
188
209
  finishIterationForContinuation(iteration: IterationIdentity): void;
189
210
  append(input: AgentEventInput): Promise<void>;
211
+ updateAssistantTextDelta?(update: AssistantTextDeltaUpdate): void;
190
212
  onToolCompletionsCommitted?(input: {
191
213
  completions: readonly ToolCompletionInput[];
192
214
  committed: readonly CommittedToolCompletion[];
@@ -247,6 +269,7 @@ type CommonRuntimeSessionInput = {
247
269
  projectInstruction?: ProjectInstructionManifest;
248
270
  skillCatalog?: SkillCatalogSnapshot;
249
271
  presentationSinks?: EventSink[];
272
+ assistantTextDeltaSink?: AssistantTextDeltaSink;
250
273
  persistence?:
251
274
  | false
252
275
  | {
@@ -257,6 +280,12 @@ type CommonRuntimeSessionInput = {
257
280
  toolingConfig?: PublicToolingConfig;
258
281
  memorySearch?: ToolExecutor;
259
282
  completedTurnHook?: CompletedTurnHook;
283
+ enableTurnUndo?: boolean;
284
+ bashGuard?: {
285
+ readonly mode: "guard" | "yolo";
286
+ readonly source: Exclude<BashGuardSource, "session">;
287
+ readonly surface: "tui" | "one-shot";
288
+ };
260
289
  };
261
290
 
262
291
  type CreateNewRuntimeSessionInput = CommonRuntimeSessionInput & {
@@ -309,6 +338,7 @@ type RuntimeSessionState =
309
338
  | "executing"
310
339
  | "compacting"
311
340
  | "maintaining_context"
341
+ | "undoing"
312
342
  | "faulted"
313
343
  | "disposing"
314
344
  | "disposed";
@@ -388,6 +418,20 @@ class DefaultRuntimeSession implements RuntimeSession {
388
418
  private pendingAutomaticContextMaintenance = false;
389
419
  private readonly skillCatalog: SkillCatalogSnapshot;
390
420
  private skillCoordinator = new SkillActivationCoordinator();
421
+ private bashGuardMode: "guard" | "yolo";
422
+ private bashGuardSource: BashGuardSource;
423
+ private bashGuardSnapshot: BashGuardSnapshot;
424
+ private readonly bashGuardListeners = new Set<() => void>();
425
+ private assistantTextDeltaSinkDisabled = false;
426
+ private pendingBashConfirmation?: {
427
+ readonly command: string;
428
+ readonly reason: string;
429
+ readonly startedAt: number;
430
+ readonly call: ToolCallIdentity;
431
+ readonly resolve: (decision: "allow" | "deny") => void;
432
+ readonly reject: (error: unknown) => void;
433
+ readonly removeAbortListener: () => void;
434
+ };
391
435
 
392
436
  private readonly context: RuntimeSessionContext;
393
437
 
@@ -401,6 +445,12 @@ class DefaultRuntimeSession implements RuntimeSession {
401
445
  ) {
402
446
  this.sessionId = input.selection.sessionId;
403
447
  this.resumed = input.selection.mode === "resume";
448
+ this.bashGuardMode = input.bashGuard?.mode ?? "guard";
449
+ this.bashGuardSource = input.bashGuard?.source ?? "default";
450
+ this.bashGuardSnapshot = Object.freeze({
451
+ mode: this.bashGuardMode,
452
+ source: this.bashGuardSource,
453
+ });
404
454
  this.skillCatalog =
405
455
  input.skillCatalog ??
406
456
  createSkillCatalogSnapshot({
@@ -423,6 +473,12 @@ class DefaultRuntimeSession implements RuntimeSession {
423
473
  finishIterationForContinuation: (iteration) =>
424
474
  this.finishIterationForContinuation(iteration),
425
475
  append: (event) => this.append(event),
476
+ ...(input.assistantTextDeltaSink === undefined
477
+ ? {}
478
+ : {
479
+ updateAssistantTextDelta: (update: AssistantTextDeltaUpdate) =>
480
+ this.updateAssistantTextDelta(update),
481
+ }),
426
482
  onToolCompletionsCommitted: (completion) =>
427
483
  this.onToolCompletionsCommitted(completion),
428
484
  prepareModelDispatch: (dispatch) => this.prepareModelDispatch(dispatch),
@@ -556,8 +612,14 @@ class DefaultRuntimeSession implements RuntimeSession {
556
612
  workspaceRoot: input.workspaceRoot,
557
613
  runtimeSession: session.context,
558
614
  historyReader: store.historyReader(),
615
+ ...(input.enableTurnUndo === true ? { enableTurnUndo: true } : {}),
559
616
  webFetchRefiner: input.webFetchRefiner,
560
617
  toolingConfig: input.toolingConfig,
618
+ bashGuard: {
619
+ surface: input.bashGuard?.surface ?? "one-shot",
620
+ confirm: (call, request, signal) =>
621
+ session.confirmBashCommand(call, request, signal),
622
+ },
561
623
  ...(input.memorySearch === undefined
562
624
  ? {}
563
625
  : { memorySearch: input.memorySearch }),
@@ -902,6 +964,134 @@ class DefaultRuntimeSession implements RuntimeSession {
902
964
  return this.input.modelClient.inputModalities?.includes("image") === true;
903
965
  }
904
966
 
967
+ bashGuard(): BashGuardSnapshot {
968
+ return this.bashGuardSnapshot;
969
+ }
970
+
971
+ private refreshBashGuardSnapshot(): void {
972
+ this.bashGuardSnapshot = Object.freeze({
973
+ mode: this.bashGuardMode,
974
+ source: this.bashGuardSource,
975
+ ...(this.pendingBashConfirmation === undefined
976
+ ? {}
977
+ : {
978
+ pending: Object.freeze({
979
+ command: this.pendingBashConfirmation.command,
980
+ reason: this.pendingBashConfirmation.reason,
981
+ }),
982
+ }),
983
+ });
984
+ }
985
+
986
+ subscribeBashGuard(listener: () => void): () => void {
987
+ this.bashGuardListeners.add(listener);
988
+ return () => this.bashGuardListeners.delete(listener);
989
+ }
990
+
991
+ setYoloMode(enabled: boolean): void {
992
+ this.bashGuardMode = enabled ? "yolo" : "guard";
993
+ this.bashGuardSource = "session";
994
+ this.refreshBashGuardSnapshot();
995
+ this.notifyBashGuardListeners();
996
+ }
997
+
998
+ async resolveBashConfirmation(decision: "allow" | "deny"): Promise<void> {
999
+ const pending = this.pendingBashConfirmation;
1000
+ if (pending === undefined) {
1001
+ throw new Error("No Bash confirmation is pending.");
1002
+ }
1003
+ this.pendingBashConfirmation = undefined;
1004
+ this.refreshBashGuardSnapshot();
1005
+ pending.removeAbortListener();
1006
+ await this.append({
1007
+ type: "tool.confirmation.resolved",
1008
+ ...pending.call,
1009
+ data: {
1010
+ command: pending.command,
1011
+ reason: pending.reason,
1012
+ decision,
1013
+ durationMs: Date.now() - pending.startedAt,
1014
+ },
1015
+ });
1016
+ pending.resolve(decision);
1017
+ this.notifyBashGuardListeners();
1018
+ }
1019
+
1020
+ private async confirmBashCommand(
1021
+ call: ToolCallIdentity,
1022
+ request: { command: string; reason: string },
1023
+ signal: AbortSignal,
1024
+ ): Promise<"allow" | "deny"> {
1025
+ const startedAt = Date.now();
1026
+ await this.append({
1027
+ type: "tool.confirmation.requested",
1028
+ ...call,
1029
+ data: request,
1030
+ });
1031
+
1032
+ const surface = this.input.bashGuard?.surface ?? "one-shot";
1033
+ if (this.bashGuardMode === "yolo" || surface === "one-shot") {
1034
+ const decision = this.bashGuardMode === "yolo" ? "allow" : "deny";
1035
+ await this.append({
1036
+ type: "tool.confirmation.resolved",
1037
+ ...call,
1038
+ data: {
1039
+ ...request,
1040
+ decision,
1041
+ durationMs: Date.now() - startedAt,
1042
+ },
1043
+ });
1044
+ return decision;
1045
+ }
1046
+
1047
+ if (this.pendingBashConfirmation !== undefined) {
1048
+ throw new Error("Another Bash confirmation is already pending.");
1049
+ }
1050
+
1051
+ return new Promise<"allow" | "deny">((resolve, reject) => {
1052
+ const onAbort = () => {
1053
+ const pending = this.pendingBashConfirmation;
1054
+ if (pending?.call.toolCallId !== call.toolCallId) {
1055
+ return;
1056
+ }
1057
+ this.pendingBashConfirmation = undefined;
1058
+ this.refreshBashGuardSnapshot();
1059
+ void this.append({
1060
+ type: "tool.confirmation.resolved",
1061
+ ...call,
1062
+ data: {
1063
+ ...request,
1064
+ decision: "cancelled",
1065
+ durationMs: Date.now() - startedAt,
1066
+ },
1067
+ }).finally(() => {
1068
+ reject(cancellationError(signal));
1069
+ this.notifyBashGuardListeners();
1070
+ });
1071
+ };
1072
+ signal.addEventListener("abort", onAbort, { once: true });
1073
+ this.pendingBashConfirmation = {
1074
+ ...request,
1075
+ startedAt,
1076
+ call,
1077
+ resolve,
1078
+ reject,
1079
+ removeAbortListener: () => signal.removeEventListener("abort", onAbort),
1080
+ };
1081
+ this.refreshBashGuardSnapshot();
1082
+ this.notifyBashGuardListeners();
1083
+ if (signal.aborted) {
1084
+ onAbort();
1085
+ }
1086
+ });
1087
+ }
1088
+
1089
+ private notifyBashGuardListeners(): void {
1090
+ for (const listener of this.bashGuardListeners) {
1091
+ listener();
1092
+ }
1093
+ }
1094
+
905
1095
  async importImage(
906
1096
  sourcePath: string,
907
1097
  signal: AbortSignal,
@@ -1613,6 +1803,36 @@ class DefaultRuntimeSession implements RuntimeSession {
1613
1803
  );
1614
1804
  }
1615
1805
 
1806
+ async undoLatestFileMutationTurn(): Promise<TurnUndoResult> {
1807
+ if (
1808
+ this.state !== "ready" ||
1809
+ this.activeTurn !== undefined ||
1810
+ this.activeContextRevision !== undefined
1811
+ ) {
1812
+ throw new Error("Cannot undo while a turn or context operation is active.");
1813
+ }
1814
+ const tooling = this.requireTooling();
1815
+ if (
1816
+ tooling.taskManager
1817
+ .listBackgroundTasks()
1818
+ .some((task) => task.status === "running" || task.status === "stopping")
1819
+ ) {
1820
+ throw new Error("Cannot undo while a background task is active.");
1821
+ }
1822
+ if (tooling.turnUndoManager === undefined) {
1823
+ return { status: "nothing" };
1824
+ }
1825
+
1826
+ this.state = "undoing";
1827
+ try {
1828
+ return await tooling.turnUndoManager.undoLatest();
1829
+ } finally {
1830
+ if (this.state === "undoing") {
1831
+ this.state = "ready";
1832
+ }
1833
+ }
1834
+ }
1835
+
1616
1836
  async cloneSession(targetSessionId: SessionId): Promise<void> {
1617
1837
  if (!this.canSwitchSession()) {
1618
1838
  throw new Error(
@@ -1744,6 +1964,7 @@ class DefaultRuntimeSession implements RuntimeSession {
1744
1964
  await this.appendTerminalEvent(turn, result, projectedMessageCount);
1745
1965
  pendingLedgerTurn.finish(result);
1746
1966
  settled = true;
1967
+ this.requireTooling().turnUndoManager?.completeTurn(turn);
1747
1968
  if (result.status === "completed") {
1748
1969
  this.notifyCompletedTurn(turn);
1749
1970
  }
@@ -2156,6 +2377,32 @@ class DefaultRuntimeSession implements RuntimeSession {
2156
2377
  return identity;
2157
2378
  }
2158
2379
 
2380
+ private updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
2381
+ const sink = this.input.assistantTextDeltaSink;
2382
+ const iteration = this.iterations.get(update.iterationId);
2383
+ if (
2384
+ sink === undefined ||
2385
+ this.assistantTextDeltaSinkDisabled ||
2386
+ this.state !== "executing" ||
2387
+ update.content === "" ||
2388
+ !Number.isSafeInteger(update.attemptNumber) ||
2389
+ update.attemptNumber < 1 ||
2390
+ iteration === undefined ||
2391
+ iteration.sessionId !== update.sessionId ||
2392
+ iteration.turnId !== update.turnId ||
2393
+ iteration.turnNumber !== update.turnNumber ||
2394
+ iteration.iterationNumber !== update.iterationNumber
2395
+ ) {
2396
+ return;
2397
+ }
2398
+
2399
+ try {
2400
+ sink.updateAssistantTextDelta(update);
2401
+ } catch {
2402
+ this.assistantTextDeltaSinkDisabled = true;
2403
+ }
2404
+ }
2405
+
2159
2406
  private createToolCall(
2160
2407
  iteration: IterationIdentity,
2161
2408
  toolCallNumber: number,
@@ -8,6 +8,7 @@ export type CliCommand =
8
8
  | {
9
9
  readonly type: "run";
10
10
  readonly profileName?: string;
11
+ readonly yolo?: boolean;
11
12
  readonly promptSource: PromptSource;
12
13
  };
13
14
 
@@ -72,6 +73,7 @@ export async function parseCommandLine(
72
73
  .option(contract.run.profileOption.flags, contract.run.profileOption.description)
73
74
  .option(contract.run.stdinOption.flags, contract.run.stdinOption.description)
74
75
  .option(contract.run.fileOption.flags, contract.run.fileOption.description)
76
+ .option(contract.run.yoloOption.flags, contract.run.yoloOption.description)
75
77
  .addHelpText("after", `\n${contract.run.helpAfter}\n`)
76
78
  .showHelpAfterError('Run "tinker run --help" for usage.')
77
79
  .showSuggestionAfterError(false)
@@ -80,7 +82,12 @@ export async function parseCommandLine(
80
82
  .action(
81
83
  (
82
84
  prompt: string | undefined,
83
- options: { profile?: string; stdin?: boolean; file?: string },
85
+ options: {
86
+ profile?: string;
87
+ stdin?: boolean;
88
+ file?: string;
89
+ yolo?: boolean;
90
+ },
84
91
  ) => {
85
92
  const sources: PromptSource[] = [];
86
93
  if (prompt !== undefined) {
@@ -116,6 +123,7 @@ export async function parseCommandLine(
116
123
  ...(options.profile === undefined
117
124
  ? {}
118
125
  : { profileName: validateProfile(options.profile, "run") }),
126
+ ...(options.yolo === true ? { yolo: true } : {}),
119
127
  promptSource,
120
128
  });
121
129
  },
package/src/cli/config.ts CHANGED
@@ -37,11 +37,14 @@ export type RunnerConfig = {
37
37
  readonly profileName?: string;
38
38
  readonly inputModalities: readonly ModelInputModality[];
39
39
  readonly tokenEstimator?: ModelTokenEstimatorProfile;
40
+ readonly bashGuardMode: "guard" | "yolo";
41
+ readonly bashGuardSource: "default" | "environment" | "cli";
40
42
  };
41
43
 
42
44
  export type RunnerConfigSelection = {
43
45
  readonly sessionId: SessionId;
44
46
  readonly profileName?: string;
47
+ readonly yolo?: boolean;
45
48
  };
46
49
 
47
50
  type RunnerConfigTemplate = Omit<RunnerConfig, "sessionId">;
@@ -144,7 +147,7 @@ export function deriveRunnerConfig(
144
147
  if (template === undefined) {
145
148
  throw unknownProfileError(profileName, snapshot.profiles);
146
149
  }
147
- return runnerConfigFromTemplate(template, selection.sessionId);
150
+ return runnerConfigFromTemplate(template, selection);
148
151
  }
149
152
 
150
153
  if (selection.profileName !== undefined) {
@@ -152,7 +155,7 @@ export function deriveRunnerConfig(
152
155
  `Cannot select model profile ${JSON.stringify(selection.profileName)} because TINKER_MODELS is not configured.`,
153
156
  );
154
157
  }
155
- return runnerConfigFromTemplate(snapshot.template, selection.sessionId);
158
+ return runnerConfigFromTemplate(snapshot.template, selection);
156
159
  }
157
160
 
158
161
  function runnerConfigTemplateFromProfile(
@@ -172,6 +175,8 @@ function runnerConfigTemplateFromProfile(
172
175
  contextBudget: deriveModelContextBudget(contextProfile),
173
176
  profileName: profile.name,
174
177
  inputModalities: profile.inputModalities,
178
+ bashGuardMode: environment.bashGuardMode,
179
+ bashGuardSource: environment.bashGuardSource,
175
180
  ...(profile.tokenEstimator === undefined
176
181
  ? {}
177
182
  : { tokenEstimator: profile.tokenEstimator }),
@@ -196,14 +201,22 @@ function runnerConfigTemplateFromEnvironment(
196
201
  contextProfile,
197
202
  contextBudget: deriveModelContextBudget(contextProfile),
198
203
  inputModalities: Object.freeze(["text"] as const),
204
+ bashGuardMode: environment.bashGuardMode,
205
+ bashGuardSource: environment.bashGuardSource,
199
206
  });
200
207
  }
201
208
 
202
209
  function runnerConfigFromTemplate(
203
210
  template: RunnerConfigTemplate,
204
- sessionId: SessionId,
211
+ selection: RunnerConfigSelection,
205
212
  ): RunnerConfig {
206
- return Object.freeze({ ...template, sessionId });
213
+ return Object.freeze({
214
+ ...template,
215
+ sessionId: selection.sessionId,
216
+ ...(selection.yolo === true
217
+ ? { bashGuardMode: "yolo" as const, bashGuardSource: "cli" as const }
218
+ : {}),
219
+ });
207
220
  }
208
221
 
209
222
  export function eventLogPath(workspaceRoot: string, sessionId: SessionId): string {
package/src/cli/main.ts CHANGED
@@ -144,6 +144,7 @@ export async function main(
144
144
  ...(parsed.command.profileName === undefined
145
145
  ? {}
146
146
  : { profileName: parsed.command.profileName }),
147
+ ...(parsed.command.type === "run" && parsed.command.yolo ? { yolo: true } : {}),
147
148
  });
148
149
  } catch (error) {
149
150
  await writeCliOutput(
@@ -60,6 +60,10 @@ export const PUBLIC_CLI_CONTRACT = Object.freeze({
60
60
  description: "Read the prompt from a UTF-8 text file.",
61
61
  valueName: "path",
62
62
  } satisfies PublicCliOption),
63
+ yoloOption: Object.freeze({
64
+ flags: "--yolo",
65
+ description: "Skip destructive Bash command confirmation; use at your own risk.",
66
+ } satisfies PublicCliOption),
63
67
  promptSources: RUN_PROMPT_SOURCES,
64
68
  helpAfter:
65
69
  "Use exactly one prompt source. For complex or sensitive prompts, prefer --stdin or --file.",
@@ -174,6 +174,17 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
174
174
  section: "tooling",
175
175
  description: "Maximum Bash foreground timeout in milliseconds.",
176
176
  }),
177
+ publicField({
178
+ name: "TINKER_YOLO",
179
+ valueKind: "boolean",
180
+ requiredIn: "never",
181
+ appliesIn: "always",
182
+ defaultValue: false,
183
+ secret: false,
184
+ section: "tooling",
185
+ description:
186
+ "Allow high-confidence destructive Bash commands without confirmation.",
187
+ }),
177
188
  publicField({
178
189
  name: "TINKER_GREP_TIMEOUT_MS",
179
190
  valueKind: "positive-integer",
@@ -479,6 +490,8 @@ type ParsedCommonEnvironment = {
479
490
  readonly workspaceRoot: string;
480
491
  readonly maxIterations: number;
481
492
  readonly tooling: PublicToolingConfig;
493
+ readonly bashGuardMode: "guard" | "yolo";
494
+ readonly bashGuardSource: "default" | "environment";
482
495
  };
483
496
 
484
497
  export type ParsedPublicEnvironment =
@@ -562,7 +575,18 @@ export function parsePublicEnvironment(
562
575
  );
563
576
  }
564
577
 
565
- const common = { workspaceRoot, maxIterations, tooling };
578
+ const common = {
579
+ workspaceRoot,
580
+ maxIterations,
581
+ tooling,
582
+ bashGuardMode: requiredBooleanValue(values, "TINKER_YOLO")
583
+ ? ("yolo" as const)
584
+ : ("guard" as const),
585
+ bashGuardSource:
586
+ optionalRawString(env.TINKER_YOLO) === undefined
587
+ ? ("default" as const)
588
+ : ("environment" as const),
589
+ };
566
590
  if (mode === "profile") {
567
591
  if (configuredModelsPath === undefined) {
568
592
  throw new Error("Profile mode requires TINKER_MODELS.");
@@ -74,6 +74,11 @@ export async function runOneShot(
74
74
  options.eventLogPath === false ? false : { eventLogPath: options.eventLogPath },
75
75
  webFetchRefiner: createWebFetchRefiner(config, options.env),
76
76
  toolingConfig: options.tooling,
77
+ bashGuard: {
78
+ mode: config.bashGuardMode,
79
+ source: config.bashGuardSource,
80
+ surface: "one-shot",
81
+ },
77
82
  });
78
83
 
79
84
  const result = await session.executeTurn({
@@ -7,6 +7,10 @@ import {
7
7
  } from "../agent/runtime-session";
8
8
  import type { EventSink } from "../events/event-sink";
9
9
  import type { AgentEvent } from "../events/types";
10
+ import type {
11
+ AssistantTextDeltaSink,
12
+ AssistantTextDeltaUpdate,
13
+ } from "../agent/assistant-text-delta";
10
14
  import { createUuidV7 } from "../ids/uuid-v7";
11
15
  import type { SessionId } from "../ids/runtime-id";
12
16
  import {
@@ -43,6 +47,7 @@ import { loadProjectSlashCommands } from "../tui/project-slash-commands";
43
47
  import { createWorkspaceFileLister } from "../tui/workspace-file-search";
44
48
  import { clipboardWriterForEnvironment } from "../tui/clipboard";
45
49
  import { initializeTuiMemory } from "./tui-memory";
50
+ import { prepareShikiHighlighter } from "../tui/shiki-highlighter";
46
51
 
47
52
  export type RunTuiOptions = {
48
53
  readonly publicConfig: ResolvedPublicConfig;
@@ -51,6 +56,7 @@ export type RunTuiOptions = {
51
56
  };
52
57
 
53
58
  export async function runTui(options: RunTuiOptions): Promise<void> {
59
+ const shikiPreparation = prepareShikiHighlighter();
54
60
  const profiles =
55
61
  options.publicConfig.mode === "profile" ? options.publicConfig.profiles : undefined;
56
62
  const config = options.initialRunnerConfig;
@@ -74,7 +80,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
74
80
  sessionConfig: RunnerConfig,
75
81
  mode: "new" | "resume",
76
82
  sessionId: SessionId,
77
- sink: EventSink,
83
+ sink: EventSink & AssistantTextDeltaSink,
78
84
  ): Promise<RuntimeSession> => {
79
85
  const modelClient = createRunnerModelClient(
80
86
  sessionConfig,
@@ -100,8 +106,15 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
100
106
  projectInstruction: projectInstructionManifest(projectInstructions),
101
107
  skillCatalog,
102
108
  presentationSinks: [sink],
109
+ assistantTextDeltaSink: sink,
103
110
  webFetchRefiner: createWebFetchRefiner(sessionConfig, options.env),
104
111
  toolingConfig: options.publicConfig.tooling,
112
+ enableTurnUndo: true,
113
+ bashGuard: {
114
+ mode: sessionConfig.bashGuardMode,
115
+ source: sessionConfig.bashGuardSource,
116
+ surface: "tui" as const,
117
+ },
105
118
  ...(memoryCoordinator === undefined
106
119
  ? {}
107
120
  : {
@@ -256,6 +269,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
256
269
  createFreshSession,
257
270
  );
258
271
 
272
+ await shikiPreparation;
259
273
  instance = render(
260
274
  <App
261
275
  sessionController={controller}
@@ -285,6 +299,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
285
299
  : () => memoryCoordinator.listStoredMemories()
286
300
  }
287
301
  />,
302
+ { incrementalRendering: true },
288
303
  );
289
304
  await instance.waitUntilExit();
290
305
  } catch (error) {
@@ -317,7 +332,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
317
332
  }
318
333
  }
319
334
 
320
- class DeferredProjectionSink implements EventSink {
335
+ class DeferredProjectionSink implements EventSink, AssistantTextDeltaSink {
321
336
  readonly name = "deferred-tui-projection";
322
337
  private target?: TuiProjectionStore;
323
338
  private readonly buffered: AgentEvent[] = [];
@@ -330,6 +345,10 @@ class DeferredProjectionSink implements EventSink {
330
345
  await this.target.append(event);
331
346
  }
332
347
 
348
+ updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
349
+ this.target?.updateAssistantTextDelta(update);
350
+ }
351
+
333
352
  async attach(target: TuiProjectionStore): Promise<void> {
334
353
  if (this.target !== undefined) {
335
354
  throw new Error("Deferred projection sink is already attached.");
@@ -39,6 +39,27 @@ export function renderObservationLogEvent(event: AgentEvent): string | undefined
39
39
  return renderAssistantProgress(event);
40
40
  case "tool.observation":
41
41
  return renderToolObservation(event);
42
+ case "tool.confirmation.requested":
43
+ return [
44
+ "## Bash confirmation requested",
45
+ "",
46
+ `Reason: ${event.data.reason}`,
47
+ "",
48
+ event.data.command,
49
+ "",
50
+ "---",
51
+ "",
52
+ ].join("\n");
53
+ case "tool.confirmation.resolved":
54
+ return [
55
+ "## Bash confirmation resolved",
56
+ "",
57
+ `Decision: ${event.data.decision}`,
58
+ `Duration: ${event.data.durationMs} ms`,
59
+ "",
60
+ "---",
61
+ "",
62
+ ].join("\n");
42
63
  case "turn.finished":
43
64
  return renderTurnFinished(event.data);
44
65
  case "turn.cancelled":
@@ -117,6 +117,16 @@ export class StdoutEventPrinter implements EventSink {
117
117
  `${formatToolLine("tool.finished", event.data.call).trimEnd()} ok=${event.data.ok}\n`,
118
118
  );
119
119
  break;
120
+ case "tool.confirmation.requested":
121
+ this.stdout.write(
122
+ `tool.confirmation.requested toolCallId=${event.toolCallId} reason=${JSON.stringify(event.data.reason)} command=${JSON.stringify(event.data.command)}\n`,
123
+ );
124
+ break;
125
+ case "tool.confirmation.resolved":
126
+ this.stdout.write(
127
+ `tool.confirmation.resolved toolCallId=${event.toolCallId} decision=${event.data.decision} durationMs=${event.data.durationMs}\n`,
128
+ );
129
+ break;
120
130
  case "mcp.server.connected":
121
131
  this.stdout.write(
122
132
  `mcp.server.connected name=${event.data.serverName} tools=${event.data.toolCount}\n`,
@@ -199,6 +209,7 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
199
209
  case "skill":
200
210
  return [formatSkillResult(raw)];
201
211
  case "read":
212
+ case "delete":
202
213
  case "glob":
203
214
  case "grep":
204
215
  case "web_search":