@gajae-code/coding-agent 0.11.5 → 0.11.7

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 (138) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +67 -7
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +35 -4
  40. package/dist/types/session/yield-queue.d.ts +8 -0
  41. package/dist/types/setup/credential-import.d.ts +5 -1
  42. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  43. package/dist/types/ssh/utils.d.ts +1 -0
  44. package/dist/types/task/discovery.d.ts +3 -1
  45. package/dist/types/tools/fetch.d.ts +5 -8
  46. package/dist/types/tools/image-gen.d.ts +16 -0
  47. package/dist/types/tools/skill-discovery.d.ts +7 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  49. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  50. package/dist/types/web/insane/bridge.d.ts +1 -0
  51. package/dist/types/web/insane/url-guard.d.ts +17 -0
  52. package/package.json +8 -8
  53. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  54. package/src/capability/index.ts +50 -42
  55. package/src/capability/types.ts +3 -0
  56. package/src/cli/setup-cli.ts +10 -4
  57. package/src/cli.ts +34 -9
  58. package/src/commands/daemon.ts +22 -5
  59. package/src/commands/sdk.ts +42 -9
  60. package/src/config/keybindings.ts +1 -1
  61. package/src/config/model-profiles.ts +18 -0
  62. package/src/config/settings-schema.ts +71 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  64. package/src/discovery/ssh.ts +12 -1
  65. package/src/eval/py/kernel.ts +1 -21
  66. package/src/eval/py/runner-artifact.ts +84 -0
  67. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  68. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  69. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  71. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/internal-urls/local-protocol.ts +32 -6
  75. package/src/main.ts +87 -28
  76. package/src/modes/action-registry.ts +1 -1
  77. package/src/modes/components/btw-panel.ts +82 -16
  78. package/src/modes/components/model-selector.ts +31 -3
  79. package/src/modes/components/tool-status-header.ts +6 -1
  80. package/src/modes/controllers/btw-controller.ts +119 -36
  81. package/src/modes/controllers/command-controller.ts +15 -0
  82. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  83. package/src/modes/controllers/input-controller.ts +31 -0
  84. package/src/modes/controllers/selector-controller.ts +106 -12
  85. package/src/modes/interactive-mode.ts +16 -7
  86. package/src/modes/runtime-init.ts +16 -2
  87. package/src/modes/types.ts +1 -0
  88. package/src/prompts/system/btw-user.md +3 -8
  89. package/src/prompts/tools/skill-discovery.md +1 -0
  90. package/src/runtime-mcp/client.ts +41 -55
  91. package/src/runtime-mcp/content-limits.ts +71 -0
  92. package/src/runtime-mcp/json-rpc.ts +23 -9
  93. package/src/runtime-mcp/transports/http.ts +50 -16
  94. package/src/sdk/bus/chat-daemon-control.ts +20 -7
  95. package/src/sdk/bus/index.ts +8 -7
  96. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  97. package/src/sdk/bus/notification-service.ts +41 -18
  98. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  99. package/src/sdk/bus/telegram-daemon-control.ts +26 -8
  100. package/src/sdk/bus/telegram-daemon.ts +1736 -338
  101. package/src/sdk/bus/topic-registry.ts +451 -12
  102. package/src/sdk/client/discovery.ts +19 -3
  103. package/src/sdk/client/liveness.ts +49 -0
  104. package/src/sdk/host/control/dispatch.ts +14 -5
  105. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  106. package/src/sdk/session.ts +29 -5
  107. package/src/sdk/transport/auth-preface.ts +82 -0
  108. package/src/sdk/transport/index.ts +18 -0
  109. package/src/sdk/transport/relay.ts +252 -0
  110. package/src/sdk/transport/serve-cli.ts +105 -0
  111. package/src/sdk/transport/socket.ts +197 -0
  112. package/src/sdk/transport/stdio.ts +17 -0
  113. package/src/session/agent-session.ts +1212 -768
  114. package/src/session/blob-store.ts +19 -6
  115. package/src/session/btw-contract.ts +59 -0
  116. package/src/session/internal/managed-session-scope.ts +313 -57
  117. package/src/session/internal/managed-session-storage.ts +146 -35
  118. package/src/session/session-manager.ts +385 -85
  119. package/src/session/yield-queue.ts +20 -0
  120. package/src/setup/credential-import.ts +28 -3
  121. package/src/setup/provider-onboarding.ts +30 -1
  122. package/src/setup/provider-presets.json +2 -1
  123. package/src/slash-commands/builtin-registry.ts +37 -1
  124. package/src/ssh/utils.ts +14 -0
  125. package/src/task/discovery.ts +8 -2
  126. package/src/tools/ask-answer-registry.ts +15 -1
  127. package/src/tools/ask.ts +1 -1
  128. package/src/tools/fetch.ts +13 -131
  129. package/src/tools/image-gen.ts +301 -20
  130. package/src/tools/read.ts +2 -0
  131. package/src/tools/skill-discovery.ts +39 -3
  132. package/src/tools/sqlite-reader.ts +63 -5
  133. package/src/utils/pasted-image-path.ts +15 -6
  134. package/src/utils/shell-snapshot.ts +173 -63
  135. package/src/web/insane/bridge.ts +5 -1
  136. package/src/web/insane/url-guard.ts +138 -27
  137. package/src/web/scrapers/types.ts +19 -49
  138. package/src/web/scrapers/utils.ts +12 -26
@@ -18,7 +18,6 @@ import * as native from "@gajae-code/natives";
18
18
  import { getTerminalId } from "@gajae-code/tui";
