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
@@ -3,7 +3,7 @@ import {
3
3
  bashResultDetail,
4
4
  type BashDisplayDetail,
5
5
  } from "../events/bash-result-detail";
6
- import type { AgentEvent } from "../events/types";
6
+ import type { AgentEvent, ModelRequestFailedData } from "../events/types";
7
7
  import type { ContextUsageSnapshot } from "../agent/context-meter";
8
8
  import type {
9
9
  ModelContextBudget,
@@ -162,12 +162,21 @@ export function reduceTuiProjection(
162
162
  })
163
163
  : updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
164
164
  ...item,
165
- text: `model iteration ${event.iterationNumber} · retrying`,
165
+ text: `model iteration ${event.iterationNumber} · retrying (attempt ${event.data.attemptNumber}/${event.data.maxAttempts})`,
166
166
  status: "running",
167
167
  })),
168
168
  );
169
169
  case "model.request.failed":
170
- return state;
170
+ if (event.data.retryDisposition !== "scheduled") {
171
+ return state;
172
+ }
173
+ return updateActiveTurn(state, event, policy, (turn) =>
174
+ updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
175
+ ...item,
176
+ text: `model iteration ${requireEventNumber(event.iterationNumber, "model.request.failed iterationNumber")} · ${modelRequestRetryText(event.data)}`,
177
+ status: "running",
178
+ })),
179
+ );
171
180
  case "model.request.finished":
172
181
  return updateActiveTurn(state, event, policy, (turn) =>
173
182
  updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
@@ -214,6 +223,37 @@ export function reduceTuiProjection(
214
223
  status: event.data.ok ? "ok" : "failed",
215
224
  })),
216
225
  );
226
+ case "tool.confirmation.requested":
227
+ return updateActiveTurn(state, event, policy, (turn) =>
228
+ updateTurnItem(
229
+ turn,
230
+ toolCallRef(
231
+ requireEventString(
232
+ event.toolCallId,
233
+ "tool.confirmation.requested toolCallId",
234
+ ),
235
+ ),
236
+ (item) => ({
237
+ ...item,
238
+ text: `Bash confirmation requested · ${event.data.reason}`,
239
+ status: "running",
240
+ }),
241
+ ),
242
+ );
243
+ case "tool.confirmation.resolved":
244
+ return updateActiveTurn(state, event, policy, (turn) =>
245
+ appendTurnItem(turn, {
246
+ id: `confirmation-${event.toolCallId}-${event.eventSequence}`,
247
+ label: "bash guard",
248
+ text: `${event.data.decision} · ${event.data.reason}`,
249
+ status:
250
+ event.data.decision === "allow"
251
+ ? "ok"
252
+ : event.data.decision === "cancelled"
253
+ ? "cancelled"
254
+ : "failed",
255
+ }),
256
+ );
217
257
  case "bash.task.backgrounded":
218
258
  case "bash.task.stopping":
219
259
  case "bash.task.finished":
@@ -679,6 +719,21 @@ export function completedModelRequestText(
679
719
  return `model iteration ${iterationNumber} -> assistant response`;
680
720
  }
681
721
 
