@orion-agents/orion-code 0.3.10 → 0.3.12

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 (35) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/README.md +8 -5
  3. package/README.zh-CN.md +5 -5
  4. package/dist/cli.js +14 -1
  5. package/dist/web/context-text.d.ts +17 -0
  6. package/dist/web/context-text.js +40 -0
  7. package/dist/web/file-read-service.d.ts +23 -0
  8. package/dist/web/file-read-service.js +134 -0
  9. package/dist/web/git-read-model-service.d.ts +14 -0
  10. package/dist/web/git-read-model-service.js +64 -0
  11. package/dist/web/host-daemon.d.ts +9 -0
  12. package/dist/web/host-daemon.js +45 -1
  13. package/dist/web/protocol.d.ts +7 -0
  14. package/dist/web/protocol.js +26 -0
  15. package/dist/web/review-service.d.ts +14 -0
  16. package/dist/web/review-service.js +34 -14
  17. package/dist/web/server.js +82 -0
  18. package/dist/web/workbench-controller.d.ts +45 -0
  19. package/dist/web/workbench-controller.js +84 -0
  20. package/dist/web-client/assets/{DiffViewer-MMhvMif-.js → DiffViewer-Bs1C5uRM.js} +1 -1
  21. package/dist/web-client/assets/FilesPanel-dW61EHHn.js +2 -0
  22. package/dist/web-client/assets/GitPanel-ykbaVNlo.js +1 -0
  23. package/dist/web-client/assets/ResourceSplitLayout-B0Zs9aMC.js +1 -0
  24. package/dist/web-client/assets/ReviewPanel-1oqfaqoh.js +1 -0
  25. package/dist/web-client/assets/{TerminalPanel-D8C1uybv.js → TerminalPanel-Cz0ohyPm.js} +1 -1
  26. package/dist/web-client/assets/index-B11PjVU2.css +1 -0
  27. package/dist/web-client/assets/index-DiNz6Qhw.js +17 -0
  28. package/dist/web-client/index.html +2 -2
  29. package/npm-shrinkwrap.json +2 -2
  30. package/package.json +1 -1
  31. package/dist/web-client/assets/FilesPanel-4litEN1k.js +0 -2
  32. package/dist/web-client/assets/GitPanel-BVwlDRVo.js +0 -1
  33. package/dist/web-client/assets/ReviewPanel-ChA0Zd2g.js +0 -1
  34. package/dist/web-client/assets/index-DQfpqPTD.css +0 -1
  35. package/dist/web-client/assets/index-Dv_uE9dk.js +0 -17
package/CHANGELOG.md CHANGED
@@ -22,9 +22,74 @@ which is **not** a pass.
22
22
 
23
23
  ## [Unreleased]
24
24
 
25
- ## [0.3.10] — CANDIDATE
25
+ ## [0.3.12] — CANDIDATE
26
26
 