19
19
  import {
20
20
  getBlobsDir,
21
- getAgentDir as getDefaultAgentDir,
22
21
  getProjectDir,
23
22
  getSessionsDir,
24
23
  getTerminalSessionsDir,
@@ -58,6 +57,7 @@ import {
58
57
  canonicalizeTrustedPath,
59
58
  deleteManagedSessionCandidate,
60
59
  listManagedCandidates,
60
+ type ManagedCandidateWriteAuthority,
61
61
  type ManagedMigrationPolicy,
62
62
  type ManagedScope,
63
63
  managedDirectoryAuthorityForScope,
@@ -65,11 +65,14 @@ import {
65
65
  openManagedCandidateForWrite,
66
66
  prepareManagedSessionScopeForWriteSync,
67
67
  resolveManagedScope,
68
+ resolveManagedScopeForWrite,
68
69
  } from "./internal/managed-session-scope";
69
70
  import {
71
+ assertManagedDirectoryRoot,
70
72
  type ManagedDirectoryRoot,
71
73
  type ManagedFileSnapshot,
72
74
  ManagedSessionDescendantStore,
75
+ type ManagedSessionSecurityPolicy,
73
76
  retainManagedDirectoryAuthority,
74
77
  } from "./internal/managed-session-storage";
75
78
 
@@ -853,6 +856,32 @@ function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | un
853
856
  return path.resolve(sessionDir) === path.resolve(resolved.scope.directoryPath) ? sessionsRoot : undefined;
854
857
  }
855
858
 
859
+ /**
860
+ * Resolve `target` through the real path of its nearest existing ancestor,
861
+ * re-appending any not-yet-created trailing segments. Symlink/reparse components
862
+ * in the existing prefix are canonicalized (so aliases cannot escape a containment
863
+ * check or alias another file), while a missing leaf (e.g. an uncommitted session
864
+ * transcript) is still resolved deterministically. Non-ENOENT errors (ELOOP,
865
+ * EACCES, ...) surface rather than silently falling back to the lexical path.
866
+ */
867
+ function canonicalizeThroughExistingAncestor(target: string): string {
868
+ const resolved = path.resolve(target);
869
+ const tail: string[] = [];
870
+ let current = resolved;
871
+ while (true) {
872
+ try {
873
+ const real = fs.realpathSync(current);
874
+ return tail.length === 0 ? real : path.join(real, ...tail.reverse());
875
+ } catch (err) {
876
+ if (!isEnoent(err)) throw err;
877
+ const parent = path.dirname(current);
878
+ if (parent === current) return resolved;
879
+ tail.push(path.basename(current));
880
+ current = parent;
881
+ }
882
+ }
883
+ }
884
+
856
885
  function isSupportedSessionVersion(version: unknown): boolean {
857
886
  return (
858
887
  version === undefined ||
@@ -1298,9 +1327,12 @@ function computeDefaultSessionDir(
1298
1327
  sessionsRoot: string = getSessionsDir(),
1299
1328
  ): string {
1300
1329
  if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1301
- const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
1330
+ const resolved = resolveManagedScopeForWrite({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
1302
1331
  if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1303
- const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
1332
+ const prepared = prepareManagedSessionScopeForWriteSync(
1333
+ resolved.scope,
1334
+ process.platform === "win32" ? "windows-existing-verify-first" : "default",
1335
+ );
1304
1336
  if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1305
1337
  return prepared.scope.directoryPath;
1306
1338
  }
@@ -1382,6 +1414,13 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
1382
1414
  );
1383
1415
  }
1384
1416
  const trustedSessionDestinations = new WeakSet<SessionDestination>();
1417
+ const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
1418
+
1419
+ function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
1420
+ const policy = managedSecurityPolicies.get(securityContext);
1421
+ if (!policy) throw new Error("Managed session security policy unavailable");
1422
+ return policy;
1423
+ }
1385
1424
 
1386
1425
  function managedStoreFromContext(
1387
1426
  securityContext: ManagedSessionSecurityContext,
@@ -1393,6 +1432,7 @@ function managedStoreFromContext(
1393
1432
  securityContext.retainedAuthority
1394
1433
  ? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
1395
1434
  : undefined,
1435
+ managedSecurityPolicyForContext(securityContext),
1396
1436
  );
1397
1437
  }
1398
1438
 
@@ -1405,6 +1445,10 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1405
1445
  rootAuthority,
1406
1446
  retainedAuthority: managedDirectoryAuthorityForScope(scope),
1407
1447
  });
1448
+ managedSecurityPolicies.set(
1449
+ securityContext,
1450
+ scope.platform === "win32" ? "windows-existing-verify-first" : "default",
1451
+ );
1408
1452
  const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
1409
1453
  trustedSessionDestinations.add(destination);
1410
1454
  return destination;
@@ -1419,9 +1463,16 @@ function explicitDestination(directory: string): SessionDestination {
1419
1463
  function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
1420
1464
  if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1421
1465
  const sessionsRoot = getSessionsDir(agentDir);
1422
- const resolved = resolveManagedScope({ cwd, agentDir: agentDir ?? path.resolve(sessionsRoot, ".."), sessionsRoot });
1466
+ const resolved = resolveManagedScopeForWrite({
1467
+ cwd,
1468
+ agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
1469
+ sessionsRoot,
1470
+ });
1423
1471
  if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1424
- const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
1472
+ const prepared = prepareManagedSessionScopeForWriteSync(
1473
+ resolved.scope,
1474
+ process.platform === "win32" ? "windows-existing-verify-first" : "default",
1475
+ );
1425
1476
  if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1426
1477
  return freezeManagedDestination(prepared.scope);
1427
1478
  }
