@orion-agents/orion-code 0.3.25 → 0.3.26

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.
package/CHANGELOG.md CHANGED
@@ -22,15 +22,40 @@ which is **not** a pass.
22
22
 
23
23
  ## [Unreleased]
24
24
 
25
- ## [0.3.25] — UNRELEASED
26
-
27
- > **Status: candidate.** Open. Four open-issue fixes: the Git conflict view reads the
28
- > edited working file from the filesystem instead of an unstored object (#269), file
29
- > history walks the requested revision instead of always HEAD (#270), model switching
30
- > gates the compact preflight on effective input budgets instead of raw context windows
31
- > (#271), and the left project navigation keeps a stable registration order when
32
- > sessions are activated (#272). Plan: `docs/plan/v0.3.25-plan.md`. Not merged, tagged
33
- > or published.
25
+ ## [0.3.26] — UNRELEASED
26
+
27
+ > **Status: candidate.** Open. Structured-diff integrity and Session lifecycle: body
28
+ > rows that look like file headers stay body rows (#274), a hunk split across
29
+ > pagination keeps its rows, line numbers and identity (#275), permanent Session
30
+ > deletion removes the v2 Thread records (#276), and exiting a cutover Session no
31
+ > longer rewrites its summary from the frozen legacy JSONL (#277). Plan:
32
+ > `docs/plan/v0.3.26-plan.md`. Not merged, tagged or published.
33
+
34
+ ### Fixed
35
+
36
+ - #274: a deletion row whose text starts with `-- ` (`--- old comment`) is no longer
37
+ mistaken for the old-file header; the hunk stays open and the remaining body is
38
+ no longer demoted to document metadata.
39
+ - #275: the structured diff continuation contract carries the split hunk's state
40
+ (header, index, running line cursors) in the cursor, so every page of a large
41
+ hunk keeps its rows with correct line numbers and the stable hunk identity, and
42
+ the last page reports the true completeness.
43
+ - #276: permanently deleting a cutover Session removes its v2 records — cutover
44
+ index entry, event log, head, projection, compact state and checkpoints,
45
+ session-index manifest, transcript pages and import receipt — under the same
46
+ file-lock and atomic-write discipline as the cutover itself.
47
+ - #277: `/exit` (and the shutdown path) no longer rewrites a cutover Session's
48
+ `messageCount`/`historySizeBytes` from the frozen legacy JSONL; the summary comes
49
+ from the authoritative v2 Thread.
50
+
51
+ ## [0.3.25] — 2026-10-13
52
+
53
+ > **Status: published.** Released as `@orion-agents/orion-code@0.3.25` from
54
+ > `refs/tags/v0.3.25`, and merged to `main` as `c356323` (#273). Four open-issue fixes
55
+ > for the Git conflict view (#269), file-history revisions (#270), the model-switch
56
+ > compact preflight (#271) and the stable project navigation order (#272). Plan:
57
+ > `docs/plan/v0.3.25-plan.md`. GitHub Release:
58
+ > https://github.com/orion-agents/orion-code/releases/tag/v0.3.25
34
59
 
35
60
  ### Fixed
36
61
 
package/README.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  Local-first, goal-driven coding agent for the terminal and browser.
4
4
 
5
- > v0.3.25 candidate — open. Four open-issue fixes: the Git conflict view reads the edited
6
- > working file from the filesystem (#269), file history walks the requested revision
7
- > (#270), model switching gates the compact preflight on effective input budgets (#271),
8
- > and the left project navigation keeps a stable registration order (#272). The previous
9
- > release 0.3.24 is on npm (registry `latest`), tagged `v0.3.24` (`85fadb4`, #268) and
10
- > released on GitHub.
5
+ > v0.3.26 candidate — open. Structured-diff integrity and Session lifecycle: header-lookalike
6
+ > body rows stay body rows (#274), hunks split across pagination keep their rows and line
7
+ > numbers (#275), permanent Session deletion removes the v2 Thread records (#276), and
8
+ > exiting a cutover Session no longer regresses its summary from the legacy JSONL (#277).
9
+ > The previous release 0.3.25 is on npm (registry `latest`), tagged `v0.3.25` (`c356323`,
10
+ > #273) and released on GitHub.
11
11
 
12
12
  [中文说明](README.zh-CN.md) ·
13
13
  [v0.3.24 plan](docs/plan/v0.3.24-plan.md) ·
@@ -170,10 +170,10 @@ user extension boundaries.
170
170
  Node.js 22.12+, 24, and 26 are supported. Use Node 24 LTS for production or Node 26 Current for
171
171
  current development environments. Node 20 is upstream EOL and is no longer a v0.3 runtime.
172
172
 
173
- To install the pinned v0.3.25 package when it is available on npm:
173
+ To install the pinned v0.3.26 package when it is available on npm:
174
174
 
175
175
  ```bash
176
- npm install -g @orion-agents/orion-code@0.3.25
176
+ npm install -g @orion-agents/orion-code@0.3.26
177
177
  orion --version
178
178
  orion doctor
179
179
  ```
package/README.zh-CN.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  面向终端与浏览器、本地优先的目标驱动 Coding Agent。
4
4
 
5
- > v0.3.25 候选版本:进行中。四个开放 issue 修复:Git 冲突视图从文件系统读取已编辑的
6
- > 工作区文件(#269)、文件历史按请求的 revision 遍历(#270)、模型切换按有效输入预算
7
- > 触发压缩预检(#271)、左侧项目导航保持稳定注册序(#272)。上一版 0.3.24 已发布 npm
8
- > (registry `latest`)、打 tag `v0.3.24`(`85fadb4`,#268)并发布 GitHub Release。
5
+ > v0.3.26 候选版本:进行中。结构化 diff 完整性与会话生命周期:形似文件头的 body 行保持为
6
+ > body 行(#274)、跨页拆分的 hunk 保留行与行号(#275)、永久删除会话同时清除 v2 Thread
7
+ > 记录(#276)、退出已切换会话不再从旧 JSONL 回退 summary(#277)。上一版 0.3.25 已发布
8
+ > npm(registry `latest`)、打 tag `v0.3.25`(`c356323`,#273)并发布 GitHub Release。
9
9
 
10
10
  [English](README.md) ·
11
11
  [v0.3.24 方案](docs/plan/v0.3.24-plan.md) ·
@@ -100,10 +100,10 @@ MCP 仍是用户可见的扩展边界。
100
100
  支持 Node.js 22.12+、24 和 26。生产环境建议使用 Node 24 LTS,当前开发环境也支持 Node 26
101
101
  Current。Node 20 已结束上游维护,不再属于 v0.3 Runtime 合同。
102
102
 
103
- v0.3.25 在 npm 可用后,可安装固定版本:
103
+ v0.3.26 在 npm 可用后,可安装固定版本:
104
104
 
105
105
  ```bash
106
- npm install -g @orion-agents/orion-code@0.3.25
106
+ npm install -g @orion-agents/orion-code@0.3.26
107
107
  orion --version
108
108
  orion doctor
109
109
  ```
@@ -102,4 +102,17 @@ export declare function openSessionCheckpointStorageV1(projectPathInput: string,
102
102
  */
103
103
  export declare function openSessionStorageV1(projectPathInput: string, sessionId: string): OpenedSessionStorageV1;
104
104
  export declare function loadThreadCutoverIndexV1(projectPath: string): ThreadCutoverIndexV1;
105
+ /**
106
+ * v0.3.26 #276 — permanently removes one Session's durable v2 records: the
107
+ * cutover index entry plus every thread file the materializer and the event
108
+ * store publish (event log, projection, head, compact state and checkpoints,
109
+ * session-index manifest, transcript pages, import receipt). Returns the
110
+ * removed thread id, or null when the Session was never cut over.
111
+ *
112
+ * The index write takes the same file lock and atomic-write discipline as the
113
+ * cutover itself, with the generation bumped; an emptied index keeps its file
114
+ * with an empty session map so readers stay on the atomic authority instead of
115
+ * treating a missing file as "no v2 Sessions at all".
116
+ */
117
+ export declare function deleteSessionThreadRecordsV1(projectPathInput: string, sessionId: string): string | null;
105
118
  //# sourceMappingURL=legacy-thread-materializer.d.ts.map
@@ -7,6 +7,7 @@ exports.resolveSessionStorageV1 = resolveSessionStorageV1;
7
7
  exports.openSessionCheckpointStorageV1 = openSessionCheckpointStorageV1;
8
8
  exports.openSessionStorageV1 = openSessionStorageV1;
9
9
  exports.loadThreadCutoverIndexV1 = loadThreadCutoverIndexV1;
10
+ exports.deleteSessionThreadRecordsV1 = deleteSessionThreadRecordsV1;
10
11
  const fs_1 = require("fs");
11
12
  const path_1 = require("path");
12
13
  const atomic_write_1 = require("../services/atomic-write");
@@ -205,6 +206,77 @@ function openSessionStorageV1(projectPathInput, sessionId) {
205
206
  function loadThreadCutoverIndexV1(projectPath) {
206
207
  return readThreadCutoverIndex((0, paths_1.getProjectThreadsV2IndexPath)((0, path_1.resolve)(projectPath)));
207
208
  }
209
+ /**
210
+ * v0.3.26 #276 — permanently removes one Session's durable v2 records: the
211
+ * cutover index entry plus every thread file the materializer and the event
212
+ * store publish (event log, projection, head, compact state and checkpoints,
213
+ * session-index manifest, transcript pages, import receipt). Returns the
214
+ * removed thread id, or null when the Session was never cut over.
215
+ *
216
+ * The index write takes the same file lock and atomic-write discipline as the
217
+ * cutover itself, with the generation bumped; an emptied index keeps its file
218
+ * with an empty session map so readers stay on the atomic authority instead of
219
+ * treating a missing file as "no v2 Sessions at all".
220
+ */
221
+ function deleteSessionThreadRecordsV1(projectPathInput, sessionId) {
222
+ const projectPath = (0, path_1.resolve)(projectPathInput);
223
+ const indexPath = (0, paths_1.getProjectThreadsV2IndexPath)(projectPath);
224
+ // A project that never cut a Session over has no index and no threads-v2
225
+ // directory; taking the lock would create one on a pure legacy deletion.
226
+ if (!(0, fs_1.existsSync)(indexPath))
227
+ return null;
228
+ const threadsDir = (0, paths_1.getProjectThreadsV2Dir)(projectPath);
229
+ return (0, file_lock_1.withFileLockSync)(indexPath, () => {
230
+ const current = readThreadCutoverIndex(indexPath);
231
+ const entry = current.sessions[sessionId];
232
+ if (!entry)
233
+ return null;
234
+ const sessions = { ...current.sessions };
235
+ delete sessions[sessionId];
236
+ const content = { version: 1, generation: current.generation + 1, sessions };
237
+ const next = deepFreeze({ ...content, digest: (0, canonical_1.digestRuntimeValue)(content) });
238
+ (0, atomic_write_1.atomicWriteFileSync)(indexPath, `${(0, canonical_1.canonicalRuntimeJson)(next)}\n`, {
239
+ mode: 0o600,
240
+ fsync: true,
241
+ });
242
+ removeThreadFiles(threadsDir, projectPath, entry.threadId);
243
+ return entry.threadId;
244
+ });
245
+ }
246
+ /** v0.3.26 #276 — removes every durable file of one thread. Best effort per
247
+ * file: a missing artifact is already deleted, and an unlink failure must not
248
+ * resurrect the cutover entry that was just removed. */
249
+ function removeThreadFiles(threadsDir, projectPath, threadId) {
250
+ const files = [
251
+ (0, path_1.join)(threadsDir, `${threadId}.events.v1.jsonl`),
252
+ (0, path_1.join)(threadsDir, `${threadId}.projection.v1.json`),
253
+ (0, path_1.join)(threadsDir, `${threadId}.head.v1.json`),
254
+ (0, path_1.join)(threadsDir, `${threadId}.compact-state.v1.json`),
255
+ (0, path_1.join)(threadsDir, `${threadId}.session-index.v1.json`),
256
+ (0, paths_1.getProjectThreadV2ImportReceiptPath)(projectPath, threadId),
257
+ ];
258
+ for (const file of files) {
259
+ try {
260
+ if ((0, fs_1.existsSync)(file))
261
+ (0, fs_1.unlinkSync)(file);
262
+ }
263
+ catch {
264
+ // Durable leftovers are reported by the integrity checks, never by
265
+ // throwing here: the cutover entry removal is already committed.
266
+ }
267
+ }
268
+ for (const dir of [
269
+ (0, path_1.join)(threadsDir, `${threadId}.compact-checkpoints.v1`),
270
+ (0, path_1.join)(threadsDir, `${threadId}.transcript-pages.v1`),
271
+ ]) {
272
+ try {
273
+ (0, fs_1.rmSync)(dir, { recursive: true, force: true });
274
+ }
275
+ catch {
276
+ // See above.
277
+ }
278
+ }
279
+ }
208
280
  function buildMaterializedEvents(snapshot) {
209
281
  const receipt = snapshot.receipt;
210
282
  const pending = [
@@ -1858,6 +1858,21 @@ function updateSessionSummary(sessionId, _messages) {
1858
1858
  // Callers may hold a stale transcript snapshot. Re-read under the same lock
1859
1859
  // used by append/delete so reconciliation can never roll metadata backwards.
1860
1860
  withLockedSession(sessionId, session => {
1861
+ // v0.3.26 #277 — a cutover Session's summary comes from the authoritative
1862
+ // v2 Thread. Its legacy JSONL is frozen at cutover, so summarizing from it
1863
+ // (as /exit and the shutdown path do) would move messageCount and
1864
+ // historySizeBytes backwards while the Thread history is intact. Reopening
1865
+ // the Session repairs the read model from v2; this makes the exit path
1866
+ // agree instead of regressing it.
1867
+ const threadView = (0, thread_session_view_1.loadThreadSessionViewV1)(session.projectPath, session.id);
1868
+ if (threadView) {
1869
+ session.messageCount = threadView.messageCount;
1870
+ session.historySizeBytes = threadView.historySizeBytes;
1871
+ session.updatedAt = threadView.updatedAt;
1872
+ session.updatedAtIso = new Date(session.updatedAt).toISOString();
1873
+ writeSessionMetaUnlocked(session);
1874
+ return;
1875
+ }
1861
1876
  const summary = summarizeSessionMessages(readSessionMessagesForSession(session));
1862
1877
  session.toolsUsed = summary.toolsUsed;
1863
1878
  session.filesModified = summary.filesModified;
@@ -2553,6 +2568,12 @@ function deleteSession(sessionId) {
2553
2568
  updateSessionCatalog(catalog => {
2554
2569
  delete catalog.sessions[sessionId];
2555
2570
  });
2571
+ // v0.3.26 #276 — a cutover Session's v2 records are the authoritative
2572
+ // history; permanent deletion must remove them with the legacy files.
2573
+ // The Web deletion dialog promises exactly that.
2574
+ if ((0, legacy_thread_materializer_1.deleteSessionThreadRecordsV1)(session.projectPath, sessionId)) {
2575
+ deleted = true;
2576
+ }
2556
2577
  return deleted;
2557
2578
  }) ?? false);
2558
2579
  }
@@ -41,6 +41,18 @@ export interface GitHunkV2 {
41
41
  readonly complete: boolean;
42
42
  }
43
43
  export type GitDiffDocumentKindV2 = 'text' | 'binary' | 'symlink' | 'submodule' | 'metadata';
44
+ /**
45
+ * v0.3.26 #275 — parser state for a hunk that a page boundary split. Carried in
46
+ * the continuation cursor so the next page re-opens the hunk with its stable
47
+ * identity (`index:oldStart:newStart`) and correct running line cursors instead
48
+ * of demoting the continuation rows to document meta.
49
+ */
50
+ export interface GitDiffHunkResumeV1 {
51
+ readonly header: string;
52
+ readonly index: number;
53
+ readonly oldCursor: number;
54
+ readonly newCursor: number;
55
+ }
44
56
  export interface GitDiffCapabilitiesV2 {
45
57
  readonly stageFile: boolean;
46
58
  readonly unstageFile: boolean;
@@ -63,6 +75,11 @@ export interface GitDiffDocumentV2 {
63
75
  readonly hunks: readonly GitHunkV2[];
64
76
  /** Metadata lines that are not part of any hunk (file headers, mode changes, …). */
65
77
  readonly meta: readonly string[];
78
+ /**
79
+ * v0.3.26 #275 — the hunk still open when this page ended, for the
80
+ * continuation cursor. Null when the page ends on a hunk boundary.
81
+ */
82
+ readonly pendingHunk: GitDiffHunkResumeV1 | null;
66
83
  readonly completeness: 'complete' | 'paged' | 'limited';
67
84
  readonly additions: number;
68
85
  readonly deletions: number;
@@ -79,5 +96,7 @@ export declare function parseGitDiffDocument(input: {
79
96
  readonly hasMore: boolean;
80
97
  /** Host-signed continuation cursor, or null. Never synthesised here. */
81
98
  readonly nextCursor: string | null;
99
+ /** v0.3.26 #275 — hunk state from the previous page's `pendingHunk`. */
100
+ readonly resume?: GitDiffHunkResumeV1 | null;
82
101
  }): GitDiffDocumentV2;
83
102
  //# sourceMappingURL=git-diff-document.d.ts.map
@@ -75,6 +75,31 @@ function parseGitDiffDocument(input) {
75
75
  let additions = 0;
76
76
  let deletions = 0;
77
77
  let current;
78
+ const openHunk = (header, index, oldCursor, newCursor) => {
79
+ const headerMatch = HUNK_HEADER.exec(header);
80
+ if (!headerMatch)
81
+ return;
82
+ const oldStart = Number(headerMatch[1]);
83
+ const newStart = Number(headerMatch[3]);
84
+ current = {
85
+ header,
86
+ oldStart,
87
+ oldLines: headerMatch[2] === undefined ? 1 : Number(headerMatch[2]),
88
+ newStart,
89
+ newLines: headerMatch[4] === undefined ? 1 : Number(headerMatch[4]),
90
+ contextLabel: (headerMatch[5] ?? '').trim(),
91
+ lines: [],
92
+ oldCursor,
93
+ newCursor,
94
+ index,
95
+ };
96
+ };
97
+ // v0.3.26 #275 — a continuation page starts mid-hunk: re-open it from the
98
+ // resume state before the first line is classified. A page that happens to
99
+ // start with its own `@@` header simply ignores the resume state.
100
+ if (input.resume && !HUNK_HEADER.test(input.lines[0] ?? '')) {
101
+ openHunk(input.resume.header, input.resume.index, input.resume.oldCursor, input.resume.newCursor);
102
+ }
78
103
  const closeHunk = () => {
79
104
  if (!current)
80
105
  return;
@@ -134,14 +159,22 @@ function parseGitDiffDocument(input) {
134
159
  }));
135
160
  continue;
136
161
  }
137
- if (isMetaLine(raw)) {
162
+ // v0.3.26 #274 — the body marker decides first. A deletion of a line that
163
+ // itself starts with `-- ` produces the body row `--- old comment`, which a
164
+ // header-first check mistook for the old-file header, closing the hunk and
165
+ // demoting the remaining body to document meta. Genuine document-level lines
166
+ // carry no body marker by construction: context rows start with a space,
167
+ // additions with `+`, deletions with `-`, and file boundaries are announced
168
+ // by a markerless `diff --git` line.
169
+ const marker = raw[0];
170
+ const isBodyRow = marker === '+' || marker === '-' || marker === ' ' || raw === '';
171
+ if (!isBodyRow && isMetaLine(raw)) {
138
172
  // A file boundary inside one page (multi-command reads) closes the current hunk.
139
173
  closeHunk();
140
174
  if (raw)
141
175
  meta.push(raw);
142
176
  continue;
143
177
  }
144
- const marker = raw[0];
145
178
  const text = raw.length > 0 ? raw.slice(1) : '';
146
179
  let lineKind;
147
180
  let oldLineNumber = null;
@@ -185,6 +218,16 @@ function parseGitDiffDocument(input) {
185
218
  raw,
186
219
  }));
187
220
  }
221
+ // v0.3.26 #275 — a page that ends inside a hunk publishes the hunk state for
222
+ // the continuation cursor; a page ending on a boundary has nothing to resume.
223
+ const pendingHunk = input.hasMore && current
224
+ ? Object.freeze({
225
+ header: current.header,
226
+ index: current.index,
227
+ oldCursor: current.oldCursor,
228
+ newCursor: current.newCursor,
229
+ })
230
+ : null;
188
231
  closeHunk();
189
232
  const frozenHunks = Object.freeze(hunks);
190
233
  let completeness = 'complete';
@@ -203,6 +246,7 @@ function parseGitDiffDocument(input) {
203
246
  binary,
204
247
  hunks: frozenHunks,
205
248
  meta: Object.freeze(meta),
249
+ pendingHunk,
206
250
  completeness,
207
251
  additions,
208
252
  deletions,
@@ -236,7 +236,13 @@ class GitReadModelServiceV1 {
236
236
  */
237
237
  async diffDocument(input) {
238
238
  const page = await this.diff(input);
239
- return (0, git_diff_document_1.parseGitDiffDocument)({
239
+ // v0.3.26 #275 — the incoming cursor may carry the hunk state this page
240
+ // continues; diff() already validated the cursor, so decoding it here only
241
+ // reads the resume state.
242
+ const resumeHunk = input.cursor
243
+ ? this.decodeCursor(input.cursor, 'diff', page.repositoryRevision, page.fileId, diffScope(input.ignoreWhitespace === true, input.wordDiff === true)).resumeHunk
244
+ : undefined;
245
+ const document = (0, git_diff_document_1.parseGitDiffDocument)({
240
246
  fileToken: page.fileId,
241
247
  path: page.path,
242
248
  source: page.source,
@@ -244,7 +250,18 @@ class GitReadModelServiceV1 {
244
250
  lines: page.lines,
245
251
  hasMore: page.nextCursor !== null,
246
252
  nextCursor: page.nextCursor,
253
+ ...(resumeHunk ? { resume: resumeHunk } : {}),
247
254
  });
255
+ // A page that ends inside a hunk re-mints the cursor with the hunk state so
256
+ // the next page resumes it instead of demoting the rows to meta.
257
+ if (page.nextCursor && document.pendingHunk) {
258
+ const decoded = this.decodeCursor(page.nextCursor, 'diff', page.repositoryRevision, page.fileId, diffScope(input.ignoreWhitespace === true, input.wordDiff === true));
259
+ return Object.freeze({
260
+ ...document,
261
+ nextCursor: this.encodeCursor({ ...decoded, resumeHunk: document.pendingHunk }),
262
+ });
263
+ }
264
+ return document;
248
265
  }
249
266
  historyReader() {
250
267
  this.historyReaderInstance ?? (this.historyReaderInstance = (0, git_history_service_1.createGitHistoryReader)({
@@ -300,7 +317,8 @@ class GitReadModelServiceV1 {
300
317
  .update(`compare-diff:${input.baseOid}:${input.headOid}:${path}:${snapshot.revision}`)
301
318
  .digest('hex')
302
319
  .slice(0, 24);
303
- const offset = input.cursor ? decodeCommitDiffCursor(input.cursor, margin) : 0;
320
+ const cursorState = input.cursor ? decodeCommitDiffCursor(input.cursor, margin) : null;
321
+ const offset = cursorState?.offset ?? 0;
304
322
  const page = await streamDiffPage({
305
323
  cwd: root,
306
324
  commands: Object.freeze([
@@ -339,9 +357,19 @@ class GitReadModelServiceV1 {
339
357
  nextCursor: page.hasMore
340
358
  ? encodeCommitDiffCursor({ margin, offset: offset + page.returnedLines })
341
359
  : null,
360
+ ...(cursorState?.resumeHunk ? { resume: cursorState.resumeHunk } : {}),
342
361
  });
362
+ // v0.3.26 #275 — re-mint the continuation cursor with the split hunk state.
363
+ const nextCursor = page.hasMore && document.pendingHunk
364
+ ? encodeCommitDiffCursor({
365
+ margin,
366
+ offset: offset + page.returnedLines,
367
+ resumeHunk: document.pendingHunk,
368
+ })
369
+ : document.nextCursor;
343
370
  return Object.freeze({
344
371
  ...document,
372
+ nextCursor,
345
373
  baseOid: input.baseOid,
346
374
  baseLabel: `比较 ${input.baseOid.slice(0, 10)} → ${input.headOid.slice(0, 10)}`,
347
375
  });
@@ -450,7 +478,8 @@ class GitReadModelServiceV1 {
450
478
  .update(`commit-diff:${detail.id}:${detail.baseOid}:${parentIndex}:${snapshot.revision}`)
451
479
  .digest('hex')
452
480
  .slice(0, 24);
453
- const offset = input.cursor ? decodeCommitDiffCursor(input.cursor, margin) : 0;
481
+ const cursorState = input.cursor ? decodeCommitDiffCursor(input.cursor, margin) : null;
482
+ const offset = cursorState?.offset ?? 0;
454
483
  const page = await streamDiffPage({
455
484
  cwd: root,
456
485
  commands: Object.freeze([
@@ -489,8 +518,22 @@ class GitReadModelServiceV1 {
489
518
  nextCursor: page.hasMore
490
519
  ? encodeCommitDiffCursor({ margin, offset: offset + page.returnedLines })
491
520
  : null,
521
+ ...(cursorState?.resumeHunk ? { resume: cursorState.resumeHunk } : {}),
522
+ });
523
+ // v0.3.26 #275 — re-mint the continuation cursor with the split hunk state.
524
+ const nextCursor = page.hasMore && document.pendingHunk
525
+ ? encodeCommitDiffCursor({
526
+ margin,
527
+ offset: offset + page.returnedLines,
528
+ resumeHunk: document.pendingHunk,
529
+ })
530
+ : document.nextCursor;
531
+ return Object.freeze({
532
+ ...document,
533
+ nextCursor,
534
+ baseLabel: detail.baseLabel,
535
+ baseOid: detail.baseOid,
492
536
  });
493
- return Object.freeze({ ...document, baseLabel: detail.baseLabel, baseOid: detail.baseOid });
494
537
  }
495
538
  async capture() {
496
539
  let root;
@@ -1211,6 +1254,9 @@ class GitReadModelServiceV1 {
1211
1254
  parsed.offset < 0) {
1212
1255
  return invalidCursor();
1213
1256
  }
1257
+ if (parsed.resumeHunk !== undefined && !isDiffHunkResume(parsed.resumeHunk)) {
1258
+ return invalidCursor();
1259
+ }
1214
1260
  if (parsed.revision !== revision) {
1215
1261
  throw new errors_1.WebWorkbenchError(409, 'Git cursor revision is stale.', 'git_revision_conflict');
1216
1262
  }
@@ -1224,6 +1270,22 @@ class GitReadModelServiceV1 {
1224
1270
  }
1225
1271
  }
1226
1272
  exports.GitReadModelServiceV1 = GitReadModelServiceV1;
1273
+ /** v0.3.26 #275 — the hunk header shape, mirrored for cursor validation. */
1274
+ const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/u;
1275
+ /** v0.3.26 #275 — validates a cursor-carried hunk resume state. */
1276
+ function isDiffHunkResume(value) {
1277
+ if (!value || typeof value !== 'object')
1278
+ return false;
1279
+ const candidate = value;
1280
+ return (typeof candidate.header === 'string' &&
1281
+ HUNK_HEADER_PATTERN.test(candidate.header) &&
1282
+ Number.isSafeInteger(candidate.index) &&
1283
+ candidate.index >= 0 &&
1284
+ Number.isSafeInteger(candidate.oldCursor) &&
1285
+ candidate.oldCursor >= 1 &&
1286
+ Number.isSafeInteger(candidate.newCursor) &&
1287
+ candidate.newCursor >= 1);
1288
+ }
1227
1289
  function parsePorcelainV1(output) {
1228
1290
  const fields = output.split('\0');
1229
1291
  const records = [];
@@ -1692,7 +1754,16 @@ function decodeCommitDiffCursor(cursor, margin) {
1692
1754
  candidate.offset < 0) {
1693
1755
  throw new errors_1.WebWorkbenchError(409, 'Diff changed; reload before paginating.', 'git_cursor_invalid');
1694
1756
  }
1695
- return candidate.offset;
1757
+ if (candidate.resumeHunk !== undefined && !isDiffHunkResume(candidate.resumeHunk)) {
1758
+ throw new errors_1.WebWorkbenchError(400, 'Diff cursor is not valid.', 'git_cursor_invalid');
1759
+ }
1760
+ return {
1761
+ margin,
1762
+ offset: candidate.offset,
1763
+ ...(candidate.resumeHunk !== undefined
1764
+ ? { resumeHunk: candidate.resumeHunk }
1765
+ : {}),
1766
+ };
1696
1767
  }
1697
1768
  /**
1698
1769
  * Kills a detached child and everything it spawned.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@orion-agents/orion-code",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@orion-agents/orion-code",
9
- "version": "0.3.25",
9
+ "version": "0.3.26",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clack/prompts": "^0.7.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-agents/orion-code",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Orion Code - local-first goal-driven coding agent for terminal and Web.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",