@orion-agents/orion-code 0.3.22 → 0.3.25

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 (51) hide show
  1. package/CHANGELOG.md +81 -3
  2. package/README.md +9 -5
  3. package/README.zh-CN.md +7 -4
  4. package/dist/harness/failure-attribution.d.ts +23 -0
  5. package/dist/harness/failure-attribution.js +103 -0
  6. package/dist/harness/verified-repair.d.ts +71 -0
  7. package/dist/harness/verified-repair.js +212 -0
  8. package/dist/product/paths.d.ts +20 -1
  9. package/dist/product/paths.js +293 -10
  10. package/dist/runtime/model-coordinator.d.ts +1 -0
  11. package/dist/runtime/model-coordinator.js +9 -2
  12. package/dist/runtime/release-receipts.d.ts +3 -3
  13. package/dist/runtime/release-receipts.js +29 -0
  14. package/dist/runtime/session-composer-control.js +5 -1
  15. package/dist/services/config-dir.d.ts +3 -2
  16. package/dist/services/config-dir.js +6 -29
  17. package/dist/services/repo-intelligence/index.d.ts +11 -0
  18. package/dist/services/repo-intelligence/index.js +20 -0
  19. package/dist/services/repo-intelligence/recall.d.ts +44 -0
  20. package/dist/services/repo-intelligence/recall.js +115 -0
  21. package/dist/services/repo-intelligence/symbol-index.d.ts +52 -0
  22. package/dist/services/repo-intelligence/symbol-index.js +258 -0
  23. package/dist/services/session-storage.d.ts +18 -2
  24. package/dist/services/session-storage.js +196 -60
  25. package/dist/services/workspace-registry.d.ts +9 -0
  26. package/dist/services/workspace-registry.js +13 -2
  27. package/dist/web/git-history-service.js +3 -0
  28. package/dist/web/git-read-model-service.d.ts +9 -0
  29. package/dist/web/git-read-model-service.js +35 -0
  30. package/dist/web/git-version-service.d.ts +8 -0
  31. package/dist/web/git-version-service.js +50 -26
  32. package/dist/web/protocol.d.ts +13 -0
  33. package/dist/web/workbench-controller.js +47 -2
  34. package/dist/web-client/assets/{FilesPanel-CKeXPofZ.js → FilesPanel-CdoCa8u0.js} +1 -1
  35. package/dist/web-client/assets/{GitPanel-DollPxv0.js → GitPanel-CRr_iIDz.js} +1 -1
  36. package/dist/web-client/assets/{ReviewPanel-CNADacIn.js → ReviewPanel-C1Cu2IRV.js} +1 -1
  37. package/dist/web-client/assets/{TerminalPanel-Cyns4uJt.js → TerminalPanel-BniCjatZ.js} +1 -1
  38. package/dist/web-client/assets/index-kQqA3li6.js +20 -0
  39. package/dist/web-client/assets/{useAutoNotice-BLADeFFj.js → useAutoNotice-DPXFWIwK.js} +1 -1
  40. package/dist/web-client/index.html +1 -1
  41. package/npm-shrinkwrap.json +2 -2
  42. package/package.json +1 -1
  43. package/dist/services/auto-fix/autoFixConfig.d.ts +0 -30
  44. package/dist/services/auto-fix/autoFixConfig.js +0 -78
  45. package/dist/services/auto-fix/autoFixHook.d.ts +0 -27
  46. package/dist/services/auto-fix/autoFixHook.js +0 -99
  47. package/dist/services/auto-fix/autoFixRunner.d.ts +0 -60
  48. package/dist/services/auto-fix/autoFixRunner.js +0 -207
  49. package/dist/services/auto-fix/index.d.ts +0 -7
  50. package/dist/services/auto-fix/index.js +0 -17
  51. package/dist/web-client/assets/index-BhGhORAX.js +0 -20
@@ -10,9 +10,11 @@ exports.SESSION_TAG_MAX_LENGTH = exports.SESSION_TAG_LIMIT = exports.SessionComp
10
10
  exports.resolveProjectPath = resolveProjectPath;
11
11
  exports.getProjectKey = getProjectKey;
12
12
  exports.isSessionMetaFile = isSessionMetaFile;
13
+ exports.prepareSessionCatalogAsync = prepareSessionCatalogAsync;
13
14
  exports.prepareSessionCompactSourceReceipt = prepareSessionCompactSourceReceipt;
14
15
  exports.advanceSessionCompactSourceReceipt = advanceSessionCompactSourceReceipt;
15
16
  exports.createSession = createSession;
17
+ exports.createSessionAsync = createSessionAsync;
16
18
  exports.saveSessionMeta = saveSessionMeta;
