@orion-agents/orion-code 0.3.20 → 0.3.22

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,12 +22,44 @@ which is **not** a pass.
22
22
 
23
23
  ## [Unreleased]
24
24
 
25
- ## [0.3.20]
25
+ ## [0.3.22] — UNRELEASED
26
26
 
27
- > **Status: candidate.** Open. Repairs the Web E2E suite that has been red since
28
- > v0.3.11 (the dock's tabs became rail buttons) independently of the Git panel
29
- > work, and fixes the product defects the repair exposed. Not merged, tagged or
30
- > published.
27
+ > **Status: candidate.** Open. Agent Reliability & Efficiency — hardens ToolGateway
28
+ > in-flight request identity (T22-01), adds the durability fault-injection matrix (T22-02),
29
+ > the four-state release verification gate (T22-03), and a real coding-task evaluation
30
+ > harness (T22-04). Plan: `docs/plan/v0.3.22-plan.md`. Not merged, tagged or published.
31
+
32
+ ### Fixed (product)
33
+
34
+ - ToolGateway in-flight dedupe now compares the full request digest: the same invocationId
35
+ with different arguments, tool, snapshot, or parent conflicts
36
+ (`ORION_TOOL_INVOCATION_CONFLICT`) instead of reusing the running execution's promise.
37
+
38
+ ## [0.3.21] — 2026-10-09
39
+
40
+ > **Status: published.** Released as `@orion-agents/orion-code@0.3.21` from
41
+ > `refs/tags/v0.3.21`, and merged to `main` as `0d22c9a` (#264). Adds user-authorized
42
+ > local project-root discovery to `LOCAL WORKSPACE` (bounded scan, explicit
43
+ > inspect → confirm → activate reuse, no Home/network-volume scanning, no silent
44
+ > registration) and corrects the v0.3.20 release-facts documentation
45
+ > (`docs/plan/evidence/v0.3.20-e2e/release-receipt-v0.3.20.md`).
46
+
47
+ ### Added (planned)
48
+
49
+ - Saved project roots registry (`WorkspaceRootsRegistryV1`) and a bounded, cancellable
50
+ discovery service with depth/candidate/time budgets and partial-result semantics.
51
+
52
+ ### Fixed (planned)
53
+
54
+ - README / README.zh-CN / CHANGELOG release-state drift for v0.3.20, plus a release-check
55
+ guard that fails when a README describes a tagged version as unpublished.
56
+
57
+ ## [0.3.20] — 2026-10-07
58
+
59
+ > **Status: published.** Released as `@orion-agents/orion-code@0.3.20` from
60
+ > `refs/tags/v0.3.20`, and merged to `main` as `e658932` (#261). Repairs the Web E2E
61
+ > suite that has been red since v0.3.11 (the dock's tabs became rail buttons)
62
+ > independently of the Git panel work, and fixes the product defects the repair exposed.
31
63
  >
32
64
  > ### Fixed (product)
33
65
  >
package/README.md CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  Local-first, goal-driven coding agent for the terminal and browser.
4
4
 
5
- > v0.3.20 candidate — open. The v0.3.19 round found that the Web E2E suite has been red
6
- > since v0.3.11 (the dock's tabs became rail buttons), independently of the Git panel work;
7
- > repairing those specs is this cycle's intended content.
8
- > 0.3.19 is published to npm and tagged `v0.3.19`; 0.3.20 is not.
5
+ > v0.3.22 candidate — open. Hardens agent reliability (ToolGateway in-flight request
6
+ > identity) and builds a real coding-task evaluation harness. The previous release 0.3.21
7
+ > is on npm (registry `latest`) and tagged `v0.3.21` (`0d22c9a`, #264).
9
8
 
10
9
  [中文说明](README.zh-CN.md) ·
11
10
  [v0.3.7 plan](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
@@ -170,7 +169,7 @@ current development environments. Node 20 is upstream EOL and is no longer a v0.
170
169
  After the immutable `0.3.5` npm receipt exists:
171
170
 
172
171
  ```bash
173
- npm install -g @orion-agents/orion-code@0.3.20
172
+ npm install -g @orion-agents/orion-code@0.3.22
174
173
  orion --version
175
174
  orion doctor
176
175
  ```
package/README.zh-CN.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  面向终端与浏览器、本地优先的目标驱动 Coding Agent。
4
4
 
5
- > v0.3.20 候选版本:进行中。v0.3.19 那轮验证发现 Web E2E 套件自 v0.3.11(停靠栏 tab 改为
6
- > rail 按钮)起就与产品脱节,与 Git 工作栏的工作无关;修好这些 spec 是本周期的目标。
7
- > 0.3.19 已发布 npm 并打 tag `v0.3.19`,0.3.20 尚未发布。
5
+ > v0.3.22 候选版本:进行中。强化 Agent 可靠性(ToolGateway 执行中请求身份)并建设真实编码
6
+ > 任务评测体系。上一版 0.3.21 已发布 npm(registry `latest`)并打 tag `v0.3.21`(`0d22c9a`,#264)。
8
7
 
9
8
  [English](README.md) ·
10
9
  [v0.3.7 方案](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
@@ -101,7 +100,7 @@ Current。Node 20 已结束上游维护,不再属于 v0.3 Runtime 合同。
101
100
  当 npm 已存在不可变的 `0.3.4` 发布凭据后:
102
101
 
103
102
  ```bash
104
- npm install -g @orion-agents/orion-code@0.3.20
103
+ npm install -g @orion-agents/orion-code@0.3.22
105
104
  orion --version
106
105
  orion doctor
107
106
  ```
@@ -29,7 +29,7 @@ export declare const WEB_E2E_WEB37_SCENARIOS_V1: readonly ["WEB37-P0-01", "WEB37
29
29
  * v0.3.17 — LOCAL WORKSPACE open path. The picker is driven by a script file
30
30
  * (`ORION_CODE_WEB_PICKER_FIXTURE`) so CI never opens a real Finder dialog.
31
31
  */
32
- export declare const WEB_E2E_WEB38_SCENARIOS_V1: readonly ["WEB38-P0-01", "WEB38-P0-02", "WEB38-P0-03", "WEB38-P0-04", "WEB38-P0-05"];
32
+ export declare const WEB_E2E_WEB38_SCENARIOS_V1: readonly ["WEB38-P0-01", "WEB38-P0-02", "WEB38-P0-03", "WEB38-P0-04", "WEB38-P0-05", "WEB38-P0-06"];
33
33
  /**
34
34
  * v0.3.17 — Git right-hand work panel (`docs/plan/v0.3.17-plan.md`).
35
35
  *
@@ -149,6 +149,7 @@ exports.WEB_E2E_WEB38_SCENARIOS_V1 = Object.freeze([
149
149
  'WEB38-P0-03',
150
150
  'WEB38-P0-04',
151
151
  'WEB38-P0-05',
152
+ 'WEB38-P0-06',
152
153
  ]);
153
154
  /**
154
155
  * v0.3.17 — Git right-hand work panel (`docs/plan/v0.3.17-plan.md`).
@@ -140,6 +140,12 @@ export declare class ToolGatewayError extends Error {
140
140
  /** Capability → Policy → Approval → Sandbox → Execute → durable terminal receipt. */
141
141
  export declare class ToolGateway {
142
142
  private readonly services;
143
+ /**
144
+ * v0.3.22 T22-01 — in-flight entries carry the full request digest so the
145
+ * same invocationId with different arguments, tool, snapshot, or parent
146
+ * conflicts instead of silently reusing the first execution's promise
147
+ * (matching the durable journal's guard strength).
148
+ */
143
149
  private readonly inflight;
144
150
  private readonly clock;
145
151
  constructor(services: ToolGatewayServicesV1);
@@ -19,16 +19,38 @@ exports.ToolGatewayError = ToolGatewayError;
19
19
  class ToolGateway {
20
20
  constructor(services) {
21
21
  this.services = services;
22
+ /**
23
+ * v0.3.22 T22-01 — in-flight entries carry the full request digest so the
24
+ * same invocationId with different arguments, tool, snapshot, or parent
25
+ * conflicts instead of silently reusing the first execution's promise
26
+ * (matching the durable journal's guard strength).
27
+ */
22
28
  this.inflight = new Map();
23
29
  this.clock = services.clock ?? Date.now;
24
30
  }
25
31
  invoke(invocation) {
32
+ const requestDigest = computeRequestDigest(invocation);
26
33
  const existing = this.inflight.get(invocation.invocationId);
27
- if (existing)
28
- return existing;
29
- const run = this.invokeOnce(invocation).finally(() => this.inflight.delete(invocation.invocationId));
30
- this.inflight.set(invocation.invocationId, run);
31
- return run;
34
+ if (existing) {
35
+ if (existing.requestDigest !== requestDigest) {
36
+ // Reject (rather than throw synchronously) so callers awaiting the
37
+ // invocation observe the conflict through the same promise channel as
38
+ // the durable-journal conflict.
39
+ return Promise.reject(new ToolGatewayError('ORION_TOOL_INVOCATION_CONFLICT', 'Invocation ID is already executing with different arguments or snapshot', invocation.invocationId));
40
+ }
41
+ return existing.promise;
42
+ }
43
+ const promise = this.invokeOnce(invocation).finally(() => {
44
+ // Remove only our own entry: the guard keeps a defensive barrier against
45
+ // deleting an entry created by a later invocation after this run
46
+ // finished.
47
+ const current = this.inflight.get(invocation.invocationId);
48
+ if (current && current.requestDigest === requestDigest) {
49
+ this.inflight.delete(invocation.invocationId);
50
+ }
51
+ });
52
+ this.inflight.set(invocation.invocationId, { requestDigest, promise });
53
+ return promise;
32
54
  }
33
55
  async invokeOnce(invocation) {
34
56
  validateInvocation(invocation);
@@ -237,8 +259,13 @@ function createStaticApprovalDecisionV1(input) {
237
259
  function createSandboxPreparationV1(input) {
238
260
  return deepFreeze({ ...input, digest: (0, canonical_1.digestRuntimeValue)(input) });
239
261
  }
240
- function createIntent(invocation, startedAt) {
241
- const requestDigest = (0, canonical_1.digestRuntimeValue)({
262
+ /**
263
+ * v0.3.22 T22-01 — the authoritative request identity, shared by the durable
264
+ * intent and the in-flight dedupe map. Excludes startedAt so identity stays
265
+ * stable across retries within one execution.
266
+ */
267
+ function computeRequestDigest(invocation) {
268
+ return (0, canonical_1.digestRuntimeValue)({
242
269
  invocationId: invocation.invocationId,
243
270
  parentInvocationId: invocation.parentInvocationId,
244
271
  threadId: invocation.snapshot.threadId,
@@ -248,6 +275,9 @@ function createIntent(invocation, startedAt) {
248
275
  snapshotDigest: invocation.snapshot.digest,
249
276
  argsDigest: (0, canonical_1.digestRuntimeValue)(invocation.args),
250
277
  });
278
+ }
279
+ function createIntent(invocation, startedAt) {
280
+ const requestDigest = computeRequestDigest(invocation);
251
281
  const content = {
252
282
  version: 1,
253
283
  invocationId: invocation.invocationId,
@@ -0,0 +1,46 @@
1
+ /** Saved roots are user-authorized scan boundaries, not a project catalog. */
2
+ export declare const MAX_WORKSPACE_ROOTS = 12;
3
+ export interface WorkspaceRootEntryV1 {
4
+ readonly id: string;
5
+ readonly canonicalPath: string;
6
+ readonly label: string;
7
+ readonly addedAt: string;
8
+ readonly lastScannedAt?: string;
9
+ }
10
+ export interface WorkspaceRootsRegistryOptions {
11
+ readonly storagePath?: string;
12
+ readonly now?: () => Date;
13
+ readonly createId?: () => string;
14
+ }
15
+ export declare class WorkspaceRootsRegistryError extends Error {
16
+ readonly code: 'workspace_roots_invalid' | 'workspace_roots_capacity' | 'workspace_root_unavailable';
17
+ constructor(message: string, code: 'workspace_roots_invalid' | 'workspace_roots_capacity' | 'workspace_root_unavailable');
18
+ }
19
+ /**
20
+ * v0.3.21 — durable registry of user-authorized project-root directories.
21
+ *
22
+ * A root is a scan boundary the user explicitly granted through the native
23
+ * picker (or an explicit migration path); it is a different entity from a
24
+ * workspace and lives in its own versioned file so discovery can never be
25
+ * confused with `WorkspaceRegistryV1` (opened workspaces). Adding a root never
26
+ * registers a workspace, activates a Context, or touches the session catalog.
27
+ */
28
+ export declare class WorkspaceRootsRegistryV1 {
29
+ private readonly storagePath;
30
+ private readonly now;
31
+ private readonly createId;
32
+ constructor(options?: WorkspaceRootsRegistryOptions);
33
+ list(): readonly WorkspaceRootEntryV1[];
34
+ find(id: string): WorkspaceRootEntryV1 | undefined;
35
+ /** Canonicalize, dedupe by path, cap the count, and persist atomically. */
36
+ add(path: string): WorkspaceRootEntryV1;
37
+ remove(id: string): boolean;
38
+ /** Record that a discovery scan completed for this root (success or partial). */
39
+ markScanned(id: string, scannedAt?: string): WorkspaceRootEntryV1 | undefined;
40
+ private readDocument;
41
+ private writeDocument;
42
+ private ensureStorageDirectory;
43
+ }
44
+ /** Re-exported for tests: a root must be an existing, readable directory. */
45
+ export declare function canonicalWorkspaceRoot(path: string): string;
46
+ //# sourceMappingURL=workspace-roots-registry.d.ts.map
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WorkspaceRootsRegistryV1 = exports.WorkspaceRootsRegistryError = exports.MAX_WORKSPACE_ROOTS = void 0;
4
+ exports.canonicalWorkspaceRoot = canonicalWorkspaceRoot;
5
+ const crypto_1 = require("crypto");
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ const paths_1 = require("../product/paths");
9
+ const atomic_write_1 = require("./atomic-write");
10
+ const file_lock_1 = require("./file-lock");
11
+ const workspace_registry_1 = require("./workspace-registry");
12
+ /** Translate the shared directory error into this registry's own code. */
13
+ function canonicalRootPath(path) {
14
+ try {
15
+ return (0, workspace_registry_1.canonicalWorkspaceDirectory)(path);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof workspace_registry_1.WorkspaceRegistryError) {
19
+ throw new WorkspaceRootsRegistryError('Workspace root directory is unavailable.', 'workspace_root_unavailable');
20
+ }
21
+ throw error;
22
+ }
23
+ }
24
+ const WORKSPACE_ROOTS_SCHEMA_VERSION = 1;
25
+ const DEFAULT_ROOTS_FILENAME = 'workspace-roots.v1.json';
26
+ /** Saved roots are user-authorized scan boundaries, not a project catalog. */
27
+ exports.MAX_WORKSPACE_ROOTS = 12;
28
+ class WorkspaceRootsRegistryError extends Error {
29
+ constructor(message, code) {
30
+ super(message);
31
+ this.code = code;
32
+ this.name = 'WorkspaceRootsRegistryError';
33
+ }
34
+ }
35
+ exports.WorkspaceRootsRegistryError = WorkspaceRootsRegistryError;
36
+ /**
37
+ * v0.3.21 — durable registry of user-authorized project-root directories.
38
+ *
39
+ * A root is a scan boundary the user explicitly granted through the native
40
+ * picker (or an explicit migration path); it is a different entity from a
41
+ * workspace and lives in its own versioned file so discovery can never be
42
+ * confused with `WorkspaceRegistryV1` (opened workspaces). Adding a root never
43
+ * registers a workspace, activates a Context, or touches the session catalog.
44
+ */
45
+ class WorkspaceRootsRegistryV1 {
46
+ constructor(options = {}) {
47
+ this.storagePath = options.storagePath ?? (0, path_1.join)((0, paths_1.getConfigHome)(), DEFAULT_ROOTS_FILENAME);
48
+ this.now = options.now ?? (() => new Date());
49
+ this.createId = options.createId ?? crypto_1.randomUUID;
50
+ }
51
+ list() {
52
+ return freezeEntries(this.readDocument().entries);
53
+ }
54
+ find(id) {
55
+ return this.readDocument().entries.find(entry => entry.id === id);
56
+ }
57
+ /** Canonicalize, dedupe by path, cap the count, and persist atomically. */
58
+ add(path) {
59
+ const canonicalPath = canonicalRootPath(path);
60
+ this.ensureStorageDirectory();
61
+ return (0, file_lock_1.withFileLockSync)(this.storagePath, () => {
62
+ const document = this.readDocument();
63
+ const existing = document.entries.find(entry => entry.canonicalPath === canonicalPath);
64
+ if (existing)
65
+ return existing;
66
+ if (document.entries.length >= exports.MAX_WORKSPACE_ROOTS) {
67
+ throw new WorkspaceRootsRegistryError(`Workspace roots registry cannot contain more than ${exports.MAX_WORKSPACE_ROOTS} entries.`, 'workspace_roots_capacity');
68
+ }
69
+ const now = this.now().toISOString();
70
+ const entry = Object.freeze({
71
+ id: this.createId(),
72
+ canonicalPath,
73
+ label: workspaceRootLabel(canonicalPath),
74
+ addedAt: now,
75
+ });
76
+ this.writeDocument([...document.entries, entry]);
77
+ return entry;
78
+ });
79
+ }
80
+ remove(id) {
81
+ this.ensureStorageDirectory();
82
+ return (0, file_lock_1.withFileLockSync)(this.storagePath, () => {
83
+ const document = this.readDocument();
84
+ const entries = document.entries.filter(entry => entry.id !== id);
85
+ if (entries.length === document.entries.length)
86
+ return false;
87
+ this.writeDocument(entries);
88
+ return true;
89
+ });
90
+ }
91
+ /** Record that a discovery scan completed for this root (success or partial). */
92
+ markScanned(id, scannedAt) {
93
+ this.ensureStorageDirectory();
94
+ const at = (scannedAt ?? this.now().toISOString()).trim();
95
+ return (0, file_lock_1.withFileLockSync)(this.storagePath, () => {
96
+ const document = this.readDocument();
97
+ const target = document.entries.find(entry => entry.id === id);
98
+ if (!target)
99
+ return undefined;
100
+ const updated = Object.freeze({
101
+ ...target,
102
+ lastScannedAt: Number.isFinite(Date.parse(at)) ? new Date(at).toISOString() : at,
103
+ });
104
+ this.writeDocument(document.entries.map(entry => (entry.id === id ? updated : entry)));
105
+ return updated;
106
+ });
107
+ }
108
+ readDocument() {
109
+ if (!(0, fs_1.existsSync)(this.storagePath)) {
110
+ return Object.freeze({ schemaVersion: WORKSPACE_ROOTS_SCHEMA_VERSION, entries: [] });
111
+ }
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse((0, fs_1.readFileSync)(this.storagePath, 'utf8'));
115
+ }
116
+ catch {
117
+ throw new WorkspaceRootsRegistryError('Workspace roots registry is not valid JSON.', 'workspace_roots_invalid');
118
+ }
119
+ return parseDocument(parsed);
120
+ }
121
+ writeDocument(entries) {
122
+ this.ensureStorageDirectory();
123
+ const normalized = [...entries]
124
+ .sort((left, right) => Date.parse(right.addedAt) - Date.parse(left.addedAt) ||
125
+ left.label.localeCompare(right.label) ||
126
+ left.id.localeCompare(right.id))
127
+ .map(entry => ({ ...entry }));
128
+ (0, atomic_write_1.atomicWriteFileSync)(this.storagePath, `${JSON.stringify({ schemaVersion: WORKSPACE_ROOTS_SCHEMA_VERSION, entries: normalized }, null, 2)}\n`, { mode: 0o600, fsync: true });
129
+ }
130
+ ensureStorageDirectory() {
131
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(this.storagePath), { recursive: true, mode: 0o700 });
132
+ }
133
+ }
134
+ exports.WorkspaceRootsRegistryV1 = WorkspaceRootsRegistryV1;
135
+ function parseDocument(value) {
136
+ if (!value || typeof value !== 'object' || Array.isArray(value))
137
+ return invalidDocument();
138
+ const row = value;
139
+ if (row.schemaVersion !== WORKSPACE_ROOTS_SCHEMA_VERSION || !Array.isArray(row.entries)) {
140
+ return invalidDocument();
141
+ }
142
+ if (row.entries.length > exports.MAX_WORKSPACE_ROOTS)
143
+ return invalidDocument();
144
+ const entries = row.entries.map(parseEntry);
145
+ if (new Set(entries.map(entry => entry.id)).size !== entries.length ||
146
+ new Set(entries.map(entry => entry.canonicalPath)).size !== entries.length) {
147
+ return invalidDocument();
148
+ }
149
+ return Object.freeze({
150
+ schemaVersion: WORKSPACE_ROOTS_SCHEMA_VERSION,
151
+ entries: freezeEntries(entries),
152
+ });
153
+ }
154
+ function parseEntry(value) {
155
+ if (!value || typeof value !== 'object' || Array.isArray(value))
156
+ return invalidDocument();
157
+ const row = value;
158
+ if (typeof row.id !== 'string' ||
159
+ !row.id.trim() ||
160
+ row.id.length > 128 ||
161
+ typeof row.canonicalPath !== 'string' ||
162
+ !row.canonicalPath.trim() ||
163
+ typeof row.label !== 'string' ||
164
+ !row.label.trim() ||
165
+ typeof row.addedAt !== 'string' ||
166
+ !Number.isFinite(Date.parse(row.addedAt)) ||
167
+ (row.lastScannedAt !== undefined &&
168
+ (typeof row.lastScannedAt !== 'string' || !Number.isFinite(Date.parse(row.lastScannedAt))))) {
169
+ return invalidDocument();
170
+ }
171
+ return Object.freeze({
172
+ id: row.id,
173
+ canonicalPath: row.canonicalPath,
174
+ label: row.label,
175
+ addedAt: row.addedAt,
176
+ ...(row.lastScannedAt !== undefined ? { lastScannedAt: row.lastScannedAt } : {}),
177
+ });
178
+ }
179
+ function invalidDocument() {
180
+ throw new WorkspaceRootsRegistryError('Workspace roots registry document is invalid.', 'workspace_roots_invalid');
181
+ }
182
+ function freezeEntries(entries) {
183
+ return Object.freeze([...entries]
184
+ .sort((left, right) => Date.parse(right.addedAt) - Date.parse(left.addedAt) ||
185
+ left.label.localeCompare(right.label) ||
186
+ left.id.localeCompare(right.id))
187
+ .map(entry => Object.freeze({ ...entry })));
188
+ }
189
+ function workspaceRootLabel(path) {
190
+ const normalized = path.replace(/[\\/]+$/u, '');
191
+ return normalized.split(/[\\/]/u).filter(Boolean).at(-1) ?? normalized;
192
+ }
193
+ /** Re-exported for tests: a root must be an existing, readable directory. */
194
+ function canonicalWorkspaceRoot(path) {
195
+ const canonical = (0, workspace_registry_1.canonicalWorkspaceDirectory)(path);
196
+ (0, fs_1.statSync)(canonical);
197
+ return canonical;
198
+ }
199
+ //# sourceMappingURL=workspace-roots-registry.js.map
@@ -419,6 +419,42 @@ export interface WebWorkspaceInspectRequestV1 {
419
419
  readonly path: string;
420
420
  readonly source?: WebWorkspaceCandidateSourceV1;
421
421
  }
422
+ export type WebWorkspaceRootDiscoveryHintV1 = 'git' | 'manifest';
423
+ export interface WebWorkspaceRootEntryV1 {
424
+ readonly id: string;
425
+ readonly canonicalPath: string;
426
+ readonly label: string;
427
+ readonly addedAt: string;
428
+ readonly lastScannedAt?: string;
429
+ }
430
+ export interface WebWorkspaceRootDiscoveryStatusV1 {
431
+ readonly status: 'ready' | 'partial';
432
+ readonly candidateCount: number;
433
+ readonly durationMs: number;
434
+ readonly errorCode?: string;
435
+ readonly cached: boolean;
436
+ }
437
+ export interface WebWorkspaceRootViewV1 extends WebWorkspaceRootEntryV1 {
438
+ /** The cached outcome of the most recent scan, when one is still fresh. */
439
+ readonly discovery: WebWorkspaceRootDiscoveryStatusV1 | null;
440
+ }
441
+ export interface WebWorkspaceRootCandidateV1 {
442
+ readonly rootId: string;
443
+ readonly relativePath: string;
444
+ readonly label: string;
445
+ readonly hint: WebWorkspaceRootDiscoveryHintV1;
446
+ readonly status: 'ready' | 'partial';
447
+ }
448
+ export interface WebWorkspaceRootDiscoverOutcomeV1 {
449
+ readonly requestId?: string;
450
+ readonly rootId: string;
451
+ readonly status: 'ready' | 'partial';
452
+ readonly candidates: readonly WebWorkspaceRootCandidateV1[];
453
+ readonly errorCode?: string;
454
+ readonly durationMs: number;
455
+ readonly scannedEntries: number;
456
+ readonly lastScannedAt?: string;
457
+ }
422
458
  export type WebWorkspaceInvalidationReasonV1 = 'context-change' | 'filesystem-change' | 'terminal-command' | 'tool-finished';
423
459
  export type WebToolDetailSummaryV1 = ToolDetailSummary;
424
460
  export type WebToolDetailPageV1 = ToolDetailPage;
@@ -300,6 +300,51 @@ async function handleRequest(context) {
300
300
  sendJson(response, 200, { requestId, candidate });
301
301
  return;
302
302
  }
303
+ // v0.3.21 — saved project roots: read-only list. Roots are scan boundaries,
304
+ // not workspaces; listing them changes nothing.
305
+ if (method === 'GET' && path === '/workspaces/roots') {
306
+ sendJson(response, 200, { roots: context.workbench.listWorkspaceRoots() });
307
+ return;
308
+ }
309
+ // v0.3.21 — add a root through the native picker's confirmed path. A local
310
+ // configuration mutation: transport-guarded, naturally idempotent (roots
311
+ // dedupe by canonical path), and never a Context mutation.
312
+ if (method === 'POST' && path === '/workspaces/roots/add') {
313
+ assertMutation(request, context.nonce, context.origin);
314
+ const body = requireRecord(await readJson(request), 'Workspace root request');
315
+ assertOnlyKeys(body, ['requestId', 'path']);
316
+ const requestId = requireUuid(body.requestId, 'requestId');
317
+ const rootPath = requireText(body.path, 'path', 4096);
318
+ const root = await context.workbench.addWorkspaceRoot(rootPath, requestId);
319
+ sendJson(response, 200, { requestId, root });
320
+ return;
321
+ }
322
+ // v0.3.21 — remove a root. Cancels any in-flight scan for it first; the
323
+ // workspaces registry is untouched.
324
+ if (method === 'POST' && path === '/workspaces/roots/remove') {
325
+ assertMutation(request, context.nonce, context.origin);
326
+ const body = requireRecord(await readJson(request), 'Workspace root request');
327
+ assertOnlyKeys(body, ['requestId', 'rootId']);
328
+ const requestId = requireUuid(body.requestId, 'requestId');
329
+ const rootId = requireText(body.rootId, 'rootId', 128);
330
+ const removed = context.workbench.removeWorkspaceRoot(rootId);
331
+ sendJson(response, 200, { requestId, removed });
332
+ return;
333
+ }
334
+ // v0.3.21 — bounded discovery inside one saved root. Read-only, abortable,
335
+ // host-wide single-flight; the newest request aborts the previous scan. A
336
+ // candidate is a hint: the client still runs inspect → confirm → activate.
337
+ if (method === 'POST' && path === '/workspaces/roots/discover') {
338
+ assertMutation(request, context.nonce, context.origin);
339
+ const body = requireRecord(await readJson(request), 'Workspace discovery request');
340
+ assertOnlyKeys(body, ['requestId', 'rootId', 'refresh']);
341
+ const requestId = requireUuid(body.requestId, 'requestId');
342
+ const rootId = requireText(body.rootId, 'rootId', 128);
343
+ const refresh = body.refresh === undefined ? true : body.refresh === true;
344
+ const outcome = await context.workbench.discoverWorkspaceRoot(rootId, requestId, { refresh });
345
+ sendJson(response, 200, outcome);
346
+ return;
347
+ }
303
348
  if (method === 'GET' && path === '/sessions') {
304
349
  const contextGuard = requireContextGuardQuery(url);
305
350
  sendJson(response, 200, collectionPage(url, 'sessions', context.workbench.listSessions(contextGuard), session => session.id));
@@ -6,8 +6,10 @@ import { WorkspaceRegistryV1 } from '../services/workspace-registry';
6
6
  import { WebEventHub } from './event-hub';
7
7
  import { FileReadServiceV1 } from './file-read-service';
8
8
  import { GitReadModelServiceV1 } from './git-read-model-service';
9
- import { type WebComposerActionResultV1, type WebComposerActionV1, type WebComposerControlStateV1, type WebBootstrapV1, type WebCommandResultV1, type WebContextActivateRequestV1, type WebContextGuardV1, type WebMcpServerSummaryV1, type WebPageV1, type WebSessionSnapshotV1, type WebSessionSummaryV1, type WebSettingsMutationResultV1, type WebSettingsSnapshotV1, type WebSettingsUpdateRequestV1, type WebSkillSummaryV1, type WebToolDetailPageV1, type WebToolDetailSummaryV1, type WebWorkspaceSummaryV1, type WebWorkspaceProjectSummaryV1, type WebWorkspaceCandidateSourceV1, type WebWorkspaceCandidateV1 } from './protocol';
9
+ import { type WebComposerActionResultV1, type WebComposerActionV1, type WebComposerControlStateV1, type WebBootstrapV1, type WebCommandResultV1, type WebContextActivateRequestV1, type WebContextGuardV1, type WebMcpServerSummaryV1, type WebPageV1, type WebSessionSnapshotV1, type WebSessionSummaryV1, type WebSettingsMutationResultV1, type WebSettingsSnapshotV1, type WebSettingsUpdateRequestV1, type WebSkillSummaryV1, type WebToolDetailPageV1, type WebToolDetailSummaryV1, type WebWorkspaceSummaryV1, type WebWorkspaceProjectSummaryV1, type WebWorkspaceCandidateSourceV1, type WebWorkspaceCandidateV1, type WebWorkspaceRootViewV1, type WebWorkspaceRootDiscoverOutcomeV1 } from './protocol';
10
10
  import { type NativeDirectoryPickResult, type NativeDirectoryPicker } from './native-directory-picker';
11
+ import { WorkspaceDiscoveryTelemetryV1 } from './workspace-discovery';
12
+ import { WorkspaceRootsRegistryV1 } from '../services/workspace-roots-registry';
11
13
  import { type WebSessionRuntimeSummaryV1 } from './session-runtime-registry';
12
14
  import { TerminalManagerV1 } from './terminal-manager';
13
15
  export { WebWorkbenchError } from './errors';
@@ -17,6 +19,10 @@ export interface WebWorkbenchControllerOptions {
17
19
  readonly createRuntime?: (cwd: string) => Promise<OrionCodeUiRuntime>;
18
20
  readonly createSessionRuntime?: (cwd: string) => Promise<OrionCodeUiRuntime>;
19
21
  readonly workspaceRegistry?: WorkspaceRegistryV1;
22
+ /** v0.3.21 — user-authorized scan boundaries; injectable for tests. */
23
+ readonly workspaceRootsRegistry?: WorkspaceRootsRegistryV1;
24
+ /** v0.3.21 — injectable discovery telemetry for tests. */
25
+ readonly discoveryTelemetry?: WorkspaceDiscoveryTelemetryV1;
20
26
  /** v0.3.16 — Host native directory picker; injectable for tests. */
21
27
  readonly nativeDirectoryPicker?: NativeDirectoryPicker;
22
28
  }
@@ -44,6 +50,14 @@ export declare class WebWorkbenchController {
44
50
  private pickerInFlight;
45
51
  /** v0.3.17 — bounded, path-free open-path traces exposed through diagnostics(). */
46
52
  private readonly openTelemetry;
53
+ private readonly workspaceRootsRegistry;
54
+ private readonly discoveryTelemetry;
55
+ private readonly discoveryService;
56
+ /**
57
+ * v0.3.21 — global discovery single-flight: at most one scan runs per Host;
58
+ * a new discover request aborts the previous one (scan_cancelled) and wins.
59
+ */
60
+ private readonly discoveryInflight;
47
61
  readonly terminalManager: TerminalManagerV1;
48
62
  private readonly mutationResults;
49
63
  private readonly sessionViews;
@@ -111,6 +125,13 @@ export declare class WebWorkbenchController {
111
125
  * file contents, or touches the session catalog beyond a warm-cache peek.
112
126
  */
113
127
  inspectWorkspaceFast(path: string, source?: WebWorkspaceCandidateSourceV1, requestId?: string): Promise<WebWorkspaceCandidateV1>;
128
+ listWorkspaceRoots(): readonly WebWorkspaceRootViewV1[];
129
+ addWorkspaceRoot(path: string, requestId?: string): Promise<WebWorkspaceRootViewV1>;
130
+ removeWorkspaceRoot(rootId: string): boolean;
131
+ discoverWorkspaceRoot(rootId: string, requestId?: string, options?: {
132
+ readonly refresh?: boolean;
133
+ }): Promise<WebWorkspaceRootDiscoverOutcomeV1>;
134
+ private toDiscoverOutcome;
114
135
  listWorkspaceSessions(workspaceId: string, context?: WebContextGuardV1): readonly WebSessionSummaryV1[];
115
136
  /** Archived sessions of a Workspace (v0.3.7), newest archive first. */
116
137
  listArchivedWorkspaceSessions(workspaceId: string, context?: WebContextGuardV1): readonly WebSessionSummaryV1[];