@bubblebrain-ai/bubble 0.0.19 → 0.0.21

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 (96) hide show
  1. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
  2. package/dist/agent/internal-reminder-sanitizer.js +46 -0
  3. package/dist/agent.d.ts +10 -0
  4. package/dist/agent.js +310 -18
  5. package/dist/approval/controller.d.ts +6 -0
  6. package/dist/approval/controller.js +104 -11
  7. package/dist/checkpoints.d.ts +57 -0
  8. package/dist/checkpoints.js +0 -0
  9. package/dist/debug-trace.js +4 -0
  10. package/dist/feishu/agent-host/run-driver.js +29 -0
  11. package/dist/hooks/config.d.ts +9 -0
  12. package/dist/hooks/config.js +278 -0
  13. package/dist/hooks/controller.d.ts +24 -0
  14. package/dist/hooks/controller.js +254 -0
  15. package/dist/hooks/index.d.ts +6 -0
  16. package/dist/hooks/index.js +4 -0
  17. package/dist/hooks/log.d.ts +14 -0
  18. package/dist/hooks/log.js +54 -0
  19. package/dist/hooks/runner.d.ts +5 -0
  20. package/dist/hooks/runner.js +225 -0
  21. package/dist/hooks/trust.d.ts +37 -0
  22. package/dist/hooks/trust.js +143 -0
  23. package/dist/hooks/types.d.ts +173 -0
  24. package/dist/hooks/types.js +46 -0
  25. package/dist/main.js +86 -13
  26. package/dist/memory/prompts.js +3 -1
  27. package/dist/model-catalog.js +2 -0
  28. package/dist/model-pricing.js +8 -0
  29. package/dist/network/chatgpt-transport.d.ts +0 -1
  30. package/dist/network/chatgpt-transport.js +40 -121
  31. package/dist/network/provider-transport.d.ts +32 -0
  32. package/dist/network/provider-transport.js +265 -0
  33. package/dist/network/retry.d.ts +29 -0
  34. package/dist/network/retry.js +88 -0
  35. package/dist/network/system-proxy.d.ts +18 -0
  36. package/dist/network/system-proxy.js +175 -0
  37. package/dist/provider-anthropic.d.ts +1 -0
  38. package/dist/provider-anthropic.js +127 -52
  39. package/dist/provider-openai-codex.js +19 -29
  40. package/dist/session-log.js +3 -3
  41. package/dist/session.d.ts +31 -0
  42. package/dist/session.js +69 -0
  43. package/dist/slash-commands/commands.js +164 -0
  44. package/dist/slash-commands/types.d.ts +6 -0
  45. package/dist/tools/bash.js +4 -0
  46. package/dist/tools/edit-apply.js +63 -3
  47. package/dist/tools/edit.d.ts +2 -1
  48. package/dist/tools/edit.js +6 -5
  49. package/dist/tools/index.d.ts +7 -0
  50. package/dist/tools/index.js +2 -2
  51. package/dist/tools/write.d.ts +2 -1
  52. package/dist/tools/write.js +2 -1
  53. package/dist/tui/display-history.d.ts +4 -3
  54. package/dist/tui/display-history.js +34 -57
  55. package/dist/tui/display-sanitizer.d.ts +3 -0
  56. package/dist/tui/display-sanitizer.js +38 -0
  57. package/dist/tui/image-paste.d.ts +18 -0
  58. package/dist/tui/image-paste.js +60 -0
  59. package/dist/tui/paste-placeholder.d.ts +1 -0
  60. package/dist/tui/paste-placeholder.js +7 -0
  61. package/dist/tui/run.d.ts +2 -0
  62. package/dist/tui/run.js +568 -223
  63. package/dist/tui/trace-groups.d.ts +16 -0
  64. package/dist/tui/trace-groups.js +82 -5
  65. package/dist/tui/transcript-scroll.d.ts +25 -0
  66. package/dist/tui/transcript-scroll.js +20 -0
  67. package/dist/tui/wordmark.d.ts +1 -0
  68. package/dist/tui/wordmark.js +56 -54
  69. package/dist/tui-ink/app.d.ts +4 -1
  70. package/dist/tui-ink/app.js +303 -248
  71. package/dist/tui-ink/display-history.d.ts +16 -1
  72. package/dist/tui-ink/display-history.js +50 -21
  73. package/dist/tui-ink/footer.d.ts +6 -12
  74. package/dist/tui-ink/footer.js +10 -29
  75. package/dist/tui-ink/image-paste.d.ts +59 -0
  76. package/dist/tui-ink/image-paste.js +277 -0
  77. package/dist/tui-ink/input-box.d.ts +26 -1
  78. package/dist/tui-ink/input-box.js +171 -41
  79. package/dist/tui-ink/message-list.d.ts +1 -1
  80. package/dist/tui-ink/message-list.js +46 -29
  81. package/dist/tui-ink/run.d.ts +7 -2
  82. package/dist/tui-ink/run.js +73 -23
  83. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  84. package/dist/tui-ink/terminal-mouse.js +4 -0
  85. package/dist/tui-ink/trace-groups.d.ts +16 -0
  86. package/dist/tui-ink/trace-groups.js +90 -6
  87. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  88. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  89. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  90. package/dist/tui-ink/transcript-viewport.js +83 -0
  91. package/dist/tui-ink/welcome.d.ts +9 -7
  92. package/dist/tui-ink/welcome.js +7 -33
  93. package/dist/tui-opentui/app.js +2 -1
  94. package/dist/tui-opentui/trace-groups.js +40 -4
  95. package/dist/types.d.ts +27 -0
  96. package/package.json +1 -1
