@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
@@ -3,17 +3,28 @@
3
3
  * consumer, and consumers can attach again with their last seen sequence.
4
4
  */
5
5
  export class ReplayEventLog {
6
- entries = [];
6
+ retention;
7
+ entries = new Map();
8
+ nextIndex = 0;
9
+ firstIndex = 0;
10
+ readers = new Map();
7
11
  signal = deferredSignal();
8
12
  closed = false;
9
13
  failure;
14
+ constructor(retention = Infinity) {
15
+ this.retention = retention;
16
+ if (retention !== Infinity && (!Number.isSafeInteger(retention) || retention < 1))
17
+ throw new Error('Invalid event retention');
18
+ }
10
19
  get length() {
11
- return this.entries.length;
20
+ return this.nextIndex;
12
21
  }
22
+ get retainedCount() { return this.entries.size; }
13
23
  append(value) {
14
24
  if (this.closed)
15
25
  return;
16
- this.entries.push(value);
26
+ this.entries.set(this.nextIndex++, value);
27
+ this.prune();
17
28
  this.wake();
18
29
  }
19
30
  close(error) {
@@ -25,23 +36,62 @@ export class ReplayEventLog {
25
36
  }
26
37
  iterate(options = {}) {
27
38
  const start = Math.max(0, options.from ?? 0);
28
- return this.readFrom(start, options.signal);
39
+ const reader = {};
40
+ const cancelled = new AbortController();
41
+ const signal = options.signal ? AbortSignal.any([options.signal, cancelled.signal]) : cancelled.signal;
42
+ const release = () => { this.readers.delete(reader); this.prune(); };
43
+ // Reserve the cursor when subscribing, before a synchronous producer can
44
+ // prune events and before the consumer calls next(). close/return also
45
+ // release subscriptions which were never advanced.
46
+ if (!signal.aborted)
47
+ this.readers.set(reader, start);
48
+ signal.addEventListener('abort', release, { once: true });
49
+ const cleanup = () => { signal.removeEventListener('abort', release); release(); };
50
+ const iterator = this.readFrom(start, reader, signal, cleanup);
51
+ const finish = iterator.return.bind(iterator);
52
+ const fail = iterator.throw.bind(iterator);
53
+ iterator.return = async (value) => { cancelled.abort(); cleanup(); return finish(value); };
54
+ iterator.throw = async (error) => { cancelled.abort(); cleanup(); return fail(error); };
55
+ return iterator;
29
56
  }
30
- async *readFrom(start, abortSignal) {
57
+ async *readFrom(start, reader, abortSignal, cleanup) {
31
58
  let index = start;
32
- while (true) {
33
- while (index < this.entries.length)
34
- yield this.entries[index++];
35
- if (this.closed) {
36
- if (this.failure !== undefined)
37
- throw this.failure;
38
- return;
59
+ try {
60
+ while (true) {
61
+ while (index < this.nextIndex) {
62
+ if (abortSignal.aborted)
63
+ return;
64
+ if (index < this.firstIndex) {
65
+ throw new Error('Replay cursor expired; restore persisted history and subscribe from the latest sequence.');
66
+ }
67
+ const value = this.entries.get(index);
68
+ this.readers.set(reader, ++index);
69
+ this.prune();
70
+ yield value;
71
+ }
72
+ if (this.closed) {
73
+ if (this.failure !== undefined)
74
+ throw this.failure;
75
+ return;
76
+ }
77
+ if (abortSignal.aborted)
78
+ return;
79
+ const currentSignal = this.signal.promise;
80
+ await waitForSignal(currentSignal, abortSignal);
39
81
  }
40
- if (abortSignal?.aborted)
41
- return;
42
- const currentSignal = this.signal.promise;
43
- await waitForSignal(currentSignal, abortSignal);
44
82
  }
83
+ finally {
84
+ cleanup();
85
+ }
86
+ }
87
+ prune() {
88
+ if (this.retention === Infinity)
89
+ return;
90
+ let before = this.nextIndex - this.retention;
91
+ for (const index of this.readers.values())
92
+ before = Math.min(before, index);
93
+ while (this.firstIndex < before)
94
+ this.entries.delete(this.firstIndex++);
45
95
  }
46
96
  wake() {
47
97
  const previous = this.signal;
@@ -0,0 +1,16 @@
1
+ import type { SessionManager } from "./session.js";
2
+ /** The revision of a host's resident history, not the manager's refreshed log.
3
+ * Reads and audit/metadata refreshes never legitimize a foreign conversation.
4
+ * Local clear/rewind/manual compaction replace history explicitly; other local
5
+ * writes advance the fence only when they extend the host's existing snapshot.
6
+ */
7
+ export declare class SessionContextFence {
8
+ private readonly session;
9
+ private revision;
10
+ private readonly unsubscribe;
11
+ constructor(session: SessionManager);
12
+ getRevision(): string;
13
+ /** Call only alongside replacing the host's resident messages. */
14
+ reloadHistory(): import("./types.js").Message[];
15
+ dispose(): void;
16
+ }
@@ -0,0 +1,26 @@
1
+ /** The revision of a host's resident history, not the manager's refreshed log.
2
+ * Reads and audit/metadata refreshes never legitimize a foreign conversation.
3
+ * Local clear/rewind/manual compaction replace history explicitly; other local
4
+ * writes advance the fence only when they extend the host's existing snapshot.
5
+ */
6
+ export class SessionContextFence {
7
+ session;
8
+ revision;
9
+ unsubscribe;
10
+ constructor(session) {
11
+ this.session = session;
12
+ this.revision = session.getRevision();
13
+ this.unsubscribe = session.subscribeContextCommits((previous, revision, replacement) => {
14
+ if (replacement || previous === this.revision)
15
+ this.revision = revision;
16
+ });
17
+ }
18
+ getRevision() { return this.revision; }
19
+ /** Call only alongside replacing the host's resident messages. */
20
+ reloadHistory() {
21
+ const messages = this.session.getMessages();
22
+ this.revision = this.session.getRevision();
23
+ return messages;
24
+ }
25
+ dispose() { this.unsubscribe(); }
26
+ }
@@ -1,15 +1,24 @@
1
1
  import type { Message } from "./types.js";
2
- import type { SessionLogEntry, SessionMarkerKind, SessionMetadata, SessionProviderErrorEntry, SessionSummaryEntry } from "./session-types.js";
2
+ import type { SessionLogEntry, SessionMarkerKind, SessionMetadata, SessionProviderErrorEntry } from "./session-types.js";
3
3
  import type { SanitizedProviderError } from "./provider-error-record.js";
4
+ /** Records that change conversational context or its execution state.
5
+ * Keep this shared by revision hashing and checkpoint receipt supersession.
6
+ * Unknown/new markers are conservative: only known diagnostics are excluded.
7
+ */
8
+ export declare function affectsContextRevision(entry: SessionLogEntry): boolean;
4
9
  export declare class SessionLog {
5
10
  private entries;
11
+ private nextId;
12
+ private contextRevision;
13
+ private allocateId;
14
+ appendEntries(entries: SessionLogEntry[]): void;
15
+ getRevision(): string;
6
16
  load(lines: string[]): void;
7
17
  replace(entries: SessionLogEntry[]): void;
8
18
  list(): SessionLogEntry[];
9
19
  getMetadata(): SessionMetadata;
10
20
  setMetadata(metadata: SessionMetadata): SessionLogEntry[];
11
21
  appendMessage(message: Message): SessionLogEntry[];
12
- appendSummary(summary: string): SessionSummaryEntry;
13
22
  appendMarker(kind: SessionMarkerKind, value: string): SessionLogEntry;
14
23
  appendProviderError(error: SanitizedProviderError): SessionProviderErrorEntry;
15
24
  toMessages(): Message[];
@@ -1,12 +1,42 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
3
+ import { tryCheckpointMessages } from "./context/checkpoint.js";
4
+ import { isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
5
+ /** Records that change conversational context or its execution state.
6
+ * Keep this shared by revision hashing and checkpoint receipt supersession.
7
+ * Unknown/new markers are conservative: only known diagnostics are excluded.
8
+ */
9
+ export function affectsContextRevision(entry) {
10
+ if (entry.type === "metadata" || entry.type === "provider_error")
11
+ return false;
12
+ if (entry.type === "marker") {
13
+ return !["task_started", "task_finished", "task_killed"].includes(entry.kind);
14
+ }
15
+ return true;
16
+ }
2
17
  export class SessionLog {
3
18
  entries = [];
19
+ nextId = 1;
20
+ contextRevision = "empty";
21
+ allocateId() { return String(this.nextId++); }
22
+ appendEntries(entries) {
23
+ this.entries.push(...entries);
24
+ for (const entry of entries) {
25
+ this.nextId = Math.max(this.nextId, (parseInt(entry.id, 10) || 0) + 1);
26
+ if (affectsContextRevision(entry)) {
27
+ this.contextRevision = createHash("sha256").update(this.contextRevision).update(JSON.stringify(entry)).digest("hex");
28
+ }
29
+ }
30
+ }
31
+ getRevision() { return this.contextRevision; }
4
32
  load(lines) {
5
33
  this.entries = [];
34
+ this.nextId = 1;
35
+ this.contextRevision = "empty";
6
36
  for (const line of lines) {
7
37
  try {
8
38
  const raw = JSON.parse(line);
9
- this.entries.push(...normalizeEntry(raw));
39
+ this.appendEntries(normalizeEntry(raw));
10
40
  }
11
41
  catch {
12
42
  // skip corrupt lines
@@ -14,13 +44,23 @@ export class SessionLog {
14
44
  }
15
45
  }
16
46
  replace(entries) {
17
- this.entries = entries;
47
+ this.entries = [];
48
+ this.nextId = 1;
49
+ this.contextRevision = "empty";
50
+ this.appendEntries(entries);
18
51
  }
19
52
  list() {
20
53
  return [...this.entries];
21
54
  }
22
55
  getMetadata() {
23
- const entry = this.entries.find((item) => item.type === "metadata");
56
+ let entry;
57
+ for (let i = this.entries.length - 1; i >= 0; i--) {
58
+ const item = this.entries[i];
59
+ if (item.type === "metadata") {
60
+ entry = item;
61
+ break;
62
+ }
63
+ }
24
64
  const metadata = entry?.metadata ?? {};
25
65
  return {
26
66
  ...metadata,
@@ -46,50 +86,49 @@ export class SessionLog {
46
86
  return next;
47
87
  }
48
88
  appendMessage(message) {
49
- const normalized = normalizeMessageToEntries(message, nextEntryId(this.entries), Date.now());
50
- this.entries.push(...normalized);
89
+ const normalized = normalizeMessageToEntries(message, this.allocateId(), Date.now());
90
+ this.appendEntries(normalized);
51
91
  return normalized;
52
92
  }
53
- appendSummary(summary) {
54
- const entry = {
55
- id: nextEntryId(this.entries),
56
- type: "summary",
57
- summary,
58
- timestamp: Date.now(),
59
- };
60
- this.entries.push(entry);
61
- return entry;
62
- }
63
93
  appendMarker(kind, value) {
64
94
  const entry = {
65
- id: nextEntryId(this.entries),
95
+ id: this.allocateId(),
66
96
  type: "marker",
67
97
  kind,
68
98
  value,
69
99
  timestamp: Date.now(),
70
100
  };
71
- this.entries.push(entry);
101
+ this.appendEntries([entry]);
72
102
  return entry;
73
103
  }
74
104
  appendProviderError(error) {
75
105
  const entry = {
76
- id: nextEntryId(this.entries),
106
+ id: this.allocateId(),
77
107
  type: "provider_error",
78
108
  error,
79
109
  timestamp: Date.now(),
80
110
  };
81
- this.entries.push(entry);
111
+ this.appendEntries([entry]);
82
112
  return entry;
83
113
  }
84
114
  toMessages() {
85
115
  const messages = [];
86
116
  let latestSummaryIndex = -1;
87
117
  let latestClearIndex = -1;
118
+ // An unreadable checkpoint is skipped, not fatal: its originals are still in
119
+ // the log, so replay continues from the previous readable boundary.
120
+ let checkpointProjection;
88
121
  for (let index = this.entries.length - 1; index >= 0; index--) {
89
- if (this.entries[index].type === "summary") {
90
- latestSummaryIndex = index;
91
- break;
122
+ const entry = this.entries[index];
123
+ if (entry.type === "context_checkpoint") {
124
+ checkpointProjection = tryCheckpointMessages(entry.checkpoint);
125
+ if (!checkpointProjection)
126
+ continue;
92
127
  }
128
+ else if (entry.type !== "summary")
129
+ continue;
130
+ latestSummaryIndex = index;
131
+ break;
93
132
  }
94
133
  for (let index = this.entries.length - 1; index >= 0; index--) {
95
134
  const entry = this.entries[index];
@@ -99,17 +138,49 @@ export class SessionLog {
99
138
  }
100
139
  }
101
140
  if (latestSummaryIndex > latestClearIndex) {
102
- const summary = this.entries[latestSummaryIndex];
103
- messages.push({
104
- role: "system",
105
- content: `Previous conversation summary: ${summary.summary}`,
106
- });
141
+ const entry = this.entries[latestSummaryIndex];
142
+ if (entry.type === "context_checkpoint") {
143
+ messages.push(...(checkpointProjection ?? []));
144
+ }
145
+ else if (entry.type === "summary") {
146
+ // Legacy summaries remain readable; already discarded originals cannot be reconstructed.
147
+ messages.push({ role: "system", content: `Previous conversation summary: ${entry.summary}` });
148
+ }
107
149
  }
108
150
  const startIndex = Math.max(latestSummaryIndex > latestClearIndex ? latestSummaryIndex + 1 : 0, latestClearIndex + 1);
151
+ const committedPrefixLength = latestSummaryIndex > latestClearIndex
152
+ && this.entries[latestSummaryIndex].type === "context_checkpoint" ? messages.length : 0;
153
+ // Every checkpoint past the readable boundary is unreadable by construction.
154
+ // Its projection is unusable, but its position still proves the originals
155
+ // before it were committed at a complete-tool-group model boundary.
156
+ let unreadableBoundaryLength = 0;
157
+ // Permission-mode switches become mode reminders where they happened, so
158
+ // the model reads the mode timeline in order: a later "plan mode is
159
+ // active" outranks an earlier tool result saying the mode went back to
160
+ // default. A switch is written out before the next user message (never
161
+ // inside a tool group); one from before a compaction/clear boundary still
162
+ // applies and opens the replayed history.
163
+ let pendingMode = lastModeSwitchBefore(this.entries, startIndex);
164
+ const flushMode = () => {
165
+ if (pendingMode === undefined)
166
+ return;
167
+ messages.push({ role: "meta", kind: "system-reminder", content: reminderForMode(pendingMode) });
168
+ pendingMode = undefined;
169
+ };
109
170
  for (let index = startIndex; index < this.entries.length; index++) {
110
171
  const entry = this.entries[index];
111
172
  switch (entry.type) {
173
+ case "marker": {
174
+ const mode = entry.kind === "mode_switch" ? permissionModeOf(entry.value) : undefined;
175
+ if (mode)
176
+ pendingMode = mode;
177
+ break;
178
+ }
179
+ case "context_checkpoint":
180
+ unreadableBoundaryLength = messages.length;
181
+ break;
112
182
  case "user_message":
183
+ flushMode();
113
184
  messages.push(cloneMessage(entry.message));
114
185
  break;
115
186
  case "assistant_message":
@@ -120,7 +191,7 @@ export class SessionLog {
120
191
  reasoning: entry.message.reasoning !== undefined
121
192
  ? sanitizeInternalReasoningText(entry.message.reasoning)
122
193
  : undefined,
123
- providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(entry.message.providerMetadata)),
194
+ providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(entry.message.providerMetadata), entry.message.modelId ?? entry.message.model),
124
195
  });
125
196
  break;
126
197
  case "tool_call": {
@@ -145,6 +216,34 @@ export class SessionLog {
145
216
  break;
146
217
  }
147
218
  }
219
+ flushMode();
220
+ retireStaleModeReminders(messages);
221
+ // A committed checkpoint may end in completed tools without a final text
222
+ // response. It is a resumable model boundary, not an interrupted user turn.
223
+ const boundary = Math.max(committedPrefixLength, unreadableBoundaryLength);
224
+ if (boundary > 0) {
225
+ let head = messages.slice(0, boundary);
226
+ if (unreadableBoundaryLength > committedPrefixLength) {
227
+ // Replayed originals, not a validated projection: hold the turn the
228
+ // boundary split to complete-tool-group semantics, as its continuation is.
229
+ let turnStart = head.length;
230
+ for (let i = head.length - 1; i >= committedPrefixLength; i--) {
231
+ if (head[i].role === "user") {
232
+ turnStart = i;
233
+ break;
234
+ }
235
+ }
236
+ head = [...head.slice(0, turnStart), ...pruneIncompleteToolGroups(head.slice(turnStart))];
237
+ }
238
+ const tail = messages.slice(boundary);
239
+ const nextUser = tail.findIndex(message => message.role === "user");
240
+ const continuationEnd = nextUser < 0 ? tail.length : nextUser;
241
+ return [
242
+ ...head,
243
+ ...pruneIncompleteToolGroups(tail.slice(0, continuationEnd)),
244
+ ...pruneIncompleteTail(tail.slice(continuationEnd)),
245
+ ];
246
+ }
148
247
  return pruneIncompleteTail(messages);
149
248
  }
150
249
  }
@@ -193,7 +292,7 @@ function normalizeMessageToEntries(message, id, timestamp) {
193
292
  usage: message.usage,
194
293
  systemFingerprint: message.systemFingerprint,
195
294
  error: message.error,
196
- providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(message.providerMetadata)),
295
+ providerMetadata: sanitizeAssistantProviderMetadata(cloneProviderMetadata(message.providerMetadata), message.modelId ?? message.model),
197
296
  },
198
297
  timestamp,
199
298
  };
@@ -221,6 +320,7 @@ function normalizeMessageToEntries(message, id, timestamp) {
221
320
  function isSessionLogEntry(entry) {
222
321
  return [
223
322
  "metadata",
323
+ "context_checkpoint",
224
324
  "summary",
225
325
  "marker",
226
326
  "user_message",
@@ -230,16 +330,6 @@ function isSessionLogEntry(entry) {
230
330
  "provider_error",
231
331
  ].includes(entry.type);
232
332
  }
233
- function nextEntryId(entries) {
234
- let max = 0;
235
- for (const entry of entries) {
236
- const match = /^(\d+)/.exec(entry.id);
237
- if (!match)
238
- continue;
239
- max = Math.max(max, Number(match[1]));
240
- }
241
- return `${max + 1}`;
242
- }
243
333
  function cloneMessage(message) {
244
334
  if (message.role === "assistant") {
245
335
  return {
@@ -264,6 +354,64 @@ function cloneProviderMetadata(metadata) {
264
354
  return undefined;
265
355
  return JSON.parse(JSON.stringify(metadata));
266
356
  }
357
+ // A checkpoint can split a user turn. Its continuation has no user anchor,
358
+ // so validate complete tool groups directly rather than relying on turn pruning.
359
+ const PERMISSION_MODES = new Set(["default", "plan", "bypassPermissions"]);
360
+ function permissionModeOf(value) {
361
+ return PERMISSION_MODES.has(value) ? value : undefined;
362
+ }
363
+ function lastModeSwitchBefore(entries, end) {
364
+ for (let index = end - 1; index >= 0; index--) {
365
+ const entry = entries[index];
366
+ if (entry.type === "marker" && entry.kind === "mode_switch") {
367
+ const mode = permissionModeOf(entry.value);
368
+ if (mode)
369
+ return mode;
370
+ }
371
+ }
372
+ return undefined;
373
+ }
374
+ /** Only the latest mode reminder speaks to the model, as in a live agent. */
375
+ function retireStaleModeReminders(messages) {
376
+ let latest = true;
377
+ for (let index = messages.length - 1; index >= 0; index--) {
378
+ const message = messages[index];
379
+ if (message.role !== "meta" || message.kind !== "system-reminder" || !isPermissionModeReminder(message.content))
380
+ continue;
381
+ if (latest)
382
+ latest = false;
383
+ else
384
+ message.includeInLlm = false;
385
+ }
386
+ }
387
+ function pruneIncompleteToolGroups(messages) {
388
+ const pending = new Set();
389
+ let groupStart = -1;
390
+ for (let index = 0; index < messages.length; index++) {
391
+ const message = messages[index];
392
+ if (message.role === "system" || message.role === "meta")
393
+ continue;
394
+ if (message.role === "tool") {
395
+ if (!pending.delete(message.toolCallId))
396
+ return messages.slice(0, groupStart < 0 ? index : groupStart);
397
+ if (pending.size === 0)
398
+ groupStart = -1;
399
+ }
400
+ else {
401
+ if (pending.size)
402
+ return messages.slice(0, groupStart);
403
+ if (message.role === "assistant" && message.toolCalls?.length) {
404
+ groupStart = index;
405
+ for (const call of message.toolCalls) {
406
+ if (!call.id || pending.has(call.id))
407
+ return messages.slice(0, groupStart);
408
+ pending.add(call.id);
409
+ }
410
+ }
411
+ }
412
+ }
413
+ return pending.size ? messages.slice(0, groupStart) : messages;
414
+ }
267
415
  function pruneIncompleteTail(messages) {
268
416
  let currentTurnStart = -1;
269
417
  let hasCompletedAssistant = false;
@@ -41,6 +41,10 @@ export interface SessionMetadataEntry extends BaseSessionLogEntry {
41
41
  type: "metadata";
42
42
  metadata: SessionMetadata;
43
43
  }
44
+ export interface SessionContextCheckpointEntry extends BaseSessionLogEntry {
45
+ type: "context_checkpoint";
46
+ checkpoint: import("./context/checkpoint.js").ContextCheckpoint;
47
+ }
44
48
  export interface SessionSummaryEntry extends BaseSessionLogEntry {
45
49
  type: "summary";
46
50
  summary: string;
@@ -70,7 +74,7 @@ export interface SessionProviderErrorEntry extends BaseSessionLogEntry {
70
74
  type: "provider_error";
71
75
  error: SanitizedProviderError;
72
76
  }
73
- export type SessionLogEntry = SessionMetadataEntry | SessionSummaryEntry | SessionMarkerEntry | SessionUserMessageEntry | SessionAssistantMessageEntry | SessionToolCallEntry | SessionToolResultEntry | SessionProviderErrorEntry;
77
+ export type SessionLogEntry = SessionMetadataEntry | SessionSummaryEntry | SessionContextCheckpointEntry | SessionMarkerEntry | SessionUserMessageEntry | SessionAssistantMessageEntry | SessionToolCallEntry | SessionToolResultEntry | SessionProviderErrorEntry;
74
78
  export interface LegacySessionEntry {
75
79
  id: string;
76
80
  type: "metadata" | "message" | "compaction";
package/dist/session.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import { CheckpointStore } from "./checkpoints.js";
5
5
  import { type CompactOptions, type CompactResult } from "./context/compact.js";
6
6
  import type { Message } from "./types.js";
7
+ import { type ContextCheckpoint } from "./context/checkpoint.js";
7
8
  import type { SessionLogEntry, SessionMarkerKind, SessionMetadata } from "./session-types.js";
8
9
  import type { SanitizedProviderError } from "./provider-error-record.js";
9
10
  export interface SessionSummary {
@@ -27,6 +28,15 @@ export interface UserTurn {
27
28
  text: string;
28
29
  timestamp: number;
29
30
  }
31
+ /** A fenced write found the session log diverged from the writer's resident
32
+ * snapshot — a foreign append, clear, or rewind landed while a turn was in
33
+ * flight. The write is refused; the host must roll its resident history back
34
+ * to the file's truth (SessionContextFence.reloadHistory, then replace the
35
+ * agent's messages) before writing again, or every later fenced write keeps
36
+ * rejecting on the stale revision. */
37
+ export declare class SessionHistoryDivergedError extends Error {
38
+ constructor(message?: string);
39
+ }
30
40
  export interface RewindResult {
31
41
  /** Number of log entries removed. */
32
42
  removedEntries: number;
@@ -37,7 +47,18 @@ export declare class SessionManager {
37
47
  private sessionFile;
38
48
  private log;
39
49
  private checkpoints?;
50
+ private diskRevision;
51
+ private pendingCompaction?;
52
+ /** Conversational revision excludes metadata-only writes (for example titles). */
53
+ getRevision(): string;
54
+ private refresh;
55
+ private currentDiskRevision;
56
+ private withWriteLock;
40
57
  private readonly metadataListeners;
58
+ private readonly contextListeners;
59
+ /** Local commits only: disk refreshes must never advance a host's history fence. */
60
+ subscribeContextCommits(listener: (previous: string, revision: string, replacement: boolean) => void): () => void;
61
+ private publishContextCommit;
41
62
  constructor(sessionFile: string);
42
63
  static create(cwd: string, sessionName?: string): SessionManager;
43
64
  static resume(cwd: string, sessionName?: string): SessionManager | undefined;
@@ -47,6 +68,8 @@ export declare class SessionManager {
47
68
  static listAllSessions(): SessionSummary[];
48
69
  private load;
49
70
  private persist;
71
+ /** Refresh, derive the replacement from the refreshed log, and swap the file
72
+ * as one locked transaction; `derive` returning undefined leaves it untouched. */
50
73
  private rewrite;
51
74
  getMetadata(): SessionMetadata;
52
75
  /**
@@ -57,13 +80,17 @@ export declare class SessionManager {
57
80
  subscribeMetadata(listener: (metadata: SessionMetadata) => void): () => void;
58
81
  getOrCreatePromptCacheKey(): string;
59
82
  setMetadata(metadata: SessionMetadata): void;
83
+ /** Refresh, derive, and append as one locked transaction. Metadata records
84
+ * are full snapshots, so a snapshot built outside the lock would silently
85
+ * overwrite a concurrent writer's fields. */
86
+ mutateMetadata(build: (current: SessionMetadata) => SessionMetadata): void;
60
87
  updateMetadata(patch: Partial<SessionMetadata>): void;
61
88
  clearTitleMetadata(): void;
62
89
  clearExternalRuntimeMetadata(): void;
63
- appendMessage(message: Message): void;
64
- appendCompaction(summary: string): void;
65
- appendMarker(kind: SessionMarkerKind, value: string): void;
66
- appendProviderError(error: SanitizedProviderError): void;
90
+ appendMessage(message: Message, expectedRevision?: string): void;
91
+ private reloadAfterWriteFailure;
92
+ appendMarker(kind: SessionMarkerKind, value: string, expectedRevision?: string): void;
93
+ appendProviderError(error: SanitizedProviderError, expectedRevision?: string): void;
67
94
  compact(options?: CompactOptions): CompactResult;
68
95
  /**
69
96
  * Inspect whether the session is large enough to compact and, if so, return
@@ -76,7 +103,7 @@ export declare class SessionManager {
76
103
  } | null;
77
104
  /**
78
105
  * Apply a precomputed (typically LLM-generated) summary as the compaction
79
- * checkpoint, rewriting the log to [metadata, summary, kept turns]. Mirrors
106
+ * checkpoint, appending an exact projection without deleting originals. Mirrors
80
107
  * `compact()` but skips the built-in heuristic summarizer. Returns
81
108
  * `{ compacted: false }` if the session is no longer compactable.
82
109
  */
@@ -87,7 +114,11 @@ export declare class SessionManager {
87
114
  * Lives next to the session JSONL as `<session>.checkpoints/`.
88
115
  */
89
116
  getCheckpoints(): CheckpointStore;
90
- /** Entry id of the most recent user message, or "0" before the first one. */
117
+ /**
118
+ * Entry id of the most recent user turn (as listUserTurns counts them), or
119
+ * "0" before the first one. Checkpoints are keyed by it, so edits made after
120
+ * a tool screenshot or a harness-injected block stay with the user's turn.
121
+ */
91
122
  lastUserEntryId(): string;
92
123
  /** User messages after the latest /clear, oldest first — the valid rewind anchors. */
93
124
  listUserTurns(): UserTurn[];
@@ -98,6 +129,7 @@ export declare class SessionManager {
98
129
  rewindToEntry(entryId: string): RewindResult | undefined;
99
130
  getEntries(): SessionLogEntry[];
100
131
  getSessionFile(): string;
101
- private maybeAutoCompact;
132
+ /** Commit the exact conversational projection, never delete its source history. */
133
+ commitContextCheckpoint(checkpoint: ContextCheckpoint, expectedRevision?: string): void;
102
134
  }
103
135
  export declare function getSessionsDir(cwd: string): string;