@@ -1788,12 +1839,46 @@ function inspectResumeSessionFile(
1788
1839
  mtimeNs: snapshot.mtimeNs,
1789
1840
  sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
1790
1841
  };
1791
- return { identity, content: Uint8Array.from(bytes), entries, context, migrationApplied };
1842
+ return { identity, content: bytes, entries, context, migrationApplied };
1792
1843
  } catch {
1793
1844
  return { kind: "error", reason: "malformed" };
1794
1845
  }
1795
1846
  }
1796
1847
 
1848
+ /** Revalidate previously parsed authority without another decode, parse, migration, or context build. */
1849
+ function revalidateResumeSessionIdentity(
1850
+ filePath: string,
1851
+ storage: SessionStorage,
1852
+ expected: ResumeSessionIdentity,
1853
+ ): StrictSessionOpenFailure | { kind: "valid" } {
1854
+ const canonicalPath = resolveEquivalentPath(path.resolve(filePath));
1855
+ let before: SessionStorageStat;
1856
+ let snapshot: SessionStorageSnapshot;
1857
+ let after: SessionStorageStat;
1858
+ try {
1859
+ before = storage.statSync(canonicalPath);
1860
+ if (!before.isFile || !storage.readSnapshotSync) return { kind: "error", reason: "read-failed" };
1861
+ snapshot = storage.readSnapshotSync(canonicalPath);
1862
+ after = storage.statSync(canonicalPath);
1863
+ } catch (error) {
1864
+ return resumeReadFailure(error, storage, canonicalPath);
1865
+ }
1866
+ if (!sameResumeStat(before, snapshot.stat) || !sameResumeStat(snapshot.stat, after)) {
1867
+ return { kind: "error", reason: "unstable" };
1868
+ }
1869
+ const observed: ResumeSessionIdentity = {
1870
+ canonicalPath,
1871
+ sessionId: expected.sessionId,
1872
+ dev: snapshot.stat.dev,
1873
+ ino: snapshot.stat.ino,
1874
+ size: snapshot.stat.size,
1875
+ mtimeMs: snapshot.stat.mtimeMs,
1876
+ mtimeNs: snapshot.stat.mtimeNs,
1877
+ sha256: crypto.createHash("sha256").update(snapshot.bytes).digest("hex"),
1878
+ };
1879
+ return sameResumeIdentity(expected, observed) ? { kind: "valid" } : { kind: "error", reason: "identity-mismatch" };
1880
+ }
1881
+
1797
1882
  /**
1798
1883
  * Convert legacy persisted blob references in loaded entries into resident sentinels.
1799
1884
  * Images then materialize lazily at provider/display/export chokepoints instead of
@@ -3841,9 +3926,11 @@ export async function resolveResumableSession(
3841
3926
  cwd: string,
3842
3927
  sessionDir?: string,
3843
3928
  storage: SessionStorage = new FileSessionStorage(),
3929
+ managedAgentDir?: string,
3844
3930
  ): Promise<ResolvedSessionMatch | undefined> {
3845
- const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
3846
- const localSessions = await SessionManager.list(cwd, localSessionDir, storage);
3931
+ const localSessions = sessionDir
3932
+ ? await SessionManager.list(cwd, sessionDir, storage)
3933
+ : await SessionManager.listManagedForResumePickerReadOnly(cwd, managedAgentDir, storage);
3847
3934
  const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
3848
3935
  if (localMatch) {
3849
3936
  return { session: localMatch, scope: "local" };
@@ -3853,7 +3940,7 @@ export async function resolveResumableSession(
3853
3940
  return undefined;
3854
3941
  }
3855
3942
 
3856
- const globalSessions = await SessionManager.listAll(storage);
3943
+ const globalSessions = await SessionManager.listAll(storage, managedAgentDir);
3857
3944
  const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
3858
3945
  if (!globalMatch) {
3859
3946
  return undefined;
@@ -3885,6 +3972,7 @@ export class SessionManager {
3885
3972
  #ensuredOnDisk: boolean = false;
3886
3973
  #recoveryHydrationContext: RecoveryHydrationContext | undefined;
3887
3974
  #fileEntries: FileEntry[] = [];
3975
+ #pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
3888
3976
  #byId: Map<string, SessionEntry> = new Map();
3889
3977
  #labelsById: Map<string, string> = new Map();
3890
3978
  #leafId: string | null = null;
@@ -4131,15 +4219,16 @@ export class SessionManager {
4131
4219
  }
4132
4220
 
4133
4221
  async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
4134
- const ownedEntries = structuredClone(entries) as FileEntry[];
4135
- const header = ownedEntries[0] as SessionHeader;
4222
+ // Strict inspection creates these entries solely for this hydration path. Adopt that
4223
+ // final, validated representation instead of cloning the complete transcript again.
4224
+ const header = entries[0] as SessionHeader;
4136
4225
  this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4137
4226
  this.#sessionId = header.id;
4138
4227
  this.#sessionName = header.title;
4139
4228
  this.#titleSource = header.titleSource;
4140
4229
  this.#needsFullRewriteOnNextPersist = migrationApplied;
4141
- await resolveBlobRefsInEntries(ownedEntries, this.#blobStore);
4142
- this.#fileEntries = ownedEntries;
4230
+ await resolveBlobRefsInEntries(entries, this.#blobStore);
4231
+ this.#fileEntries = entries;
4143
4232
  this.#resetResidentTextBlobStore();
4144
4233
  this.#fileEntries = this.#fileEntries.map(entry =>
4145
4234
  prepareEntryForResidentSync(entry, this.#residentBlobStores()),
@@ -4158,12 +4247,25 @@ export class SessionManager {
4158
4247
 
4159
4248
  /** Switch to a different session file (used for resume and branching) */