package/dist/session.js CHANGED
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { getBubbleHome } from "./bubble-home.js";
8
+ import { CheckpointStore } from "./checkpoints.js";
8
9
  import { compactSessionEntries } from "./context/compact.js";
9
10
  import { SessionLog } from "./session-log.js";
10
11
  import { normalizeSingleLine, truncateVisual } from "./text-display.js";
@@ -14,6 +15,7 @@ const AUTO_COMPACT_KEEP_RECENT_TURNS = 3;
14
15
  export class SessionManager {
15
16
  sessionFile;
16
17
  log = new SessionLog();
18
+ checkpoints;
17
19
  constructor(sessionFile) {
18
20
  this.sessionFile = sessionFile;
19
21
  if (existsSync(sessionFile)) {
@@ -163,6 +165,73 @@ export class SessionManager {
163
165
  getMessages() {
164
166
  return this.log.toMessages();
165
167
  }
168
+ /**
169
+ * Pre-edit file snapshot store for this session, used by /rewind.
170
+ * Lives next to the session JSONL as `<session>.checkpoints/`.
171
+ */
172
+ getCheckpoints() {
173
+ if (!this.checkpoints) {
174
+ this.checkpoints = new CheckpointStore(this.sessionFile.replace(/\.jsonl$/, "") + ".checkpoints", () => this.lastUserEntryId());
175
+ }
176
+ return this.checkpoints;
177
+ }
178
+ /** Entry id of the most recent user message, or "0" before the first one. */
179
+ lastUserEntryId() {
180
+ const entries = this.log.list();
181
+ for (let i = entries.length - 1; i >= 0; i--) {
182
+ const entry = entries[i];
183
+ if (entry.type === "user_message")
184
+ return entry.id;
185
+ }
186
+ return "0";
187
+ }
188
+ /** User messages after the latest /clear, oldest first — the valid rewind anchors. */
189
+ listUserTurns() {
190
+ const entries = this.log.list();
191
+ let start = 0;
192
+ for (let i = entries.length - 1; i >= 0; i--) {
193
+ const entry = entries[i];
194
+ if (entry.type === "marker" && entry.kind === "conversation_clear") {
195
+ start = i + 1;
196
+ break;
197
+ }
198
+ }
199
+ const turns = [];
200
+ for (let i = start; i < entries.length; i++) {
201
+ const entry = entries[i];
202
+ if (entry.type !== "user_message")
203
+ continue;
204
+ const text = messageText(entry.message);
205
+ turns.push({
206
+ id: entry.id,
207
+ text,
208
+ preview: truncateVisual(normalizeSingleLine(text), 80) || "(empty message)",
209
+ timestamp: entry.timestamp,
210
+ });
211
+ }
212
+ return turns;
213
+ }
214
+ /**
215
+ * Truncate the session to just before the user message with the given
216
+ * entry id. Returns undefined when the id does not name a user message.
217
+ */
218
+ rewindToEntry(entryId) {
219
+ const entries = this.log.list();
220
+ const index = entries.findIndex((entry) => entry.id === entryId && entry.type === "user_message");
221
+ if (index < 0)
222
+ return undefined;
223
+ const target = entries[index];
224
+ const removed = entries.slice(index);
225
+ this.rewrite(entries.slice(0, index));
226
+ const metadata = this.log.getMetadata();
227
+ if (metadata.titleUserMessageId && removed.some((entry) => entry.id === metadata.titleUserMessageId)) {
228
+ this.clearTitleMetadata();
229
+ }
230
+ return {
231
+ removedEntries: removed.length,
232
+ targetText: target.type === "user_message" ? messageText(target.message) : "",
233
+ };
234
+ }
166
235
  getEntries() {
167
236
  return this.log.list();
168
237
  }
@@ -6,6 +6,7 @@ import { parseRule } from "../permissions/rule.js";
6
6
  import { encodeModel, decodeModel, displayModel, BUILTIN_PROVIDERS, isUserVisibleProvider } from "../provider-registry.js";
7
7
  import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
8
8
  import { buildSystemPrompt } from "../system-prompt.js";
9
+ import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
9
10
  import { isThinkingLevel } from "../variant/thinking-level.js";
10
11
  import { collectUsageStatsBundle, formatStatsText } from "../stats/usage.js";
11
12
  import { buildMemoryPrompt, getMemoryStatus, isMemoryDisabled, resetMemory, searchMemory, } from "../memory/index.js";
@@ -49,6 +50,45 @@ function handlePermissionsMutation(sub, tokens, ctx) {
49
50
  return `Rule not found in ${scope} ${list}: ${rule}`;
50
51
  return `Removed from ${scope} ${list}: ${rule}`;
51
52
  }
53
+ async function handleHooksCommand(args, ctx) {
54
+ const hooks = ctx.hookController;
55
+ if (!hooks)
56
+ return "Hooks controller is not attached to this session.";
57
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
58
+ const sub = tokens[0] ?? "status";
59
+ if (sub === "status" || sub === "list" || sub === "") {
60
+ return hooks.status();
61
+ }
62
+ if (sub === "reload") {
63
+ hooks.reload();
64
+ return `Reloaded hooks.\n\n${hooks.status()}`;
65
+ }
66
+ if (sub === "trust" && tokens[1] === "project") {
67
+ return hooks.trustProject();
68
+ }
69
+ if (sub === "untrust" && tokens[1] === "project") {
70
+ return hooks.untrustProject();
71
+ }
72
+ if (sub === "test") {
73
+ const event = tokens[1];
74
+ if (!isHookEventName(event)) {
75
+ return `Usage: /hooks test <event> [target]\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
76
+ }
77
+ return hooks.test(event, tokens.slice(2).join(" ") || undefined);
78
+ }
79
+ if (sub === "explain") {
80
+ const event = tokens[1];
81
+ if (!isHookEventName(event)) {
82
+ return `Usage: /hooks explain <event>\nEvents: ${HOOK_EVENT_NAMES.join(", ")}`;
83
+ }
84
+ return hooks.explain(event);
85
+ }
86
+ if (sub === "logs") {
87
+ const limit = Number(tokens[1] ?? 20);
88
+ return hooks.logs(Number.isFinite(limit) ? limit : 20);
89
+ }
90
+ return "Usage: /hooks [status|reload|trust project|untrust project|test <event> [target]|explain <event>|logs [limit]]";
91
+ }
52
92
  function persistSelectedModel(model, ctx) {
53
93
  const userConfig = new UserConfig();
54
94
  userConfig.setDefaultModel(model);
@@ -359,6 +399,86 @@ const builtinSlashCommandEntries = [
359
399
  ctx.clearMessages();
360
400
  },
361
401
  },
402
+ {
403
+ name: "rewind",
404
+ description: "Rewind conversation and/or file edits to before an earlier message. Usage: /rewind [n] [--code|--chat]",
405
+ async handler(args, ctx) {
406
+ const session = ctx.sessionManager;
407
+ if (!session) {
408
+ return "Rewind requires an active session.";
409
+ }
410
+ const turns = session.listUserTurns();
411
+ if (turns.length === 0) {
412
+ return "Nothing to rewind: no user messages in this session.";
413
+ }
414
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
415
+ const flags = tokens.filter((token) => token.startsWith("--"));
416
+ const positional = tokens.filter((token) => !token.startsWith("--"));
417
+ const checkpoints = session.getCheckpoints();
418
+ if (positional.length === 0) {
419
+ if (ctx.openRewindPicker) {
420
+ ctx.openRewindPicker();
421
+ return;
422
+ }
423
+ const lines = ["Rewind points (oldest first):", ""];
424
+ turns.forEach((turn, index) => {
425
+ const time = new Date(turn.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
426
+ const files = checkpoints.filesTouchedAt(turn.id).length;
427
+ const fileNote = files > 0 ? ` [${files} file${files === 1 ? "" : "s"} changed]` : "";
428
+ lines.push(` ${index + 1}. ${time} ${turn.preview}${fileNote}`);
429
+ });
430
+ lines.push("", "Usage:", " /rewind <n> restore conversation AND files to just before message n", " /rewind <n> --chat conversation only", " /rewind <n> --code files only", "", "Note: only edits made by the edit/write tools are tracked; changes from", "bash commands are not. Checkpoints complement git, they don't replace it.");
431
+ return lines.join("\n");
432
+ }
433
+ const n = Number(positional[0]);
434
+ if (!Number.isInteger(n) || n < 1 || n > turns.length) {
435
+ return `Invalid rewind point "${positional[0]}". Run /rewind to list points (1-${turns.length}).`;
436
+ }
437
+ const target = turns[n - 1];
438
+ const codeOnly = flags.includes("--code");
439
+ const chatOnly = flags.includes("--chat") || flags.includes("--conversation");
440
+ if (codeOnly && chatOnly) {
441
+ return "Pick at most one of --code / --chat.";
442
+ }
443
+ // The "⏪" prefix is recognized by the TUIs: they rebuild the visible
444
+ // transcript from the rewound agent.messages before showing this text.
445
+ const lines = [
446
+ codeOnly
447
+ ? `Files restored to just before: ${target.preview}`
448
+ : `⏪ Rewound to before: ${target.preview}`,
449
+ ];
450
+ if (!chatOnly) {
451
+ const restore = await checkpoints.restoreTo(target.id);
452
+ const touched = restore.restored.length + restore.deleted.length;
453
+ if (touched === 0 && restore.failed.length === 0) {
454
+ lines.push("Files: no tracked edits to undo.");
455
+ }
456
+ else {
457
+ for (const file of restore.restored)
458
+ lines.push(`Restored ${file}`);
459
+ for (const file of restore.deleted)
460
+ lines.push(`Deleted ${file} (created after this point)`);
461
+ for (const file of restore.failed)
462
+ lines.push(`FAILED to restore ${file}`);
463
+ }
464
+ }
465
+ if (!codeOnly) {
466
+ session.rewindToEntry(target.id);
467
+ const head = ctx.agent.messages.filter((m) => m.role === "system" || m.role === "meta");
468
+ ctx.agent.messages = [...head, ...session.getMessages()];
469
+ ctx.agent.setTodos(session.getTodos());
470
+ ctx.agent.resetContextUsageAnchor();
471
+ if (ctx.fillComposer) {
472
+ // Put the rewound message back into the input box for re-editing.
473
+ ctx.fillComposer(target.text);
474
+ }
475
+ else {
476
+ lines.push("", "Rewound message (copy to re-edit):", target.text);
477
+ }
478
+ }
479
+ return lines.join("\n");
480
+ },
481
+ },
362
482
  {
363
483
  name: "session",
364
484
  description: "Show current session information",
@@ -667,6 +787,13 @@ const builtinSlashCommandEntries = [
667
787
  return lines.join("\n");
668
788
  },
669
789
  },
790
+ {
791
+ name: "hooks",
792
+ description: "Inspect and manage lifecycle hooks. Usage: /hooks [status|trust project|test <event>]",
793
+ async handler(args, ctx) {
794
+ return handleHooksCommand(args, ctx);
795
+ },
796
+ },
670
797
  {
671
798
  name: "lsp",
672
799
  description: "Inspect or restart language servers. Usage: /lsp [status|diagnostics|restart]",
@@ -788,8 +915,33 @@ const builtinSlashCommandEntries = [
788
915
  if (!ctx.sessionManager) {
789
916
  return "Compaction requires session persistence. Start an interactive session first.";
790
917
  }
918
+ const preHook = await ctx.hookController?.runEvent({
919
+ eventName: "PreCompact",
920
+ cwd: ctx.cwd,
921
+ sessionId: ctx.sessionManager.getSessionFile(),
922
+ agentRole: "driver",
923
+ target: "manual",
924
+ payload: {
925
+ kind: "manual",
926
+ messageCount: ctx.agent.messages.length,
927
+ },
928
+ });
929
+ if (preHook?.decision === "deny") {
930
+ return preHook.reason ?? `Compaction blocked by hook ${preHook.sourceHookId ?? "<unknown>"}.`;
931
+ }
791
932
  const result = ctx.sessionManager.compact();
792
933
  if (!result.compacted) {
934
+ await ctx.hookController?.runEvent({
935
+ eventName: "PostCompact",
936
+ cwd: ctx.cwd,
937
+ sessionId: ctx.sessionManager.getSessionFile(),
938
+ agentRole: "driver",
939
+ target: "manual",
940
+ payload: {
941
+ kind: "manual",
942
+ compacted: false,
943
+ },
944
+ });
793
945
  return "Session is already compact enough.";
794
946
  }
795
947
  const systemMessage = ctx.agent.messages.find((message) => message.role === "system");
@@ -799,6 +951,18 @@ const builtinSlashCommandEntries = [
799
951
  ];
800
952
  ctx.agent.resetContextUsageAnchor();
801
953
  const dropped = result.droppedEntries ?? 0;
954
+ await ctx.hookController?.runEvent({
955
+ eventName: "PostCompact",
956
+ cwd: ctx.cwd,
957
+ sessionId: ctx.sessionManager.getSessionFile(),
958
+ agentRole: "driver",
959
+ target: "manual",
960
+ payload: {
961
+ kind: "manual",
962
+ compacted: true,
963
+ droppedEntries: dropped,
964
+ },
965
+ });
802
966
  return `✓ Compaction complete · ${dropped} log entr${dropped === 1 ? "y" : "ies"} summarized`;
803
967
  },
804
968
  },
@@ -9,6 +9,7 @@ import type { McpManager } from "../mcp/manager.js";
9
9
  import type { LspService } from "../lsp/index.js";
10
10
  import type { MemoryScope } from "../memory/index.js";
11
11
  import type { ThemeMode } from "../config.js";
12
+ import type { ExternalHookController } from "../hooks/controller.js";
12
13
  export type SidebarMode = "auto" | "expanded" | "collapsed";
13
14
  export interface SidebarCommandState {
14
15
  mode: SidebarMode;
@@ -28,6 +29,7 @@ export interface SlashCommandContext {
28
29
  skillRegistry: SkillRegistry;
29
30
  bashAllowlist?: BashAllowlist;
30
31
  settingsManager?: SettingsManager;
32
+ hookController?: ExternalHookController;
31
33
  mcpManager?: McpManager;
32
34
  lspService?: LspService;
33
35
  flushMemory?: () => Promise<void>;
@@ -46,6 +48,10 @@ export interface SlashCommandContext {
46
48
  setSidebarMode?: (mode: SidebarMode) => SidebarCommandState;
47
49
  /** Open the feedback dialog. `initialDescription` prefills the description field. */
48
50
  openFeedback?: (initialDescription: string) => void;
51
+ /** Open the interactive rewind picker. When absent, /rewind falls back to a text listing. */
52
+ openRewindPicker?: () => void;
53
+ /** Replace the composer/input box content (e.g. /rewind restores the rewound message for re-editing). */
54
+ fillComposer?: (text: string) => void;
49
55
  /** Open the interactive usage stats panel. */
50
56
  openStats?: () => void;
51
57
  }
@@ -21,6 +21,10 @@ export function createBashTool(cwd, approval, _fileState) {
21
21
  type: "object",
22
22
  properties: {
23
23
  command: { type: "string", description: "Bash command to execute" },
24
+ description: {
25
+ type: "string",
26
+ description: "One short sentence (5-10 words) describing what this command does, shown to the user in the UI. Write it in the same language the user is conversing in.",
27
+ },
24
28
  timeout: { type: "number", description: "Timeout in seconds (optional)" },
25
29
  },
26
30
  required: ["command"],
@@ -1,3 +1,4 @@
1
+ import { isSensitivePath } from "./sensitive-paths.js";
1
2
  export class EditApplyError extends Error {
2
3
  status;
3
4
  constructor(message, status = "no_match") {
@@ -6,6 +7,9 @@ export class EditApplyError extends Error {
6
7
  this.name = "EditApplyError";
7
8
  }
8
9
  }
10
+ const CANDIDATE_EXCERPT_CONTEXT_LINES = 3;
11
+ const CANDIDATE_EXCERPT_MAX_LINES = 8;
12
+ const CANDIDATE_EXCERPT_MAX_CHARS = 1200;
9
13
  function detectLineEnding(content) {
10
14
  const crlf = content.indexOf("\r\n");
11
15
  const lf = content.indexOf("\n");
@@ -234,19 +238,75 @@ function findBestLineHint(content, oldText) {
234
238
  return undefined;
235
239
  const contentLines = nonBlankLines(splitLines(content));
236
240
  let best;
241
+ let tieCount = 0;
237
242
  for (let i = 0; i < contentLines.length; i++) {
238
243
  let score = 0;
239
244
  for (let j = 0; j < oldLines.length && i + j < contentLines.length; j++) {
240
245
  if (contentLines[i + j].normalized === oldLines[j])
241
246
  score++;
242
247
  }
243
- if (!best || score > best.score)
248
+ if (!best || score > best.score) {
244
249
  best = { index: i, score };
250
+ tieCount = 1;
251
+ }
252
+ else if (score === best.score) {
253
+ tieCount++;
254
+ }
245
255
  }
246
256
  if (!best || best.score === 0)
247
257
  return undefined;
248
258
  const startLine = contentLines[best.index].lineIndex + 1;
249
- return `Closest line-based candidate starts near line ${startLine} and matched ${best.score}/${oldLines.length} non-blank lines.`;
259
+ return {
260
+ startLine,
261
+ score: best.score,
262
+ total: oldLines.length,
263
+ lineIndex: contentLines[best.index].lineIndex,
264
+ tieCount,
265
+ };
266
+ }
267
+ function isHighConfidenceLineHint(hint) {
268
+ return hint.score >= 2 && hint.score / hint.total >= 0.5 && hint.tieCount === 1;
269
+ }
270
+ function formatLineHint(hint) {
271
+ if (hint.tieCount > 1) {
272
+ return `Closest ambiguous line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines, but ${hint.tieCount} candidates tied. Current bytes were not included because the candidate may be unrelated.`;
273
+ }
274
+ if (!isHighConfidenceLineHint(hint)) {
275
+ return `Closest low-confidence line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines. Current bytes were not included because the candidate may be unrelated.`;
276
+ }
277
+ return `Closest line-based candidate starts near line ${hint.startLine} and matched ${hint.score}/${hint.total} non-blank lines.`;
278
+ }
279
+ function formatFence(content) {
280
+ let fence = "```";
281
+ while (content.includes(fence))
282
+ fence += "`";
283
+ return `${fence}\n${content}\n${fence}`;
284
+ }
285
+ function truncateExcerpt(excerpt) {
286
+ if (excerpt.length <= CANDIDATE_EXCERPT_MAX_CHARS)
287
+ return excerpt;
288
+ const marker = "\n...[truncated current candidate excerpt]";
289
+ return excerpt.slice(0, Math.max(0, CANDIDATE_EXCERPT_MAX_CHARS - marker.length)) + marker;
290
+ }
291
+ function formatCandidateExcerpt(content, hint) {
292
+ const lines = splitLines(content);
293
+ const startLineIndex = Math.max(0, hint.lineIndex - CANDIDATE_EXCERPT_CONTEXT_LINES);
294
+ const requestedEnd = Math.min(lines.length, hint.lineIndex + CANDIDATE_EXCERPT_CONTEXT_LINES + 1);
295
+ const endLineIndex = Math.min(requestedEnd, startLineIndex + CANDIDATE_EXCERPT_MAX_LINES);
296
+ const excerpt = truncateExcerpt(lines.slice(startLineIndex, endLineIndex).map((line) => line.text).join("\n"));
297
+ return [
298
+ `Current candidate excerpt (high confidence, current file lines ${startLineIndex + 1}-${endLineIndex}, not guaranteed target):`,
299
+ formatFence(excerpt),
300
+ ].join("\n");
301
+ }
302
+ function formatBestLineHint(content, hint, options) {
303
+ const lineHint = formatLineHint(hint);
304
+ if (!isHighConfidenceLineHint(hint))
305
+ return lineHint;
306
+ if (options?.path && isSensitivePath(options.path)) {
307
+ return `${lineHint}\nCurrent bytes were not included because this path is blocked by the sensitive-path read policy.`;
308
+ }
309
+ return `${lineHint}\n\n${formatCandidateExcerpt(content, hint)}`;
250
310
  }
251
311
  function matchEdit(content, edit, index, total, options) {
252
312
  if (edit.oldText.length === 0) {
@@ -350,7 +410,7 @@ function matchEdit(content, edit, index, total, options) {
350
410
  }
351
411
  }
352
412
  const hint = findBestLineHint(content, oldText);
353
- const hintSuffix = hint ? `\n${hint}` : "";
413
+ const hintSuffix = hint ? `\n${formatBestLineHint(content, hint, options)}` : "";
354
414
  const recovery = [
355
415
  "",
356
416
  "How to recover:",
@@ -4,6 +4,7 @@
4
4
  * This is the safest way to edit files: old_string must exist exactly once.
5
5
  */
6
6
  import type { ApprovalController } from "../approval/types.js";
7
+ import type { CheckpointStore } from "../checkpoints.js";
7
8
  import type { ToolRegistryEntry } from "../types.js";
8
9
  import { type LspService } from "../lsp/index.js";
9
10
  import { type FileStateTracker } from "./file-state.js";
@@ -14,4 +15,4 @@ export interface EditArgs {
14
15
  newText: string;
15
16
  }>;
16
17
  }
17
- export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
18
+ export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker, checkpoints?: () => CheckpointStore | undefined): ToolRegistryEntry;
@@ -60,17 +60,17 @@ function firstString(...values) {
60
60
  }
61
61
  return undefined;
62
62
  }
63
- export function createEditTool(cwd, approval, lsp, fileState) {
63
+ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
64
64
  return {
65
65
  name: "edit",
66
66
  effect: "write_direct",
67
67
  requiresApproval: true,
68
- description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
68
+ description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes.",
69
69
  promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
70
70
  promptGuidelines: [
71
- "Use edit for precise changes; edits[].oldText should be copied from a recent read and must identify a unique target.",
72
- "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls.",
73
- "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
71
+ "Use edit for precise changes; each edits[].oldText must be copied verbatim from a fresh read of the current exact target block and must identify a unique target. Do not reconstruct oldText from memory, stale reads, or similar code elsewhere.",
72
+ "When changing multiple small, clearly disjoint locations copied from the same fresh read, you may use one edit call with multiple entries in edits[]. Use separate smaller edit calls after re-reading when anchors are uncertain, stale, or likely to drift.",
73
+ "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits; merge only truly overlapping targets.",
74
74
  "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
75
75
  ],
76
76
  parameters: {
@@ -178,6 +178,7 @@ export function createEditTool(cwd, approval, lsp, fileState) {
178
178
  },
179
179
  };
180
180
  }
181
+ await checkpoints?.()?.captureBefore(filePath, original);
181
182
  await writeFile(filePath, applied.content, "utf-8");
182
183
  await fileState?.observe(filePath, "edit", applied.content).catch(() => undefined);
183
184
  let output = `Edited ${filePath}${formatEditMatchNotes(applied.matches)}\n\nDiff:\n${diff}`;
@@ -28,6 +28,7 @@ import { type LspService } from "../lsp/index.js";
28
28
  import { type TodoStore } from "./todo.js";
29
29
  import { type ToolSearchController } from "./tool-search.js";
30
30
  import type { QuestionController } from "../question/index.js";
31
+ import type { CheckpointStore } from "../checkpoints.js";
31
32
  import { FileStateTracker } from "./file-state.js";
32
33
  export interface CreateAllToolsOptions {
33
34
  todoStore?: TodoStore;
@@ -37,5 +38,11 @@ export interface CreateAllToolsOptions {
37
38
  toolSearchController?: ToolSearchController;
38
39
  lspService?: LspService;
39
40
  fileStateTracker?: FileStateTracker;
41
+ /**
42
+ * Lazy accessor for the session's checkpoint store (the session manager may
43
+ * not exist yet when tools are created). Used by edit/write to snapshot
44
+ * files before mutating them so /rewind can restore.
45
+ */
46
+ checkpoints?: () => CheckpointStore | undefined;
40
47
  }
41
48
  export declare function createAllTools(cwd: string, skillRegistry?: SkillRegistry, options?: CreateAllToolsOptions): ToolRegistryEntry[];
@@ -48,8 +48,8 @@ export function createAllTools(cwd, skillRegistry, options = {}) {
48
48
  createReadTool(cwd, approval, lsp, fileState),
49
49
  createBashTool(cwd, approval, fileState),
50
50
  ...createManagedServerTools(cwd, approval),
51
- createWriteTool(cwd, {}, approval, lsp, fileState),
52
- createEditTool(cwd, approval, lsp, fileState),
51
+ createWriteTool(cwd, {}, approval, lsp, fileState, options.checkpoints),
52
+ createEditTool(cwd, approval, lsp, fileState, options.checkpoints),
53
53
  createGlobTool(cwd),
54
54
  createGrepTool(cwd),
55
55
  createLspTool(cwd, lsp, approval),
@@ -2,8 +2,9 @@
2
2
  * Write tool - create files or replace full file contents.
3
3
  */
4
4
  import type { ApprovalController } from "../approval/types.js";
5
+ import type { CheckpointStore } from "../checkpoints.js";
5
6
  import type { ToolRegistryEntry } from "../types.js";
6
7
  import { type LspService } from "../lsp/index.js";
7
8
  import { type FileStateTracker } from "./file-state.js";
8
9
  export type WriteToolOptions = Record<string, never>;
9
- export declare function createWriteTool(cwd: string, _options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
10
+ export declare function createWriteTool(cwd: string, _options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker, checkpoints?: () => CheckpointStore | undefined): ToolRegistryEntry;
@@ -18,7 +18,7 @@ function prepareWriteArguments(input) {
18
18
  }
19
19
  return args;
20
20
  }
21
- export function createWriteTool(cwd, _options = {}, approval, lsp, fileState) {
21
+ export function createWriteTool(cwd, _options = {}, approval, lsp, fileState, checkpoints) {
22
22
  return {
23
23
  name: "write",
24
24
  effect: "write_direct",
@@ -76,6 +76,7 @@ export function createWriteTool(cwd, _options = {}, approval, lsp, fileState) {
76
76
  return changedDuringApprovalResult(filePath, changed);
77
77
  }
78
78
  try {
79
+ await checkpoints?.()?.captureBefore(filePath, existed ? oldContent : null);
79
80
  await mkdir(dirname(filePath), { recursive: true });
80
81
  await writeFile(filePath, args.content, "utf-8");
81
82
  await fileState?.observe(filePath, "write", args.content).catch(() => undefined);
@@ -2,7 +2,6 @@ import type { ToolResultMetadata, TokenUsage } from "../types.js";
2
2
  export interface CompactionMeta {
3
3
  turns: number;
4
4
  messages: number;
5
- tokensSaved: number;
6
5
  summarySections: Array<{
7
6
  label: string;
8
7
  content: string;
@@ -10,11 +9,12 @@ export interface CompactionMeta {
10
9
  contextWindow?: number;
11
10
  compactedAt: number;
12
11
  }
12
+ export type UserInputStatus = "queued" | "pending_steer";
13
13
  export interface DisplayMessage {
14
14
  role: "user" | "assistant" | "error";
15
15
  content: string;
16
16
  clientId?: string;
17
- queued?: boolean;
17
+ inputStatus?: UserInputStatus;
18
18
  reasoning?: string;
19
19
  toolCalls?: DisplayToolCall[];
20
20
  parts?: DisplayMessagePart[];
@@ -53,11 +53,12 @@ export interface DisplayToolCall {
53
53
  startedAt?: number;
54
54
  completedAt?: number;
55
55
  }
56
+ export declare function userInputStatusBadgeLabel(status?: UserInputStatus): string | undefined;
57
+ export declare function setUserInputStatus(message: DisplayMessage, inputStatus?: UserInputStatus): DisplayMessage;
56
58
  export declare function appendTextPart(parts: DisplayMessagePart[], content: string): void;
57
59
  export declare function appendToolPart(parts: DisplayMessagePart[], toolCall: DisplayToolCall): void;
58
60
  export declare function snapshotDisplayParts(parts: DisplayMessagePart[]): DisplayMessagePart[];
59
61
  export declare function contentFromParts(parts: DisplayMessagePart[]): string;
60
62
  export declare function toolCallsFromParts(parts: DisplayMessagePart[]): DisplayToolCall[];
61
63
  export declare function compactDisplayMessages(messages: DisplayMessage[]): DisplayMessage[];
62
- export declare function truncateText(value: string, maxChars: number): string;
63
64
  export declare function formatCompactNumber(n: number): string;