27
- > **Status: candidate.** Not merged, tagged or published to npm.
27
+ > **Status: candidate.** Wide Right Workspace + repository-truth + guarded Git
28
+ > mutations (plan docs/plan/v0.3.12-plan.md) plus the v0.3.13 follow-ups that
29
+ > landed on this branch (plans v3/v4/v5). Not merged, tagged or published.
30
+
31
+ ### Added
32
+
33
+ - Wide Right Workspace logic layer: five-panel registry, per-workspace layout
34
+ preferences (schema v4 with per-panel navigator widths), pure dock/rail/drawer
35
+ geometry with 320px conversation minimum and 360/560/960/max snap points.
36
+ - Repository snapshot store shared by Git/Review/Files decorations
37
+ (coalescing, one-shot invalidation, revision-stale detection).
38
+ - Progressive Review verification paging (GET /review/verification).
39
+ - Guarded Git stage/unstage/commit (POST /git/\*) with host-resolved file ids,
40
+ repository-revision CAS and an explicit user-gesture header.
41
+ - Bounded workspace search (GET /files/search; name/content scopes).
42
+ - Context V2 file_range references (revision-bound, line-extracted, budgeted).
43
+ - **Resource split layout (v0.3.13-plan-v4):** Files/Git/Review share one
44
+ content | splitter | navigator shell (`ResourceSplitLayout`) with explicit
45
+ named grid areas (no more sparse grid auto-placement gaps). Git now shows its
46
+ diff on the left, matching Files/Review. Each panel has an inner draggable
47
+ separator measured against the split root — never the workbench shell — so
48
+ dragging never moves the outer Right Workspace; per-workspace + per-panel
49
+ navigator widths are persisted (300px default, 160–420px drag range,
50
+ double-click/keyboard reset), and <=620px containers stack navigator-over-
51
+ content and hide the handle.
52
+ - **Conversation history navigator (v0.3.13-plan-v5):** a left-inside rail on
53
+ the loaded transcript derived from the unified order timeline. A pure bucket
54
+ model (<=48–64 stable buckets, user/assistant/errors always visible) feeds
55
+ decorative ticks plus one focusable `role="slider"` (Arrow/Page/Home/End,
56
+ reduced-motion aware). An IntersectionObserver + rAF-throttled viewport
57
+ marker tracks the reading position; pointer scrub uses capture and cancels
58
+ pinned auto-follow; the top entry drives the existing paginated
59
+ load-earlier. Container-query degradation: full rail >760px, compact
60
+ 641–760px, hidden <=640px with no horizontal overflow.
61
+ - **Unified Orion brand mark (v0.3.13-plan-v5 §9):** one decorative
62
+ `OrionBrandMark` inline SVG (20×20 artboard, three bars, theme-token
63
+ gradients with forced-colors fallback) replaces the duplicated
64
+ `.brand-mark`/`.boot-mark` span CSS in the project navigator and boot
65
+ screen; the mark stays aria-hidden and non-interactive.
66
+ - E2E: rail-panel scenario ids on the allowlist; real-conversation smoke spec;
67
+ Files/Review/Git split-layout, history-rail and brand visual specs.
68
+
69
+ ## [0.3.11] — NPM-PUBLISHED
70
+
71
+ > **Status: npm-published.** `@orion-agents/orion-code@0.3.11` is available through the npm
72
+ > registry (latest tag). Published 2026-09-05; git tag/release may lag.
73
+
74
+ ### Added
75
+
76
+ - The right work panel becomes a **vertical-rail menu** (v0.3.8-plan carried
77
+ forward): a persistent 48px icon rail replaces the expand-then-horizontal-tabs
78
+ flow; clicking an icon shows exactly that panel, re-clicking collapses back to
79
+ the rail, and Esc closes the content area (input/terminal/alertdialog
80
+ exempted). The rail carries terminal status badges (running/queued/approval/
81
+ failed), keyboard shortcuts (Cmd/Ctrl+Shift+1-5) jump directly without
82
+ toggling, and the terminal keeps its xterm session while resource panels
83
+ mount active-only. An optional user icon order is persisted in the layout
84
+ preference (`workPanel.order`).
85
+ - Idempotency contract tests carried over from the v0.3.8 branch: reducer
86
+ archive/restore/delete no-op and no-duplicate cases; storage archive-twice
87
+ and restore-never-archived cases.
88
+
89
+ ## [0.3.10] — NPM-PUBLISHED
90
+
91
+ > **Status: npm-published.** `@orion-agents/orion-code@0.3.10` is available through the npm
92
+ > registry (latest tag). Published 2026-09-04; git tag/release may lag.
28
93
 
29
94
  ### Added
30
95
 
package/README.md CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  Local-first, goal-driven coding agent for the terminal and browser.
4
4
 
5
- > v0.3.10 candidate — the local Web host now defaults to port 4242 (M42, the
6
- > Orion Nebula) with a first-class lifecycle: `orion web start|status|stop|restart`
7
- > manage a detached host (pidfile + logs), and `--background` stays supported.
8
- > Candidate source is not an npm publication, Git tag, or merge-ready release.
5
+ > v0.3.12 candidate — a Wide Right Workspace on top of the vertical rail: pure
6
+ > dock/rail/drawer geometry (detail dockable to 80vw while the conversation
7
+ > keeps a 320px minimum), per-workspace layout preferences, a shared repository
8
+ > snapshot store, progressive Review verification, guarded Git stage/unstage/
9
+ > commit, bounded workspace search and revision-bound file_range Context
10
+ > references. Candidate source is not an npm publication, Git tag, or
11
+ > merge-ready release.
9
12
 
10
13
  [中文说明](README.zh-CN.md) ·