4160
4249
  async setSessionFile(sessionFile: string): Promise<void> {
4250
+ const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4251
+ const strictAdoption = this.#pendingStrictAdoption;
4252
+ this.#pendingStrictAdoption = undefined;
4253
+ if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
4254
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
4255
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4256
+ throw new Error("Prepared managed session changed before strict adoption.");
4257
+ }
4161
4258
  await this.#closePersistWriter();
4162
4259
  this.#persistError = undefined;
4163
4260
  this.#persistErrorReported = false;
4164
- this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
4261
+ this.#sessionFile = resolvedSessionFile;
4165
4262
  writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
4166
4263
  this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
4264
+ if (strictAdoption) {
4265
+ const inspected = inspectResumeSessionFile(this.#sessionFile, this.storage);
4266
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
4267
+ throw new Error("Prepared managed session changed during strict adoption.");
4268
+ }
4167
4269
  if (this.#fileEntries.length > 0) {
4168
4270
  const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
4169
4271
  this.#sessionId = header?.id ?? createSessionId();
@@ -4239,6 +4341,46 @@ export class SessionManager {
4239
4341
  }
4240
4342
  }
4241
4343
 
4344
+ /**
4345
+ * Exact-delete a session transcript and its artifacts by path WITHOUT requiring
4346
+ * managed logical authorization. This is only for discarding an UNCOMMITTED
4347
+ * successor that a transaction (e.g. handoff) created via `newSession()` but
4348
+ * never durably authorized — such a session has no managed candidate listing
4349
+ * yet, so `dropSession` would refuse it and leak its resident-cache/artifact
4350
+ * root. Bounded to this manager's configured session root and refuses to touch
4351
+ * the active session; tolerates missing files.
4352
+ */
4353
+ async discardUncommittedSession(sessionPath: string): Promise<void> {
4354
+ // Close the writer FIRST so the canonical checks and the delete run with no
4355
+ // intervening await, eliminating the check/use window in which a parent
4356
+ // directory could be swapped for a symlink between authorization and deletion.
4357
+ await this.#closePersistWriter();
4358
+ // Canonicalize the candidate (and the active file / configured dir) through
4359
+ // their nearest existing ancestor so symlink/reparse components cannot alias
4360
+ // the active session or escape the configured session directory, even when
4361
+ // the successor transcript itself does not yet exist.
4362
+ const canonicalCandidate = canonicalizeThroughExistingAncestor(path.resolve(sessionPath));
4363
+ if (this.#sessionFile) {
4364
+ const canonicalActive = canonicalizeThroughExistingAncestor(path.resolve(this.#sessionFile));
4365
+ if (canonicalActive === canonicalCandidate) {
4366
+ throw new Error("Refusing to discard the active session as uncommitted.");
4367
+ }
4368
+ }
4369
+ // Least-authority containment: only within this manager's configured session
4370
+ // directory (canonical), where newSession() allocates the successor. The
4371
+ // broader managed root (parent of sessionDir) is deliberately NOT accepted.
4372
+ const canonicalSessionDir = canonicalizeThroughExistingAncestor(path.resolve(this.sessionDir));
4373
+ if (!pathIsWithin(canonicalSessionDir, canonicalCandidate)) {
4374
+ throw new Error("Uncommitted session discard is limited to this manager's configured session directory.");
4375
+ }
4376
+ try {
4377
+ await this.storage.deleteSessionWithArtifacts(canonicalCandidate);
4378
+ } catch (err) {
4379
+ if (isEnoent(err)) return;
4380
+ throw err;
4381
+ }
4382
+ }
4383
+
4242
4384
  /**
4243
4385
  * Fork the current session, creating a new session file with the same entries.
4244
4386
  * Returns both the old and new session file paths for artifact copying.
@@ -4467,17 +4609,14 @@ export class SessionManager {
4467
4609
  const previousDestination = this.destination;
4468
4610
  const previousArtifactDir = previousSessionFile?.slice(0, -6);
4469
4611
 
4470
- const nextDestination = (() => {
4471
- if (this.destination.kind === "managed") {
4472
- return managedDestination(resolvedCwd, this.storage, this.destination.securityContext.agentDir);
4473
- }
4474
- if (!(this.storage instanceof FileSessionStorage)) return this.destination;
4475
- const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
4476
- const directory = managedSessionsRoot
4477
- ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
4478
- : computeDefaultSessionDir(resolvedCwd, this.storage);
4479
- return explicitDestination(directory);
4480
- })();
4612
+ const nextDestination =
4613
+ this.storage instanceof FileSessionStorage
4614
+ ? managedDestination(
4615
+ resolvedCwd,
4616
+ this.storage,
4617
+ this.destination.kind === "managed" ? this.destination.securityContext.agentDir : undefined,
4618
+ )
4619
+ : this.destination;
4481
4620
  const newSessionDir = nextDestination.directory;
4482
4621
  let hadSessionFile = false;
4483
4622
  const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
@@ -5457,6 +5596,17 @@ export class SessionManager {
5457
5596
  return this.sessionDir;
5458
5597
  }
5459
5598
 
5599
+ /** Lists picker candidates within this manager's captured destination authority. */
5600
+ async listForResumePickerReadOnly(): Promise<SessionInfo[]> {
5601
+ return this.destination.kind === "managed"
5602
+ ? await SessionManager.listManagedForResumePickerReadOnly(
5603
+ this.cwd,
5604
+ this.destination.securityContext.agentDir,
5605
+ this.storage,
5606
+ )
5607
+ : await SessionManager.listForResumePickerReadOnly(this.cwd, this.destination.directory, this.storage);
5608
+ }
5609
+
5460
5610
  getSessionId(): string {
5461
5611
  return this.#sessionId;
5462
5612
  }
@@ -5486,13 +5636,22 @@ export class SessionManager {
5486
5636
  getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
5487
5637
  if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
5488
5638
  const store = this.#managedTranscriptStore();
5639
+ const legacyArtifactsRoot = path.basename(this.#sessionFile.slice(0, -6));
5640
+ const legacyLocalRoot = path.posix.join(legacyArtifactsRoot, "local");
5489
5641
  return {
5490
5642
  capture: async () => {
5491
5643
  let snapshot: native.NativeDirectoryTreeSnapshot;
5492
5644
  try {
5493
- snapshot = store.captureTree("local");
5645
+ snapshot = store.captureTree(legacyLocalRoot);
5494
5646
  } catch (error) {
5495
5647
  if (error instanceof Error && error.message === "not_found") return null;
5648
+ if (error instanceof Error && error.message === "reparse_point") {
5649
+ try {
5650
+ store.captureTree(legacyArtifactsRoot);
5651
+ } catch (artifactsError) {
5652
+ if (artifactsError instanceof Error && artifactsError.message === "not_found") return null;
5653
+ }
5654
+ }
5496
5655
  throw error;
5497
5656
  }
5498
5657
  let totalBytes = 0;
@@ -5500,7 +5659,7 @@ export class SessionManager {
5500
5659
  if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
5501
5660
  const entries = snapshot.entries.map(entry => {
5502
5661
  if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
5503
- const captured = store.readExpected(path.posix.join("local", entry.relativePath));
5662
+ const captured = store.readExpected(path.posix.join(legacyLocalRoot, entry.relativePath));
5504
5663
  if (
5505
5664
  !captured ||
5506
5665
  captured.identity.dev.toString() !== entry.dev ||
@@ -5516,12 +5675,12 @@ export class SessionManager {
5516
5675
  sha256: entry.sha256,
5517
5676
  };
5518
5677
  });
5519
- const verified = store.captureTree("local");
5678
+ const verified = store.captureTree(legacyLocalRoot);
5520
5679
  if (JSON.stringify(verified) !== JSON.stringify(snapshot))
5521
5680
  throw new Error("Legacy local:// migration source changed during capture");
5522
5681
  return { snapshot, entries };
5523
5682
  },
5524
- retire: snapshot => store.removeTreeExpected("local", snapshot),
5683
+ retire: snapshot => store.removeTreeExpected(legacyLocalRoot, snapshot),
5525
5684
  };
5526
5685
  }
5527
5686
 
@@ -7014,6 +7173,12 @@ export class SessionManager {
7014
7173
  authority instanceof ManagedSessionDescendantStore
7015
7174
  ? authority.retainAuthority()
7016
7175
  : retainManagedDirectoryAuthority(rootAuthority, directory);
7176
+ const policy =
7177
+ authority instanceof ManagedSessionDescendantStore
7178
+ ? authority.securityPolicy
7179
+ : process.platform === "win32"
7180
+ ? "windows-existing-verify-first"
7181
+ : "default";
7017
7182
  const securityContext = createManagedSessionSecurityContext({
7018
7183
  agentDir: rootAuthority.canonicalPath,
7019
7184
  sessionsRoot: rootAuthority.canonicalPath,
@@ -7021,6 +7186,7 @@ export class SessionManager {
7021
7186
  rootAuthority,
7022
7187
  retainedAuthority,
7023
7188
  });
7189
+ managedSecurityPolicies.set(securityContext, policy);
7024
7190
  const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
7025
7191
  trustedSessionDestinations.add(destination);
7026
7192
  return destination;
@@ -7049,20 +7215,59 @@ export class SessionManager {
7049
7215
  return manager;
7050
7216
  }
7051
7217
 
7218
+ /** Prepare a selected candidate using this manager's captured managed destination authority. */
7219
+ async prepareManagedCandidateForWrite(
7220
+ filePath: string,
7221
+ migrationPolicy: SessionDirectoryMigrationPolicy,
7222
+ expectedIdentity?: ResumeSessionIdentity,
7223
+ ): Promise<string> {
7224
+ return SessionManager.prepareManagedCandidateForWrite(
7225
+ filePath,
7226
+ migrationPolicy,
7227
+ this.destination,
7228
+ expectedIdentity,
7229
+ );
7230
+ }
7231
+
7232
+ /** Prepare a managed candidate and retain its exact post-preparation identity for the next adoption. */
7233
+ async prepareManagedCandidateForStrictAdoption(
7234
+ filePath: string,
7235
+ migrationPolicy: SessionDirectoryMigrationPolicy,
7236
+ expectedIdentity: ResumeSessionIdentity,
7237
+ ): Promise<string> {
7238
+ const preparedPath = await this.prepareManagedCandidateForWrite(filePath, migrationPolicy, expectedIdentity);
7239
+ const inspected = inspectResumeSessionFile(preparedPath, this.storage);
7240
+ if ("kind" in inspected) throw new Error(`Could not inspect prepared managed session: ${inspected.reason}`);
7241
+ this.#pendingStrictAdoption = { canonicalPath: inspected.identity.canonicalPath, identity: inspected.identity };
7242
+ return preparedPath;
7243
+ }
7244
+
7052
7245
  /** Resolve a default-managed candidate through binding validation and copy-retain migration before mutation. */
7053
7246
  static async prepareManagedCandidateForWrite(
7054
7247
  filePath: string,
7055
7248
  migrationPolicy: SessionDirectoryMigrationPolicy,
7249
+ destination: SessionDestination,
7250
+ expectedIdentity?: ResumeSessionIdentity,
7056
7251
  ): Promise<string> {
7252
+ if (!trustedSessionDestinations.has(destination) || destination.kind !== "managed")
7253
+ throw new Error("Managed candidate preparation requires a trusted managed destination authority");
7057
7254
  const storage = new FileSessionStorage();
7255
+ const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
7256
+ const assertManagedDestinationBound = (): void => {
7257
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
7258
+ managedDestinationStore.assertBound();
7259
+ };
7260
+ assertManagedDestinationBound();
7058
7261
  const inspected = inspectResumeSessionFile(filePath, storage);
7059
7262
  if ("kind" in inspected) {
7060
7263
  if (inspected.reason === "missing") return filePath;
7061
7264
  throw new Error(`Could not inspect managed session: ${inspected.reason}`);
7062
7265
  }
7266
+ if (expectedIdentity && !sameResumeIdentity(expectedIdentity, inspected.identity))
7267
+ throw new Error("Managed session changed before migration authority was adopted.");
7063
7268
  const header = inspected.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
7064
7269
  if (!header?.cwd) return filePath;
7065
- const sessionsRoot = getSessionsDir();
7270
+ const sessionsRoot = destination.securityContext.sessionsRoot;
7066
7271
  const resolvedPath = path.resolve(filePath);
7067
7272
  const relativeToManagedRoot = path.relative(sessionsRoot, resolvedPath);
7068
7273
  const isManagedPath =
@@ -7070,14 +7275,17 @@ export class SessionManager {
7070
7275
  (!relativeToManagedRoot.startsWith("..") && !path.isAbsolute(relativeToManagedRoot));
7071
7276
 
7072
7277
  if (!isManagedPath) return filePath;
7278
+ assertManagedDestinationBound();
7073
7279
  const resolved = resolveManagedScope({
7074
7280
  cwd: header.cwd,
7075
- agentDir: path.resolve(sessionsRoot, ".."),
7281
+ agentDir: destination.securityContext.agentDir,
7076
7282
  sessionsRoot,
7077
7283
  });
7078
7284
  if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
7079
7285
 
7286
+ assertManagedDestinationBound();
7080
7287
  const listing = listManagedCandidates(resolved.scope);
7288
+ assertManagedDestinationBound();
7081
7289
  if (listing.kind !== "complete") throw new Error(`Managed session scan failed: ${listing.message}`);
7082
7290
 
7083
7291
  const candidate = listing.owned.find(item => path.resolve(item.path) === resolvedPath);
@@ -7094,7 +7302,24 @@ export class SessionManager {
7094
7302
  candidate.identity.sha256 !== inspected.identity.sha256
7095
7303
  )
7096
7304
  throw new Error("Managed session changed before migration authority was adopted.");
7097
- const opened = await openManagedCandidateForWrite(resolved.scope, candidate, migrationPolicy);
7305
+ assertManagedDestinationBound();
7306
+ const authority: ManagedCandidateWriteAuthority = {
7307
+ rootAuthority: destination.securityContext.rootAuthority,
7308
+ ...(path.resolve(destination.directory) === path.resolve(resolved.scope.directoryPath)
7309
+ ? {
7310
+ retainedAuthority: destination.securityContext.retainedAuthority,
7311
+ retainedDirectory: destination.directory,
7312
+ }
7313
+ : {}),
7314
+ };
7315
+ const opened = await openManagedCandidateForWrite(
7316
+ resolved.scope,
7317
+ candidate,
7318
+ expectedIdentity ?? inspected.identity,
7319
+ migrationPolicy,
7320
+ authority,
7321
+ );
7322
+ assertManagedDestinationBound();
7098
7323
  if (opened.kind === "error") {
7099
7324
  if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
7100
7325
  throw new Error(`Could not open managed session: ${opened.message}`);
@@ -7116,7 +7341,7 @@ export class SessionManager {
7116
7341
  const destination = destinationFor(cwd, destinationInput, storage);
7117
7342
  const managedSourcePath =
7118
7343
  destination.kind === "managed" && storage instanceof FileSessionStorage
7119
- ? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
7344
+ ? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy, destination)
7120
7345
  : sourcePath;
7121
7346
  const dir = destination.directory;
7122
7347
  const manager = new SessionManager(cwd, dir, true, storage, destination);
@@ -7180,10 +7405,9 @@ export class SessionManager {
7180
7405
  return manager;
7181
7406
  }
7182
7407
 
7183
- const managedPath = await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy);
7184
- const inspected = inspectResumeSessionFile(managedPath, storage);
7408
+ const inspected = inspectResumeSessionFile(filePath, storage);
7185
7409
  if ("kind" in inspected) {
7186
- if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
7410
+ if (inspected.reason === "missing") {
7187
7411
  const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
7188
7412
  await manager.#initSessionFile(filePath);
7189
7413
  return manager;
@@ -7251,46 +7475,47 @@ export class SessionManager {
7251
7475
  return manager;
7252
7476
  }
7253
7477
  /**
7254
- * List sessions for the resume picker without recovery or other maintenance writes.
7478
+ * List default-managed sessions for the resume picker without recovery or other
7479
+ * maintenance writes. This is the only picker inventory that includes legacy
7480
+ * sibling directories.
7481
+ */
7482
+ static async listManagedForResumePickerReadOnly(
7483
+ cwd: string,
7484
+ managedAgentDir?: string,
7485
+ storage: SessionStorage = new FileSessionStorage(),
7486
+ ): Promise<SessionInfo[]> {
7487
+ if (!(storage instanceof FileSessionStorage)) return [];
7488
+ const sessionsRoot = getSessionsDir(managedAgentDir);
7489
+ const resolved = resolveManagedScope({
7490
+ cwd,
7491
+ agentDir: managedAgentDir ?? path.resolve(sessionsRoot, ".."),
7492
+ sessionsRoot,
7493
+ });
7494
+ if (resolved.kind === "error") return [];
7495
+ const listing = listManagedCandidates(resolved.scope);
7496
+ if (listing.kind === "error") return [];
7497
+ return await collectSessionsFromFiles(
7498
+ listing.owned.map(candidate => candidate.path),
7499
+ storage,
7500
+ );
7501
+ }
7502
+
7503
+ /**
7504
+ * List sessions from an explicitly supplied picker directory without recovery
7505
+ * or other maintenance writes. Unlike managed inventory, this never scans
7506
+ * legacy sibling directories.
7255
7507
  */
7256
7508
  static async listForResumePickerReadOnly(
7257
7509
  cwd: string,
7258
7510
  sessionDir?: string,
7259
7511
  storage: SessionStorage = new FileSessionStorage(),
7260
7512
  ): Promise<SessionInfo[]> {
7261
- if (!sessionDir && !(storage instanceof FileSessionStorage)) return [];
7262
- if (sessionDir && !resolveManagedSessionRoot(sessionDir, cwd)) {
7263
- try {
7264
- return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
7265
- } catch {
7266
- return [];
7267
- }
7268
- }
7269
- const sessionsRoot = sessionDir
7270
- ? (resolveManagedSessionRoot(sessionDir, cwd) ?? getSessionsDir())
7271
- : getSessionsDir();
7272
- const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
7273
- if (resolved.kind === "error") return [];
7274
- if (storage instanceof FileSessionStorage) {
7275
- const listing = listManagedCandidates(resolved.scope);
7276
- if (listing.kind === "error") return [];
7277
- return await collectSessionsFromFiles(
7278
- listing.owned.map(candidate => candidate.path),
7279
- storage,
7280
- );
7281
- }
7282
- const legacyDirectory = `--${resolved.scope.canonicalCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
7283
- const files = new Set<string>();
7513
+ if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
7284
7514
  try {
7285
- for (const directory of [resolved.scope.directoryPath, path.join(sessionsRoot, legacyDirectory)]) {
7286
- for (const file of storage.listFilesSync(directory, "*.jsonl")) files.add(file);
7287
- }
7515
+ return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
7288
7516
  } catch {
7289
7517
  return [];
7290
7518
  }
7291
- return (await collectSessionsFromFiles([...files], storage)).filter(
7292
- session => resolveEquivalentPath(session.cwd) === resolved.scope.canonicalCwd,
7293
- );
7294
7519
  }
7295
7520
 
7296
7521
  /** Tombstone and exact-delete a default-managed candidate. */
@@ -7592,36 +7817,39 @@ export class SessionManager {
7592
7817
  let sessionPath = identity.canonicalPath;
7593
7818
  if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
7594
7819
  try {
7595
- sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
7820
+ sessionPath = await SessionManager.prepareManagedCandidateForWrite(
7821
+ sessionPath,
7822
+ migrationPolicy,
7823
+ destination,
7824
+ identity,
7825
+ );
7596
7826
  } catch (error) {
7597
7827
  if (error instanceof SessionMigrationPolicyError)
7598
7828
  return { kind: "error", reason: "legacy_migration_disabled" };
7829
+ if (
7830
+ error instanceof Error &&
7831
+ (error.message.includes("source_changed") || error.message.includes("changed before migration"))
7832
+ )
7833
+ return { kind: "error", reason: "identity-mismatch" };
7599
7834
  throw error;
7600
7835
  }
7601
7836
  }
7602
7837
  const inspected = inspectResumeSessionFile(sessionPath, storage);
7603
7838
  if ("kind" in inspected) return inspected;
7604
- if (
7605
- (sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) ||
7606
- (sessionPath !== identity.canonicalPath &&
7607
- (identity.sessionId !== inspected.identity.sessionId || identity.sha256 !== inspected.identity.sha256))
7608
- ) {
7839
+ if (sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) {
7609
7840
  return { kind: "error", reason: "identity-mismatch" };
7610
7841
  }
7842
+
7611
7843
  const entries = inspected.entries;
7612
7844
  const header = entries[0] as SessionHeader;
7613
7845
  const dir = destination.directory;
7614
7846
  const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
7615
7847
  await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
7616
- const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
7617
- if ("kind" in ownershipInspection) {
7848
+ const ownershipInspection = revalidateResumeSessionIdentity(sessionPath, storage, inspected.identity);
7849
+ if (ownershipInspection.kind === "error") {
7618
7850
  await manager.close();
7619
7851
  return ownershipInspection;
7620
7852
  }
7621
- if (!sameResumeIdentity(inspected.identity, ownershipInspection.identity)) {
7622
- await manager.close();
7623
- return { kind: "error", reason: "identity-mismatch" };
7624
- }
7625
7853
  try {
7626
7854
  await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7627
7855
  writeTerminalBreadcrumb(manager.cwd, sessionPath);
@@ -7665,9 +7893,20 @@ export class SessionManager {
7665
7893
  const managedCandidates =
7666
7894
  destination.kind === "explicit"
7667
7895
  ? []
7668
- : await SessionManager.listForResumePickerReadOnly(cwd, destination.directory, storage);
7896
+ : await SessionManager.listManagedForResumePickerReadOnly(
7897
+ cwd,
7898
+ destination.securityContext.agentDir,
7899
+ storage,
7900
+ );
7901
+
7669
7902
  const terminalSession = await readTerminalBreadcrumb(cwd);
7670
- if (terminalSession) {
7903
+ const terminalSessionIsInExplicitRoot = (() => {
7904
+ if (destination.kind === "managed" || !terminalSession) return destination.kind === "managed";
7905
+ const canonicalRoot = canonicalizeTrustedPath(destination.directory);
7906
+ const canonicalSessionPath = canonicalizeTrustedPath(terminalSession);
7907
+ return pathIsWithin(canonicalRoot, canonicalSessionPath);
7908
+ })();
7909
+ if (terminalSession && terminalSessionIsInExplicitRoot) {
7671
7910
  const opened = await openSelectedStrictly(terminalSession);
7672
7911
  if (opened) return opened;
7673
7912
  }
@@ -7705,7 +7944,7 @@ export class SessionManager {
7705
7944
  sessionDir?: string,
7706
7945
  storage: SessionStorage = new FileSessionStorage(),
7707
7946
  ): Promise<SessionInfo[]> {
7708
- if (!sessionDir) return await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
7947
+ if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
7709
7948
  try {
7710
7949
  await recoverOrphanedBackups(sessionDir, storage);
7711
7950
  return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
@@ -7717,9 +7956,12 @@ export class SessionManager {
7717
7956
  /**
7718
7957
  * List all sessions across all project directories.
7719
7958
  */
7720
- static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
7959
+ static async listAll(
7960
+ storage: SessionStorage = new FileSessionStorage(),
7961
+ managedAgentDir?: string,
7962
+ ): Promise<SessionInfo[]> {
7721
7963
  if (!(storage instanceof FileSessionStorage)) return [];
7722
- const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
7964
+ const sessionsRoot = getSessionsDir(managedAgentDir);
7723
7965
  try {
7724
7966
  const directories = await fs.promises.readdir(sessionsRoot, { withFileTypes: true });
7725
7967
  const seedFiles = directories
@@ -7755,6 +7997,19 @@ export class SessionManager {
7755
7997
  return [];
7756
7998
  }
7757
7999
  }
8000
+ /**
8001
+ * Strict inventory bound to this manager's captured session authority. Managed
8002
+ * managers include authorized legacy and current candidates; explicit managers
8003
+ * authorize only their exact directory.
8004
+ */
8005
+ inventorySessionsStrict(): StrictInventoryResult {
8006
+ return SessionManager.inventorySessionsStrict(this.cwd, {
8007
+ sessionDir: this.destination.kind === "explicit" ? this.destination.directory : undefined,
8008
+ storage: this.storage,
8009
+ destination: this.destination,
8010
+ });
8011
+ }
8012
+
7758
8013
  /**
7759
8014
  * Strict raw scoped inventory for ACP authorization. Enumerates the scoped
7760
8015
  * session directory without suppressing any root/scan/lstat/read/parse/stat/
@@ -7764,10 +8019,55 @@ export class SessionManager {
7764
8019
  */
7765
8020
  static inventorySessionsStrict(
7766
8021
  cwd: string,
7767
- options?: { sessionDir?: string; storage?: SessionStorage },
8022
+ options?: { sessionDir?: string; storage?: SessionStorage; destination?: SessionDestination },
7768
8023
  ): StrictInventoryResult {
7769
8024
  const storage = options?.storage ?? new FileSessionStorage();
7770
- const dir = options?.sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
8025
+ const destination = options?.destination;
8026
+ const dir =
8027
+ options?.sessionDir ?? destination?.directory ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
8028
+ if (destination?.kind === "managed") {
8029
+ if (!trustedSessionDestinations.has(destination) || !(storage instanceof FileSessionStorage)) {
8030
+ return {
8031
+ kind: "failure",
8032
+ failures: [{ kind: "scan", message: "Managed strict inventory authority is unavailable", path: dir }],
8033
+ };
8034
+ }
8035
+ try {
8036
+ const store = managedStoreFromContext(destination.securityContext, destination.directory);
8037
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
8038
+ store.assertBound();
8039
+ const resolved = resolveManagedScope({
8040
+ cwd,
8041
+ agentDir: destination.securityContext.agentDir,
8042
+ sessionsRoot: destination.securityContext.sessionsRoot,
8043
+ });
8044
+ if (resolved.kind === "error")
8045
+ return { kind: "failure", failures: [{ kind: "root", message: resolved.message, path: dir }] };
8046
+ const listing = listManagedCandidates(resolved.scope);
8047
+ if (listing.kind === "error")
8048
+ return { kind: "failure", failures: [{ kind: "scan", message: listing.message, path: dir }] };
8049
+ const failures: StrictInventoryFailure[] = [];
8050
+ const candidates: StrictInventoryCandidate[] = [];
8051
+ for (const managedCandidate of listing.owned) {
8052
+ const candidate = inventoryReadCandidate(
8053
+ storage,
8054
+ managedCandidate.path,
8055
+ cwd,
8056
+ path.dirname(managedCandidate.path),
8057
+ );
8058
+ if ("failures" in candidate) failures.push(...candidate.failures);
8059
+ else candidates.push(candidate.candidate);
8060
+ }
8061
+ assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
8062
+ store.assertBound();
8063
+ return failures.length > 0 ? { kind: "failure", failures } : { kind: "complete", candidates };
8064
+ } catch {
8065
+ return {
8066
+ kind: "failure",
8067
+ failures: [{ kind: "root", message: "Managed strict inventory authority changed", path: dir }],
8068
+ };
8069
+ }
8070
+ }
7771
8071
  if (!storage.listFilesStrictSync) {
7772
8072
  return {
7773
8073
  kind: "failure",