722
+ function modelRequestRetryText(data: ModelRequestFailedData): string {
723
+ const attempt = `attempt ${data.attemptNumber + 1}/${data.maxAttempts}`;
724
+ if (data.retryDelayMs !== undefined && data.retryDelayMs > 0) {
725
+ const seconds = Math.max(1, Math.round(data.retryDelayMs / 1000));
726
+ const reason =
727
+ data.code === "provider_rate_limited"
728
+ ? "rate limited"
729
+ : data.code === "provider_unavailable"
730
+ ? "provider unavailable"
731
+ : data.code;
732
+ return `${reason} · retrying in ${seconds}s (${attempt})`;
733
+ }
734
+ return `retrying (${attempt})`;
735
+ }
736
+
682
737
  export function toolCallStartedProjection(input: {
683
738
  name: string;
684
739
  args: unknown;
@@ -716,7 +771,11 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
716
771
  if (input.name === "WebFetch") {
717
772
  return `WebFetch ${toolUrl(input.args) ?? ""}`.trim();
718
773
  }
719
- if (input.name === "TaskOutput" || input.name === "TaskStop") {
774
+ if (
775
+ input.name === "TaskOutput" ||
776
+ input.name === "TaskInput" ||
777
+ input.name === "TaskStop"
778
+ ) {
720
779
  return `${input.name} ${toolTaskId(input.args) ?? ""}`.trim();
721
780
  }
722
781
  if (input.name === "TaskList") {
@@ -774,6 +833,8 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
774
833
  ? base
775
834
  : `${base} -> ${raw.bytesWritten} bytes`;
776
835
  }
836
+ case "delete":
837
+ return `${base} -> deleted`;
777
838
  case "web_search":
778
839
  return raw.resultCount === undefined
779
840
  ? base
@@ -809,6 +870,10 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
809
870
  return `${base} -> ${raw.status}, ${raw.outputLines} line${raw.outputLines === 1 ? "" : "s"}`;
810
871
  }
811
872
  return base;
873
+ case "task_input":
874
+ return raw.ok
875
+ ? `${base} -> ${raw.status}, wrote ${raw.writtenBytes} byte${raw.writtenBytes === 1 ? "" : "s"}, ${raw.screenColumns}x${raw.screenRows}`
876
+ : base;
812
877
  case "task_stop": {
813
878
  if (raw.status === undefined) {
814
879
  return base;
@@ -854,9 +919,14 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
854
919
  const detail = bashResultDetail(raw);
855
920
  return detail === undefined ? {} : { bash: detail };
856
921
  }
922
+ case "task_input": {
923
+ const detail = bashResultDetail(raw);
924
+ return detail === undefined ? {} : { bash: detail };
925
+ }
857
926
  case "read":
858
927
  case "write":
859
928
  case "edit":
929
+ case "delete":
860
930
  case "glob":
861
931
  case "grep":
862
932
  case "task_list":
@@ -890,11 +960,13 @@ function toolRawResultDiff(
890
960
  diffTruncated: raw.patchTruncated === true,
891
961
  };
892
962
  case "read":
963
+ case "delete":
893
964
  case "glob":
894
965
  case "grep":
895
966
  case "bash":
896
967
  case "task_list":
897
968
  case "task_output":
969
+ case "task_input":
898
970
  case "task_stop":
899
971
  case "web_search":
900
972
  case "web_fetch":
@@ -24,6 +24,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
24
24
  usage: "/mcp",
25
25
  description: "Show MCP servers and runtime tools",
26
26
  },
27
+ {
28
+ name: "yolo",
29
+ usage: "/yolo [on|off]",
30
+ description: "Show or change destructive Bash confirmation",
31
+ },
27
32
  {
28
33
  name: "memory",
29
34
  usage: "/memory",
@@ -34,6 +39,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
34
39
  usage: "/compact [retire]",
35
40
  description: "Swap tool output or retire a cold history prefix",
36
41
  },
42
+ {
43
+ name: "undo",
44
+ usage: "/undo",
45
+ description: "Undo the latest Write/Edit/Delete turn",
46
+ },
37
47
  {
38
48
  name: "clear",
39
49
  usage: "/clear",
@@ -70,11 +80,14 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
70
80
 
71
81
  export type ParsedSlashCommand =
72
82
  | { type: "status" }
83
+ | { type: "yolo_status" }
84
+ | { type: "yolo"; enabled: boolean }
73
85
  | { type: "skills" }
74
86
  | { type: "mcp" }
75
87
  | { type: "memory" }
76
88
  | { type: "compact" }
77
89
  | { type: "compact_retire" }
90
+ | { type: "undo" }
78
91
  | { type: "clear" }
79
92
  | { type: "fork" }
80
93
  | { type: "view"; filePath: string }
@@ -111,6 +124,15 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
111
124
  if (command === "/status" && tokens.length === 1) {
112
125
  return { type: "status" };
113
126
  }
127
+ if (command === "/yolo") {
128
+ if (tokens.length === 1) {
129
+ return { type: "yolo_status" };
130
+ }
131
+ if (tokens.length === 2 && (tokens[1] === "on" || tokens[1] === "off")) {
132
+ return { type: "yolo", enabled: tokens[1] === "on" };
133
+ }
134
+ throw slashCommandUsageError("yolo");
135
+ }
114
136
  if (command === "/skills" && tokens.length === 1) {
115
137
  return { type: "skills" };
116
138
  }
@@ -135,6 +157,12 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
135
157
  }
136
158
  throw slashCommandUsageError("compact");
137
159
  }
160
+ if (command === "/undo") {
161
+ if (tokens.length === 1) {
162
+ return { type: "undo" };
163
+ }
164
+ throw slashCommandUsageError("undo");
165
+ }
138
166
  if (command === "/clear") {
139
167
  if (tokens.length === 1) {
140
168
  return { type: "clear" };
@@ -1,5 +1,9 @@
1
1
  import type { EventSink } from "../events/event-sink";
2
2
  import type { AgentEvent } from "../events/types";
3
+ import type {
4
+ AssistantTextDeltaSink,
5
+ AssistantTextDeltaUpdate,
6
+ } from "../agent/assistant-text-delta";
3
7
  import {
4
8
  createInitialTuiProjectionState,
5
9
  firstRunningIndex,
@@ -14,6 +18,7 @@ import {
14
18
  type TuiProjectionPolicy,
15
19
  validateTuiProjectionPolicy,
16
20
  } from "./tui-projection-policy";
21
+ import { MarkdownSectionFramer } from "./assistant-markdown-section-framer";
17
22
 
18
23
  export type TuiProjectionStoreInput = {
19
24
  sessionId: string;
@@ -23,18 +28,49 @@ export type TuiProjectionStoreInput = {
23
28
  initialSnapshot?: TuiProjectionState;
24
29
  };
25
30
 
31
+ export type AssistantStreamSectionItem = Readonly<{
32
+ kind: "assistant-stream-section";
33
+ id: string;
34
+ iterationId: string;
35
+ attemptNumber: number;
36
+ sectionNumber: number;
37
+ markdown: string;
38
+ showAssistantLabel: boolean;
39
+ }>;
40
+
41
+ export type TuiCommittedItem = TimelineItem | AssistantStreamSectionItem;
42
+
43
+ export function isAssistantStreamSectionItem(
44
+ item: TuiCommittedItem,
45
+ ): item is AssistantStreamSectionItem {
46
+ return "kind" in item && item.kind === "assistant-stream-section";
47
+ }
48
+
26
49
  export type TuiTimelineLog = Readonly<{
27
- committed: readonly TimelineItem[];
50
+ committed: readonly TuiCommittedItem[];
28
51
  live: readonly TimelineItem[];
29
52
  }>;
30
53
 
31
- export class TuiProjectionStore implements EventSink {
54
+ type AssistantStreamAttempt = {
55
+ readonly sessionId: string;
56
+ readonly turnId: string;
57
+ readonly turnNumber: number;
58
+ readonly iterationId: string;
59
+ readonly iterationNumber: number;
60
+ readonly attemptNumber: number;
61
+ readonly framer: MarkdownSectionFramer;
62
+ sectionCount: number;
63
+ };
64
+
65
+ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
32
66
  readonly name = "tui-projection-store";
33
67
  private readonly listeners = new Set<() => void>();
34
68
  private readonly policy: TuiProjectionPolicy;
35
69
  private readonly printed = new Set<string>();
70
+ private readonly physicallyAdoptedIterations = new Map<string, string>();
36
71
  private snapshot: TuiProjectionState;
37
72
  private log: TuiTimelineLog = { committed: [], live: [] };
73
+ private assistantStreamAttempt?: AssistantStreamAttempt;
38
74
 
39
75
  constructor(input: TuiProjectionStoreInput) {
40
76
  this.policy = validateTuiProjectionPolicy(
@@ -62,15 +98,40 @@ export class TuiProjectionStore implements EventSink {
62
98
 
63
99
  async append(event: AgentEvent): Promise<void> {
64
100
  const next = reduceTuiProjection(this.snapshot, event, this.policy);
65
- if (next === this.snapshot) {
101
+ const snapshotChanged = next !== this.snapshot;
102
+ this.snapshot = next;
103
+ const presentationChanged = this.applyAssistantStreamEvent(event);
104
+ if (!snapshotChanged && !presentationChanged) {
66
105
  return;
67
106
  }
68
107
 
69
- this.snapshot = next;
70
- this.refreshLog();
71
- for (const listener of this.listeners) {
72
- listener();
108
+ if (snapshotChanged) {
109
+ this.refreshLog();
110
+ }
111
+ this.notifyListeners();
112
+ }
113
+
114
+ updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
115
+ const attempt = this.assistantStreamAttempt;
116
+ if (
117
+ attempt === undefined ||
118
+ update.content === "" ||
119
+ !sameAssistantStreamAttempt(attempt, update)
120
+ ) {
121
+ return;
122
+ }
123
+
124
+ const frames = attempt.framer.push(update.content);
125
+ if (frames.length === 0) {
126
+ return;
127
+ }
128
+ const committed = [...this.log.committed];
129
+ for (const frame of frames) {
130
+ attempt.sectionCount += 1;
131
+ committed.push(this.sectionItem(attempt, frame.markdown));
73
132
  }
133
+ this.log = { committed, live: this.log.live };
134
+ this.notifyListeners();
74
135
  }
75
136
 
76
137
  hydrate(snapshot: TuiProjectionState): void {
@@ -91,6 +152,130 @@ export class TuiProjectionStore implements EventSink {
91
152
  this.policy,
92
153
  );
93
154
  this.refreshLog(visibleTimelineItems(this.snapshot));
155
+ this.notifyListeners();
156
+ }
157
+
158
+ private applyAssistantStreamEvent(event: AgentEvent): boolean {
159
+ switch (event.type) {
160
+ case "model.request.started":
161
+ this.assistantStreamAttempt = assistantStreamAttempt(event);
162
+ return false;
163
+ case "model.request.failed":
164
+ return this.failAssistantStreamAttempt(event);
165
+ case "model.request.finished":
166
+ return this.finishAssistantStreamAttempt(event);
167
+ case "assistant.progress":
168
+ if (
169
+ event.iterationId !== undefined &&
170
+ this.physicallyAdoptedIterations.has(event.iterationId)
171
+ ) {
172
+ this.printed.add(`assistant-${event.iterationId}-${event.eventSequence}`);
173
+ }
174
+ return false;
175
+ case "turn.finished": {
176
+ const adopted = this.physicallyAdoptedIterations.has(
177
+ event.data.lastIteration.iterationId,
178
+ );
179
+ if (adopted) {
180
+ this.printed.add(`turn-${event.turnId}-final-${event.eventSequence}`);
181
+ }
182
+ this.clearAdoptedTurn(event.turnId);
183
+ this.assistantStreamAttempt = undefined;
184
+ return false;
185
+ }
186
+ case "turn.failed":
187
+ case "turn.cancelled":
188
+ this.clearAdoptedTurn(event.turnId);
189
+ this.assistantStreamAttempt = undefined;
190
+ return false;
191
+ case "session.finished":
192
+ this.physicallyAdoptedIterations.clear();
193
+ this.assistantStreamAttempt = undefined;
194
+ return false;
195
+ default:
196
+ return false;
197
+ }
198
+ }
199
+
200
+ private failAssistantStreamAttempt(
201
+ event: Extract<AgentEvent, { type: "model.request.failed" }>,
202
+ ): boolean {
203
+ const attempt = this.assistantStreamAttempt;
204
+ if (attempt === undefined || !sameAssistantStreamEvent(attempt, event)) {
205
+ return false;
206
+ }
207
+ this.assistantStreamAttempt = undefined;
208
+ if (event.data.retryDisposition !== "scheduled" || attempt.sectionCount === 0) {
209
+ return false;
210
+ }
211
+ this.appendCommitted({
212
+ id: `assistant-stream-retry-${attempt.iterationId}-${attempt.attemptNumber}`,
213
+ text: "assistant response interrupted · retrying",
214
+ status: "info",
215
+ });
216
+ return true;
217
+ }
218
+
219
+ private finishAssistantStreamAttempt(
220
+ event: Extract<AgentEvent, { type: "model.request.finished" }>,
221
+ ): boolean {
222
+ const attempt = this.assistantStreamAttempt;
223
+ if (attempt === undefined || !sameAssistantStreamEvent(attempt, event)) {
224
+ return false;
225
+ }
226
+ this.assistantStreamAttempt = undefined;
227
+ const result = attempt.framer.finish();
228
+ if (
229
+ attempt.sectionCount === 0 ||
230
+ result.content !== (event.data.output.message.content ?? "")
231
+ ) {
232
+ return false;
233
+ }
234
+
235
+ this.printed.add(`model-${attempt.iterationId}`);
236
+ this.physicallyAdoptedIterations.set(attempt.iterationId, attempt.turnId);
237
+ if (result.tail === "") {
238
+ return false;
239
+ }
240
+ attempt.sectionCount += 1;
241
+ this.appendCommitted(this.sectionItem(attempt, result.tail));
242
+ return true;
243
+ }
244
+
245
+ private sectionItem(
246
+ attempt: AssistantStreamAttempt,
247
+ markdown: string,
248
+ ): AssistantStreamSectionItem {
249
+ return Object.freeze({
250
+ kind: "assistant-stream-section",
251
+ id: `assistant-stream-${attempt.iterationId}-${attempt.attemptNumber}-${attempt.sectionCount}`,
252
+ iterationId: attempt.iterationId,
253
+ attemptNumber: attempt.attemptNumber,
254
+ sectionNumber: attempt.sectionCount,
255
+ markdown,
256
+ showAssistantLabel: attempt.sectionCount === 1,
257
+ });
258
+ }
259
+
260
+ private appendCommitted(item: TuiCommittedItem): void {
261
+ this.log = {
262
+ committed: [...this.log.committed, item],
263
+ live: this.log.live,
264
+ };
265
+ }
266
+
267
+ private clearAdoptedTurn(turnId: string | undefined): void {
268
+ if (turnId === undefined) {
269
+ return;
270
+ }
271
+ for (const [iterationId, adoptedTurnId] of this.physicallyAdoptedIterations) {
272
+ if (adoptedTurnId === turnId) {
273
+ this.physicallyAdoptedIterations.delete(iterationId);
274
+ }
275
+ }
276
+ }
277
+
278
+ private notifyListeners(): void {
94
279
  for (const listener of this.listeners) {
95
280
  listener();
96
281
  }
@@ -112,6 +297,60 @@ export class TuiProjectionStore implements EventSink {
112
297
  }
113
298
  }
114
299
 
300
+ function assistantStreamAttempt(
301
+ event: Extract<AgentEvent, { type: "model.request.started" }>,
302
+ ): AssistantStreamAttempt | undefined {
303
+ if (
304
+ event.turnId === undefined ||
305
+ event.turnNumber === undefined ||
306
+ event.iterationId === undefined ||
307
+ event.iterationNumber === undefined
308
+ ) {
309
+ return undefined;
310
+ }
311
+ return {
312
+ sessionId: event.sessionId,
313
+ turnId: event.turnId,
314
+ turnNumber: event.turnNumber,
315
+ iterationId: event.iterationId,
316
+ iterationNumber: event.iterationNumber,
317
+ attemptNumber: event.data.attemptNumber,
318
+ framer: new MarkdownSectionFramer(),
319
+ sectionCount: 0,
320
+ };
321
+ }
322
+
323
+ function sameAssistantStreamAttempt(
324
+ attempt: AssistantStreamAttempt,
325
+ update: AssistantTextDeltaUpdate,
326
+ ): boolean {
327
+ return (
328
+ attempt.sessionId === update.sessionId &&
329
+ attempt.turnId === update.turnId &&
330
+ attempt.turnNumber === update.turnNumber &&
331
+ attempt.iterationId === update.iterationId &&
332
+ attempt.iterationNumber === update.iterationNumber &&
333
+ attempt.attemptNumber === update.attemptNumber
334
+ );
335
+ }
336
+
337
+ function sameAssistantStreamEvent(
338
+ attempt: AssistantStreamAttempt,
339
+ event: Extract<
340
+ AgentEvent,
341
+ { type: "model.request.failed" | "model.request.finished" }
342
+ >,
343
+ ): boolean {
344
+ return (
345
+ attempt.sessionId === event.sessionId &&
346
+ attempt.turnId === event.turnId &&
347
+ attempt.turnNumber === event.turnNumber &&
348
+ attempt.iterationId === event.iterationId &&
349
+ attempt.iterationNumber === event.iterationNumber &&
350
+ attempt.attemptNumber === event.data.attemptNumber
351
+ );
352
+ }
353
+
115
354
  function validateInitialSnapshot(
116
355
  input: Pick<TuiProjectionStoreInput, "sessionId" | "modelName" | "workspaceRoot">,
117
356
  snapshot: TuiProjectionState,
@@ -5,11 +5,13 @@ import type {
5
5
  import type {
6
6
  ExecuteTurnInput,
7
7
  AcceptedTurn,
8
+ BashGuardSnapshot,
8
9
  RuntimeSession,
9
10
  RuntimeSkillsSnapshot,
10
11
  SessionDisposeReason,
11
12
  } from "../agent/runtime-session";
12
13
  import type { RunAgentResult, UserMessage } from "../agent/types";
14
+ import type { TurnUndoResult } from "../tools/turn-undo-manager";
13
15
  import type { ImageAssetRef } from "../image/image-types";
14
16
  import type { ImportedImageAsset } from "../image/image-asset-store";
15
17
  import type { SessionId } from "../ids/runtime-id";
@@ -39,6 +41,10 @@ export type TuiSessionBinding = {
39
41
  ) => Promise<void>;
40
42
  admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
41
43
  executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
44
+ bashGuard(): BashGuardSnapshot;
45
+ subscribeBashGuard(listener: () => void): () => void;
46
+ setYoloMode(enabled: boolean): void;
47
+ resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
42
48
  };
43
49
 
44
50
  export type TuiSessionController = {
@@ -47,6 +53,7 @@ export type TuiSessionController = {
47
53
  listSessions: () => Promise<readonly SessionSummary[]>;
48
54
  compact: () => Promise<ContextCompactionResult>;
49
55
  retire: () => Promise<ContextRetirementResult>;
56
+ undo: () => Promise<TurnUndoResult>;
50
57
  fork: (beforeCommit?: () => void) => Promise<SessionId>;
51
58
  clear: (beforeCommit?: () => void) => Promise<void>;
52
59
  resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
@@ -89,7 +96,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
89
96
  };
90
97
 
91
98
  listSessions(): Promise<readonly SessionSummary[]> {
92
- return this.catalog.list(this.binding.sessionId);
99
+ return this.catalog.listAll(this.binding.sessionId);
93
100
  }
94
101
 
95
102
  compact(): Promise<ContextCompactionResult> {
@@ -100,6 +107,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
100
107
  return this.serialize(() => this.binding.runtimeSession.retireContext());
101
108
  }
102
109
 
110
+ undo(): Promise<TurnUndoResult> {
111
+ return this.serialize(() =>
112
+ this.binding.runtimeSession.undoLatestFileMutationTurn(),
113
+ );
114
+ }
115
+
103
116
  fork(beforeCommit?: () => void): Promise<SessionId> {
104
117
  return this.serialize(async () => {
105
118
  const targetSessionId = createUuidV7() as SessionId;
@@ -230,6 +243,11 @@ export function managedTuiBinding(input: {
230
243
  userMessage,
231
244
  signal,
232
245
  } satisfies ExecuteTurnInput),
246
+ bashGuard: () => input.runtimeSession.bashGuard(),
247
+ subscribeBashGuard: (listener) => input.runtimeSession.subscribeBashGuard(listener),
248
+ setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
249
+ resolveBashConfirmation: (decision) =>
250
+ input.runtimeSession.resolveBashConfirmation(decision),
233
251
  };
234
252
  }
235
253