@gajae-code/coding-agent 0.11.4 → 0.11.6
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 +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +98 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +31 -1
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-runtime.ts +38 -12
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +36 -12
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +538 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +388 -94
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
- package/src/sdk/bus/telegram-daemon-control.ts +312 -76
- package/src/sdk/bus/telegram-daemon.ts +1916 -285
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +1447 -852
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +200 -38
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1121 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/session/yield-queue.ts +20 -0
- package/src/slash-commands/builtin-registry.ts +36 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "@gajae-code/utils";
|
|
34
34
|
import type { TtsrInjectionRecord } from "../export/ttsr";
|
|
35
35
|
import { writeTextAtomic } from "../gjc-runtime/state-writer";
|
|
36
|
+
import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
|
|
36
37
|
|
|
37
38
|
import * as git from "../utils/git";
|
|
38
39
|
import { ArtifactManager } from "./artifacts";
|
|
@@ -58,10 +59,19 @@ import {
|
|
|
58
59
|
deleteManagedSessionCandidate,
|
|
59
60
|
listManagedCandidates,
|
|
60
61
|
type ManagedMigrationPolicy,
|
|
62
|
+
type ManagedScope,
|
|
63
|
+
managedDirectoryAuthorityForScope,
|
|
64
|
+
managedRootForScope,
|
|
61
65
|
openManagedCandidateForWrite,
|
|
62
66
|
prepareManagedSessionScopeForWriteSync,
|
|
63
67
|
resolveManagedScope,
|
|
64
68
|
} from "./internal/managed-session-scope";
|
|
69
|
+
import {
|
|
70
|
+
type ManagedDirectoryRoot,
|
|
71
|
+
type ManagedFileSnapshot,
|
|
72
|
+
ManagedSessionDescendantStore,
|
|
73
|
+
retainManagedDirectoryAuthority,
|
|
74
|
+
} from "./internal/managed-session-storage";
|
|
65
75
|
|
|
66
76
|
import {
|
|
67
77
|
type BashExecutionMessage,
|
|
@@ -77,6 +87,7 @@ import {
|
|
|
77
87
|
} from "./messages";
|
|
78
88
|
import { type SessionManagerReadAccess, sessionManagerReadCapability } from "./session-manager-internal";
|
|
79
89
|
import type {
|
|
90
|
+
ManagedSessionSecurityContext,
|
|
80
91
|
SessionStorage,
|
|
81
92
|
SessionStorageSnapshot,
|
|
82
93
|
SessionStorageStat,
|
|
@@ -85,14 +96,13 @@ import type {
|
|
|
85
96
|
VerifiedSessionDeleteResult,
|
|
86
97
|
VerifiedSessionDeleteTarget,
|
|
87
98
|
} from "./session-storage";
|
|
88
|
-
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
99
|
+
import { createManagedSessionSecurityContext, FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
89
100
|
|
|
90
|
-
export const CURRENT_SESSION_VERSION =
|
|
101
|
+
export const CURRENT_SESSION_VERSION = 5;
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
|
-
* Version
|
|
94
|
-
*
|
|
95
|
-
* must not be used to edit a session after a v4 writer has updated it.
|
|
104
|
+
* Version 5 separates persisted MCP and discovered-built-in selection authority.
|
|
105
|
+
* Version 4 patch records remain readable; older writers must not edit v5 sessions.
|
|
96
106
|
*/
|
|
97
107
|
|
|
98
108
|
function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
|
|
@@ -326,6 +336,18 @@ export interface MCPToolSelectionEntry extends SessionEntryBase {
|
|
|
326
336
|
type: "mcp_tool_selection";
|
|
327
337
|
/** MCP tool names selected for visibility in discovery mode. */
|
|
328
338
|
selectedToolNames: string[];
|
|
339
|
+
/** Legacy v4 combined built-in authority, retained for read compatibility. */
|
|
340
|
+
selectedDiscoveredBuiltinToolNames?: string[];
|
|
341
|
+
/** Correlates the ordered MCP and built-in entries emitted by one combined activation. */
|
|
342
|
+
mutationCorrelationId?: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Persisted discovered-built-in selection state, independent of MCP authority. */
|
|
346
|
+
export interface DiscoveredBuiltinToolSelectionEntry extends SessionEntryBase {
|
|
347
|
+
type: "discovered_builtin_tool_selection";
|
|
348
|
+
selectedToolNames: string[];
|
|
349
|
+
/** Correlates the ordered MCP and built-in entries emitted by one combined activation. */
|
|
350
|
+
mutationCorrelationId?: string;
|
|
329
351
|
}
|
|
330
352
|
|
|
331
353
|
/** Session init entry - captures initial context for subagent sessions (debugging/replay). */
|
|
@@ -389,6 +411,7 @@ export type SessionEntry =
|
|
|
389
411
|
| LabelEntry
|
|
390
412
|
| TtsrInjectionEntry
|
|
391
413
|
| MCPToolSelectionEntry
|
|
414
|
+
| DiscoveredBuiltinToolSelectionEntry
|
|
392
415
|
| SessionInitEntry
|
|
393
416
|
| ModeChangeEntry
|
|
394
417
|
| ConfiguredModelChainEntry;
|
|
@@ -456,8 +479,12 @@ export interface SessionContext {
|
|
|
456
479
|
|
|
457
480
|
/** MCP tool names selected through discovery for this session branch. */
|
|
458
481
|
selectedMCPToolNames: string[];
|
|
482
|
+
/** Built-in discoverable tool names activated through discovery, when explicitly persisted. */
|
|
483
|
+
selectedDiscoveredBuiltinToolNames?: string[];
|
|
459
484
|
/** Whether this branch contains an explicit persisted MCP selection entry. */
|
|
460
485
|
hasPersistedMCPToolSelection: boolean;
|
|
486
|
+
/** Whether this branch contains an explicit persisted discovered-built-in selection entry. */
|
|
487
|
+
hasPersistedDiscoveredBuiltinToolSelection?: boolean;
|
|
461
488
|
/** Active mode (e.g. "plan") or "none" if no special mode is active */
|
|
462
489
|
mode: string;
|
|
463
490
|
/** Mode-specific data from the last mode_change entry */
|
|
@@ -521,7 +548,7 @@ export interface StrictSessionOpenSuccess {
|
|
|
521
548
|
|
|
522
549
|
export interface StrictSessionOpenFailure {
|
|
523
550
|
kind: "error";
|
|
524
|
-
reason: ResumeTailError["reason"] | "identity-mismatch";
|
|
551
|
+
reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required";
|
|
525
552
|
}
|
|
526
553
|
|
|
527
554
|
/** Exact transcript bytes and authority captured from one descriptor-bound read. */
|
|
@@ -541,6 +568,27 @@ export type StrictSessionForkResult = { kind: "forked"; manager: SessionManager
|
|
|
541
568
|
/** Result of opening an inspected session without create-or-rewrite fallback. */
|
|
542
569
|
export type StrictSessionOpenResult = StrictSessionOpenSuccess | StrictSessionOpenFailure;
|
|
543
570
|
|
|
571
|
+
/**
|
|
572
|
+
* Capability returned only by a strict recovery hydration open. It represents
|
|
573
|
+
* immutable transcript authority and is consumed by the promotion seam.
|
|
574
|
+
*/
|
|
575
|
+
export interface RecoveryHydrationContext {
|
|
576
|
+
readonly identity: ResumeSessionIdentity;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export interface RecoveryHydrationOpenSuccess {
|
|
580
|
+
readonly kind: "hydrated";
|
|
581
|
+
readonly manager: SessionManager;
|
|
582
|
+
readonly context: RecoveryHydrationContext;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export type RecoveryHydrationOpenResult = RecoveryHydrationOpenSuccess | StrictSessionOpenFailure;
|
|
586
|
+
|
|
587
|
+
export interface RecoveryHydrationPromotionFence {
|
|
588
|
+
/** The caller has durably published ownership and acquired its writer lease. */
|
|
589
|
+
readonly ownershipReady: true;
|
|
590
|
+
}
|
|
591
|
+
|
|
544
592
|
export interface SessionInfo {
|
|
545
593
|
path: string;
|
|
546
594
|
id: string;
|
|
@@ -805,24 +853,60 @@ function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | un
|
|
|
805
853
|
return path.resolve(sessionDir) === path.resolve(resolved.scope.directoryPath) ? sessionsRoot : undefined;
|
|
806
854
|
}
|
|
807
855
|
|
|
856
|
+
/**
|
|
857
|
+
* Resolve `target` through the real path of its nearest existing ancestor,
|
|
858
|
+
* re-appending any not-yet-created trailing segments. Symlink/reparse components
|
|
859
|
+
* in the existing prefix are canonicalized (so aliases cannot escape a containment
|
|
860
|
+
* check or alias another file), while a missing leaf (e.g. an uncommitted session
|
|
861
|
+
* transcript) is still resolved deterministically. Non-ENOENT errors (ELOOP,
|
|
862
|
+
* EACCES, ...) surface rather than silently falling back to the lexical path.
|
|
863
|
+
*/
|
|
864
|
+
function canonicalizeThroughExistingAncestor(target: string): string {
|
|
865
|
+
const resolved = path.resolve(target);
|
|
866
|
+
const tail: string[] = [];
|
|
867
|
+
let current = resolved;
|
|
868
|
+
while (true) {
|
|
869
|
+
try {
|
|
870
|
+
const real = fs.realpathSync(current);
|
|
871
|
+
return tail.length === 0 ? real : path.join(real, ...tail.reverse());
|
|
872
|
+
} catch (err) {
|
|
873
|
+
if (!isEnoent(err)) throw err;
|
|
874
|
+
const parent = path.dirname(current);
|
|
875
|
+
if (parent === current) return resolved;
|
|
876
|
+
tail.push(path.basename(current));
|
|
877
|
+
current = parent;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function isSupportedSessionVersion(version: unknown): boolean {
|
|
883
|
+
return (
|
|
884
|
+
version === undefined ||
|
|
885
|
+
(typeof version === "number" && Number.isInteger(version) && version <= CURRENT_SESSION_VERSION)
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
808
889
|
/** Exported for compaction.test.ts */
|
|
809
890
|
export function parseSessionEntries(content: string): FileEntry[] {
|
|
810
891
|
const records = parseJsonlLenient<FileEntry | SessionPatchRecord>(content);
|
|
892
|
+
const rawHeader = records.find((record): record is SessionHeader => record.type === "session");
|
|
893
|
+
if (!isSupportedSessionVersion(rawHeader?.version)) {
|
|
894
|
+
throw new Error(`Unsupported session version: ${String(rawHeader?.version)}`);
|
|
895
|
+
}
|
|
811
896
|
const entries: FileEntry[] = [];
|
|
812
897
|
const entriesById = new Map<string, SessionEntry>();
|
|
813
898
|
let header: SessionHeader | undefined;
|
|
814
899
|
|
|
815
900
|
for (const record of records) {
|
|
816
|
-
// Patch records are
|
|
817
|
-
//
|
|
818
|
-
// later header/entry when replay catches up.
|
|
901
|
+
// Patch records are valid in v4 and v5 only. Ignore patches before a valid
|
|
902
|
+
// header so malformed prefixes cannot affect later records.
|
|
819
903
|
if (record.type === "header_patch") {
|
|
820
|
-
if (header?.version
|
|
904
|
+
if (header?.version !== undefined && header.version >= 4 && isHeaderPatchRecord(record))
|
|
821
905
|
applyHeaderPatch(header, record.patch);
|
|
822
906
|
continue;
|
|
823
907
|
}
|
|
824
908
|
if (record.type === "entry_patch") {
|
|
825
|
-
if (header?.version
|
|
909
|
+
if (header?.version !== undefined && header.version >= 4 && isEntryPatchRecord(record)) {
|
|
826
910
|
const entry = entriesById.get(record.entryId);
|
|
827
911
|
if (entry?.type === "message" && record.patch.message) entry.message = record.patch.message;
|
|
828
912
|
}
|
|
@@ -939,6 +1023,7 @@ export function buildSessionContext(
|
|
|
939
1023
|
|
|
940
1024
|
selectedMCPToolNames: [],
|
|
941
1025
|
hasPersistedMCPToolSelection: false,
|
|
1026
|
+
hasPersistedDiscoveredBuiltinToolSelection: false,
|
|
942
1027
|
mode: "none",
|
|
943
1028
|
};
|
|
944
1029
|
}
|
|
@@ -963,6 +1048,7 @@ export function buildSessionContext(
|
|
|
963
1048
|
ttsrMessageCount: 0,
|
|
964
1049
|
selectedMCPToolNames: [],
|
|
965
1050
|
hasPersistedMCPToolSelection: false,
|
|
1051
|
+
hasPersistedDiscoveredBuiltinToolSelection: false,
|
|
966
1052
|
mode: "none",
|
|
967
1053
|
};
|
|
968
1054
|
}
|
|
@@ -992,6 +1078,8 @@ export function buildSessionContext(
|
|
|
992
1078
|
|
|
993
1079
|
let selectedMCPToolNames: string[] = [];
|
|
994
1080
|
let hasPersistedMCPToolSelection = false;
|
|
1081
|
+
let selectedDiscoveredBuiltinToolNames: string[] | undefined;
|
|
1082
|
+
let hasPersistedDiscoveredBuiltinToolSelection = false;
|
|
995
1083
|
let mode = "none";
|
|
996
1084
|
let modeData: Record<string, unknown> | undefined;
|
|
997
1085
|
// Track whether an explicit `model_change` with role="default" has been
|
|
@@ -1052,7 +1140,14 @@ export function buildSessionContext(
|
|
|
1052
1140
|
}
|
|
1053
1141
|
} else if (entry.type === "mcp_tool_selection") {
|
|
1054
1142
|
selectedMCPToolNames = [...entry.selectedToolNames];
|
|
1143
|
+
if (entry.selectedDiscoveredBuiltinToolNames !== undefined) {
|
|
1144
|
+
selectedDiscoveredBuiltinToolNames = [...entry.selectedDiscoveredBuiltinToolNames];
|
|
1145
|
+
hasPersistedDiscoveredBuiltinToolSelection = true;
|
|
1146
|
+
}
|
|
1055
1147
|
hasPersistedMCPToolSelection = true;
|
|
1148
|
+
} else if (entry.type === "discovered_builtin_tool_selection") {
|
|
1149
|
+
selectedDiscoveredBuiltinToolNames = [...entry.selectedToolNames];
|
|
1150
|
+
hasPersistedDiscoveredBuiltinToolSelection = true;
|
|
1056
1151
|
} else if (entry.type === "mode_change") {
|
|
1057
1152
|
mode = entry.mode;
|
|
1058
1153
|
modeData = entry.data;
|
|
@@ -1176,7 +1271,9 @@ export function buildSessionContext(
|
|
|
1176
1271
|
ttsrMessageCount,
|
|
1177
1272
|
|
|
1178
1273
|
selectedMCPToolNames,
|
|
1274
|
+
selectedDiscoveredBuiltinToolNames,
|
|
1179
1275
|
hasPersistedMCPToolSelection,
|
|
1276
|
+
hasPersistedDiscoveredBuiltinToolSelection,
|
|
1180
1277
|
mode,
|
|
1181
1278
|
modeData,
|
|
1182
1279
|
};
|
|
@@ -1201,6 +1298,9 @@ function cloneSessionContext(context: SessionContext): SessionContext {
|
|
|
1201
1298
|
ttsrMessageCount: context.ttsrMessageCount,
|
|
1202
1299
|
|
|
1203
1300
|
selectedMCPToolNames: [...context.selectedMCPToolNames],
|
|
1301
|
+
selectedDiscoveredBuiltinToolNames: context.selectedDiscoveredBuiltinToolNames
|
|
1302
|
+
? [...context.selectedDiscoveredBuiltinToolNames]
|
|
1303
|
+
: undefined,
|
|
1204
1304
|
modeData: cloneJsonSemantic(context.modeData),
|
|
1205
1305
|
};
|
|
1206
1306
|
}
|
|
@@ -1231,6 +1331,140 @@ function computeDefaultSessionDir(
|
|
|
1231
1331
|
return prepared.scope.directoryPath;
|
|
1232
1332
|
}
|
|
1233
1333
|
|
|
1334
|
+
/** A session directory's authority is distinct from its string path. */
|
|
1335
|
+
export type SessionDestination =
|
|
1336
|
+
| { readonly kind: "managed"; readonly directory: string; readonly securityContext: ManagedSessionSecurityContext }
|
|
1337
|
+
| { readonly kind: "explicit"; readonly directory: string };
|
|
1338
|
+
export type SessionDestinationInput = string | SessionDestination | undefined;
|
|
1339
|
+
|
|
1340
|
+
interface ForkArtifactPublication {
|
|
1341
|
+
readonly snapshot: native.NativeDirectoryTreeSnapshot;
|
|
1342
|
+
readonly store: ManagedSessionDescendantStore;
|
|
1343
|
+
readonly cleanupStore: ManagedSessionDescendantStore;
|
|
1344
|
+
readonly cleanupRelativePath: string;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function retainedTreeSnapshotEquals(
|
|
1348
|
+
left: native.NativeDirectoryTreeSnapshot,
|
|
1349
|
+
right: native.NativeDirectoryTreeSnapshot,
|
|
1350
|
+
): boolean {
|
|
1351
|
+
return (
|
|
1352
|
+
left.rootDev === right.rootDev &&
|
|
1353
|
+
left.rootIno === right.rootIno &&
|
|
1354
|
+
left.entries.length === right.entries.length &&
|
|
1355
|
+
left.entries.every((entry, index) => {
|
|
1356
|
+
const other = right.entries[index];
|
|
1357
|
+
return (
|
|
1358
|
+
other !== undefined &&
|
|
1359
|
+
entry.relativePath === other.relativePath &&
|
|
1360
|
+
entry.kind === other.kind &&
|
|
1361
|
+
entry.dev === other.dev &&
|
|
1362
|
+
entry.ino === other.ino &&
|
|
1363
|
+
entry.size === other.size &&
|
|
1364
|
+
entry.mtimeNs === other.mtimeNs &&
|
|
1365
|
+
entry.ctimeNs === other.ctimeNs &&
|
|
1366
|
+
entry.sha256 === other.sha256
|
|
1367
|
+
);
|
|
1368
|
+
})
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function retainedTreeSnapshotEqualsAfterRename(
|
|
1373
|
+
left: native.NativeDirectoryTreeSnapshot,
|
|
1374
|
+
right: native.NativeDirectoryTreeSnapshot,
|
|
1375
|
+
): boolean {
|
|
1376
|
+
return (
|
|
1377
|
+
left.rootDev === right.rootDev &&
|
|
1378
|
+
left.rootIno === right.rootIno &&
|
|
1379
|
+
left.entries.length === right.entries.length &&
|
|
1380
|
+
left.entries.every((entry, index) => {
|
|
1381
|
+
const other = right.entries[index];
|
|
1382
|
+
return (
|
|
1383
|
+
other !== undefined &&
|
|
1384
|
+
entry.relativePath === other.relativePath &&
|
|
1385
|
+
entry.kind === other.kind &&
|
|
1386
|
+
entry.dev === other.dev &&
|
|
1387
|
+
entry.ino === other.ino &&
|
|
1388
|
+
entry.size === other.size &&
|
|
1389
|
+
entry.mtimeNs === other.mtimeNs &&
|
|
1390
|
+
(entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
|
|
1391
|
+
entry.sha256 === other.sha256
|
|
1392
|
+
);
|
|
1393
|
+
})
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
|
|
1398
|
+
return (
|
|
1399
|
+
left !== null &&
|
|
1400
|
+
right !== null &&
|
|
1401
|
+
left.identity.dev === right.identity.dev &&
|
|
1402
|
+
left.identity.ino === right.identity.ino &&
|
|
1403
|
+
left.identity.size === right.identity.size &&
|
|
1404
|
+
left.identity.mtimeNs === right.identity.mtimeNs &&
|
|
1405
|
+
left.identity.ctimeNs === right.identity.ctimeNs &&
|
|
1406
|
+
left.identity.sha256 === right.identity.sha256 &&
|
|
1407
|
+
left.bytes.equals(right.bytes)
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
const trustedSessionDestinations = new WeakSet<SessionDestination>();
|
|
1411
|
+
|
|
1412
|
+
function managedStoreFromContext(
|
|
1413
|
+
securityContext: ManagedSessionSecurityContext,
|
|
1414
|
+
directory: string,
|
|
1415
|
+
): ManagedSessionDescendantStore {
|
|
1416
|
+
return new ManagedSessionDescendantStore(
|
|
1417
|
+
securityContext.rootAuthority,
|
|
1418
|
+
directory,
|
|
1419
|
+
securityContext.retainedAuthority
|
|
1420
|
+
? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
|
|
1421
|
+
: undefined,
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
1426
|
+
const rootAuthority = managedRootForScope(scope);
|
|
1427
|
+
const securityContext = createManagedSessionSecurityContext({
|
|
1428
|
+
agentDir: scope.agentDir,
|
|
1429
|
+
sessionsRoot: scope.sessionsRoot,
|
|
1430
|
+
sessionDir: scope.directoryPath,
|
|
1431
|
+
rootAuthority,
|
|
1432
|
+
retainedAuthority: managedDirectoryAuthorityForScope(scope),
|
|
1433
|
+
});
|
|
1434
|
+
const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
|
|
1435
|
+
trustedSessionDestinations.add(destination);
|
|
1436
|
+
return destination;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function explicitDestination(directory: string): SessionDestination {
|
|
1440
|
+
const destination = Object.freeze({ kind: "explicit" as const, directory });
|
|
1441
|
+
trustedSessionDestinations.add(destination);
|
|
1442
|
+
return destination;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
|
|
1446
|
+
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1447
|
+
const sessionsRoot = getSessionsDir(agentDir);
|
|
1448
|
+
const resolved = resolveManagedScope({ cwd, agentDir: agentDir ?? path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
1449
|
+
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
1450
|
+
const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
|
|
1451
|
+
if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
|
|
1452
|
+
return freezeManagedDestination(prepared.scope);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function destinationFor(
|
|
1456
|
+
cwd: string,
|
|
1457
|
+
input: SessionDestinationInput,
|
|
1458
|
+
storage: SessionStorage,
|
|
1459
|
+
agentDir?: string,
|
|
1460
|
+
): SessionDestination {
|
|
1461
|
+
if (typeof input === "object") {
|
|
1462
|
+
if (!trustedSessionDestinations.has(input)) throw new Error("Untrusted session destination authority");
|
|
1463
|
+
return input;
|
|
1464
|
+
}
|
|
1465
|
+
return input === undefined ? managedDestination(cwd, storage, agentDir) : explicitDestination(input);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1234
1468
|
// =============================================================================
|
|
1235
1469
|
// Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
|
|
1236
1470
|
// =============================================================================
|
|
@@ -1475,7 +1709,19 @@ function hasStrictSessionSchema(entries: FileEntry[]): boolean {
|
|
|
1475
1709
|
case "mcp_tool_selection":
|
|
1476
1710
|
if (
|
|
1477
1711
|
!Array.isArray(value.selectedToolNames) ||
|
|
1478
|
-
!value.selectedToolNames.every(name => typeof name === "string")
|
|
1712
|
+
!value.selectedToolNames.every(name => typeof name === "string") ||
|
|
1713
|
+
(value.selectedDiscoveredBuiltinToolNames !== undefined &&
|
|
1714
|
+
(!Array.isArray(value.selectedDiscoveredBuiltinToolNames) ||
|
|
1715
|
+
!value.selectedDiscoveredBuiltinToolNames.every(name => typeof name === "string"))) ||
|
|
1716
|
+
(value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
|
|
1717
|
+
)
|
|
1718
|
+
return false;
|
|
1719
|
+
break;
|
|
1720
|
+
case "discovered_builtin_tool_selection":
|
|
1721
|
+
if (
|
|
1722
|
+
!Array.isArray(value.selectedToolNames) ||
|
|
1723
|
+
!value.selectedToolNames.every(name => typeof name === "string") ||
|
|
1724
|
+
(value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
|
|
1479
1725
|
)
|
|
1480
1726
|
return false;
|
|
1481
1727
|
break;
|
|
@@ -1806,8 +2052,13 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
|
|
|
1806
2052
|
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1807
2053
|
if (entries.length === 0) return;
|
|
1808
2054
|
const header = entries[0] as Record<string, unknown>;
|
|
1809
|
-
if (
|
|
1810
|
-
|
|
2055
|
+
if (
|
|
2056
|
+
header.type !== "session" ||
|
|
2057
|
+
typeof header.id !== "string" ||
|
|
2058
|
+
!isSupportedSessionVersion(header.version)
|
|
2059
|
+
)
|
|
2060
|
+
return;
|
|
2061
|
+
if (typeof header.version === "number" && header.version >= 4) {
|
|
1811
2062
|
for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
|
|
1812
2063
|
applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
|
|
1813
2064
|
}
|
|
@@ -3005,11 +3256,16 @@ class NdjsonFileWriter {
|
|
|
3005
3256
|
#onError: ((err: Error) => void) | undefined;
|
|
3006
3257
|
#closeDrained = false;
|
|
3007
3258
|
|
|
3008
|
-
constructor(
|
|
3259
|
+
constructor(
|
|
3260
|
+
storage: SessionStorage,
|
|
3261
|
+
path: string,
|
|
3262
|
+
options?: { flags?: "a" | "w"; onError?: (err: Error) => void; securityContext?: ManagedSessionSecurityContext },
|
|
3263
|
+
) {
|
|
3009
3264
|
this.#onError = options?.onError;
|
|
3010
3265
|
this.#writer = storage.openWriter(path, {
|
|
3011
3266
|
flags: options?.flags ?? "a",
|
|
3012
3267
|
onError: (err: Error) => this.#recordError(err),
|
|
3268
|
+
securityContext: options?.securityContext,
|
|
3013
3269
|
});
|
|
3014
3270
|
}
|
|
3015
3271
|
|
|
@@ -3489,7 +3745,7 @@ async function collectSessionFromFile(
|
|
|
3489
3745
|
const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
|
|
3490
3746
|
const header = parseSessionListHeader(content, entries);
|
|
3491
3747
|
if (!header) return undefined;
|
|
3492
|
-
if (header.version === CURRENT_SESSION_VERSION) {
|
|
3748
|
+
if (typeof header.version === "number" && header.version >= 4 && header.version <= CURRENT_SESSION_VERSION) {
|
|
3493
3749
|
for (const patch of await readSessionListTrailingPatches(file, storage, buffer)) {
|
|
3494
3750
|
applySessionListHeaderPatch(header, patch);
|
|
3495
3751
|
}
|
|
@@ -3653,6 +3909,7 @@ export class SessionManager {
|
|
|
3653
3909
|
#flushed: boolean = false;
|
|
3654
3910
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
3655
3911
|
#ensuredOnDisk: boolean = false;
|
|
3912
|
+
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
3656
3913
|
#fileEntries: FileEntry[] = [];
|
|
3657
3914
|
#byId: Map<string, SessionEntry> = new Map();
|
|
3658
3915
|
#labelsById: Map<string, string> = new Map();
|
|
@@ -3673,6 +3930,7 @@ export class SessionManager {
|
|
|
3673
3930
|
|
|
3674
3931
|
#artifactManager: ArtifactManager | null = null;
|
|
3675
3932
|
#artifactManagerSessionFile: string | null = null;
|
|
3933
|
+
#managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
|
|
3676
3934
|
// When set, take precedence over the lazily-derived per-session manager.
|
|
3677
3935
|
// Subagents adopt the parent's manager so artifact IDs are unique across the
|
|
3678
3936
|
// whole agent tree and all files land in the parent's artifacts dir.
|
|
@@ -3718,6 +3976,7 @@ export class SessionManager {
|
|
|
3718
3976
|
private sessionDir: string,
|
|
3719
3977
|
private readonly persist: boolean,
|
|
3720
3978
|
private readonly storage: SessionStorage,
|
|
3979
|
+
private destination: SessionDestination = explicitDestination(sessionDir),
|
|
3721
3980
|
) {
|
|
3722
3981
|
this.#blobStore = persist ? new BlobStore(getBlobsDir()) : this.#residentTextBlobStore;
|
|
3723
3982
|
this.#residentImageBlobStore = this.#blobStore;
|
|
@@ -3822,7 +4081,12 @@ export class SessionManager {
|
|
|
3822
4081
|
|
|
3823
4082
|
#resetResidentTextBlobStore(): void {
|
|
3824
4083
|
this.#disposeResidentTextBlobStore();
|
|
3825
|
-
if (
|
|
4084
|
+
if (
|
|
4085
|
+
this.destination.kind === "explicit" &&
|
|
4086
|
+
this.persist &&
|
|
4087
|
+
this.#sessionFile &&
|
|
4088
|
+
this.storage instanceof FileSessionStorage
|
|
4089
|
+
) {
|
|
3826
4090
|
this.#residentTextBlobStore = new EphemeralBlobStore(this.#residentCacheDir(this.#sessionFile));
|
|
3827
4091
|
}
|
|
3828
4092
|
}
|
|
@@ -4001,6 +4265,46 @@ export class SessionManager {
|
|
|
4001
4265
|
}
|
|
4002
4266
|
}
|
|
4003
4267
|
|
|
4268
|
+
/**
|
|
4269
|
+
* Exact-delete a session transcript and its artifacts by path WITHOUT requiring
|
|
4270
|
+
* managed logical authorization. This is only for discarding an UNCOMMITTED
|
|
4271
|
+
* successor that a transaction (e.g. handoff) created via `newSession()` but
|
|
4272
|
+
* never durably authorized — such a session has no managed candidate listing
|
|
4273
|
+
* yet, so `dropSession` would refuse it and leak its resident-cache/artifact
|
|
4274
|
+
* root. Bounded to this manager's configured session root and refuses to touch
|
|
4275
|
+
* the active session; tolerates missing files.
|
|
4276
|
+
*/
|
|
4277
|
+
async discardUncommittedSession(sessionPath: string): Promise<void> {
|
|
4278
|
+
// Close the writer FIRST so the canonical checks and the delete run with no
|
|
4279
|
+
// intervening await, eliminating the check/use window in which a parent
|
|
4280
|
+
// directory could be swapped for a symlink between authorization and deletion.
|
|
4281
|
+
await this.#closePersistWriter();
|
|
4282
|
+
// Canonicalize the candidate (and the active file / configured dir) through
|
|
4283
|
+
// their nearest existing ancestor so symlink/reparse components cannot alias
|
|
4284
|
+
// the active session or escape the configured session directory, even when
|
|
4285
|
+
// the successor transcript itself does not yet exist.
|
|
4286
|
+
const canonicalCandidate = canonicalizeThroughExistingAncestor(path.resolve(sessionPath));
|
|
4287
|
+
if (this.#sessionFile) {
|
|
4288
|
+
const canonicalActive = canonicalizeThroughExistingAncestor(path.resolve(this.#sessionFile));
|
|
4289
|
+
if (canonicalActive === canonicalCandidate) {
|
|
4290
|
+
throw new Error("Refusing to discard the active session as uncommitted.");
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
// Least-authority containment: only within this manager's configured session
|
|
4294
|
+
// directory (canonical), where newSession() allocates the successor. The
|
|
4295
|
+
// broader managed root (parent of sessionDir) is deliberately NOT accepted.
|
|
4296
|
+
const canonicalSessionDir = canonicalizeThroughExistingAncestor(path.resolve(this.sessionDir));
|
|
4297
|
+
if (!pathIsWithin(canonicalSessionDir, canonicalCandidate)) {
|
|
4298
|
+
throw new Error("Uncommitted session discard is limited to this manager's configured session directory.");
|
|
4299
|
+
}
|
|
4300
|
+
try {
|
|
4301
|
+
await this.storage.deleteSessionWithArtifacts(canonicalCandidate);
|
|
4302
|
+
} catch (err) {
|
|
4303
|
+
if (isEnoent(err)) return;
|
|
4304
|
+
throw err;
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4004
4308
|
/**
|
|
4005
4309
|
* Fork the current session, creating a new session file with the same entries.
|
|
4006
4310
|
* Returns both the old and new session file paths for artifact copying.
|
|
@@ -4014,6 +4318,9 @@ export class SessionManager {
|
|
|
4014
4318
|
const oldSessionFile = this.#sessionFile;
|
|
4015
4319
|
const oldSessionId = this.#sessionId;
|
|
4016
4320
|
const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
|
|
4321
|
+
const oldFileEntries = this.#fileEntries;
|
|
4322
|
+
const oldSessionName = this.#sessionName;
|
|
4323
|
+
const oldTitleSource = this.#titleSource;
|
|
4017
4324
|
|
|
4018
4325
|
// Close the current writer
|
|
4019
4326
|
await this.#closePersistWriter();
|
|
@@ -4049,13 +4356,169 @@ export class SessionManager {
|
|
|
4049
4356
|
this.#reexternalizeFileEntriesForResidentStore();
|
|
4050
4357
|
this.#bumpAllRevisions();
|
|
4051
4358
|
|
|
4052
|
-
// Write the new session file
|
|
4359
|
+
// Write the new session file, import artifacts, and adopt only after the
|
|
4360
|
+
// retained destination manifest is complete.
|
|
4053
4361
|
this.#flushed = false;
|
|
4054
|
-
|
|
4362
|
+
let forkArtifactPublication: ForkArtifactPublication | undefined;
|
|
4363
|
+
let forkTranscriptPublication: ManagedFileSnapshot | null = null;
|
|
4364
|
+
let forkTranscriptStore: ManagedSessionDescendantStore | undefined;
|
|
4365
|
+
try {
|
|
4366
|
+
forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, this.#sessionFile);
|
|
4367
|
+
await this.#rewriteFile();
|
|
4368
|
+
if (this.destination.kind === "managed") {
|
|
4369
|
+
forkTranscriptStore = this.#managedTranscriptStore();
|
|
4370
|
+
forkTranscriptPublication = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
|
|
4371
|
+
if (!forkTranscriptPublication) throw new Error("managed_fork_transcript_publish_missing");
|
|
4372
|
+
}
|
|
4373
|
+
forkArtifactPublication?.store.verifyRootSecurity();
|
|
4374
|
+
forkArtifactPublication?.store.assertBound();
|
|
4375
|
+
if (forkArtifactPublication) {
|
|
4376
|
+
const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
|
|
4377
|
+
if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot)) {
|
|
4378
|
+
throw new Error("artifact_destination_changed_during_transcript_publication");
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
if (forkTranscriptStore && forkTranscriptPublication) {
|
|
4382
|
+
const terminalTranscript = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
|
|
4383
|
+
if (!managedFileSnapshotEquals(terminalTranscript, forkTranscriptPublication))
|
|
4384
|
+
throw new Error("managed_fork_transcript_changed");
|
|
4385
|
+
}
|
|
4386
|
+
} catch (error) {
|
|
4387
|
+
let failure = error;
|
|
4388
|
+
const failedSessionFile = this.#sessionFile;
|
|
4389
|
+
if (this.destination.kind === "managed") {
|
|
4390
|
+
try {
|
|
4391
|
+
if (forkTranscriptStore && forkTranscriptPublication)
|
|
4392
|
+
forkTranscriptStore.removeExpected(path.basename(failedSessionFile), forkTranscriptPublication);
|
|
4393
|
+
if (forkArtifactPublication)
|
|
4394
|
+
forkArtifactPublication.cleanupStore.removeTreeExpected(
|
|
4395
|
+
forkArtifactPublication.cleanupRelativePath,
|
|
4396
|
+
forkArtifactPublication.snapshot,
|
|
4397
|
+
);
|
|
4398
|
+
} catch (cleanupError) {
|
|
4399
|
+
failure = new Error(`Failed to clean up managed fork destination: ${toError(cleanupError).message}`, {
|
|
4400
|
+
cause: toError(error),
|
|
4401
|
+
});
|
|
4402
|
+
}
|
|
4403
|
+
} else {
|
|
4404
|
+
try {
|
|
4405
|
+
await this.storage.unlink(failedSessionFile);
|
|
4406
|
+
} catch {
|
|
4407
|
+
// Preserve ambiguous cleanup evidence while restoring the active manager.
|
|
4408
|
+
}
|
|
4409
|
+
}
|
|
4410
|
+
this.#sessionId = oldSessionId;
|
|
4411
|
+
this.#sessionFile = oldSessionFile;
|
|
4412
|
+
this.#fileEntries = oldFileEntries;
|
|
4413
|
+
this.#sessionName = oldSessionName;
|
|
4414
|
+
this.#titleSource = oldTitleSource;
|
|
4415
|
+
this.#persistChain = Promise.resolve();
|
|
4416
|
+
this.#persistError = undefined;
|
|
4417
|
+
this.#persistErrorReported = false;
|
|
4418
|
+
this.#resetResidentTextBlobStore();
|
|
4419
|
+
this.#reexternalizeFileEntriesForResidentStore();
|
|
4420
|
+
this.#bumpAllRevisions();
|
|
4421
|
+
throw failure;
|
|
4422
|
+
}
|
|
4055
4423
|
|
|
4056
4424
|
return { oldSessionFile, newSessionFile: this.#sessionFile };
|
|
4057
4425
|
}
|
|
4058
4426
|
|
|
4427
|
+
async copyArtifactsForFork(
|
|
4428
|
+
oldSessionFile: string,
|
|
4429
|
+
newSessionFile: string,
|
|
4430
|
+
): Promise<ForkArtifactPublication | undefined> {
|
|
4431
|
+
const sourceDir = oldSessionFile.slice(0, -6);
|
|
4432
|
+
const finalDestinationDir = newSessionFile.slice(0, -6);
|
|
4433
|
+
if (this.destination.kind !== "managed") {
|
|
4434
|
+
try {
|
|
4435
|
+
const sourceStat = fs.lstatSync(sourceDir);
|
|
4436
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw new Error("unsafe_artifacts");
|
|
4437
|
+
} catch (error) {
|
|
4438
|
+
if (isEnoent(error)) return;
|
|
4439
|
+
throw error;
|
|
4440
|
+
}
|
|
4441
|
+
await fs.promises.cp(sourceDir, finalDestinationDir, { recursive: true });
|
|
4442
|
+
return undefined;
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
const sourceName = path.basename(sourceDir);
|
|
4446
|
+
const destinationName = path.basename(finalDestinationDir);
|
|
4447
|
+
const stagingName = `.${destinationName}.${crypto.randomUUID()}.fork-staging`;
|
|
4448
|
+
const parentStore = this.#managedTranscriptStore(oldSessionFile);
|
|
4449
|
+
let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
|
|
4450
|
+
try {
|
|
4451
|
+
sourceSnapshot = parentStore.captureTree(sourceName);
|
|
4452
|
+
} catch (error) {
|
|
4453
|
+
if (error instanceof Error && error.message === "not_found") return;
|
|
4454
|
+
throw error;
|
|
4455
|
+
}
|
|
4456
|
+
let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4457
|
+
let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4458
|
+
try {
|
|
4459
|
+
parentStore.ensureDirectory(stagingName);
|
|
4460
|
+
stagingSnapshot = parentStore.captureTree(stagingName);
|
|
4461
|
+
for (const entry of sourceSnapshot.entries) {
|
|
4462
|
+
if (entry.relativePath === "") continue;
|
|
4463
|
+
const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
|
|
4464
|
+
if (entry.kind === "directory") {
|
|
4465
|
+
parentStore.ensureDirectory(destinationRelativePath);
|
|
4466
|
+
} else if (entry.kind === "file") {
|
|
4467
|
+
const sourceFile = parentStore.readExpected(path.posix.join(sourceName, entry.relativePath));
|
|
4468
|
+
if (
|
|
4469
|
+
!sourceFile ||
|
|
4470
|
+
sourceFile.identity.dev.toString() !== entry.dev ||
|
|
4471
|
+
sourceFile.identity.ino.toString() !== entry.ino ||
|
|
4472
|
+
sourceFile.identity.size.toString() !== entry.size ||
|
|
4473
|
+
sourceFile.identity.mtimeNs.toString() !== entry.mtimeNs ||
|
|
4474
|
+
crypto.createHash("sha256").update(sourceFile.bytes).digest("hex") !== entry.sha256
|
|
4475
|
+
) {
|
|
4476
|
+
throw new Error("artifact_source_changed");
|
|
4477
|
+
}
|
|
4478
|
+
await parentStore.publishNoReplace(destinationRelativePath, sourceFile.bytes);
|
|
4479
|
+
} else {
|
|
4480
|
+
throw new Error("unsafe_artifacts");
|
|
4481
|
+
}
|
|
4482
|
+
stagingSnapshot = parentStore.captureTree(stagingName);
|
|
4483
|
+
}
|
|
4484
|
+
if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
|
|
4485
|
+
throw new Error("artifact_source_changed");
|
|
4486
|
+
parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4487
|
+
publishedSnapshot = parentStore.captureTree(destinationName);
|
|
4488
|
+
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4489
|
+
tree.entries.map(entry => ({
|
|
4490
|
+
relativePath: entry.relativePath,
|
|
4491
|
+
kind: entry.kind,
|
|
4492
|
+
size: entry.kind === "file" ? entry.size : undefined,
|
|
4493
|
+
sha256: entry.sha256,
|
|
4494
|
+
}));
|
|
4495
|
+
if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(sourceSnapshot)))
|
|
4496
|
+
throw new Error("artifact_destination_terminal_mismatch");
|
|
4497
|
+
const publishedStore = parentStore.deriveSubtree(destinationName);
|
|
4498
|
+
publishedStore.verifyRootSecurity();
|
|
4499
|
+
const retainedPublishedSnapshot = publishedStore.captureTree("");
|
|
4500
|
+
if (!retainedTreeSnapshotEqualsAfterRename(retainedPublishedSnapshot, publishedSnapshot))
|
|
4501
|
+
throw new Error("artifact_destination_retained_identity_changed");
|
|
4502
|
+
publishedSnapshot = retainedPublishedSnapshot;
|
|
4503
|
+
return {
|
|
4504
|
+
snapshot: publishedSnapshot,
|
|
4505
|
+
store: publishedStore,
|
|
4506
|
+
cleanupStore: parentStore,
|
|
4507
|
+
cleanupRelativePath: destinationName,
|
|
4508
|
+
};
|
|
4509
|
+
} catch (error) {
|
|
4510
|
+
try {
|
|
4511
|
+
if (publishedSnapshot) parentStore.removeTreeExpected(destinationName, publishedSnapshot);
|
|
4512
|
+
else if (stagingSnapshot) parentStore.removeTreeExpected(stagingName, stagingSnapshot);
|
|
4513
|
+
} catch (cleanupError) {
|
|
4514
|
+
throw new Error(`Failed to clean up managed fork artifacts: ${toError(cleanupError).message}`, {
|
|
4515
|
+
cause: toError(error),
|
|
4516
|
+
});
|
|
4517
|
+
}
|
|
4518
|
+
throw error;
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4059
4522
|
/**
|
|
4060
4523
|
* Move the session to a new working directory.
|
|
4061
4524
|
* Moves session files and artifacts on disk, updates all internal references,
|
|
@@ -4067,17 +4530,26 @@ export class SessionManager {
|
|
|
4067
4530
|
const previousCwd = this.cwd;
|
|
4068
4531
|
const previousSessionDir = this.sessionDir;
|
|
4069
4532
|
const previousSessionFile = this.#sessionFile;
|
|
4533
|
+
const previousDestination = this.destination;
|
|
4070
4534
|
const previousArtifactDir = previousSessionFile?.slice(0, -6);
|
|
4071
4535
|
|
|
4072
|
-
const
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4536
|
+
const nextDestination = (() => {
|
|
4537
|
+
if (this.destination.kind === "managed") {
|
|
4538
|
+
return managedDestination(resolvedCwd, this.storage, this.destination.securityContext.agentDir);
|
|
4539
|
+
}
|
|
4540
|
+
if (!(this.storage instanceof FileSessionStorage)) return this.destination;
|
|
4541
|
+
const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
|
|
4542
|
+
const directory = managedSessionsRoot
|
|
4543
|
+
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
|
4544
|
+
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
|
4545
|
+
return explicitDestination(directory);
|
|
4546
|
+
})();
|
|
4547
|
+
const newSessionDir = nextDestination.directory;
|
|
4080
4548
|
let hadSessionFile = false;
|
|
4549
|
+
const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
|
|
4550
|
+
let managedSourceStore: ManagedSessionDescendantStore | undefined;
|
|
4551
|
+
let managedDestinationStore: ManagedSessionDescendantStore | undefined;
|
|
4552
|
+
let rollbackManagedMove: (() => Promise<void>) | undefined;
|
|
4081
4553
|
|
|
4082
4554
|
if (this.persist && this.#sessionFile) {
|
|
4083
4555
|
// Close the persist writer before moving files
|
|
@@ -4090,7 +4562,11 @@ export class SessionManager {
|
|
|
4090
4562
|
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
|
4091
4563
|
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
4092
4564
|
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
4093
|
-
|
|
4565
|
+
let managedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
|
|
4566
|
+
let managedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4567
|
+
let managedPublishedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
|
|
4568
|
+
let managedPublishedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4569
|
+
hadSessionFile = managedMove ? false : this.storage.existsSync(oldSessionFile);
|
|
4094
4570
|
let movedSessionFile = false;
|
|
4095
4571
|
let movedArtifactDir = false;
|
|
4096
4572
|
const materializedEntries = materializeResidentEntriesForReadSync(
|
|
@@ -4113,56 +4589,184 @@ export class SessionManager {
|
|
|
4113
4589
|
}
|
|
4114
4590
|
throw error;
|
|
4115
4591
|
};
|
|
4592
|
+
const copyManagedTree = async (
|
|
4593
|
+
source: ManagedSessionDescendantStore,
|
|
4594
|
+
sourceRelative: string,
|
|
4595
|
+
destination: ManagedSessionDescendantStore,
|
|
4596
|
+
destinationRelative: string,
|
|
4597
|
+
snapshot: native.NativeDirectoryTreeSnapshot,
|
|
4598
|
+
): Promise<void> => {
|
|
4599
|
+
destination.ensureDirectory(destinationRelative);
|
|
4600
|
+
for (const entry of snapshot.entries) {
|
|
4601
|
+
if (entry.relativePath === "") continue;
|
|
4602
|
+
const target = path.posix.join(destinationRelative, entry.relativePath);
|
|
4603
|
+
if (entry.kind === "directory") destination.ensureDirectory(target);
|
|
4604
|
+
else {
|
|
4605
|
+
const file = source.readExpected(path.posix.join(sourceRelative, entry.relativePath));
|
|
4606
|
+
if (
|
|
4607
|
+
!file ||
|
|
4608
|
+
file.identity.dev.toString() !== entry.dev ||
|
|
4609
|
+
file.identity.ino.toString() !== entry.ino ||
|
|
4610
|
+
file.identity.size.toString() !== entry.size ||
|
|
4611
|
+
file.identity.mtimeNs.toString() !== entry.mtimeNs ||
|
|
4612
|
+
crypto.createHash("sha256").update(file.bytes).digest("hex") !== entry.sha256
|
|
4613
|
+
)
|
|
4614
|
+
throw new Error("artifact_source_changed");
|
|
4615
|
+
await destination.publishNoReplace(target, file.bytes);
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
const imported = destination.captureTree(destinationRelative);
|
|
4619
|
+
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4620
|
+
tree.entries.map(entry => ({
|
|
4621
|
+
relativePath: entry.relativePath,
|
|
4622
|
+
kind: entry.kind,
|
|
4623
|
+
size: entry.size,
|
|
4624
|
+
sha256: entry.sha256,
|
|
4625
|
+
}));
|
|
4626
|
+
if (JSON.stringify(comparable(imported)) !== JSON.stringify(comparable(snapshot)))
|
|
4627
|
+
throw new Error("artifact_destination_mismatch");
|
|
4628
|
+
destination.fsyncTree();
|
|
4629
|
+
};
|
|
4116
4630
|
this.#disposeResidentTextBlobStore();
|
|
4117
|
-
|
|
4118
4631
|
try {
|
|
4119
|
-
|
|
4120
|
-
|
|
4632
|
+
if (managedMove) {
|
|
4633
|
+
if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
|
|
4634
|
+
throw new Error("managed_move_destination_unavailable");
|
|
4635
|
+
managedSourceStore = this.#managedTranscriptStore(oldSessionFile);
|
|
4636
|
+
managedDestinationStore = managedStoreFromContext(nextDestination.securityContext, newSessionDir);
|
|
4637
|
+
rollbackManagedMove = async () => {
|
|
4638
|
+
if (!managedSourceStore || !managedDestinationStore)
|
|
4639
|
+
throw new Error("managed_move_authority_unavailable");
|
|
4640
|
+
if (managedArtifacts && managedPublishedArtifacts) {
|
|
4641
|
+
await copyManagedTree(
|
|
4642
|
+
managedDestinationStore,
|
|
4643
|
+
path.basename(newArtifactDir),
|
|
4644
|
+
managedSourceStore,
|
|
4645
|
+
path.basename(oldArtifactDir),
|
|
4646
|
+
managedPublishedArtifacts,
|
|
4647
|
+
);
|
|
4648
|
+
}
|
|
4649
|
+
if (managedTranscript)
|
|
4650
|
+
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4651
|
+
if (managedPublishedArtifacts)
|
|
4652
|
+
managedDestinationStore.removeTreeExpected(
|
|
4653
|
+
path.basename(newArtifactDir),
|
|
4654
|
+
managedPublishedArtifacts,
|
|
4655
|
+
);
|
|
4656
|
+
if (managedPublishedTranscript)
|
|
4657
|
+
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4658
|
+
};
|
|
4659
|
+
managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
|
|
4660
|
+
hadSessionFile = managedTranscript !== null;
|
|
4661
|
+
if (managedTranscript) {
|
|
4662
|
+
await managedDestinationStore.publishNoReplace(
|
|
4663
|
+
path.basename(newSessionFile),
|
|
4664
|
+
managedTranscript.bytes,
|
|
4665
|
+
);
|
|
4666
|
+
managedPublishedTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
|
|
4667
|
+
if (!managedPublishedTranscript) throw new Error("managed_transcript_publish_missing");
|
|
4668
|
+
movedSessionFile = true;
|
|
4669
|
+
}
|
|
4121
4670
|
try {
|
|
4671
|
+
managedArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
|
|
4672
|
+
await copyManagedTree(
|
|
4673
|
+
managedSourceStore,
|
|
4674
|
+
path.basename(oldArtifactDir),
|
|
4675
|
+
managedDestinationStore,
|
|
4676
|
+
path.basename(newArtifactDir),
|
|
4677
|
+
managedArtifacts,
|
|
4678
|
+
);
|
|
4679
|
+
managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
|
|
4680
|
+
movedArtifactDir = true;
|
|
4681
|
+
} catch (error) {
|
|
4682
|
+
if (!(error instanceof Error) || error.message !== "not_found") throw error;
|
|
4683
|
+
}
|
|
4684
|
+
if (managedPublishedTranscript) {
|
|
4685
|
+
const terminalTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
|
|
4686
|
+
if (!managedFileSnapshotEquals(terminalTranscript, managedPublishedTranscript))
|
|
4687
|
+
throw new Error("managed_move_destination_transcript_changed");
|
|
4688
|
+
}
|
|
4689
|
+
if (managedPublishedArtifacts) {
|
|
4690
|
+
const terminalArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
|
|
4691
|
+
if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
|
|
4692
|
+
throw new Error("managed_move_destination_artifacts_changed");
|
|
4693
|
+
}
|
|
4694
|
+
if (managedArtifacts)
|
|
4695
|
+
managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
|
|
4696
|
+
if (managedTranscript)
|
|
4697
|
+
managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
|
|
4698
|
+
} else {
|
|
4699
|
+
if (hadSessionFile && oldSessionFile !== newSessionFile) {
|
|
4122
4700
|
await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
|
|
4123
4701
|
movedSessionFile = true;
|
|
4124
|
-
} catch (error) {
|
|
4125
|
-
if (error instanceof PathMoveCompletedWithSyncFailure) movedSessionFile = true;
|
|
4126
|
-
throw error;
|
|
4127
4702
|
}
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
const stat = await fs.promises.stat(oldArtifactDir);
|
|
4132
|
-
if (stat.isDirectory()) {
|
|
4133
|
-
try {
|
|
4703
|
+
try {
|
|
4704
|
+
const stat = await fs.promises.stat(oldArtifactDir);
|
|
4705
|
+
if (stat.isDirectory() && oldArtifactDir !== newArtifactDir) {
|
|
4134
4706
|
await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
|
|
4135
4707
|
movedArtifactDir = true;
|
|
4136
|
-
} catch (error) {
|
|
4137
|
-
if (error instanceof PathMoveCompletedWithSyncFailure) movedArtifactDir = true;
|
|
4138
|
-
throw error;
|
|
4139
4708
|
}
|
|
4709
|
+
} catch (err) {
|
|
4710
|
+
if (!isEnoent(err)) throw err;
|
|
4140
4711
|
}
|
|
4141
|
-
} catch (err) {
|
|
4142
|
-
if (!isEnoent(err)) throw err;
|
|
4143
4712
|
}
|
|
4144
4713
|
} catch (err) {
|
|
4145
|
-
if (
|
|
4714
|
+
if (managedMove && managedDestinationStore) {
|
|
4146
4715
|
try {
|
|
4147
|
-
|
|
4716
|
+
if (managedArtifacts && !managedPublishedArtifacts) {
|
|
4717
|
+
try {
|
|
4718
|
+
managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
|
|
4719
|
+
} catch (captureError) {
|
|
4720
|
+
if (!(captureError instanceof Error) || captureError.message !== "not_found")
|
|
4721
|
+
throw captureError;
|
|
4722
|
+
}
|
|
4723
|
+
}
|
|
4724
|
+
if (managedSourceStore && managedArtifacts && managedPublishedArtifacts) {
|
|
4725
|
+
let sourceArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4726
|
+
try {
|
|
4727
|
+
sourceArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
|
|
4728
|
+
} catch (captureError) {
|
|
4729
|
+
if (!(captureError instanceof Error) || captureError.message !== "not_found")
|
|
4730
|
+
throw captureError;
|
|
4731
|
+
}
|
|
4732
|
+
if (!sourceArtifacts || !retainedTreeSnapshotEquals(sourceArtifacts, managedArtifacts)) {
|
|
4733
|
+
if (sourceArtifacts)
|
|
4734
|
+
managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), sourceArtifacts);
|
|
4735
|
+
await copyManagedTree(
|
|
4736
|
+
managedDestinationStore,
|
|
4737
|
+
path.basename(newArtifactDir),
|
|
4738
|
+
managedSourceStore,
|
|
4739
|
+
path.basename(oldArtifactDir),
|
|
4740
|
+
managedPublishedArtifacts,
|
|
4741
|
+
);
|
|
4742
|
+
const restored = managedSourceStore.captureTree(path.basename(oldArtifactDir));
|
|
4743
|
+
if (!retainedTreeSnapshotEqualsAfterRename(restored, managedArtifacts))
|
|
4744
|
+
throw new Error("managed_move_source_restore_mismatch");
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
if (
|
|
4748
|
+
managedSourceStore &&
|
|
4749
|
+
managedTranscript &&
|
|
4750
|
+
!managedSourceStore.readExpected(path.basename(oldSessionFile))
|
|
4751
|
+
)
|
|
4752
|
+
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4753
|
+
if (managedPublishedArtifacts)
|
|
4754
|
+
managedDestinationStore.removeTreeExpected(
|
|
4755
|
+
path.basename(newArtifactDir),
|
|
4756
|
+
managedPublishedArtifacts,
|
|
4757
|
+
);
|
|
4758
|
+
if (managedPublishedTranscript)
|
|
4759
|
+
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4148
4760
|
} catch (rollbackErr) {
|
|
4149
4761
|
restoreResidentStateAndThrow(
|
|
4150
|
-
new Error(
|
|
4151
|
-
|
|
4152
|
-
),
|
|
4153
|
-
);
|
|
4154
|
-
}
|
|
4155
|
-
}
|
|
4156
|
-
if (movedSessionFile) {
|
|
4157
|
-
try {
|
|
4158
|
-
await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
|
|
4159
|
-
} catch (rollbackErr) {
|
|
4160
|
-
restoreResidentStateAndThrow(
|
|
4161
|
-
new Error(
|
|
4162
|
-
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
4163
|
-
),
|
|
4762
|
+
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
4763
|
+
cause: toError(err),
|
|
4764
|
+
}),
|
|
4164
4765
|
);
|
|
4165
4766
|
}
|
|
4767
|
+
} else {
|
|
4768
|
+
if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
|
|
4769
|
+
if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
|
|
4166
4770
|
}
|
|
4167
4771
|
restoreResidentStateAndThrow(err);
|
|
4168
4772
|
}
|
|
@@ -4176,6 +4780,11 @@ export class SessionManager {
|
|
|
4176
4780
|
// Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
|
|
4177
4781
|
this.cwd = resolvedCwd;
|
|
4178
4782
|
this.sessionDir = newSessionDir;
|
|
4783
|
+
this.destination = nextDestination;
|
|
4784
|
+
this.#managedTranscriptStoreCache =
|
|
4785
|
+
managedMove && managedDestinationStore
|
|
4786
|
+
? { directory: path.resolve(newSessionDir), store: managedDestinationStore }
|
|
4787
|
+
: null;
|
|
4179
4788
|
|
|
4180
4789
|
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
4181
4790
|
try {
|
|
@@ -4196,6 +4805,21 @@ export class SessionManager {
|
|
|
4196
4805
|
this.#persistChain = Promise.resolve();
|
|
4197
4806
|
this.#persistError = undefined;
|
|
4198
4807
|
this.#persistErrorReported = false;
|
|
4808
|
+
if (rollbackManagedMove) {
|
|
4809
|
+
await rollbackManagedMove();
|
|
4810
|
+
this.#sessionFile = previousSessionFile;
|
|
4811
|
+
this.cwd = previousCwd;
|
|
4812
|
+
this.sessionDir = previousSessionDir;
|
|
4813
|
+
this.destination = previousDestination;
|
|
4814
|
+
this.#managedTranscriptStoreCache =
|
|
4815
|
+
managedMove && managedSourceStore
|
|
4816
|
+
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4817
|
+
: null;
|
|
4818
|
+
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4819
|
+
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4820
|
+
this.#headerExportRevision++;
|
|
4821
|
+
throw error;
|
|
4822
|
+
}
|
|
4199
4823
|
if (
|
|
4200
4824
|
this.storage instanceof FileSessionStorage &&
|
|
4201
4825
|
failedArtifactDir &&
|
|
@@ -4217,6 +4841,8 @@ export class SessionManager {
|
|
|
4217
4841
|
this.#sessionFile = previousSessionFile;
|
|
4218
4842
|
this.cwd = previousCwd;
|
|
4219
4843
|
this.sessionDir = previousSessionDir;
|
|
4844
|
+
this.destination = previousDestination;
|
|
4845
|
+
this.#managedTranscriptStoreCache = null;
|
|
4220
4846
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4221
4847
|
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4222
4848
|
this.#headerExportRevision++;
|
|
@@ -4348,6 +4974,10 @@ export class SessionManager {
|
|
|
4348
4974
|
#ensurePersistWriter(): NdjsonFileWriter | undefined {
|
|
4349
4975
|
if (!this.persist || !this.#sessionFile) return undefined;
|
|
4350
4976
|
if (this.#persistError) throw this.#persistError;
|
|
4977
|
+
if (this.destination.kind === "managed") {
|
|
4978
|
+
this.#managedTranscriptStore();
|
|
4979
|
+
return undefined;
|
|
4980
|
+
}
|
|
4351
4981
|
if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
|
|
4352
4982
|
if (this.#persistWriter.isOpen()) return this.#persistWriter;
|
|
4353
4983
|
// Cached writer for the current file is mid-close (queued
|
|
@@ -4511,8 +5141,31 @@ export class SessionManager {
|
|
|
4511
5141
|
}
|
|
4512
5142
|
}
|
|
4513
5143
|
|
|
5144
|
+
#managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
|
|
5145
|
+
if (this.destination.kind !== "managed" || !sessionFile) {
|
|
5146
|
+
throw new Error("Managed transcript authority is unavailable");
|
|
5147
|
+
}
|
|
5148
|
+
const sessionDir = path.resolve(path.dirname(sessionFile));
|
|
5149
|
+
if (sessionDir !== path.resolve(this.destination.directory)) {
|
|
5150
|
+
throw new Error("Managed transcript escaped its session directory");
|
|
5151
|
+
}
|
|
5152
|
+
if (this.#managedTranscriptStoreCache) {
|
|
5153
|
+
if (this.#managedTranscriptStoreCache.directory !== sessionDir)
|
|
5154
|
+
throw new Error("Managed transcript store transition was not verified");
|
|
5155
|
+
return this.#managedTranscriptStoreCache.store;
|
|
5156
|
+
}
|
|
5157
|
+
const store = managedStoreFromContext(this.destination.securityContext, sessionDir);
|
|
5158
|
+
this.#managedTranscriptStoreCache = { directory: sessionDir, store };
|
|
5159
|
+
return store;
|
|
5160
|
+
}
|
|
5161
|
+
|
|
4514
5162
|
#writeEntriesAtomicallySync(entries: FileEntry[]): void {
|
|
4515
5163
|
if (!this.#sessionFile) return;
|
|
5164
|
+
if (this.destination.kind === "managed") {
|
|
5165
|
+
const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
5166
|
+
this.#managedTranscriptStore().replaceSync(path.basename(this.#sessionFile), bytes);
|
|
5167
|
+
return;
|
|
5168
|
+
}
|
|
4516
5169
|
const dir = path.resolve(this.#sessionFile, "..");
|
|
4517
5170
|
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
4518
5171
|
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
@@ -4539,6 +5192,11 @@ export class SessionManager {
|
|
|
4539
5192
|
}
|
|
4540
5193
|
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
4541
5194
|
if (!this.#sessionFile) return;
|
|
5195
|
+
if (this.destination.kind === "managed") {
|
|
5196
|
+
const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
5197
|
+
await this.#managedTranscriptStore().replace(path.basename(this.#sessionFile), bytes);
|
|
5198
|
+
return;
|
|
5199
|
+
}
|
|
4542
5200
|
const dir = path.resolve(this.#sessionFile, "..");
|
|
4543
5201
|
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
4544
5202
|
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
@@ -4569,10 +5227,13 @@ export class SessionManager {
|
|
|
4569
5227
|
entries: readonly FileEntry[],
|
|
4570
5228
|
sessionFile: string,
|
|
4571
5229
|
): Promise<string | undefined> {
|
|
5230
|
+
if (this.destination.kind === "managed") return undefined;
|
|
4572
5231
|
if (!this.storage.existsSync(sessionFile)) return undefined;
|
|
4573
5232
|
const dir = path.resolve(sessionFile, "..");
|
|
4574
5233
|
const tempPath = path.join(dir, `.${path.basename(sessionFile)}.${Snowflake.next()}.default-selection.tmp`);
|
|
4575
|
-
const writer = new NdjsonFileWriter(this.storage, tempPath, {
|
|
5234
|
+
const writer = new NdjsonFileWriter(this.storage, tempPath, {
|
|
5235
|
+
flags: "w",
|
|
5236
|
+
});
|
|
4576
5237
|
try {
|
|
4577
5238
|
const persistedEntries = await Promise.all(
|
|
4578
5239
|
materializeResidentEntriesForPersistenceSync([...entries], this.#residentBlobStores()).map(entry =>
|
|
@@ -4702,19 +5363,31 @@ export class SessionManager {
|
|
|
4702
5363
|
return { kind: "not_promoted" };
|
|
4703
5364
|
}
|
|
4704
5365
|
if (stage.persistsToExistingFile) {
|
|
4705
|
-
if (!
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
5366
|
+
if (!this.#sessionFile) return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
5367
|
+
if (this.destination.kind === "managed") {
|
|
5368
|
+
try {
|
|
5369
|
+
const persistedEntries = materializeResidentEntriesForPersistenceSync(
|
|
5370
|
+
[...stage.entries],
|
|
5371
|
+
this.#residentBlobStores(),
|
|
5372
|
+
).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
|
|
5373
|
+
this.#writeEntriesAtomicallySync(persistedEntries);
|
|
5374
|
+
} catch (error) {
|
|
5375
|
+
return { kind: "unknown", error: toError(error) };
|
|
4712
5376
|
}
|
|
4713
|
-
}
|
|
4714
|
-
if (
|
|
4715
|
-
|
|
5377
|
+
} else {
|
|
5378
|
+
if (!stage.tempPath) return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
5379
|
+
try {
|
|
5380
|
+
this.#closePersistWriterInternalSync();
|
|
5381
|
+
const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
|
|
5382
|
+
if (replacement.kind === "restored_previous") {
|
|
5383
|
+
return { kind: "not_promoted", error: replacement.error };
|
|
5384
|
+
}
|
|
5385
|
+
} catch (error) {
|
|
5386
|
+
if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
|
|
5387
|
+
return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
|
|
5388
|
+
}
|
|
5389
|
+
return { kind: "unknown", error: toError(error) };
|
|
4716
5390
|
}
|
|
4717
|
-
return { kind: "unknown", error: toError(error) };
|
|
4718
5391
|
}
|
|
4719
5392
|
}
|
|
4720
5393
|
this.#fileEntries = [...stage.entries];
|
|
@@ -4862,15 +5535,62 @@ export class SessionManager {
|
|
|
4862
5535
|
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
4863
5536
|
* Returns null when the session is not persisted to a file.
|
|
4864
5537
|
* When this session has adopted an external ArtifactManager (subagent case),
|
|
4865
|
-
*
|
|
4866
|
-
*
|
|
5538
|
+
* never exposes that managed directory as a pathname. Reads and writes use the
|
|
5539
|
+
* adopted manager capability directly.
|
|
4867
5540
|
*/
|
|
4868
5541
|
getArtifactsDir(): string | null {
|
|
4869
|
-
if (this.#adoptedArtifactManager) return
|
|
5542
|
+
if (this.#adoptedArtifactManager) return null;
|
|
4870
5543
|
const sessionFile = this.#sessionFile;
|
|
4871
5544
|
return sessionFile ? sessionFile.slice(0, -6) : null;
|
|
4872
5545
|
}
|
|
4873
5546
|
|
|
5547
|
+
isManagedDestination(): boolean {
|
|
5548
|
+
return this.destination.kind === "managed";
|
|
5549
|
+
}
|
|
5550
|
+
|
|
5551
|
+
/** Supplies opaque retained authority for mandatory managed legacy local migration. */
|
|
5552
|
+
getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
|
|
5553
|
+
if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
|
|
5554
|
+
const store = this.#managedTranscriptStore();
|
|
5555
|
+
return {
|
|
5556
|
+
capture: async () => {
|
|
5557
|
+
let snapshot: native.NativeDirectoryTreeSnapshot;
|
|
5558
|
+
try {
|
|
5559
|
+
snapshot = store.captureTree("local");
|
|
5560
|
+
} catch (error) {
|
|
5561
|
+
if (error instanceof Error && error.message === "not_found") return null;
|
|
5562
|
+
throw error;
|
|
5563
|
+
}
|
|
5564
|
+
let totalBytes = 0;
|
|
5565
|
+
for (const entry of snapshot.entries) if (entry.kind === "file") totalBytes += Number(entry.size);
|
|
5566
|
+
if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
|
|
5567
|
+
const entries = snapshot.entries.map(entry => {
|
|
5568
|
+
if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
|
|
5569
|
+
const captured = store.readExpected(path.posix.join("local", entry.relativePath));
|
|
5570
|
+
if (
|
|
5571
|
+
!captured ||
|
|
5572
|
+
captured.identity.dev.toString() !== entry.dev ||
|
|
5573
|
+
captured.identity.ino.toString() !== entry.ino ||
|
|
5574
|
+
captured.identity.size.toString() !== entry.size ||
|
|
5575
|
+
crypto.createHash("sha256").update(captured.bytes).digest("hex") !== entry.sha256
|
|
5576
|
+
)
|
|
5577
|
+
throw new Error("Legacy local:// migration source changed during capture");
|
|
5578
|
+
return {
|
|
5579
|
+
relativePath: entry.relativePath,
|
|
5580
|
+
kind: "file" as const,
|
|
5581
|
+
bytes: captured.bytes,
|
|
5582
|
+
sha256: entry.sha256,
|
|
5583
|
+
};
|
|
5584
|
+
});
|
|
5585
|
+
const verified = store.captureTree("local");
|
|
5586
|
+
if (JSON.stringify(verified) !== JSON.stringify(snapshot))
|
|
5587
|
+
throw new Error("Legacy local:// migration source changed during capture");
|
|
5588
|
+
return { snapshot, entries };
|
|
5589
|
+
},
|
|
5590
|
+
retire: snapshot => store.removeTreeExpected("local", snapshot),
|
|
5591
|
+
};
|
|
5592
|
+
}
|
|
5593
|
+
|
|
4874
5594
|
/**
|
|
4875
5595
|
* Adopt an externally-owned ArtifactManager. Used by subagents to share
|
|
4876
5596
|
* the parent session's artifact directory and ID counter.
|
|
@@ -4906,7 +5626,12 @@ export class SessionManager {
|
|
|
4906
5626
|
return this.#artifactManager;
|
|
4907
5627
|
}
|
|
4908
5628
|
|
|
4909
|
-
const
|
|
5629
|
+
const artifactDir = sessionFile.slice(0, -6);
|
|
5630
|
+
let artifactStorage: string | ManagedSessionDescendantStore = artifactDir;
|
|
5631
|
+
if (this.destination.kind === "managed") {
|
|
5632
|
+
artifactStorage = this.#managedTranscriptStore(sessionFile).deriveSubtree(path.basename(artifactDir));
|
|
5633
|
+
}
|
|
5634
|
+
const manager = new ArtifactManager(artifactStorage);
|
|
4910
5635
|
this.#artifactManager = manager;
|
|
4911
5636
|
this.#artifactManagerSessionFile = sessionFile;
|
|
4912
5637
|
return manager;
|
|
@@ -4947,21 +5672,43 @@ export class SessionManager {
|
|
|
4947
5672
|
}
|
|
4948
5673
|
|
|
4949
5674
|
/**
|
|
4950
|
-
* Path to the unsent-input draft sidecar for
|
|
4951
|
-
* the artifacts directory so
|
|
4952
|
-
*
|
|
5675
|
+
* Path to the unsent-input draft sidecar for an explicit session. It lives
|
|
5676
|
+
* inside the artifacts directory so `dropSession` removes it with its owner.
|
|
5677
|
+
* Managed drafts use a retained descendant-store capability instead.
|
|
4953
5678
|
*/
|
|
4954
5679
|
#getDraftPath(): string | null {
|
|
5680
|
+
if (this.destination.kind === "managed") return null;
|
|
4955
5681
|
const dir = this.getArtifactsDir();
|
|
4956
5682
|
return dir ? path.join(dir, "draft.txt") : null;
|
|
4957
5683
|
}
|
|
4958
5684
|
|
|
5685
|
+
/** Retained authority for this managed session's private draft sidecar. */
|
|
5686
|
+
#getManagedDraftStore(): ManagedSessionDescendantStore | null {
|
|
5687
|
+
if (this.destination.kind !== "managed" || !this.#sessionFile) return null;
|
|
5688
|
+
return this.#managedTranscriptStore().deriveSubtree(path.basename(this.#sessionFile.slice(0, -6)));
|
|
5689
|
+
}
|
|
5690
|
+
|
|
4959
5691
|
/**
|
|
4960
5692
|
* Persist (or clear) the current editor draft so the next resume of this
|
|
4961
5693
|
* session can restore it. Empty text deletes any stale draft. No-op when the
|
|
4962
5694
|
* session is not persisted.
|
|
4963
5695
|
*/
|
|
4964
5696
|
async saveDraft(text: string): Promise<void> {
|
|
5697
|
+
if (this.destination.kind === "managed") {
|
|
5698
|
+
if (text.length === 0) {
|
|
5699
|
+
await this.#getManagedDraftStore()?.remove("draft.txt");
|
|
5700
|
+
return;
|
|
5701
|
+
}
|
|
5702
|
+
// Force the session header onto disk so resume can find the file we are
|
|
5703
|
+
// attaching this draft to. Without this, a session whose first message
|
|
5704
|
+
// never produced an assistant reply would persist a draft next to a
|
|
5705
|
+
// session file that does not exist on disk.
|
|
5706
|
+
await this.ensureOnDisk();
|
|
5707
|
+
const store = this.#getManagedDraftStore();
|
|
5708
|
+
if (!store) return;
|
|
5709
|
+
await store.replace("draft.txt", Buffer.from(text, "utf8"));
|
|
5710
|
+
return;
|
|
5711
|
+
}
|
|
4965
5712
|
const draftPath = this.#getDraftPath();
|
|
4966
5713
|
if (!draftPath || !this.persist) return;
|
|
4967
5714
|
if (text.length === 0) {
|
|
@@ -4977,7 +5724,9 @@ export class SessionManager {
|
|
|
4977
5724
|
// never produced an assistant reply would persist a draft next to a
|
|
4978
5725
|
// session file that does not exist on disk.
|
|
4979
5726
|
await this.ensureOnDisk();
|
|
4980
|
-
|
|
5727
|
+
const artifactManager = this.#getOrCreateArtifactManager();
|
|
5728
|
+
if (!artifactManager) return;
|
|
5729
|
+
await artifactManager.replaceNamed("draft.txt", text);
|
|
4981
5730
|
}
|
|
4982
5731
|
|
|
4983
5732
|
/**
|
|
@@ -4986,6 +5735,10 @@ export class SessionManager {
|
|
|
4986
5735
|
* sidecar so a subsequent resume does not re-restore the same text.
|
|
4987
5736
|
*/
|
|
4988
5737
|
async consumeDraft(): Promise<string | null> {
|
|
5738
|
+
if (this.destination.kind === "managed") {
|
|
5739
|
+
const draft = await this.#getManagedDraftStore()?.consume("draft.txt");
|
|
5740
|
+
return draft ? Buffer.from(draft).toString("utf8") : null;
|
|
5741
|
+
}
|
|
4989
5742
|
const draftPath = this.#getDraftPath();
|
|
4990
5743
|
if (!draftPath) return null;
|
|
4991
5744
|
let text: string;
|
|
@@ -5047,14 +5800,19 @@ export class SessionManager {
|
|
|
5047
5800
|
await this.#persistPatch({ type: "header_patch", patch });
|
|
5048
5801
|
}
|
|
5049
5802
|
|
|
5803
|
+
#appendManagedRecordSync(record: FileEntry | SessionPatchRecord): void {
|
|
5804
|
+
if (!this.#sessionFile) throw new Error("Managed transcript path is unavailable");
|
|
5805
|
+
const store = this.#managedTranscriptStore();
|
|
5806
|
+
const relativePath = path.basename(this.#sessionFile);
|
|
5807
|
+
store.appendSync(relativePath, Buffer.from(`${JSON.stringify(record)}\n`, "utf8"));
|
|
5808
|
+
}
|
|
5809
|
+
|
|
5050
5810
|
async #persistPatch(record: SessionPatchRecord): Promise<void> {
|
|
5051
5811
|
if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
|
|
5052
5812
|
await this.#queuePersistTask(async () => {
|
|
5053
5813
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
5054
5814
|
if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
|
|
5055
5815
|
return this.#rewriteFileContents();
|
|
5056
|
-
const writer = this.#ensurePersistWriter();
|
|
5057
|
-
if (!writer) return this.#rewriteFileContents();
|
|
5058
5816
|
const persistedRecord =
|
|
5059
5817
|
record.type === "entry_patch"
|
|
5060
5818
|
? (prepareEntryForPersistenceSync(
|
|
@@ -5066,6 +5824,12 @@ export class SessionManager {
|
|
|
5066
5824
|
this.#blobStore,
|
|
5067
5825
|
) as unknown as SessionPatchRecord)
|
|
5068
5826
|
: record;
|
|
5827
|
+
if (this.destination.kind === "managed") {
|
|
5828
|
+
this.#appendManagedRecordSync(persistedRecord);
|
|
5829
|
+
return;
|
|
5830
|
+
}
|
|
5831
|
+
const writer = this.#ensurePersistWriter();
|
|
5832
|
+
if (!writer) return this.#rewriteFileContents();
|
|
5069
5833
|
await writer.write(persistedRecord);
|
|
5070
5834
|
});
|
|
5071
5835
|
}
|
|
@@ -5108,6 +5872,16 @@ export class SessionManager {
|
|
|
5108
5872
|
// the synchronous blob-store path so blob bytes are durable before the JSONL
|
|
5109
5873
|
// line referencing them is written.
|
|
5110
5874
|
try {
|
|
5875
|
+
if (this.destination.kind === "managed") {
|
|
5876
|
+
const materializedEntry = materializeResidentEntryForPersistenceSync(
|
|
5877
|
+
entry,
|
|
5878
|
+
this.#residentBlobStores(),
|
|
5879
|
+
new Map(),
|
|
5880
|
+
);
|
|
5881
|
+
const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
|
|
5882
|
+
this.#appendManagedRecordSync(persistedEntry);
|
|
5883
|
+
return;
|
|
5884
|
+
}
|
|
5111
5885
|
const writer = this.#ensurePersistWriter();
|
|
5112
5886
|
if (!writer) {
|
|
5113
5887
|
// `#ensurePersistWriter` returns undefined here only when the cached
|
|
@@ -5446,18 +6220,29 @@ export class SessionManager {
|
|
|
5446
6220
|
// TTSR (Time Traveling Stream Rules)
|
|
5447
6221
|
// =========================================================================
|
|
5448
6222
|
|
|
5449
|
-
/**
|
|
5450
|
-
|
|
5451
|
-
* @param selectedToolNames MCP tool names selected for this branch
|
|
5452
|
-
* @returns Entry id
|
|
5453
|
-
*/
|
|
5454
|
-
appendMCPToolSelection(selectedToolNames: string[]): string {
|
|
6223
|
+
/** Append MCP discovery selection authority without altering discovered built-in authority. */
|
|
6224
|
+
appendMCPToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
|
|
5455
6225
|
const entry: MCPToolSelectionEntry = {
|
|
5456
6226
|
type: "mcp_tool_selection",
|
|
5457
6227
|
id: generateId(this.#byId),
|
|
5458
6228
|
parentId: this.#leafId,
|
|
5459
6229
|
timestamp: new Date().toISOString(),
|
|
5460
6230
|
selectedToolNames: [...selectedToolNames],
|
|
6231
|
+
mutationCorrelationId,
|
|
6232
|
+
};
|
|
6233
|
+
this.#appendEntry(entry);
|
|
6234
|
+
return entry.id;
|
|
6235
|
+
}
|
|
6236
|
+
|
|
6237
|
+
/** Append discovered built-in selection authority without altering MCP authority. */
|
|
6238
|
+
appendDiscoveredBuiltinToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
|
|
6239
|
+
const entry: DiscoveredBuiltinToolSelectionEntry = {
|
|
6240
|
+
type: "discovered_builtin_tool_selection",
|
|
6241
|
+
id: generateId(this.#byId),
|
|
6242
|
+
parentId: this.#leafId,
|
|
6243
|
+
timestamp: new Date().toISOString(),
|
|
6244
|
+
selectedToolNames: [...selectedToolNames],
|
|
6245
|
+
mutationCorrelationId,
|
|
5461
6246
|
};
|
|
5462
6247
|
this.#appendEntry(entry);
|
|
5463
6248
|
return entry.id;
|
|
@@ -6204,7 +6989,14 @@ export class SessionManager {
|
|
|
6204
6989
|
labelEntries.push(labelEntry);
|
|
6205
6990
|
parentId = labelEntry.id;
|
|
6206
6991
|
}
|
|
6207
|
-
this.
|
|
6992
|
+
if (this.destination.kind === "managed") {
|
|
6993
|
+
this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
|
|
6994
|
+
path.basename(newSessionFile),
|
|
6995
|
+
Buffer.from(`${lines.join("\n")}\n`, "utf8"),
|
|
6996
|
+
);
|
|
6997
|
+
} else {
|
|
6998
|
+
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
6999
|
+
}
|
|
6208
7000
|
this.#sessionId = newSessionId;
|
|
6209
7001
|
this.#sessionFile = newSessionFile;
|
|
6210
7002
|
this.#resetResidentTextBlobStore();
|
|
@@ -6278,9 +7070,47 @@ export class SessionManager {
|
|
|
6278
7070
|
* @param cwd Working directory (stored in session header)
|
|
6279
7071
|
* @param sessionDir Optional session directory. If omitted, uses default (~/.gjc/agent/sessions/<encoded-cwd>/).
|
|
6280
7072
|
*/
|
|
6281
|
-
static
|
|
6282
|
-
|
|
6283
|
-
|
|
7073
|
+
static nestedManagedDestination(
|
|
7074
|
+
authority: ManagedDirectoryRoot | ManagedSessionDescendantStore,
|
|
7075
|
+
directory: string,
|
|
7076
|
+
): SessionDestination {
|
|
7077
|
+
const rootAuthority =
|
|
7078
|
+
authority instanceof ManagedSessionDescendantStore ? authority.subtreeRootAuthority : authority;
|
|
7079
|
+
const retainedAuthority =
|
|
7080
|
+
authority instanceof ManagedSessionDescendantStore
|
|
7081
|
+
? authority.retainAuthority()
|
|
7082
|
+
: retainManagedDirectoryAuthority(rootAuthority, directory);
|
|
7083
|
+
const securityContext = createManagedSessionSecurityContext({
|
|
7084
|
+
agentDir: rootAuthority.canonicalPath,
|
|
7085
|
+
sessionsRoot: rootAuthority.canonicalPath,
|
|
7086
|
+
sessionDir: directory,
|
|
7087
|
+
rootAuthority,
|
|
7088
|
+
retainedAuthority,
|
|
7089
|
+
});
|
|
7090
|
+
const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
|
|
7091
|
+
trustedSessionDestinations.add(destination);
|
|
7092
|
+
return destination;
|
|
7093
|
+
}
|
|
7094
|
+
|
|
7095
|
+
static managedDestination(
|
|
7096
|
+
cwd: string,
|
|
7097
|
+
agentDir?: string,
|
|
7098
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7099
|
+
): SessionDestination {
|
|
7100
|
+
return managedDestination(cwd, storage, agentDir);
|
|
7101
|
+
}
|
|
7102
|
+
|
|
7103
|
+
static explicitDestination(directory: string): SessionDestination {
|
|
7104
|
+
return explicitDestination(directory);
|
|
7105
|
+
}
|
|
7106
|
+
|
|
7107
|
+
static create(
|
|
7108
|
+
cwd: string,
|
|
7109
|
+
destinationInput?: SessionDestinationInput,
|
|
7110
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7111
|
+
): SessionManager {
|
|
7112
|
+
const destination = destinationFor(cwd, destinationInput, storage);
|
|
7113
|
+
const manager = new SessionManager(cwd, destination.directory, true, storage, destination);
|
|
6284
7114
|
manager.#initNewSession();
|
|
6285
7115
|
return manager;
|
|
6286
7116
|
}
|
|
@@ -6345,16 +7175,17 @@ export class SessionManager {
|
|
|
6345
7175
|
static async forkFrom(
|
|
6346
7176
|
sourcePath: string,
|
|
6347
7177
|
cwd: string,
|
|
6348
|
-
|
|
7178
|
+
destinationInput?: SessionDestinationInput,
|
|
6349
7179
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6350
7180
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6351
7181
|
): Promise<SessionManager> {
|
|
7182
|
+
const destination = destinationFor(cwd, destinationInput, storage);
|
|
6352
7183
|
const managedSourcePath =
|
|
6353
|
-
|
|
7184
|
+
destination.kind === "managed" && storage instanceof FileSessionStorage
|
|
6354
7185
|
? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
|
|
6355
7186
|
: sourcePath;
|
|
6356
|
-
const dir =
|
|
6357
|
-
const manager = new SessionManager(cwd, dir, true, storage);
|
|
7187
|
+
const dir = destination.directory;
|
|
7188
|
+
const manager = new SessionManager(cwd, dir, true, storage, destination);
|
|
6358
7189
|
const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
|
|
6359
7190
|
migrateToCurrentVersion(forkEntries);
|
|
6360
7191
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
@@ -6388,52 +7219,103 @@ export class SessionManager {
|
|
|
6388
7219
|
*/
|
|
6389
7220
|
static async open(
|
|
6390
7221
|
filePath: string,
|
|
6391
|
-
|
|
7222
|
+
destinationInput?: SessionDestinationInput,
|
|
6392
7223
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6393
7224
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6394
7225
|
): Promise<SessionManager> {
|
|
7226
|
+
const destination =
|
|
7227
|
+
destinationInput === undefined
|
|
7228
|
+
? explicitDestination(path.dirname(filePath))
|
|
7229
|
+
: destinationFor(getProjectDir(), destinationInput, storage);
|
|
6395
7230
|
// Canonicalize benign ancestor symlinks in the caller-supplied path (e.g.
|
|
6396
7231
|
// macOS `/var -> /private/var`, a symlinked `$HOME`/project) so explicit
|
|
6397
7232
|
// session files land under a symlink-free trusted root that the strict
|
|
6398
7233
|
// owner-only and reparse guards accept.
|
|
6399
7234
|
if (storage instanceof FileSessionStorage) filePath = canonicalizeTrustedPath(filePath);
|
|
6400
|
-
if (
|
|
7235
|
+
if (destination.kind === "explicit" || !(storage instanceof FileSessionStorage)) {
|
|
6401
7236
|
const entries = await loadEntriesFromFile(filePath, storage);
|
|
6402
7237
|
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
6403
7238
|
const manager = new SessionManager(
|
|
6404
7239
|
header?.cwd ?? getProjectDir(),
|
|
6405
|
-
|
|
6406
|
-
(storage instanceof FileSessionStorage ? path.resolve(filePath, "..") : path.dirname(filePath)),
|
|
7240
|
+
destination.directory,
|
|
6407
7241
|
true,
|
|
6408
7242
|
storage,
|
|
7243
|
+
destination,
|
|
6409
7244
|
);
|
|
6410
7245
|
await manager.#initSessionFile(filePath);
|
|
6411
7246
|
return manager;
|
|
6412
7247
|
}
|
|
6413
7248
|
|
|
6414
|
-
const managedPath =
|
|
6415
|
-
!sessionDir && storage instanceof FileSessionStorage
|
|
6416
|
-
? await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy)
|
|
6417
|
-
: filePath;
|
|
7249
|
+
const managedPath = await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy);
|
|
6418
7250
|
const inspected = inspectResumeSessionFile(managedPath, storage);
|
|
6419
7251
|
if ("kind" in inspected) {
|
|
6420
7252
|
if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
|
|
6421
|
-
const manager = new SessionManager(getProjectDir(),
|
|
7253
|
+
const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
|
|
6422
7254
|
await manager.#initSessionFile(filePath);
|
|
6423
7255
|
return manager;
|
|
6424
7256
|
}
|
|
6425
7257
|
throw new Error(`Could not open session: ${inspected.reason}`);
|
|
6426
7258
|
}
|
|
6427
|
-
const opened = await SessionManager.openExistingStrict(
|
|
6428
|
-
inspected.identity,
|
|
6429
|
-
sessionDir ?? path.resolve(managedPath, ".."),
|
|
6430
|
-
storage,
|
|
6431
|
-
migrationPolicy,
|
|
6432
|
-
);
|
|
7259
|
+
const opened = await SessionManager.openExistingStrict(inspected.identity, destination, storage, migrationPolicy);
|
|
6433
7260
|
if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`);
|
|
6434
7261
|
return opened.manager;
|
|
6435
7262
|
}
|
|
6436
7263
|
|
|
7264
|
+
static async openNestedManaged(
|
|
7265
|
+
filePath: string,
|
|
7266
|
+
destination: SessionDestination,
|
|
7267
|
+
store: ManagedSessionDescendantStore,
|
|
7268
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7269
|
+
): Promise<SessionManager> {
|
|
7270
|
+
if (destination.kind !== "managed" || !trustedSessionDestinations.has(destination))
|
|
7271
|
+
throw new Error("Nested managed session authority is unavailable");
|
|
7272
|
+
const resolved = path.resolve(filePath);
|
|
7273
|
+
if (
|
|
7274
|
+
path.dirname(resolved) !== path.resolve(store.dir) ||
|
|
7275
|
+
path.dirname(resolved) !== path.resolve(destination.directory)
|
|
7276
|
+
)
|
|
7277
|
+
throw new Error("Nested managed session escaped retained authority");
|
|
7278
|
+
store.assertBound();
|
|
7279
|
+
const captured = store.readExpected(path.basename(resolved));
|
|
7280
|
+
const entries = captured ? parseSessionEntries(captured.bytes.toString("utf8")) : [];
|
|
7281
|
+
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
7282
|
+
const manager = new SessionManager(
|
|
7283
|
+
header?.cwd ?? getProjectDir(),
|
|
7284
|
+
destination.directory,
|
|
7285
|
+
true,
|
|
7286
|
+
storage,
|
|
7287
|
+
destination,
|
|
7288
|
+
);
|
|
7289
|
+
manager.#sessionFile = resolved;
|
|
7290
|
+
writeTerminalBreadcrumb(manager.cwd, resolved);
|
|
7291
|
+
manager.#fileEntries = entries;
|
|
7292
|
+
if (entries.length > 0) {
|
|
7293
|
+
manager.#sessionId = header?.id ?? createSessionId();
|
|
7294
|
+
manager.#sessionName = header?.title;
|
|
7295
|
+
manager.#titleSource = header?.titleSource;
|
|
7296
|
+
manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
|
|
7297
|
+
await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
|
|
7298
|
+
manager.#resetResidentTextBlobStore();
|
|
7299
|
+
manager.#fileEntries = manager.#fileEntries.map(entry =>
|
|
7300
|
+
prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
|
|
7301
|
+
);
|
|
7302
|
+
manager.#buildIndex();
|
|
7303
|
+
manager.#bumpAllRevisions();
|
|
7304
|
+
manager.#flushed = true;
|
|
7305
|
+
manager.#ensuredOnDisk = true;
|
|
7306
|
+
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7307
|
+
} else {
|
|
7308
|
+
manager.#newSessionSync();
|
|
7309
|
+
manager.#sessionFile = resolved;
|
|
7310
|
+
manager.#resetResidentTextBlobStore();
|
|
7311
|
+
await manager.#rewriteFile();
|
|
7312
|
+
manager.#flushed = true;
|
|
7313
|
+
manager.#ensuredOnDisk = true;
|
|
7314
|
+
manager.#bumpAllRevisions();
|
|
7315
|
+
}
|
|
7316
|
+
store.assertBound();
|
|
7317
|
+
return manager;
|
|
7318
|
+
}
|
|
6437
7319
|
/**
|
|
6438
7320
|
* List sessions for the resume picker without recovery or other maintenance writes.
|
|
6439
7321
|
*/
|
|
@@ -6524,9 +7406,10 @@ export class SessionManager {
|
|
|
6524
7406
|
static async forkFromCaptured(
|
|
6525
7407
|
snapshot: CapturedSessionTranscriptSnapshot,
|
|
6526
7408
|
cwd: string,
|
|
6527
|
-
|
|
7409
|
+
destinationInput?: SessionDestinationInput,
|
|
6528
7410
|
_migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6529
7411
|
): Promise<StrictSessionForkResult> {
|
|
7412
|
+
const destination = destinationFor(cwd, destinationInput, snapshot.storage);
|
|
6530
7413
|
if (crypto.createHash("sha256").update(snapshot.content).digest("hex") !== snapshot.identity.sha256) {
|
|
6531
7414
|
return { kind: "error", reason: "identity-mismatch" };
|
|
6532
7415
|
}
|
|
@@ -6553,13 +7436,17 @@ export class SessionManager {
|
|
|
6553
7436
|
return { kind: "error", reason: "identity-mismatch" };
|
|
6554
7437
|
}
|
|
6555
7438
|
|
|
6556
|
-
const dir =
|
|
7439
|
+
const dir = destination.directory;
|
|
6557
7440
|
const removeSessionDirOnFailure =
|
|
6558
|
-
|
|
7441
|
+
destination.kind !== "managed" &&
|
|
7442
|
+
snapshot.storage instanceof FileSessionStorage &&
|
|
7443
|
+
!snapshot.storage.existsSync(dir);
|
|
7444
|
+
let managedForkStore: ManagedSessionDescendantStore | undefined;
|
|
7445
|
+
let managedForkTranscript: ManagedFileSnapshot | null = null;
|
|
6559
7446
|
let manager: SessionManager | undefined;
|
|
6560
7447
|
let authorityFailure: StrictSessionOpenFailure | undefined;
|
|
6561
7448
|
try {
|
|
6562
|
-
manager = new SessionManager(cwd, dir, true, snapshot.storage);
|
|
7449
|
+
manager = new SessionManager(cwd, dir, true, snapshot.storage, destination);
|
|
6563
7450
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
6564
7451
|
manager.#fileEntries = forkEntries;
|
|
6565
7452
|
const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
@@ -6589,6 +7476,11 @@ export class SessionManager {
|
|
|
6589
7476
|
throw new Error("Captured fork source authority changed before destination write.");
|
|
6590
7477
|
}
|
|
6591
7478
|
await manager.#rewriteFile();
|
|
7479
|
+
if (destination.kind === "managed" && manager.#sessionFile) {
|
|
7480
|
+
managedForkStore = manager.#managedTranscriptStore();
|
|
7481
|
+
managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
7482
|
+
if (!managedForkTranscript) throw new Error("managed_fork_transcript_publish_missing");
|
|
7483
|
+
}
|
|
6592
7484
|
const afterWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
|
|
6593
7485
|
if ("kind" in afterWrite) {
|
|
6594
7486
|
authorityFailure = afterWrite;
|
|
@@ -6598,6 +7490,11 @@ export class SessionManager {
|
|
|
6598
7490
|
authorityFailure = { kind: "error", reason: "identity-mismatch" };
|
|
6599
7491
|
throw new Error("Captured fork source authority changed during destination write.");
|
|
6600
7492
|
}
|
|
7493
|
+
if (managedForkStore && managedForkTranscript && manager.#sessionFile) {
|
|
7494
|
+
const terminalTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
7495
|
+
if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
|
|
7496
|
+
throw new Error("managed_fork_transcript_changed");
|
|
7497
|
+
}
|
|
6601
7498
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
6602
7499
|
return { kind: "forked", manager };
|
|
6603
7500
|
} catch (error) {
|
|
@@ -6610,35 +7507,44 @@ export class SessionManager {
|
|
|
6610
7507
|
cleanupErrors.push(toError(cleanupError));
|
|
6611
7508
|
}
|
|
6612
7509
|
if (sessionFile) {
|
|
6613
|
-
|
|
6614
|
-
await manager.storage.unlink(sessionFile);
|
|
6615
|
-
} catch (cleanupError) {
|
|
6616
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
6617
|
-
}
|
|
6618
|
-
if (manager.storage instanceof FileSessionStorage) {
|
|
7510
|
+
if (destination.kind === "managed") {
|
|
6619
7511
|
try {
|
|
6620
|
-
|
|
7512
|
+
if (managedForkStore && managedForkTranscript)
|
|
7513
|
+
managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
|
|
6621
7514
|
} catch (cleanupError) {
|
|
6622
7515
|
cleanupErrors.push(toError(cleanupError));
|
|
6623
7516
|
}
|
|
7517
|
+
} else {
|
|
6624
7518
|
try {
|
|
6625
|
-
|
|
6626
|
-
const transientPrefix = `.${sessionFileName}.`;
|
|
6627
|
-
const backupPrefix = `${sessionFileName}.`;
|
|
6628
|
-
for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
|
|
6629
|
-
if (
|
|
6630
|
-
(name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
|
|
6631
|
-
(name.startsWith(backupPrefix) && name.endsWith(".bak"))
|
|
6632
|
-
) {
|
|
6633
|
-
await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
|
|
6634
|
-
recursive: true,
|
|
6635
|
-
force: true,
|
|
6636
|
-
});
|
|
6637
|
-
}
|
|
6638
|
-
}
|
|
7519
|
+
await manager.storage.unlink(sessionFile);
|
|
6639
7520
|
} catch (cleanupError) {
|
|
6640
7521
|
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
6641
7522
|
}
|
|
7523
|
+
if (manager.storage instanceof FileSessionStorage) {
|
|
7524
|
+
try {
|
|
7525
|
+
await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
|
|
7526
|
+
} catch (cleanupError) {
|
|
7527
|
+
cleanupErrors.push(toError(cleanupError));
|
|
7528
|
+
}
|
|
7529
|
+
try {
|
|
7530
|
+
const sessionFileName = path.basename(sessionFile);
|
|
7531
|
+
const transientPrefix = `.${sessionFileName}.`;
|
|
7532
|
+
const backupPrefix = `${sessionFileName}.`;
|
|
7533
|
+
for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
|
|
7534
|
+
if (
|
|
7535
|
+
(name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
|
|
7536
|
+
(name.startsWith(backupPrefix) && name.endsWith(".bak"))
|
|
7537
|
+
) {
|
|
7538
|
+
await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
|
|
7539
|
+
recursive: true,
|
|
7540
|
+
force: true,
|
|
7541
|
+
});
|
|
7542
|
+
}
|
|
7543
|
+
}
|
|
7544
|
+
} catch (cleanupError) {
|
|
7545
|
+
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7546
|
+
}
|
|
7547
|
+
}
|
|
6642
7548
|
}
|
|
6643
7549
|
}
|
|
6644
7550
|
if (removeSessionDirOnFailure) {
|
|
@@ -6673,6 +7579,67 @@ export class SessionManager {
|
|
|
6673
7579
|
: { kind: "terminal", identity: inspected.identity };
|
|
6674
7580
|
}
|
|
6675
7581
|
|
|
7582
|
+
/**
|
|
7583
|
+
* Hydrate an existing predecessor transcript for recovery without taking any
|
|
7584
|
+
* write-capable action. The caller must retain the returned context and use
|
|
7585
|
+
* the explicit promotion seam only after its ownership-ready fence and writer
|
|
7586
|
+
* lease are durable.
|
|
7587
|
+
*/
|
|
7588
|
+
static async openExistingForRecoveryHydrationStrict(
|
|
7589
|
+
identity: ResumeSessionIdentity,
|
|
7590
|
+
destinationInput?: SessionDestinationInput,
|
|
7591
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7592
|
+
): Promise<RecoveryHydrationOpenResult> {
|
|
7593
|
+
const destination =
|
|
7594
|
+
destinationInput === undefined
|
|
7595
|
+
? explicitDestination(path.dirname(identity.canonicalPath))
|
|
7596
|
+
: destinationFor(getProjectDir(), destinationInput, storage);
|
|
7597
|
+
const inspected = inspectResumeSessionFile(identity.canonicalPath, storage);
|
|
7598
|
+
if ("kind" in inspected) return inspected;
|
|
7599
|
+
if (!sameResumeIdentity(identity, inspected.identity)) return { kind: "error", reason: "identity-mismatch" };
|
|
7600
|
+
if (inspected.migrationApplied) return { kind: "error", reason: "migration-required" };
|
|
7601
|
+
|
|
7602
|
+
const header = inspected.entries[0] as SessionHeader;
|
|
7603
|
+
const manager = new SessionManager(
|
|
7604
|
+
header.cwd || getProjectDir(),
|
|
7605
|
+
destination.directory,
|
|
7606
|
+
true,
|
|
7607
|
+
storage,
|
|
7608
|
+
destination,
|
|
7609
|
+
);
|
|
7610
|
+
await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
|
|
7611
|
+
const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
|
|
7612
|
+
if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
|
|
7613
|
+
await manager.close();
|
|
7614
|
+
return "kind" in revalidated ? revalidated : { kind: "error", reason: "identity-mismatch" };
|
|
7615
|
+
}
|
|
7616
|
+
const context: RecoveryHydrationContext = Object.freeze({
|
|
7617
|
+
identity: Object.freeze({ ...identity }),
|
|
7618
|
+
});
|
|
7619
|
+
manager.#recoveryHydrationContext = context;
|
|
7620
|
+
return { kind: "hydrated", manager, context };
|
|
7621
|
+
}
|
|
7622
|
+
|
|
7623
|
+
/**
|
|
7624
|
+
* Allows the normal post-open metadata sanitation only after the caller has
|
|
7625
|
+
* fsynced its external ownership-ready fence and acquired the writer lease.
|
|
7626
|
+
*/
|
|
7627
|
+
async promoteRecoveryHydrationAfterOwnershipReadyFence(
|
|
7628
|
+
context: RecoveryHydrationContext,
|
|
7629
|
+
fence: RecoveryHydrationPromotionFence,
|
|
7630
|
+
): Promise<void> {
|
|
7631
|
+
if (fence.ownershipReady !== true || this.#recoveryHydrationContext !== context) {
|
|
7632
|
+
throw new Error("Recovery hydration promotion requires the original ownership-ready context.");
|
|
7633
|
+
}
|
|
7634
|
+
const inspected = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
|
|
7635
|
+
if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
|
|
7636
|
+
throw new Error("Recovery transcript authority changed before promotion.");
|
|
7637
|
+
}
|
|
7638
|
+
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7639
|
+
writeTerminalBreadcrumb(this.cwd, context.identity.canonicalPath);
|
|
7640
|
+
this.#recoveryHydrationContext = undefined;
|
|
7641
|
+
}
|
|
7642
|
+
|
|
6676
7643
|
/**
|
|
6677
7644
|
* Main startup code MUST retain the consented inspection identity and branch on
|
|
6678
7645
|
* the returned discriminant; an error result never creates, rewrites, or adopts
|
|
@@ -6680,12 +7647,16 @@ export class SessionManager {
|
|
|
6680
7647
|
*/
|
|
6681
7648
|
static async openExistingStrict(
|
|
6682
7649
|
identity: ResumeSessionIdentity,
|
|
6683
|
-
|
|
7650
|
+
destinationInput?: SessionDestinationInput,
|
|
6684
7651
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6685
7652
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6686
7653
|
): Promise<StrictSessionOpenResult> {
|
|
7654
|
+
const destination =
|
|
7655
|
+
destinationInput === undefined
|
|
7656
|
+
? explicitDestination(path.dirname(identity.canonicalPath))
|
|
7657
|
+
: destinationFor(getProjectDir(), destinationInput, storage);
|
|
6687
7658
|
let sessionPath = identity.canonicalPath;
|
|
6688
|
-
if (
|
|
7659
|
+
if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
|
|
6689
7660
|
try {
|
|
6690
7661
|
sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
|
|
6691
7662
|
} catch (error) {
|
|
@@ -6705,8 +7676,8 @@ export class SessionManager {
|
|
|
6705
7676
|
}
|
|
6706
7677
|
const entries = inspected.entries;
|
|
6707
7678
|
const header = entries[0] as SessionHeader;
|
|
6708
|
-
const dir =
|
|
6709
|
-
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage);
|
|
7679
|
+
const dir = destination.directory;
|
|
7680
|
+
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
|
|
6710
7681
|
await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
|
|
6711
7682
|
const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
|
|
6712
7683
|
if ("kind" in ownershipInspection) {
|
|
@@ -6734,17 +7705,18 @@ export class SessionManager {
|
|
|
6734
7705
|
*/
|
|
6735
7706
|
static async continueRecent(
|
|
6736
7707
|
cwd: string,
|
|
6737
|
-
|
|
7708
|
+
destinationInput?: SessionDestinationInput,
|
|
6738
7709
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6739
7710
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6740
7711
|
): Promise<SessionManager> {
|
|
6741
|
-
const
|
|
7712
|
+
const destination = destinationFor(cwd, destinationInput, storage);
|
|
7713
|
+
const dir = destination.directory;
|
|
6742
7714
|
const openSelectedStrictly = async (selectedPath: string): Promise<SessionManager | undefined> => {
|
|
6743
7715
|
const inspected = await SessionManager.inspectSessionTailReadOnly(selectedPath, storage);
|
|
6744
7716
|
if (inspected.kind === "error") return undefined;
|
|
6745
7717
|
const opened = await SessionManager.openExistingStrict(
|
|
6746
7718
|
inspected.identity,
|
|
6747
|
-
|
|
7719
|
+
destination,
|
|
6748
7720
|
storage,
|
|
6749
7721
|
migrationPolicy,
|
|
6750
7722
|
);
|
|
@@ -6756,9 +7728,10 @@ export class SessionManager {
|
|
|
6756
7728
|
};
|
|
6757
7729
|
// Legacy paths are candidates only: the managed scan validates each transcript
|
|
6758
7730
|
// header cwd before it can affect default resume authority.
|
|
6759
|
-
const managedCandidates =
|
|
6760
|
-
|
|
6761
|
-
|
|
7731
|
+
const managedCandidates =
|
|
7732
|
+
destination.kind === "explicit"
|
|
7733
|
+
? []
|
|
7734
|
+
: await SessionManager.listForResumePickerReadOnly(cwd, destination.directory, storage);
|
|
6762
7735
|
const terminalSession = await readTerminalBreadcrumb(cwd);
|
|
6763
7736
|
if (terminalSession) {
|
|
6764
7737
|
const opened = await openSelectedStrictly(terminalSession);
|
|
@@ -6773,7 +7746,7 @@ export class SessionManager {
|
|
|
6773
7746
|
const opened = await openSelectedStrictly(mostRecent);
|
|
6774
7747
|
if (opened) return opened;
|
|
6775
7748
|
}
|
|
6776
|
-
const manager = new SessionManager(cwd, dir, true, storage);
|
|
7749
|
+
const manager = new SessionManager(cwd, dir, true, storage, destination);
|
|
6777
7750
|
manager.#initNewSession();
|
|
6778
7751
|
return manager;
|
|
6779
7752
|
}
|