11
14
  [v0.3.7 plan](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
@@ -170,7 +173,7 @@ current development environments. Node 20 is upstream EOL and is no longer a v0.
170
173
  After the immutable `0.3.5` npm receipt exists:
171
174
 
172
175
  ```bash
173
- npm install -g @orion-agents/orion-code@0.3.10
176
+ npm install -g @orion-agents/orion-code@0.3.12
174
177
  orion --version
175
178
  orion doctor
176
179
  ```
package/README.zh-CN.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  面向终端与浏览器、本地优先的目标驱动 Coding Agent。
4
4
 
5
- > v0.3.10 候选版本:本地 Web host 默认端口改为 **4242**(M42,猎户座大星云),
6
- > 并新增一等生命周期管理:`orion web start|status|stop|restart` 管理脱离终端的
7
- > 后台 host(pidfile + 日志),`--background` 仍可用。Candidate 源码不代表已创建
8
- > npm 发布、Git tag 或达到可合并状态。
5
+ > v0.3.12 候选版本:在竖状 rail 之上的**大尺寸右侧工作区**——纯 dock/rail/drawer 几何
6
+ > (详情面可拉宽至 80vw,对话保留 320px 最小宽度)、按 workspace 的布局偏好、共享仓库快照
7
+ > store、Review 验证证据渐进分页、受控 Git stage/unstage/commit、受限工作区搜索与带版本
8
+ > 约束的 file_range Context 引用。Candidate 源码不代表已创建 npm 发布、Git tag 或达到可合并状态。
9
9
 
10
10
  [English](README.md) ·
11
11
  [v0.3.7 方案](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
@@ -102,7 +102,7 @@ Current。Node 20 已结束上游维护,不再属于 v0.3 Runtime 合同。
102
102
  当 npm 已存在不可变的 `0.3.4` 发布凭据后:
103
103
 
104
104
  ```bash
105
- npm install -g @orion-agents/orion-code@0.3.10
105
+ npm install -g @orion-agents/orion-code@0.3.12
106
106
  orion --version
107
107
  orion doctor
108
108
  ```
package/dist/cli.js CHANGED
@@ -80,8 +80,21 @@ async function manageBackgroundHost(command, args) {
80
80
  if (status.state === 'running') {
81
81
  console.log(`orion web is running: ${status.pidfile.url} (pid ${status.pidfile.pid})`);
82
82
  console.log(`workspace ${status.pidfile.workspace} · started ${new Date(status.pidfile.startedAt).toISOString()}`);
83
+ return;
84
+ }
85
+ // No pidfile-owned host, or a stale one. A live listener on the port means
86
+ // an externally managed host (e.g. a launchd LaunchAgent) is serving it —
87
+ // report that instead of a confusing 'not running'.
88
+ if (await (0, host_daemon_1.isHostPortTaken)(options.port)) {
89
+ console.log(`Port ${options.port} is being served by an externally managed Orion host ` +
90
+ `(e.g. a launchd LaunchAgent such as ai.orion-code.web). It is not owned ` +
91
+ `by \`orion web status/stop\`; inspect it with \`launchctl list | grep orion\`.`);
92
+ if (status.state === 'stale') {
93
+ console.log(`Note: the pidfile for the former pid ${status.pid} is stale and can be removed safely.`);
94
+ }
95
+ return;
83
96
  }
84
- else if (status.state === 'stale') {
97
+ if (status.state === 'stale') {
85
98
  console.log(`orion web is not running (stale pidfile for pid ${status.pid} on port ${status.port}).`);
86
99
  }
87
100
  else {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * v0.3.12 — Context text helpers shared by the controller when resolving
3
+ * version-bound references (file_range etc.). Pure and unit-tested.
4
+ */
5
+ export interface ExtractedLines {
6
+ readonly text: string;
7
+ /** True when the requested range reached the end of the file before endLine. */
8
+ readonly clampedAtEnd: boolean;
9
+ }
10
+ /** Extract a 1-based inclusive line range. Never throws for out-of-range input. */
11
+ export declare function extractLineRange(content: string, startLine: number, endLine: number): ExtractedLines;
12
+ /** Soft per-reference byte budget for line content sent to the model context. */
13
+ export declare function enforceContextBudget(text: string, maxBytes: number): {
14
+ readonly text: string;
15
+ readonly truncated: boolean;
16
+ };
17
+ //# sourceMappingURL=context-text.d.ts.map
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * v0.3.12 — Context text helpers shared by the controller when resolving
4
+ * version-bound references (file_range etc.). Pure and unit-tested.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.extractLineRange = extractLineRange;
8
+ exports.enforceContextBudget = enforceContextBudget;
9
+ /** Extract a 1-based inclusive line range. Never throws for out-of-range input. */
10
+ function extractLineRange(content, startLine, endLine) {
11
+ const start = Math.max(1, Math.trunc(startLine));
12
+ const end = Math.max(start, Math.trunc(endLine));
13
+ const lines = content.split('\n');
14
+ if (start > lines.length) {
15
+ return Object.freeze({ text: '', clampedAtEnd: true });
16
+ }
17
+ const last = Math.min(end, lines.length);
18
+ const slice = lines.slice(start - 1, last);
19
+ return Object.freeze({
20
+ text: slice.join('\n'),
21
+ clampedAtEnd: end > lines.length,
22
+ });
23
+ }
24
+ /** Soft per-reference byte budget for line content sent to the model context. */
25
+ function enforceContextBudget(text, maxBytes) {
26
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) {
27
+ return Object.freeze({ text, truncated: false });
28
+ }
29
+ let low = 0;
30
+ let high = text.length;
31
+ while (low < high) {
32
+ const mid = (low + high + 1) >>> 1;
33
+ if (Buffer.byteLength(text.slice(0, mid), 'utf8') <= maxBytes)
34
+ low = mid;
35
+ else
36
+ high = mid - 1;
37
+ }
38
+ return Object.freeze({ text: text.slice(0, low), truncated: true });
39
+ }
40
+ //# sourceMappingURL=context-text.js.map
@@ -45,12 +45,35 @@ export declare class FileReadServiceV1 {
45
45
  constructor(workspace: string);
46
46
  get rootId(): string;
47
47
  performanceCounters(): WebFileReadPerformanceCountersV1;
48
+ /** v0.3.12 S3 — resolve a remembered file id back to its workspace-relative path. */
49
+ pathForFileId(fileId: string): string;
48
50
  identifyRelativePath(relativePath: string): string;
49
51
  list(input?: {
50
52
  readonly parentId?: string;
51
53
  readonly cursor?: string;
52
54
  readonly pageSize?: number;
53
55
  }): WebFileTreePageV1;
56
+ /**
57
+ * v0.3.12 S4 — bounded workspace search. Name scope walks the tree (skipping
58
+ * VCS/dependency/ignored names, hidden directories, out-of-root symlinks and
59
+ * sensitive paths); content scope additionally matches inside the first
60
+ * 64 KiB of each text file and skips binary payloads. Results cap at
61
+ * `limit`; the scan stops early once the cap is reached or the file budget
62
+ * is exhausted.
63
+ */
64
+ search(input: {
65
+ readonly query: string;
66
+ readonly scope: 'name' | 'content';
67
+ readonly limit?: number;
68
+ }): {
69
+ readonly items: ReadonlyArray<{
70
+ readonly id: string;
71
+ readonly name: string;
72
+ readonly path: string;
73
+ }>;
74
+ readonly truncated: boolean;
75
+ };
76
+ private fileContainsQuery;
54
77
  readContent(input: {
55
78
  readonly fileId: string;
56
79
  readonly cursor?: string;
@@ -41,6 +41,14 @@ class FileReadServiceV1 {
41
41
  performanceCounters() {
42
42
  return Object.freeze({ ...this.performance });
43
43
  }
44
+ /** v0.3.12 S3 — resolve a remembered file id back to its workspace-relative path. */
45
+ pathForFileId(fileId) {
46
+ const relativePath = this.pathById.get(fileId);
47
+ if (relativePath === undefined) {
48
+ throw new errors_1.WebWorkbenchError(404, 'File node was not found.', 'file_not_found');
49
+ }
50
+ return relativePath;
51
+ }
44
52
  identifyRelativePath(relativePath) {
45
53
  const normalized = normalizeRelativePath(relativePath);
46
54
  this.resolveRelative(normalized);
@@ -107,6 +115,132 @@ class FileReadServiceV1 {
107
115
  : null,
108
116
  });
109
117
  }
118
+ /**
119
+ * v0.3.12 S4 — bounded workspace search. Name scope walks the tree (skipping
120
+ * VCS/dependency/ignored names, hidden directories, out-of-root symlinks and
121
+ * sensitive paths); content scope additionally matches inside the first
122
+ * 64 KiB of each text file and skips binary payloads. Results cap at
123
+ * `limit`; the scan stops early once the cap is reached or the file budget
124
+ * is exhausted.
125
+ */
126
+ search(input) {
127
+ const query = input.query.trim().slice(0, 200).toLowerCase();
128
+ if (!query) {
129
+ throw new errors_1.WebWorkbenchError(400, 'A non-empty search query is required.', 'file_search_query_required');
130
+ }
131
+ const limit = boundedPageSize(input.limit ?? 50);
132
+ const items = [];
133
+ const stack = [{ dir: this.root, rel: '' }];
134
+ let visitedDirectories = 0;
135
+ let visitedFiles = 0;
136
+ let truncated = false;
137
+ const fileBudget = 2500;
138
+ while (stack.length > 0 && visitedFiles < fileBudget) {
139
+ const { dir, rel } = stack.pop();
140
+ visitedDirectories += 1;
141
+ if (visitedDirectories > 4000) {
142
+ truncated = true;
143
+ break;
144
+ }
145
+ let directory;
146
+ try {
147
+ directory = (0, fs_1.opendirSync)(dir);
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ const entries = [];
153
+ try {
154
+ for (;;) {
155
+ const entry = directory.readSync();
156
+ if (!entry)
157
+ break;
158
+ if (IGNORED_NAMES.has(entry.name) ||
159
+ (entry.name.startsWith('.') && entry.isDirectory())) {
160
+ continue;
161
+ }
162
+ if (entry.isDirectory())
163
+ entries.push({ name: entry.name, kind: 'directory' });
164
+ else if (entry.isFile())
165
+ entries.push({ name: entry.name, kind: 'file' });
166
+ else if (entry.isSymbolicLink())
167
+ entries.push({ name: entry.name, kind: 'link' });
168
+ }
169
+ }
170
+ finally {
171
+ directory.closeSync();
172
+ }
173
+ entries.sort((left, right) => left.name.localeCompare(right.name));
174
+ for (const entry of entries) {
175
+ if (items.length >= limit) {
176
+ truncated = true;
177
+ break;
178
+ }
179
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
180
+ const absolute = (0, path_1.resolve)(dir, entry.name);
181
+ if (entry.kind === 'directory') {
182
+ stack.push({ dir: absolute, rel: childRel });
183
+ continue;
184
+ }
185
+ if (entry.kind === 'link') {
186
+ try {
187
+ const canonical = (0, fs_1.realpathSync)(absolute);
188
+ if (!isWithinRoot(canonical, this.root))
189
+ continue;
190
+ if ((0, fs_1.statSync)(canonical).isDirectory())
191
+ continue;
192
+ }
193
+ catch {
194
+ continue;
195
+ }
196
+ }
197
+ visitedFiles += 1;
198
+ if ((0, redaction_1.isSensitiveFilePath)(childRel))
199
+ continue;
200
+ const nameMatches = entry.name.toLowerCase().includes(query);
201
+ if (input.scope === 'name') {
202
+ if (nameMatches) {
203
+ items.push({ id: this.remember(childRel), name: entry.name, path: childRel });
204
+ }
205
+ continue;
206
+ }
207
+ let contentMatches = false;
208
+ if (!nameMatches) {
209
+ contentMatches = this.fileContainsQuery(absolute, query);
210
+ }
211
+ if (nameMatches || contentMatches) {
212
+ items.push({ id: this.remember(childRel), name: entry.name, path: childRel });
213
+ }
214
+ }
215
+ }
216
+ return Object.freeze({
217
+ items: Object.freeze(items.map(item => Object.freeze(item))),
218
+ truncated: truncated || visitedFiles >= fileBudget,
219
+ });
220
+ }
221
+ fileContainsQuery(absolutePath, query) {
222
+ try {
223
+ const descriptor = (0, fs_1.openSync)(absolutePath, fs_1.constants.O_RDONLY);
224
+ try {
225
+ const buffer = Buffer.alloc(64 * 1024);
226
+ const bytesRead = (0, fs_1.readSync)(descriptor, buffer, 0, buffer.length, 0);
227
+ if (bytesRead === 0)
228
+ return false;
229
+ if (buffer.subarray(0, bytesRead).includes(0))
230
+ return false;
231
+ return new util_1.TextDecoder('utf-8')
232
+ .decode(buffer.subarray(0, bytesRead))
233
+ .toLowerCase()
234
+ .includes(query);
235
+ }
236
+ finally {
237
+ (0, fs_1.closeSync)(descriptor);
238
+ }
239
+ }
240
+ catch {
241
+ return false;
242
+ }
243
+ }
110
244
  readContent(input) {
111
245
  this.performance.readOperations += 1;
112
246
  const limitBytes = boundedContentBytes(input.limitBytes ?? DEFAULT_CONTENT_BYTES);
@@ -77,6 +77,20 @@ export declare class GitReadModelServiceV1 {
77
77
  private capture;
78
78
  private projectFile;
79
79
  private rememberPath;
80
+ /** v0.3.12 S3 — guarded stage: paths are host-resolved file ids only. */
81
+ stagePaths(paths: readonly string[]): Promise<{
82
+ readonly repositoryRevision: string;
83
+ }>;
84
+ unstagePaths(paths: readonly string[]): Promise<{
85
+ readonly repositoryRevision: string;
86
+ }>;
87
+ commit(message: string): Promise<{
88
+ readonly repositoryRevision: string;
89
+ readonly commitSha: string;
90
+ }>;
91
+ private mutate;
92
+ private requireRepositoryRoot;
93
+ private revisionAfterMutation;
80
94
  private runGit;
81
95
  private tryGit;
82
96
  private encodeCursor;
@@ -262,6 +262,56 @@ class GitReadModelServiceV1 {
262
262
  this.pathById.set(id, path);
263
263
  return id;
264
264
  }
265
+ /** v0.3.12 S3 — guarded stage: paths are host-resolved file ids only. */
266
+ async stagePaths(paths) {
267
+ assertSafeGitPaths(paths);
268
+ if (paths.length === 0)
269
+ throw new Error('stage requires at least one path.');
270
+ await this.mutate(['add', '--', ...paths]);
271
+ return { repositoryRevision: await this.revisionAfterMutation() };
272
+ }
273
+ async unstagePaths(paths) {
274
+ assertSafeGitPaths(paths);
275
+ if (paths.length === 0)
276
+ throw new Error('unstage requires at least one path.');
277
+ await this.mutate(['reset', '-q', '--', ...paths]);
278
+ return { repositoryRevision: await this.revisionAfterMutation() };
279
+ }
280
+ async commit(message) {
281
+ const trimmed = message.trim();
282
+ if (!trimmed || trimmed.length > 2000 || /[\x00-\x1f]/.test(trimmed)) {
283
+ throw new Error('A valid commit message is required.');
284
+ }
285
+ const root = await this.requireRepositoryRoot();
286
+ const porcelain = (await this.runGit(['status', '--porcelain=v1'], root)).trim();
287
+ const stagedLines = porcelain
288
+ .split('\n')
289
+ .filter(line => line.length > 1 && line[0] !== ' ' && line[0] !== '?' && line[0] !== '!');
290
+ if (stagedLines.length === 0) {
291
+ throw new Error('Nothing is staged to commit.');
292
+ }
293
+ await this.mutate(['commit', '-m', trimmed]);
294
+ const sha = (await this.runGit(['rev-parse', 'HEAD'], root)).trim();
295
+ return { repositoryRevision: await this.revisionAfterMutation(), commitSha: sha };
296
+ }
297
+ async mutate(args) {
298
+ const root = await this.requireRepositoryRoot();
299
+ const output = await this.runGit(args, root);
300
+ if (output) {
301
+ // Warnings on stderr are not captured by runGit; treat non-empty as unexpected.
302
+ }
303
+ }
304
+ async requireRepositoryRoot() {
305
+ const snapshot = await this.capture();
306
+ if (!snapshot.isRepository || !snapshot.root) {
307
+ throw new Error('Git repository is unavailable.');
308
+ }
309
+ return snapshot.root;
310
+ }
311
+ async revisionAfterMutation() {
312
+ const root = await this.requireRepositoryRoot();
313
+ return this.runGit(['rev-parse', 'HEAD'], root).then(value => value.trim());
314
+ }
265
315
  runGit(args, cwd) {
266
316
  return new Promise((resolvePromise, reject) => {
267
317
  this.performance.processCount += 1;
@@ -590,6 +640,20 @@ function streamGitLines(input) {
590
640
  });
591
641
  });
592
642
  }
643
+ function assertSafeGitPaths(paths) {
644
+ for (const path of paths) {
645
+ if (typeof path !== 'string' ||
646
+ path.length === 0 ||
647
+ path.length > 4096 ||
648
+ path.includes('\0') ||
649
+ path.startsWith('-') ||
650
+ path.includes('/../') ||
651
+ path === '..' ||
652
+ path.startsWith('../')) {
653
+ throw new Error(`Unsafe Git path rejected: ${JSON.stringify(path)}`);
654
+ }
655
+ }
656
+ }
593
657
  function gitEnvironment() {
594
658
  return {
595
659
  PATH: process.env.PATH,
@@ -48,6 +48,15 @@ export declare function hostDaemonStatus(port: number): HostDaemonStatus;
48
48
  * stdio to `~/.orion-code/logs/web-<port>.log`, and wait for the pidfile so the
49
49
  * foreground command can print the URL and exit cleanly.
50
50
  */
51
+ /**
52
+ * True when something already listens on 127.0.0.1:port — typically another
53
+ * Orion host process (e.g. one managed by launchd, which does not write a
54
+ * pidfile). Detecting this up front turns a raw EADDRINUSE crash into a
55
+ * friendly message.
56
+ */
57
+ export declare function isHostPortTaken(port: number): Promise<boolean>;
58
+ /** Best-effort tail of the host log used when a background child exits early. */
59
+ export declare function readHostLogTail(port: number, lines?: number): string;
51
60
  export declare function spawnBackgroundHost(options: {
52
61
  readonly cwd: string;
53
62
  readonly port: number;
@@ -9,6 +9,8 @@ exports.writeHostPidfile = writeHostPidfile;
9
9
  exports.clearHostPidfile = clearHostPidfile;
10
10
  exports.isProcessAlive = isProcessAlive;
11
11
  exports.hostDaemonStatus = hostDaemonStatus;
12
+ exports.isHostPortTaken = isHostPortTaken;
13
+ exports.readHostLogTail = readHostLogTail;
12
14
  exports.spawnBackgroundHost = spawnBackgroundHost;
13
15
  exports.stopBackgroundHost = stopBackgroundHost;
14
16
  /**
@@ -26,6 +28,7 @@ exports.stopBackgroundHost = stopBackgroundHost;
26
28
  * "startedAt": 1725000000000, "workspace": "/abs/path" }
27
29
  */
28
30
  const child_process_1 = require("child_process");
31
+ const net_1 = require("net");
29
32
  const fs_1 = require("fs");
30
33
  const path_1 = require("path");
31
34
  const paths_1 = require("../product/paths");
@@ -119,7 +122,48 @@ function childArgs(webArgs) {
119
122
  * stdio to `~/.orion-code/logs/web-<port>.log`, and wait for the pidfile so the
120
123
  * foreground command can print the URL and exit cleanly.
121
124
  */
122
- function spawnBackgroundHost(options) {
125
+ /**
126
+ * True when something already listens on 127.0.0.1:port — typically another
127
+ * Orion host process (e.g. one managed by launchd, which does not write a
128
+ * pidfile). Detecting this up front turns a raw EADDRINUSE crash into a
129
+ * friendly message.
130
+ */
131
+ function isHostPortTaken(port) {
132
+ return new Promise(resolve => {
133
+ const socket = (0, net_1.connect)({ host: '127.0.0.1', port });
134
+ let settled = false;
135
+ const done = (taken) => {
136
+ if (settled)
137
+ return;
138
+ settled = true;
139
+ socket.destroy();
140
+ resolve(taken);
141
+ };
142
+ socket.once('connect', () => done(true));
143
+ socket.once('error', () => done(false));
144
+ socket.setTimeout(800, () => done(false));
145
+ });
146
+ }
147
+ /** Best-effort tail of the host log used when a background child exits early. */
148
+ function readHostLogTail(port, lines = 6) {
149
+ try {
150
+ const raw = (0, fs_1.readFileSync)((0, path_1.join)(hostLogsDirectory(), `web-${port}.log`), 'utf8');
151
+ return raw.split('\n').filter(Boolean).slice(-lines).join('\n');
152
+ }
153
+ catch {
154
+ return '';
155
+ }
156
+ }
157
+ async function assertHostPortAvailable(port) {
158
+ if (await isHostPortTaken(port)) {
159
+ throw new Error(`Port ${port} is already served by another Orion host process. This is ` +
160
+ `often a launchd LaunchAgent (ai.orion-code.web). Check with ` +
161
+ '`launchctl list | grep orion`; if the running host belongs to this ' +
162
+ `CLI, use \`orion web status --port ${port}\` to confirm before stopping it.`);
163
+ }
164
+ }
165
+ async function spawnBackgroundHost(options) {
166
+ await assertHostPortAvailable(options.port);
123
167
  const logsDirectory = hostLogsDirectory();
124
168
  (0, fs_1.mkdirSync)(logsDirectory, { recursive: true });
125
169
  const logPath = (0, path_1.join)(logsDirectory, `web-${options.port}.log`);
@@ -171,6 +171,13 @@ export type WebContextReferenceV1 = {
171
171
  readonly id: string;
172
172
  readonly label: string;
173
173
  readonly revision: string;
174
+ } | {
175
+ readonly kind: 'file_range';
176
+ readonly id: string;
177
+ readonly label: string;
178
+ readonly revision: string;
179
+ readonly startLine: number;
180
+ readonly endLine: number;
174
181
  } | {
175
182
  readonly kind: 'folder';
176
183
  readonly id: string;
@@ -523,6 +523,7 @@ function parseContextReferences(value) {
523
523
  const row = requireRecord(entry, `contextReferences[${index}]`);
524
524
  const kind = requireEnum(row.kind, `contextReferences[${index}].kind`, [
525
525
  'file',
526
+ 'file_range',
526
527
  'folder',
527
528
  'review',
528
529
  'session',
@@ -534,6 +535,22 @@ function parseContextReferences(value) {
534
535
  }
535
536
  seen.add(`${kind}:${id}`);
536
537
  const label = requireBoundedString(row.label, `contextReferences[${index}].label`, 200);
538
+ if (kind === 'file_range') {
539
+ assertOnlyKeys(row, ['kind', 'id', 'label', 'revision', 'startLine', 'endLine'], 'Context reference');
540
+ const startLine = requireLineNumber(row.startLine, `contextReferences[${index}].startLine`);
541
+ const endLine = requireLineNumber(row.endLine, `contextReferences[${index}].endLine`);
542
+ if (endLine < startLine) {
543
+ throw new WebProtocolError('file_range endLine must not precede startLine');
544
+ }
545
+ return Object.freeze({
546
+ kind,
547
+ id,
548
+ label,
549
+ revision: requireBoundedString(row.revision, `contextReferences[${index}].revision`, 256),
550
+ startLine,
551
+ endLine,
552
+ });
553
+ }
537
554
  if (kind === 'file' || kind === 'folder') {
538
555
  assertOnlyKeys(row, ['kind', 'id', 'label', 'revision'], 'Context reference');
539
556
  return Object.freeze({
@@ -573,6 +590,15 @@ function assertOnlyKeys(row, keys, subject = 'command') {
573
590
  if (unknown)
574
591
  throw new WebProtocolError(`Unknown ${subject} field: ${unknown}`);
575
592
  }
593
+ function requireLineNumber(value, name) {
594
+ if (typeof value !== 'number' ||
595
+ !Number.isSafeInteger(value) ||
596
+ value < 1 ||
597
+ value > 100000000) {
598
+ throw new WebProtocolError(`${name} must be an integer line number from 1 through 100000000`);
599
+ }
600
+ return value;
601
+ }
576
602
  function requireBoundedString(value, name, maxLength) {
577
603
  if (typeof value !== 'string' || !value.trim()) {
578
604
  throw new WebProtocolError(`${name} must be a non-empty string`);
@@ -34,6 +34,20 @@ export declare class ReviewServiceV1 {
34
34
  private readonly listReceiptRefs;
35
35
  constructor(git: GitReadModelServiceV1, listReceiptRefs: () => Promise<readonly VerifiedDurableToolReceiptRefV1[]> | readonly VerifiedDurableToolReceiptRefV1[]);
36
36
  snapshot(): Promise<WebReviewSnapshotV1>;
37
+ /**
38
+ * v0.3.12 S2 — progressive verification evidence. The Review summary loads
39
+ * first; receipts page in behind it, optionally filtered to one session.
40
+ */
41
+ verificationPage(input: {
42
+ readonly sessionId?: string;
43
+ readonly cursor?: number;
44
+ readonly pageSize?: number;
45
+ }): Promise<{
46
+ readonly items: readonly WebReviewVerificationV1[];
47
+ readonly nextCursor: number | null;
48
+ readonly totalForSession: number;
49
+ }>;
50
+ private projectVerification;
37
51
  diff(input: Parameters<GitReadModelServiceV1['diff']>[0]): Promise<WebGitDiffPageV1>;
38
52
  }
39
53
  //# sourceMappingURL=review-service.d.ts.map