17
19
  exports.updateSessionEffort = updateSessionEffort;
18
20
  exports.updateSessionComposerPreferences = updateSessionComposerPreferences;
@@ -219,7 +221,6 @@ function sanitizeTraceEvent(event) {
219
221
  // ============================================================================
220
222
  // Project helpers
221
223
  // ============================================================================
222
- const MAX_CACHE_SIZE = 256;
223
224
  const SESSION_CATALOG_VERSION = 1;
224
225
  let sessionCatalogCache = null;
225
226
  function getSessionCatalogPath() {
@@ -228,63 +229,15 @@ function getSessionCatalogPath() {
228
229
  function emptySessionCatalog() {
229
230
  return { version: SESSION_CATALOG_VERSION, sessions: {} };
230
231
  }
231
- /**
232
- * Evict the oldest entries when a Map exceeds MAX_CACHE_SIZE.
233
- * Simple LRU approximation: delete the first (oldest) entries until
234
- * the map is within bounds. Called after each insertion.
235
- */
236
- function evictOldest(map, maxSize) {
237
- while (map.size > maxSize) {
238
- const firstKey = map.keys().next().value;
239
- if (firstKey !== undefined)
240
- map.delete(firstKey);
241
- else
242
- break;
243
- }
244
- }
245
- const resolvedProjectPathCache = new Map();
246
232
  /**
247
233
  * Resolve a working directory to the project identity used for session storage.
248
234
  * Git repositories share sessions from the repository root; non-git folders use
249
235
  * their real absolute path.
250
236
  */
251
237
  function resolveProjectPath(cwd = process.cwd()) {
252
- const absolute = (0, path_1.resolve)(cwd);
253
- const cached = resolvedProjectPathCache.get(absolute);
254
- if (cached) {
255
- return cached;
256
- }
257
- let resolvedPath = absolute;
258
- if ((0, fs_1.existsSync)(absolute)) {
259
- try {
260
- const root = (0, child_process_1.execFileSync)('git', ['-C', absolute, 'rev-parse', '--show-toplevel'], {
261
- encoding: 'utf-8',
262
- stdio: ['ignore', 'pipe', 'ignore'],
263
- }).trim();
264
- if (root) {
265
- resolvedPath = (0, fs_1.realpathSync)(root);
266
- resolvedProjectPathCache.set(absolute, resolvedPath);
267
- evictOldest(resolvedProjectPathCache, MAX_CACHE_SIZE);
268
- return resolvedPath;
269
- }
270
- }
271
- catch (error) {
272
- // Not a git worktree, or git is unavailable.
273
- (0, debug_log_1.debugError)('session-storage.resolveGitRoot', error, absolute);
274
- }
275
- }
276
- try {
277
- resolvedPath = (0, fs_1.realpathSync)(absolute);
278
- }
279
- catch (error) {
280
- // Broken symlink or concurrent deletion; the literal path still works
281
- // as a stable project key.
282
- (0, debug_log_1.debugError)('session-storage.realpath', error, absolute);
283
- resolvedPath = absolute;
284
- }
285
- resolvedProjectPathCache.set(absolute, resolvedPath);
286
- evictOldest(resolvedProjectPathCache, MAX_CACHE_SIZE);
287
- return resolvedPath;
238
+ // Storage helpers and Session identity must share the same invalidation
239
+ // boundary; a second permanent cache could miss a newly initialized repo.
240
+ return (0, config_dir_1.resolveProjectStoragePath)(cwd);
288
241
  }
289
242
  function getProjectKey(projectPath) {
290
243
  return (0, config_dir_1.encodeProjectPath)(resolveProjectPath(projectPath));
@@ -307,8 +260,8 @@ function getGitBranch(projectPath) {
307
260
  return undefined;
308
261
  }
309
262
  }
310
- function normalizeSessionMeta(session) {
311
- const projectPath = resolveProjectPath(session.projectPath);
263
+ function normalizeSessionMeta(session, persistedStorage) {
264
+ const projectPath = persistedStorage?.projectPath ?? resolveProjectPath(session.projectPath);
312
265
  const startTime = session.startTime ?? Date.now();
313
266
  const updatedAt = session.updatedAt ?? session.endTime ?? startTime;
314
267
  return {
@@ -321,15 +274,20 @@ function normalizeSessionMeta(session) {
321
274
  updatedAt,
322
275
  updatedAtIso: session.updatedAtIso ?? new Date(updatedAt).toISOString(),
323
276
  messageCount: session.messageCount ?? 0,
324
- historySizeBytes: session.historySizeBytes ?? computeSessionHistorySizeBytes({ id: session.id, projectPath }),
277
+ historySizeBytes: session.historySizeBytes ??
278
+ (persistedStorage
279
+ ? (safeFileSize(persistedStorage.historyPath) ?? 0)
280
+ : computeSessionHistorySizeBytes({ id: session.id, projectPath })),
325
281
  tokenCount: session.tokenCount ?? 0,
326
282
  cost: session.cost ?? 0,
327
- gitBranch: session.gitBranch ?? getGitBranch(projectPath),
283
+ // Normalization is a read-model operation. Missing optional metadata must
284
+ // not spawn Git on every read/save of a non-repository Session.
285
+ gitBranch: session.gitBranch,
328
286
  };
329
287
  }
330
- function tryNormalizeSessionMeta(session, sourcePath) {
288
+ function tryNormalizeSessionMeta(session, sourcePath, persistedStorage) {
331
289
  try {
332
- return normalizeSessionMeta(session);
290
+ return normalizeSessionMeta(session, persistedStorage);
333
291
  }
334
292
  catch (error) {
335
293
  (0, debug_log_1.debugError)('session-storage.normalizeSessionMeta', error, sourcePath);
@@ -479,6 +437,109 @@ function updateSessionCatalog(update) {
479
437
  return catalog;
480
438
  }, { waitMs: 10000 });
481
439
  }
440
+ function catalogSourceStamp(path) {
441
+ try {
442
+ const stat = (0, fs_1.statSync)(path);
443
+ return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
444
+ }
445
+ catch (error) {
446
+ if (error.code === 'ENOENT')
447
+ return 'missing';
448
+ throw error;
449
+ }
450
+ }
451
+ async function scanSessionCatalogAsync() {
452
+ const catalog = emptySessionCatalog();
453
+ const stamps = new Map();
454
+ const projectsDir = (0, config_dir_1.getProjectsDir)();
455
+ stamps.set(projectsDir, catalogSourceStamp(projectsDir));
456
+ if (!(0, fs_1.existsSync)(projectsDir))
457
+ return { catalog, stamps };
458
+ let sinceYield = 0;
459
+ for (const projectKey of await fs_1.promises.readdir(projectsDir)) {
460
+ const sessionsDir = (0, path_1.join)(projectsDir, projectKey, 'sessions');
461
+ stamps.set(sessionsDir, catalogSourceStamp(sessionsDir));
462
+ if (!(0, fs_1.existsSync)(sessionsDir))
463
+ continue;
464
+ for (const file of (await fs_1.promises.readdir(sessionsDir)).filter(isSessionMetaFile)) {
465
+ const path = (0, path_1.join)(sessionsDir, file);
466
+ // Stamp before reading. A concurrent atomic replacement between these
467
+ // steps must invalidate the scan rather than bless stale bytes.
468
+ stamps.set(path, catalogSourceStamp(path));
469
+ let raw;
470
+ try {
471
+ raw = JSON.parse(await fs_1.promises.readFile(path, 'utf8'));
472
+ }
473
+ catch (error) {
474
+ (0, debug_log_1.debugError)('session-storage.scanCatalogAsync', error, path);
475
+ continue;
476
+ }
477
+ if (!raw || typeof raw.id !== 'string' || !raw.id || !raw.projectPath)
478
+ continue;
479
+ if (typeof raw.projectPath !== 'string')
480
+ continue;
481
+ // Historical metadata already owns its persisted root. Rediscovering
482
+ // Git here could migrate it, or let an unrelated broken repo deny Host
483
+ // startup. Rebuild the index without probing any historical repository.
484
+ const normalized = tryNormalizeSessionMeta(raw, path, {
485
+ projectPath: (0, path_1.resolve)(raw.projectPath),
486
+ historyPath: path.slice(0, -'.json'.length) + '.jsonl',
487
+ });
488
+ if (normalized) {
489
+ const previous = catalog.sessions[normalized.id];
490
+ if (!previous ||
491
+ (normalized.updatedAt ?? normalized.startTime) >
492
+ (previous.updatedAt ?? previous.startTime)) {
493
+ catalog.sessions[normalized.id] = normalized;
494
+ }
495
+ }
496
+ if (++sinceYield >= 16) {
497
+ sinceYield = 0;
498
+ await new Promise(resolveYield => setImmediate(resolveYield));
499
+ }
500
+ }
501
+ }
502
+ return { catalog, stamps };
503
+ }
504
+ /** Warm/rebuild the global read model without synchronous lock retry or scan. */
505
+ async function prepareSessionCatalogAsync(waitMs = 10000) {
506
+ (0, config_dir_1.ensureConfigDir)();
507
+ const path = getSessionCatalogPath();
508
+ if (readCachedSessionCatalog(path))
509
+ return;
510
+ const deadline = Date.now() + waitMs;
511
+ let scanned = await scanSessionCatalogAsync();
512
+ const scanChanged = new Error('Session Catalog sources changed during scan.');
513
+ while (true) {
514
+ try {
515
+ (0, file_lock_1.withFileLockSync)(path, () => {
516
+ // A writer may have published a newer Catalog while the scan ran.
517
+ if (readCachedSessionCatalog(path))
518
+ return;
519
+ for (const [sourcePath, stamp] of scanned.stamps) {
520
+ if (catalogSourceStamp(sourcePath) !== stamp)
521
+ throw scanChanged;
522
+ }
523
+ writeSessionCatalog(path, scanned.catalog);
524
+ }, { waitMs: 0 });
525
+ return;
526
+ }
527
+ catch (error) {
528
+ if (Date.now() >= deadline)
529
+ throw error;
530
+ if (error === scanChanged) {
531
+ scanned = await scanSessionCatalogAsync();
532
+ }
533
+ else if (error instanceof Error &&
534
+ error.message === `Timed out waiting for file lock ${path}.lock`) {
535
+ await new Promise(resolveWait => setTimeout(resolveWait, 10));
536
+ }
537
+ else {
538
+ throw error;
539
+ }
540
+ }
541
+ }
542
+ }
482
543
  function parseHarnessSidecarFile(path) {
483
544
  try {
484
545
  const content = (0, fs_1.readFileSync)(path, 'utf-8');
@@ -913,8 +974,9 @@ function computeSessionHistorySizeBytes(session) {
913
974
  // ============================================================================
914
975
  /**
915
976
  * 创建新会话
977
+ * @param options Optional pre-probed Git metadata. Supplying this object skips synchronous Git.
916
978
  */
917
- function createSession(projectPath, model) {
979
+ function createSession(projectPath, model, options) {
918
980
  (0, config_dir_1.ensureConfigDir)();
919
981
  const canonicalProjectPath = resolveProjectPath(projectPath);
920
982
  const now = Date.now();
@@ -929,13 +991,87 @@ function createSession(projectPath, model) {
929
991
  updatedAt: now,
930
992
  updatedAtIso: new Date(now).toISOString(),
931
993
  messageCount: 0,
932
- gitBranch: getGitBranch(canonicalProjectPath),
994
+ // Interactive Hosts can supply an asynchronously probed branch. Passing
995
+ // options (including an unavailable branch) deliberately skips the legacy
996
+ // synchronous Git probe; CLI/TUI callers retain their existing behavior.
997
+ gitBranch: options ? options.gitBranch : getGitBranch(canonicalProjectPath),
933
998
  tokenCount: 0,
934
999
  cost: 0,
935
1000
  };
936
1001
  saveSessionMeta(session);
937
1002
  return session;
938
1003
  }
1004
+ /**
1005
+ * Web creation waits for cross-process metadata/catalog locks asynchronously.
1006
+ * Both locks are acquired before any Session bytes are written. The original
1007
+ * synchronous lock owner/recovery and atomic publication remain authoritative.
1008
+ */
1009
+ async function createSessionAsync(projectPath, model, options = {}) {
1010
+ (0, config_dir_1.ensureConfigDir)();
1011
+ const deadline = Date.now() + (options.waitMs ?? 10000);
1012
+ options.beforeCommit?.();
1013
+ const storageIdentity = await (0, paths_1.resolveProjectStorageIdentityAsync)(projectPath);
1014
+ const canonicalProjectPath = storageIdentity.canonicalPath;
1015
+ await prepareSessionCatalogAsync(Math.max(0, deadline - Date.now()));
1016
+ const now = Date.now();
1017
+ const session = {
1018
+ id: (0, crypto_1.randomUUID)(),
1019
+ projectPath: canonicalProjectPath,
1020
+ projectKey: (0, config_dir_1.encodeProjectPath)(canonicalProjectPath),
1021
+ cwd: (0, path_1.resolve)(projectPath),
1022
+ model,
1023
+ startTime: now,
1024
+ createdAt: new Date(now).toISOString(),
1025
+ updatedAt: now,
1026
+ updatedAtIso: new Date(now).toISOString(),
1027
+ messageCount: 0,
1028
+ historySizeBytes: 0,
1029
+ gitBranch: options.gitBranch,
1030
+ tokenCount: 0,
1031
+ cost: 0,
1032
+ ...(options.name?.trim() ? { name: options.name.trim() } : {}),
1033
+ };
1034
+ const sessionsDir = (0, paths_1.ensureResolvedProjectDir)(storageIdentity);
1035
+ const metaPath = (0, path_1.join)(sessionsDir, `${session.id}.json`);
1036
+ const catalogPath = getSessionCatalogPath();
1037
+ const lockTimeoutMessages = new Set([metaPath, catalogPath].map(path => `Timed out waiting for file lock ${path}.lock`));
1038
+ while (true) {
1039
+ options.beforeCommit?.();
1040
+ (0, paths_1.assertProjectStorageIdentity)(storageIdentity);
1041
+ try {
1042
+ return (0, file_lock_1.withFileLockSync)(metaPath, () => (0, file_lock_1.withFileLockSync)(catalogPath, () => {
1043
+ options.beforeCommit?.();
1044
+ (0, paths_1.assertProjectStorageIdentity)(storageIdentity);
1045
+ const catalog = readSessionCatalogFile(catalogPath);
1046
+ if (!catalog) {
1047
+ throw new Error('Session Catalog changed before creation. Retry.');
1048
+ }
1049
+ if ((0, fs_1.existsSync)(metaPath))
1050
+ throw new Error('Session identity already exists.');
1051
+ (0, atomic_write_1.atomicWriteFileSync)(metaPath, JSON.stringify(session, null, 2), { mode: 0o600 });
1052
+ try {
1053
+ catalog.sessions[session.id] = session;
1054
+ writeSessionCatalog(catalogPath, catalog);
1055
+ }
1056
+ catch (error) {
1057
+ // This identity was not exposed to a caller or Catalog yet.
1058
+ // Roll back its metadata while both locks are still ours.
1059
+ (0, fs_1.unlinkSync)(metaPath);
1060
+ throw error;
1061
+ }
1062
+ return session;
1063
+ }, { waitMs: 0 }), { waitMs: 0 });
1064
+ }
1065
+ catch (error) {
1066
+ if (!(error instanceof Error) ||
1067
+ !lockTimeoutMessages.has(error.message) ||
1068
+ Date.now() >= deadline) {
1069
+ throw error;
1070
+ }
1071
+ await new Promise(resolveWait => setTimeout(resolveWait, 10));
1072
+ }
1073
+ }
1074
+ }
939
1075
  /**
940
1076
  * 保存会话元数据
941
1077
  */
@@ -3,6 +3,15 @@ export interface WorkspaceRegistryEntryV1 {
3
3
  readonly canonicalPath: string;
4
4
  readonly label: string;
5
5
  readonly lastActivatedAt: string;
6
+ /**
7
+ * v0.3.25 #272 — when the workspace was first registered. Navigation order is
8
+ * derived from this stable timestamp; `lastActivatedAt` no longer reorders
9
+ * the tree when sessions are activated. Documents persisted before this field
10
+ * existed are backfilled from `lastActivatedAt` on parse (and persisted with
11
+ * the backfilled value on the next write), which keeps the migration
12
+ * deterministic.
13
+ */
14
+ readonly registeredAt: string;
6
15
  readonly pinnedOrder?: number;
7
16
  }
8
17
  export interface WorkspaceRegistryOptions {
@@ -47,6 +47,7 @@ class WorkspaceRegistryV1 {
47
47
  canonicalPath,
48
48
  label: workspaceLabel(canonicalPath),
49
49
  lastActivatedAt: options.activated ? now : (existing?.lastActivatedAt ?? now),
50
+ registeredAt: existing?.registeredAt ?? now,
50
51
  ...(existing?.pinnedOrder !== undefined ? { pinnedOrder: existing.pinnedOrder } : {}),
51
52
  });
52
53
  const entries = existing
@@ -74,6 +75,7 @@ class WorkspaceRegistryV1 {
74
75
  canonicalPath: path,
75
76
  label: workspaceLabel(path),
76
77
  lastActivatedAt: path === active ? now : (existing?.lastActivatedAt ?? now),
78
+ registeredAt: existing?.registeredAt ?? now,
77
79
  ...(existing?.pinnedOrder !== undefined ? { pinnedOrder: existing.pinnedOrder } : {}),
78
80
  }));
79
81
  }
@@ -102,6 +104,7 @@ class WorkspaceRegistryV1 {
102
104
  canonicalPath: target.canonicalPath,
103
105
  label: target.label,
104
106
  lastActivatedAt: target.lastActivatedAt,
107
+ registeredAt: target.registeredAt,
105
108
  ...(pinned ? { pinnedOrder: target.pinnedOrder ?? currentMax + 1 } : {}),
106
109
  });
107
110
  this.writeDocument(document.entries.map(entry => (entry.id === id ? updated : entry)));
@@ -189,6 +192,11 @@ function parseEntry(value) {
189
192
  !row.label.trim() ||
190
193
  typeof row.lastActivatedAt !== 'string' ||
191
194
  !Number.isFinite(Date.parse(row.lastActivatedAt)) ||
195
+ // v0.3.25 #272 — older documents have no registeredAt; backfill from
196
+ // lastActivatedAt so the migration is deterministic and the write-back
197
+ // persists the stable value.
198
+ (row.registeredAt !== undefined &&
199
+ (typeof row.registeredAt !== 'string' || !Number.isFinite(Date.parse(row.registeredAt)))) ||
192
200
  (row.pinnedOrder !== undefined &&
193
201
  (!Number.isSafeInteger(row.pinnedOrder) || Number(row.pinnedOrder) < 1))) {
194
202
  return invalidDocument();
@@ -198,6 +206,7 @@ function parseEntry(value) {
198
206
  canonicalPath: row.canonicalPath,
199
207
  label: row.label,
200
208
  lastActivatedAt: row.lastActivatedAt,
209
+ registeredAt: typeof row.registeredAt === 'string' ? row.registeredAt : row.lastActivatedAt,
201
210
  ...(row.pinnedOrder !== undefined ? { pinnedOrder: Number(row.pinnedOrder) } : {}),
202
211
  });
203
212
  }
@@ -209,8 +218,10 @@ function compareEntries(left, right) {
209
218
  const rightPinned = right.pinnedOrder ?? Number.MAX_SAFE_INTEGER;
210
219
  if (leftPinned !== rightPinned)
211
220
  return leftPinned - rightPinned;
212
- const recent = Date.parse(right.lastActivatedAt) - Date.parse(left.lastActivatedAt);
213
- return recent || left.label.localeCompare(right.label) || left.id.localeCompare(right.id);
221
+ // v0.3.25 #272 — stable registration order: activating a session must not
222
+ // reorder the navigation tree, so lastActivatedAt no longer participates.
223
+ const registration = Date.parse(left.registeredAt) - Date.parse(right.registeredAt);
224
+ return registration || left.label.localeCompare(right.label) || left.id.localeCompare(right.id);
214
225
  }
215
226
  function freezeEntries(entries) {
216
227
  return Object.freeze([...entries].sort(compareEntries).map(entry => Object.freeze({ ...entry })));
@@ -572,11 +572,14 @@ function createGitHistoryReader(options) {
572
572
  // `--follow` only accepts a single path, which is exactly this case. It explains its own
573
573
  // limits for copies and complex merges (plan G6) — we surface `followed` rather than
574
574
  // pretending tracking is complete.
575
+ // v0.3.25 #270 — the validated revision must reach the log command: it was
576
+ // previously used only for the existence check, so every query walked HEAD.
575
577
  const raw = await runGit([
576
578
  'log',
577
579
  `--max-count=${limit + 1}`,
578
580
  `--format=${LIST_FORMAT}${RECORD_SEPARATOR}`,
579
581
  '--follow',
582
+ target,
580
583
  '--',
581
584
  path,
582
585
  ]);
@@ -207,6 +207,15 @@ export declare class GitReadModelServiceV1 {
207
207
  }): Promise<GitDiffDocumentV2>;
208
208
  private versionReaderInstance?;
209
209
  private versionReader;
210
+ /**
211
+ * v0.3.25 #269 — reads one working-tree file for the conflict view, refusing
212
+ * to leave the repository: the requested relative path (already guarded as
213
+ * relative and non-sensitive by the version reader) is resolved against the
214
+ * repository root and both the file's real path and its parent's real path
215
+ * must stay inside the root's real path. Returns null instead of throwing so
216
+ * a missing or unreadable file reads as an absent worktree version.
217
+ */
218
+ private readContainedWorktreeFile;
210
219
  /**
211
220
  * v0.3.17 S5 — base / ours / theirs / worktree for a conflicted file.
212
221
  *
@@ -6,6 +6,7 @@ exports.assertRelativeGitPath = assertRelativeGitPath;
6
6
  const child_process_1 = require("child_process");
7
7
  const crypto_1 = require("crypto");
8
8
  const fs_1 = require("fs");
9
+ const promises_1 = require("fs/promises");
9
10
  const path_1 = require("path");
10
11
  const redaction_1 = require("../services/redaction");
11
12
  const errors_1 = require("./errors");
@@ -349,9 +350,43 @@ class GitReadModelServiceV1 {
349
350
  this.versionReaderInstance ?? (this.versionReaderInstance = (0, git_version_service_1.createGitVersionReader)({
350
351
  runGit: async (args) => this.runGit(args, await this.requireRepositoryRoot()),
351
352
  runGitBytes: async (args) => this.runGitBytes(args, await this.requireRepositoryRoot()),
353
+ readWorktreeFile: async (relativePath) => this.readContainedWorktreeFile(relativePath),
352
354
  }));
353
355
  return this.versionReaderInstance;
354
356
  }
357
+ /**
358
+ * v0.3.25 #269 — reads one working-tree file for the conflict view, refusing
359
+ * to leave the repository: the requested relative path (already guarded as
360
+ * relative and non-sensitive by the version reader) is resolved against the
361
+ * repository root and both the file's real path and its parent's real path
362
+ * must stay inside the root's real path. Returns null instead of throwing so
363
+ * a missing or unreadable file reads as an absent worktree version.
364
+ */
365
+ async readContainedWorktreeFile(relativePath) {
366
+ let root;
367
+ try {
368
+ root = (0, fs_1.realpathSync)(await this.requireRepositoryRoot());
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ try {
374
+ const absolute = (0, path_1.resolve)(root, relativePath);
375
+ const fileReal = (0, fs_1.realpathSync)(absolute);
376
+ const parentReal = (0, fs_1.realpathSync)((0, path_1.resolve)(fileReal, '..'));
377
+ if (fileReal !== root && !fileReal.startsWith(root + path_1.sep))
378
+ return null;
379
+ if (parentReal !== root && !parentReal.startsWith(root + path_1.sep))
380
+ return null;
381
+ const stat = (0, fs_1.statSync)(fileReal);
382
+ if (!stat.isFile())
383
+ return null;
384
+ return await (0, promises_1.readFile)(fileReal);
385
+ }
386
+ catch {
387
+ return null;
388
+ }
389
+ }
355
390
  /**
356
391
  * v0.3.17 S5 — base / ours / theirs / worktree for a conflicted file.
357
392
  *
@@ -66,6 +66,14 @@ export interface GitVersionReaderOptions {
66
66
  readonly runGit: (args: readonly string[]) => Promise<string>;
67
67
  /** Runs git and returns raw bytes, for content that may not be valid UTF-8. */
68
68
  readonly runGitBytes: (args: readonly string[]) => Promise<Buffer>;
69
+ /**
70
+ * v0.3.25 #269 — reads one working-tree file relative to the repository root,
71
+ * returning null when it does not exist or cannot be read safely. The
72
+ * `worktree` conflict version must come from the filesystem: `hash-object`
73
+ * without `-w` computes an OID that is not in the object database, so any
74
+ * edited working file made `cat-file` fail the whole conflict response.
75
+ */
76
+ readonly readWorktreeFile?: (relativePath: string) => Promise<Buffer | null>;
69
77
  }
70
78
  export declare function createGitVersionReader(options: GitVersionReaderOptions): GitVersionReader;
71
79
  //# sourceMappingURL=git-version-service.d.ts.map
@@ -85,28 +85,25 @@ function parseLfsPointer(head) {
85
85
  });
86
86
  }
87
87
  function createGitVersionReader(options) {
88
- const { runGit, runGitBytes } = options;
88
+ const { runGit, runGitBytes, readWorktreeFile } = options;
89
+ /** Absent version — the label legitimately has no content for this shape. */
90
+ const absentVersion = (label) => Object.freeze({
91
+ label,
92
+ oid: null,
93
+ exists: false,
94
+ binary: false,
95
+ byteSize: 0,
96
+ truncated: false,
97
+ content: null,
98
+ lfs: null,
99
+ });
89
100
  /**
90
- * Reads one blob by OID, bounded, with binary and LFS detection.
91
- *
92
- * The size is read first so an enormous historical file is reported by its size instead of
93
- * being pulled into memory just to be truncated.
101
+ * v0.3.25 #269 — decodes already-fetched bytes into a version, shared by the
102
+ * object-database reader and the working-tree reader so both apply the same
103
+ * size bound, binary probe and LFS-pointer detection.
94
104
  */
95
- const readVersion = async (label, oid) => {
96
- if (oid === null) {
97
- return Object.freeze({
98
- label,
99
- oid: null,
100
- exists: false,
101
- binary: false,
102
- byteSize: 0,
103
- truncated: false,
104
- content: null,
105
- lfs: null,
106
- });
107
- }
108
- const sizeRaw = (await runGit(['cat-file', '-s', oid])).trim();
109
- const byteSize = Number(sizeRaw) || 0;
105
+ const versionFromBytes = (label, oid, bytes) => {
106
+ const byteSize = bytes.length;
110
107
  if (byteSize > MAX_VERSION_BYTES) {
111
108
  return Object.freeze({
112
109
  label,
@@ -119,7 +116,6 @@ function createGitVersionReader(options) {
119
116
  lfs: null,
120
117
  });
121
118
  }
122
- const bytes = await runGitBytes(['cat-file', 'blob', oid]);
123
119
  const probe = bytes.subarray(0, LFS_PROBE_BYTES).toString('utf8');
124
120
  const lfs = parseLfsPointer(probe);
125
121
  if (lfs) {
@@ -149,6 +145,31 @@ function createGitVersionReader(options) {
149
145
  dataUrl,
150
146
  });
151
147
  };
148
+ /**
149
+ * Reads one blob by OID, bounded, with binary and LFS detection.
150
+ *
151
+ * The size is read first so an enormous historical file is reported by its size instead of
152
+ * being pulled into memory just to be truncated.
153
+ */
154
+ const readVersion = async (label, oid) => {
155
+ if (oid === null)
156
+ return absentVersion(label);
157
+ const sizeRaw = (await runGit(['cat-file', '-s', oid])).trim();
158
+ const byteSize = Number(sizeRaw) || 0;
159
+ if (byteSize > MAX_VERSION_BYTES) {
160
+ return Object.freeze({
161
+ label,
162
+ oid,
163
+ exists: true,
164
+ binary: false,
165
+ byteSize,
166
+ truncated: true,
167
+ content: null,
168
+ lfs: null,
169
+ });
170
+ }
171
+ return versionFromBytes(label, oid, await runGitBytes(['cat-file', 'blob', oid]));
172
+ };
152
173
  return {
153
174
  async conflict(pathInput) {
154
175
  const path = assertRelativePath(pathInput);
@@ -168,15 +189,18 @@ function createGitVersionReader(options) {
168
189
  continue;
169
190
  stageOids.set(Number(stage), oid);
170
191
  }
171
- const worktreeOid = (await runGit(['hash-object', '--path=' + path, '--', path]).catch(() => ''))
172
- .toString()
173
- .trim();
174
- const worktreeExists = /^[0-9a-f]{7,64}$/iu.test(worktreeOid);
192
+ // v0.3.25 #269 — the working file is read from the filesystem. The previous
193
+ // `hash-object` (without -w) → `cat-file` chain only worked while the edited
194
+ // content happened to match an object git already stored; any real edit made
195
+ // `cat-file` fail the entire conflict response.
196
+ const worktreeBytes = await readWorktreeFile?.(path);
175
197
  const [base, ours, theirs, worktree] = await Promise.all([
176
198
  readVersion('base', stageOids.get(1) ?? null),
177
199
  readVersion('ours', stageOids.get(2) ?? null),
178
200
  readVersion('theirs', stageOids.get(3) ?? null),
179
- worktreeExists ? readVersion('worktree', worktreeOid) : readVersion('worktree', null),
201
+ worktreeBytes
202
+ ? Promise.resolve(versionFromBytes('worktree', null, worktreeBytes))
203
+ : Promise.resolve(absentVersion('worktree')),
180
204
  ]);
181
205
  const hasBase = stageOids.has(1);
182
206
  const hasOurs = stageOids.has(2);
@@ -361,6 +361,11 @@ export interface WebWorkspaceSummaryV1 {
361
361
  readonly available: boolean;
362
362
  readonly sessionCount: number;
363
363
  readonly lastActivatedAt: string;
364
+ /**
365
+ * v0.3.25 #272 — when the workspace was first registered. The navigation's
366
+ * stable default order is derived from this; optional for older Hosts.
367
+ */
368
+ readonly registeredAt?: string;
364
369
  readonly pinnedOrder?: number;
365
370
  }
366
371
  export interface WebWorkspaceProjectSummaryV1 {
@@ -471,6 +476,14 @@ export interface WebBootstrapV1 {
471
476
  readonly workspace: string;
472
477
  readonly configured: boolean;
473
478
  readonly activeSessionId: string | null;
479
+ /**
480
+ * v0.3.24 — the Host's latest durable event cursor at bootstrap time. Clients
481
+ * use it as the replay anchor: durable envelopes at or below this cursor are
482
+ * already reflected by the baseline and must not be re-applied (a reloaded
483
+ * tab otherwise replays pre-baseline `workbench_state` broadcasts that would
484
+ * clear its restored foreground Session). Older clients ignore it.
485
+ */
486
+ readonly eventCursor?: number;
474
487
  readonly settings: WebSettingsSnapshotV1;
475
488
  readonly capabilities: {
476
489
  readonly goal: true;