@gajae-code/coding-agent 0.11.4 → 0.11.5
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 +20 -0
- 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 +39 -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 +6 -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 +88 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -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 +2 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +17 -0
- 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 +7 -6
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +37 -13
- 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/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/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 +526 -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 +384 -92
- 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 +6 -3
- package/src/sdk/bus/telegram-daemon-control.ts +294 -76
- package/src/sdk/bus/telegram-daemon.ts +1788 -267
- package/src/sdk/protocol/operation-inventory.generated.json +11 -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 +495 -253
- 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 +1055 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -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.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,34 @@ 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
|
+
function isSupportedSessionVersion(version: unknown): boolean {
|
|
857
|
+
return (
|
|
858
|
+
version === undefined ||
|
|
859
|
+
(typeof version === "number" && Number.isInteger(version) && version <= CURRENT_SESSION_VERSION)
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
808
863
|
/** Exported for compaction.test.ts */
|
|
809
864
|
export function parseSessionEntries(content: string): FileEntry[] {
|
|
810
865
|
const records = parseJsonlLenient<FileEntry | SessionPatchRecord>(content);
|
|
866
|
+
const rawHeader = records.find((record): record is SessionHeader => record.type === "session");
|
|
867
|
+
if (!isSupportedSessionVersion(rawHeader?.version)) {
|
|
868
|
+
throw new Error(`Unsupported session version: ${String(rawHeader?.version)}`);
|
|
869
|
+
}
|
|
811
870
|
const entries: FileEntry[] = [];
|
|
812
871
|
const entriesById = new Map<string, SessionEntry>();
|
|
813
872
|
let header: SessionHeader | undefined;
|
|
814
873
|
|
|
815
874
|
for (const record of records) {
|
|
816
|
-
// Patch records are
|
|
817
|
-
//
|
|
818
|
-
// later header/entry when replay catches up.
|
|
875
|
+
// Patch records are valid in v4 and v5 only. Ignore patches before a valid
|
|
876
|
+
// header so malformed prefixes cannot affect later records.
|
|
819
877
|
if (record.type === "header_patch") {
|
|
820
|
-
if (header?.version
|
|
878
|
+
if (header?.version !== undefined && header.version >= 4 && isHeaderPatchRecord(record))
|
|
821
879
|
applyHeaderPatch(header, record.patch);
|
|
822
880
|
continue;
|
|
823
881
|
}
|
|
824
882
|
if (record.type === "entry_patch") {
|
|
825
|
-
if (header?.version
|
|
883
|
+
if (header?.version !== undefined && header.version >= 4 && isEntryPatchRecord(record)) {
|
|
826
884
|
const entry = entriesById.get(record.entryId);
|
|
827
885
|
if (entry?.type === "message" && record.patch.message) entry.message = record.patch.message;
|
|
828
886
|
}
|
|
@@ -939,6 +997,7 @@ export function buildSessionContext(
|
|
|
939
997
|
|
|
940
998
|
selectedMCPToolNames: [],
|
|
941
999
|
hasPersistedMCPToolSelection: false,
|
|
1000
|
+
hasPersistedDiscoveredBuiltinToolSelection: false,
|
|
942
1001
|
mode: "none",
|
|
943
1002
|
};
|
|
944
1003
|
}
|
|
@@ -963,6 +1022,7 @@ export function buildSessionContext(
|
|
|
963
1022
|
ttsrMessageCount: 0,
|
|
964
1023
|
selectedMCPToolNames: [],
|
|
965
1024
|
hasPersistedMCPToolSelection: false,
|
|
1025
|
+
hasPersistedDiscoveredBuiltinToolSelection: false,
|
|
966
1026
|
mode: "none",
|
|
967
1027
|
};
|
|
968
1028
|
}
|
|
@@ -992,6 +1052,8 @@ export function buildSessionContext(
|
|
|
992
1052
|
|
|
993
1053
|
let selectedMCPToolNames: string[] = [];
|
|
994
1054
|
let hasPersistedMCPToolSelection = false;
|
|
1055
|
+
let selectedDiscoveredBuiltinToolNames: string[] | undefined;
|
|
1056
|
+
let hasPersistedDiscoveredBuiltinToolSelection = false;
|
|
995
1057
|
let mode = "none";
|
|
996
1058
|
let modeData: Record<string, unknown> | undefined;
|
|
997
1059
|
// Track whether an explicit `model_change` with role="default" has been
|
|
@@ -1052,7 +1114,14 @@ export function buildSessionContext(
|
|
|
1052
1114
|
}
|
|
1053
1115
|
} else if (entry.type === "mcp_tool_selection") {
|
|
1054
1116
|
selectedMCPToolNames = [...entry.selectedToolNames];
|
|
1117
|
+
if (entry.selectedDiscoveredBuiltinToolNames !== undefined) {
|
|
1118
|
+
selectedDiscoveredBuiltinToolNames = [...entry.selectedDiscoveredBuiltinToolNames];
|
|
1119
|
+
hasPersistedDiscoveredBuiltinToolSelection = true;
|
|
1120
|
+
}
|
|
1055
1121
|
hasPersistedMCPToolSelection = true;
|
|
1122
|
+
} else if (entry.type === "discovered_builtin_tool_selection") {
|
|
1123
|
+
selectedDiscoveredBuiltinToolNames = [...entry.selectedToolNames];
|
|
1124
|
+
hasPersistedDiscoveredBuiltinToolSelection = true;
|
|
1056
1125
|
} else if (entry.type === "mode_change") {
|
|
1057
1126
|
mode = entry.mode;
|
|
1058
1127
|
modeData = entry.data;
|
|
@@ -1176,7 +1245,9 @@ export function buildSessionContext(
|
|
|
1176
1245
|
ttsrMessageCount,
|
|
1177
1246
|
|
|
1178
1247
|
selectedMCPToolNames,
|
|
1248
|
+
selectedDiscoveredBuiltinToolNames,
|
|
1179
1249
|
hasPersistedMCPToolSelection,
|
|
1250
|
+
hasPersistedDiscoveredBuiltinToolSelection,
|
|
1180
1251
|
mode,
|
|
1181
1252
|
modeData,
|
|
1182
1253
|
};
|
|
@@ -1201,6 +1272,9 @@ function cloneSessionContext(context: SessionContext): SessionContext {
|
|
|
1201
1272
|
ttsrMessageCount: context.ttsrMessageCount,
|
|
1202
1273
|
|
|
1203
1274
|
selectedMCPToolNames: [...context.selectedMCPToolNames],
|
|
1275
|
+
selectedDiscoveredBuiltinToolNames: context.selectedDiscoveredBuiltinToolNames
|
|
1276
|
+
? [...context.selectedDiscoveredBuiltinToolNames]
|
|
1277
|
+
: undefined,
|
|
1204
1278
|
modeData: cloneJsonSemantic(context.modeData),
|
|
1205
1279
|
};
|
|
1206
1280
|
}
|
|
@@ -1231,6 +1305,140 @@ function computeDefaultSessionDir(
|
|
|
1231
1305
|
return prepared.scope.directoryPath;
|
|
1232
1306
|
}
|
|
1233
1307
|
|
|
1308
|
+
/** A session directory's authority is distinct from its string path. */
|
|
1309
|
+
export type SessionDestination =
|
|
1310
|
+
| { readonly kind: "managed"; readonly directory: string; readonly securityContext: ManagedSessionSecurityContext }
|
|
1311
|
+
| { readonly kind: "explicit"; readonly directory: string };
|
|
1312
|
+
export type SessionDestinationInput = string | SessionDestination | undefined;
|
|
1313
|
+
|
|
1314
|
+
interface ForkArtifactPublication {
|
|
1315
|
+
readonly snapshot: native.NativeDirectoryTreeSnapshot;
|
|
1316
|
+
readonly store: ManagedSessionDescendantStore;
|
|
1317
|
+
readonly cleanupStore: ManagedSessionDescendantStore;
|
|
1318
|
+
readonly cleanupRelativePath: string;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function retainedTreeSnapshotEquals(
|
|
1322
|
+
left: native.NativeDirectoryTreeSnapshot,
|
|
1323
|
+
right: native.NativeDirectoryTreeSnapshot,
|
|
1324
|
+
): boolean {
|
|
1325
|
+
return (
|
|
1326
|
+
left.rootDev === right.rootDev &&
|
|
1327
|
+
left.rootIno === right.rootIno &&
|
|
1328
|
+
left.entries.length === right.entries.length &&
|
|
1329
|
+
left.entries.every((entry, index) => {
|
|
1330
|
+
const other = right.entries[index];
|
|
1331
|
+
return (
|
|
1332
|
+
other !== undefined &&
|
|
1333
|
+
entry.relativePath === other.relativePath &&
|
|
1334
|
+
entry.kind === other.kind &&
|
|
1335
|
+
entry.dev === other.dev &&
|
|
1336
|
+
entry.ino === other.ino &&
|
|
1337
|
+
entry.size === other.size &&
|
|
1338
|
+
entry.mtimeNs === other.mtimeNs &&
|
|
1339
|
+
entry.ctimeNs === other.ctimeNs &&
|
|
1340
|
+
entry.sha256 === other.sha256
|
|
1341
|
+
);
|
|
1342
|
+
})
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function retainedTreeSnapshotEqualsAfterRename(
|
|
1347
|
+
left: native.NativeDirectoryTreeSnapshot,
|
|
1348
|
+
right: native.NativeDirectoryTreeSnapshot,
|
|
1349
|
+
): boolean {
|
|
1350
|
+
return (
|
|
1351
|
+
left.rootDev === right.rootDev &&
|
|
1352
|
+
left.rootIno === right.rootIno &&
|
|
1353
|
+
left.entries.length === right.entries.length &&
|
|
1354
|
+
left.entries.every((entry, index) => {
|
|
1355
|
+
const other = right.entries[index];
|
|
1356
|
+
return (
|
|
1357
|
+
other !== undefined &&
|
|
1358
|
+
entry.relativePath === other.relativePath &&
|
|
1359
|
+
entry.kind === other.kind &&
|
|
1360
|
+
entry.dev === other.dev &&
|
|
1361
|
+
entry.ino === other.ino &&
|
|
1362
|
+
entry.size === other.size &&
|
|
1363
|
+
entry.mtimeNs === other.mtimeNs &&
|
|
1364
|
+
(entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
|
|
1365
|
+
entry.sha256 === other.sha256
|
|
1366
|
+
);
|
|
1367
|
+
})
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
|
|
1372
|
+
return (
|
|
1373
|
+
left !== null &&
|
|
1374
|
+
right !== null &&
|
|
1375
|
+
left.identity.dev === right.identity.dev &&
|
|
1376
|
+
left.identity.ino === right.identity.ino &&
|
|
1377
|
+
left.identity.size === right.identity.size &&
|
|
1378
|
+
left.identity.mtimeNs === right.identity.mtimeNs &&
|
|
1379
|
+
left.identity.ctimeNs === right.identity.ctimeNs &&
|
|
1380
|
+
left.identity.sha256 === right.identity.sha256 &&
|
|
1381
|
+
left.bytes.equals(right.bytes)
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
const trustedSessionDestinations = new WeakSet<SessionDestination>();
|
|
1385
|
+
|
|
1386
|
+
function managedStoreFromContext(
|
|
1387
|
+
securityContext: ManagedSessionSecurityContext,
|
|
1388
|
+
directory: string,
|
|
1389
|
+
): ManagedSessionDescendantStore {
|
|
1390
|
+
return new ManagedSessionDescendantStore(
|
|
1391
|
+
securityContext.rootAuthority,
|
|
1392
|
+
directory,
|
|
1393
|
+
securityContext.retainedAuthority
|
|
1394
|
+
? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
|
|
1395
|
+
: undefined,
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
1400
|
+
const rootAuthority = managedRootForScope(scope);
|
|
1401
|
+
const securityContext = createManagedSessionSecurityContext({
|
|
1402
|
+
agentDir: scope.agentDir,
|
|
1403
|
+
sessionsRoot: scope.sessionsRoot,
|
|
1404
|
+
sessionDir: scope.directoryPath,
|
|
1405
|
+
rootAuthority,
|
|
1406
|
+
retainedAuthority: managedDirectoryAuthorityForScope(scope),
|
|
1407
|
+
});
|
|
1408
|
+
const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
|
|
1409
|
+
trustedSessionDestinations.add(destination);
|
|
1410
|
+
return destination;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function explicitDestination(directory: string): SessionDestination {
|
|
1414
|
+
const destination = Object.freeze({ kind: "explicit" as const, directory });
|
|
1415
|
+
trustedSessionDestinations.add(destination);
|
|
1416
|
+
return destination;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
|
|
1420
|
+
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1421
|
+
const sessionsRoot = getSessionsDir(agentDir);
|
|
1422
|
+
const resolved = resolveManagedScope({ cwd, agentDir: agentDir ?? path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
1423
|
+
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
1424
|
+
const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
|
|
1425
|
+
if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
|
|
1426
|
+
return freezeManagedDestination(prepared.scope);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function destinationFor(
|
|
1430
|
+
cwd: string,
|
|
1431
|
+
input: SessionDestinationInput,
|
|
1432
|
+
storage: SessionStorage,
|
|
1433
|
+
agentDir?: string,
|
|
1434
|
+
): SessionDestination {
|
|
1435
|
+
if (typeof input === "object") {
|
|
1436
|
+
if (!trustedSessionDestinations.has(input)) throw new Error("Untrusted session destination authority");
|
|
1437
|
+
return input;
|
|
1438
|
+
}
|
|
1439
|
+
return input === undefined ? managedDestination(cwd, storage, agentDir) : explicitDestination(input);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1234
1442
|
// =============================================================================
|
|
1235
1443
|
// Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
|
|
1236
1444
|
// =============================================================================
|
|
@@ -1475,7 +1683,19 @@ function hasStrictSessionSchema(entries: FileEntry[]): boolean {
|
|
|
1475
1683
|
case "mcp_tool_selection":
|
|
1476
1684
|
if (
|
|
1477
1685
|
!Array.isArray(value.selectedToolNames) ||
|
|
1478
|
-
!value.selectedToolNames.every(name => typeof name === "string")
|
|
1686
|
+
!value.selectedToolNames.every(name => typeof name === "string") ||
|
|
1687
|
+
(value.selectedDiscoveredBuiltinToolNames !== undefined &&
|
|
1688
|
+
(!Array.isArray(value.selectedDiscoveredBuiltinToolNames) ||
|
|
1689
|
+
!value.selectedDiscoveredBuiltinToolNames.every(name => typeof name === "string"))) ||
|
|
1690
|
+
(value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
|
|
1691
|
+
)
|
|
1692
|
+
return false;
|
|
1693
|
+
break;
|
|
1694
|
+
case "discovered_builtin_tool_selection":
|
|
1695
|
+
if (
|
|
1696
|
+
!Array.isArray(value.selectedToolNames) ||
|
|
1697
|
+
!value.selectedToolNames.every(name => typeof name === "string") ||
|
|
1698
|
+
(value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
|
|
1479
1699
|
)
|
|
1480
1700
|
return false;
|
|
1481
1701
|
break;
|
|
@@ -1806,8 +2026,13 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
|
|
|
1806
2026
|
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1807
2027
|
if (entries.length === 0) return;
|
|
1808
2028
|
const header = entries[0] as Record<string, unknown>;
|
|
1809
|
-
if (
|
|
1810
|
-
|
|
2029
|
+
if (
|
|
2030
|
+
header.type !== "session" ||
|
|
2031
|
+
typeof header.id !== "string" ||
|
|
2032
|
+
!isSupportedSessionVersion(header.version)
|
|
2033
|
+
)
|
|
2034
|
+
return;
|
|
2035
|
+
if (typeof header.version === "number" && header.version >= 4) {
|
|
1811
2036
|
for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
|
|
1812
2037
|
applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
|
|
1813
2038
|
}
|
|
@@ -3005,11 +3230,16 @@ class NdjsonFileWriter {
|
|
|
3005
3230
|
#onError: ((err: Error) => void) | undefined;
|
|
3006
3231
|
#closeDrained = false;
|
|
3007
3232
|
|
|
3008
|
-
constructor(
|
|
3233
|
+
constructor(
|
|
3234
|
+
storage: SessionStorage,
|
|
3235
|
+
path: string,
|
|
3236
|
+
options?: { flags?: "a" | "w"; onError?: (err: Error) => void; securityContext?: ManagedSessionSecurityContext },
|
|
3237
|
+
) {
|
|
3009
3238
|
this.#onError = options?.onError;
|
|
3010
3239
|
this.#writer = storage.openWriter(path, {
|
|
3011
3240
|
flags: options?.flags ?? "a",
|
|
3012
3241
|
onError: (err: Error) => this.#recordError(err),
|
|
3242
|
+
securityContext: options?.securityContext,
|
|
3013
3243
|
});
|
|
3014
3244
|
}
|
|
3015
3245
|
|
|
@@ -3489,7 +3719,7 @@ async function collectSessionFromFile(
|
|
|
3489
3719
|
const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
|
|
3490
3720
|
const header = parseSessionListHeader(content, entries);
|
|
3491
3721
|
if (!header) return undefined;
|
|
3492
|
-
if (header.version === CURRENT_SESSION_VERSION) {
|
|
3722
|
+
if (typeof header.version === "number" && header.version >= 4 && header.version <= CURRENT_SESSION_VERSION) {
|
|
3493
3723
|
for (const patch of await readSessionListTrailingPatches(file, storage, buffer)) {
|
|
3494
3724
|
applySessionListHeaderPatch(header, patch);
|
|
3495
3725
|
}
|
|
@@ -3653,6 +3883,7 @@ export class SessionManager {
|
|
|
3653
3883
|
#flushed: boolean = false;
|
|
3654
3884
|
#needsFullRewriteOnNextPersist: boolean = false;
|
|
3655
3885
|
#ensuredOnDisk: boolean = false;
|
|
3886
|
+
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
3656
3887
|
#fileEntries: FileEntry[] = [];
|
|
3657
3888
|
#byId: Map<string, SessionEntry> = new Map();
|
|
3658
3889
|
#labelsById: Map<string, string> = new Map();
|
|
@@ -3673,6 +3904,7 @@ export class SessionManager {
|
|
|
3673
3904
|
|
|
3674
3905
|
#artifactManager: ArtifactManager | null = null;
|
|
3675
3906
|
#artifactManagerSessionFile: string | null = null;
|
|
3907
|
+
#managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
|
|
3676
3908
|
// When set, take precedence over the lazily-derived per-session manager.
|
|
3677
3909
|
// Subagents adopt the parent's manager so artifact IDs are unique across the
|
|
3678
3910
|
// whole agent tree and all files land in the parent's artifacts dir.
|
|
@@ -3718,6 +3950,7 @@ export class SessionManager {
|
|
|
3718
3950
|
private sessionDir: string,
|
|
3719
3951
|
private readonly persist: boolean,
|
|
3720
3952
|
private readonly storage: SessionStorage,
|
|
3953
|
+
private destination: SessionDestination = explicitDestination(sessionDir),
|
|
3721
3954
|
) {
|
|
3722
3955
|
this.#blobStore = persist ? new BlobStore(getBlobsDir()) : this.#residentTextBlobStore;
|
|
3723
3956
|
this.#residentImageBlobStore = this.#blobStore;
|
|
@@ -3822,7 +4055,12 @@ export class SessionManager {
|
|
|
3822
4055
|
|
|
3823
4056
|
#resetResidentTextBlobStore(): void {
|
|
3824
4057
|
this.#disposeResidentTextBlobStore();
|
|
3825
|
-
if (
|
|
4058
|
+
if (
|
|
4059
|
+
this.destination.kind === "explicit" &&
|
|
4060
|
+
this.persist &&
|
|
4061
|
+
this.#sessionFile &&
|
|
4062
|
+
this.storage instanceof FileSessionStorage
|
|
4063
|
+
) {
|
|
3826
4064
|
this.#residentTextBlobStore = new EphemeralBlobStore(this.#residentCacheDir(this.#sessionFile));
|
|
3827
4065
|
}
|
|
3828
4066
|
}
|
|
@@ -4014,6 +4252,9 @@ export class SessionManager {
|
|
|
4014
4252
|
const oldSessionFile = this.#sessionFile;
|
|
4015
4253
|
const oldSessionId = this.#sessionId;
|
|
4016
4254
|
const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
|
|
4255
|
+
const oldFileEntries = this.#fileEntries;
|
|
4256
|
+
const oldSessionName = this.#sessionName;
|
|
4257
|
+
const oldTitleSource = this.#titleSource;
|
|
4017
4258
|
|
|
4018
4259
|
// Close the current writer
|
|
4019
4260
|
await this.#closePersistWriter();
|
|
@@ -4049,13 +4290,169 @@ export class SessionManager {
|
|
|
4049
4290
|
this.#reexternalizeFileEntriesForResidentStore();
|
|
4050
4291
|
this.#bumpAllRevisions();
|
|
4051
4292
|
|
|
4052
|
-
// Write the new session file
|
|
4293
|
+
// Write the new session file, import artifacts, and adopt only after the
|
|
4294
|
+
// retained destination manifest is complete.
|
|
4053
4295
|
this.#flushed = false;
|
|
4054
|
-
|
|
4296
|
+
let forkArtifactPublication: ForkArtifactPublication | undefined;
|
|
4297
|
+
let forkTranscriptPublication: ManagedFileSnapshot | null = null;
|
|
4298
|
+
let forkTranscriptStore: ManagedSessionDescendantStore | undefined;
|
|
4299
|
+
try {
|
|
4300
|
+
forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, this.#sessionFile);
|
|
4301
|
+
await this.#rewriteFile();
|
|
4302
|
+
if (this.destination.kind === "managed") {
|
|
4303
|
+
forkTranscriptStore = this.#managedTranscriptStore();
|
|
4304
|
+
forkTranscriptPublication = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
|
|
4305
|
+
if (!forkTranscriptPublication) throw new Error("managed_fork_transcript_publish_missing");
|
|
4306
|
+
}
|
|
4307
|
+
forkArtifactPublication?.store.verifyRootSecurity();
|
|
4308
|
+
forkArtifactPublication?.store.assertBound();
|
|
4309
|
+
if (forkArtifactPublication) {
|
|
4310
|
+
const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
|
|
4311
|
+
if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot)) {
|
|
4312
|
+
throw new Error("artifact_destination_changed_during_transcript_publication");
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
if (forkTranscriptStore && forkTranscriptPublication) {
|
|
4316
|
+
const terminalTranscript = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
|
|
4317
|
+
if (!managedFileSnapshotEquals(terminalTranscript, forkTranscriptPublication))
|
|
4318
|
+
throw new Error("managed_fork_transcript_changed");
|
|
4319
|
+
}
|
|
4320
|
+
} catch (error) {
|
|
4321
|
+
let failure = error;
|
|
4322
|
+
const failedSessionFile = this.#sessionFile;
|
|
4323
|
+
if (this.destination.kind === "managed") {
|
|
4324
|
+
try {
|
|
4325
|
+
if (forkTranscriptStore && forkTranscriptPublication)
|
|
4326
|
+
forkTranscriptStore.removeExpected(path.basename(failedSessionFile), forkTranscriptPublication);
|
|
4327
|
+
if (forkArtifactPublication)
|
|
4328
|
+
forkArtifactPublication.cleanupStore.removeTreeExpected(
|
|
4329
|
+
forkArtifactPublication.cleanupRelativePath,
|
|
4330
|
+
forkArtifactPublication.snapshot,
|
|
4331
|
+
);
|
|
4332
|
+
} catch (cleanupError) {
|
|
4333
|
+
failure = new Error(`Failed to clean up managed fork destination: ${toError(cleanupError).message}`, {
|
|
4334
|
+
cause: toError(error),
|
|
4335
|
+
});
|
|
4336
|
+
}
|
|
4337
|
+
} else {
|
|
4338
|
+
try {
|
|
4339
|
+
await this.storage.unlink(failedSessionFile);
|
|
4340
|
+
} catch {
|
|
4341
|
+
// Preserve ambiguous cleanup evidence while restoring the active manager.
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
this.#sessionId = oldSessionId;
|
|
4345
|
+
this.#sessionFile = oldSessionFile;
|
|
4346
|
+
this.#fileEntries = oldFileEntries;
|
|
4347
|
+
this.#sessionName = oldSessionName;
|
|
4348
|
+
this.#titleSource = oldTitleSource;
|
|
4349
|
+
this.#persistChain = Promise.resolve();
|
|
4350
|
+
this.#persistError = undefined;
|
|
4351
|
+
this.#persistErrorReported = false;
|
|
4352
|
+
this.#resetResidentTextBlobStore();
|
|
4353
|
+
this.#reexternalizeFileEntriesForResidentStore();
|
|
4354
|
+
this.#bumpAllRevisions();
|
|
4355
|
+
throw failure;
|
|
4356
|
+
}
|
|
4055
4357
|
|
|
4056
4358
|
return { oldSessionFile, newSessionFile: this.#sessionFile };
|
|
4057
4359
|
}
|
|
4058
4360
|
|
|
4361
|
+
async copyArtifactsForFork(
|
|
4362
|
+
oldSessionFile: string,
|
|
4363
|
+
newSessionFile: string,
|
|
4364
|
+
): Promise<ForkArtifactPublication | undefined> {
|
|
4365
|
+
const sourceDir = oldSessionFile.slice(0, -6);
|
|
4366
|
+
const finalDestinationDir = newSessionFile.slice(0, -6);
|
|
4367
|
+
if (this.destination.kind !== "managed") {
|
|
4368
|
+
try {
|
|
4369
|
+
const sourceStat = fs.lstatSync(sourceDir);
|
|
4370
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw new Error("unsafe_artifacts");
|
|
4371
|
+
} catch (error) {
|
|
4372
|
+
if (isEnoent(error)) return;
|
|
4373
|
+
throw error;
|
|
4374
|
+
}
|
|
4375
|
+
await fs.promises.cp(sourceDir, finalDestinationDir, { recursive: true });
|
|
4376
|
+
return undefined;
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
const sourceName = path.basename(sourceDir);
|
|
4380
|
+
const destinationName = path.basename(finalDestinationDir);
|
|
4381
|
+
const stagingName = `.${destinationName}.${crypto.randomUUID()}.fork-staging`;
|
|
4382
|
+
const parentStore = this.#managedTranscriptStore(oldSessionFile);
|
|
4383
|
+
let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
|
|
4384
|
+
try {
|
|
4385
|
+
sourceSnapshot = parentStore.captureTree(sourceName);
|
|
4386
|
+
} catch (error) {
|
|
4387
|
+
if (error instanceof Error && error.message === "not_found") return;
|
|
4388
|
+
throw error;
|
|
4389
|
+
}
|
|
4390
|
+
let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4391
|
+
let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4392
|
+
try {
|
|
4393
|
+
parentStore.ensureDirectory(stagingName);
|
|
4394
|
+
stagingSnapshot = parentStore.captureTree(stagingName);
|
|
4395
|
+
for (const entry of sourceSnapshot.entries) {
|
|
4396
|
+
if (entry.relativePath === "") continue;
|
|
4397
|
+
const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
|
|
4398
|
+
if (entry.kind === "directory") {
|
|
4399
|
+
parentStore.ensureDirectory(destinationRelativePath);
|
|
4400
|
+
} else if (entry.kind === "file") {
|
|
4401
|
+
const sourceFile = parentStore.readExpected(path.posix.join(sourceName, entry.relativePath));
|
|
4402
|
+
if (
|
|
4403
|
+
!sourceFile ||
|
|
4404
|
+
sourceFile.identity.dev.toString() !== entry.dev ||
|
|
4405
|
+
sourceFile.identity.ino.toString() !== entry.ino ||
|
|
4406
|
+
sourceFile.identity.size.toString() !== entry.size ||
|
|
4407
|
+
sourceFile.identity.mtimeNs.toString() !== entry.mtimeNs ||
|
|
4408
|
+
crypto.createHash("sha256").update(sourceFile.bytes).digest("hex") !== entry.sha256
|
|
4409
|
+
) {
|
|
4410
|
+
throw new Error("artifact_source_changed");
|
|
4411
|
+
}
|
|
4412
|
+
await parentStore.publishNoReplace(destinationRelativePath, sourceFile.bytes);
|
|
4413
|
+
} else {
|
|
4414
|
+
throw new Error("unsafe_artifacts");
|
|
4415
|
+
}
|
|
4416
|
+
stagingSnapshot = parentStore.captureTree(stagingName);
|
|
4417
|
+
}
|
|
4418
|
+
if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
|
|
4419
|
+
throw new Error("artifact_source_changed");
|
|
4420
|
+
parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4421
|
+
publishedSnapshot = parentStore.captureTree(destinationName);
|
|
4422
|
+
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4423
|
+
tree.entries.map(entry => ({
|
|
4424
|
+
relativePath: entry.relativePath,
|
|
4425
|
+
kind: entry.kind,
|
|
4426
|
+
size: entry.kind === "file" ? entry.size : undefined,
|
|
4427
|
+
sha256: entry.sha256,
|
|
4428
|
+
}));
|
|
4429
|
+
if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(sourceSnapshot)))
|
|
4430
|
+
throw new Error("artifact_destination_terminal_mismatch");
|
|
4431
|
+
const publishedStore = parentStore.deriveSubtree(destinationName);
|
|
4432
|
+
publishedStore.verifyRootSecurity();
|
|
4433
|
+
const retainedPublishedSnapshot = publishedStore.captureTree("");
|
|
4434
|
+
if (!retainedTreeSnapshotEqualsAfterRename(retainedPublishedSnapshot, publishedSnapshot))
|
|
4435
|
+
throw new Error("artifact_destination_retained_identity_changed");
|
|
4436
|
+
publishedSnapshot = retainedPublishedSnapshot;
|
|
4437
|
+
return {
|
|
4438
|
+
snapshot: publishedSnapshot,
|
|
4439
|
+
store: publishedStore,
|
|
4440
|
+
cleanupStore: parentStore,
|
|
4441
|
+
cleanupRelativePath: destinationName,
|
|
4442
|
+
};
|
|
4443
|
+
} catch (error) {
|
|
4444
|
+
try {
|
|
4445
|
+
if (publishedSnapshot) parentStore.removeTreeExpected(destinationName, publishedSnapshot);
|
|
4446
|
+
else if (stagingSnapshot) parentStore.removeTreeExpected(stagingName, stagingSnapshot);
|
|
4447
|
+
} catch (cleanupError) {
|
|
4448
|
+
throw new Error(`Failed to clean up managed fork artifacts: ${toError(cleanupError).message}`, {
|
|
4449
|
+
cause: toError(error),
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
4452
|
+
throw error;
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4059
4456
|
/**
|
|
4060
4457
|
* Move the session to a new working directory.
|
|
4061
4458
|
* Moves session files and artifacts on disk, updates all internal references,
|
|
@@ -4067,17 +4464,26 @@ export class SessionManager {
|
|
|
4067
4464
|
const previousCwd = this.cwd;
|
|
4068
4465
|
const previousSessionDir = this.sessionDir;
|
|
4069
4466
|
const previousSessionFile = this.#sessionFile;
|
|
4467
|
+
const previousDestination = this.destination;
|
|
4070
4468
|
const previousArtifactDir = previousSessionFile?.slice(0, -6);
|
|
4071
4469
|
|
|
4072
|
-
const
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4470
|
+
const nextDestination = (() => {
|
|
4471
|
+
if (this.destination.kind === "managed") {
|
|
4472
|
+
return managedDestination(resolvedCwd, this.storage, this.destination.securityContext.agentDir);
|
|
4473
|
+
}
|
|
4474
|
+
if (!(this.storage instanceof FileSessionStorage)) return this.destination;
|
|
4475
|
+
const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
|
|
4476
|
+
const directory = managedSessionsRoot
|
|
4477
|
+
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
|
4478
|
+
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
|
4479
|
+
return explicitDestination(directory);
|
|
4480
|
+
})();
|
|
4481
|
+
const newSessionDir = nextDestination.directory;
|
|
4080
4482
|
let hadSessionFile = false;
|
|
4483
|
+
const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
|
|
4484
|
+
let managedSourceStore: ManagedSessionDescendantStore | undefined;
|
|
4485
|
+
let managedDestinationStore: ManagedSessionDescendantStore | undefined;
|
|
4486
|
+
let rollbackManagedMove: (() => Promise<void>) | undefined;
|
|
4081
4487
|
|
|
4082
4488
|
if (this.persist && this.#sessionFile) {
|
|
4083
4489
|
// Close the persist writer before moving files
|
|
@@ -4090,7 +4496,11 @@ export class SessionManager {
|
|
|
4090
4496
|
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
|
4091
4497
|
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
4092
4498
|
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
4093
|
-
|
|
4499
|
+
let managedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
|
|
4500
|
+
let managedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4501
|
+
let managedPublishedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
|
|
4502
|
+
let managedPublishedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4503
|
+
hadSessionFile = managedMove ? false : this.storage.existsSync(oldSessionFile);
|
|
4094
4504
|
let movedSessionFile = false;
|
|
4095
4505
|
let movedArtifactDir = false;
|
|
4096
4506
|
const materializedEntries = materializeResidentEntriesForReadSync(
|
|
@@ -4113,56 +4523,184 @@ export class SessionManager {
|
|
|
4113
4523
|
}
|
|
4114
4524
|
throw error;
|
|
4115
4525
|
};
|
|
4526
|
+
const copyManagedTree = async (
|
|
4527
|
+
source: ManagedSessionDescendantStore,
|
|
4528
|
+
sourceRelative: string,
|
|
4529
|
+
destination: ManagedSessionDescendantStore,
|
|
4530
|
+
destinationRelative: string,
|
|
4531
|
+
snapshot: native.NativeDirectoryTreeSnapshot,
|
|
4532
|
+
): Promise<void> => {
|
|
4533
|
+
destination.ensureDirectory(destinationRelative);
|
|
4534
|
+
for (const entry of snapshot.entries) {
|
|
4535
|
+
if (entry.relativePath === "") continue;
|
|
4536
|
+
const target = path.posix.join(destinationRelative, entry.relativePath);
|
|
4537
|
+
if (entry.kind === "directory") destination.ensureDirectory(target);
|
|
4538
|
+
else {
|
|
4539
|
+
const file = source.readExpected(path.posix.join(sourceRelative, entry.relativePath));
|
|
4540
|
+
if (
|
|
4541
|
+
!file ||
|
|
4542
|
+
file.identity.dev.toString() !== entry.dev ||
|
|
4543
|
+
file.identity.ino.toString() !== entry.ino ||
|
|
4544
|
+
file.identity.size.toString() !== entry.size ||
|
|
4545
|
+
file.identity.mtimeNs.toString() !== entry.mtimeNs ||
|
|
4546
|
+
crypto.createHash("sha256").update(file.bytes).digest("hex") !== entry.sha256
|
|
4547
|
+
)
|
|
4548
|
+
throw new Error("artifact_source_changed");
|
|
4549
|
+
await destination.publishNoReplace(target, file.bytes);
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
const imported = destination.captureTree(destinationRelative);
|
|
4553
|
+
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4554
|
+
tree.entries.map(entry => ({
|
|
4555
|
+
relativePath: entry.relativePath,
|
|
4556
|
+
kind: entry.kind,
|
|
4557
|
+
size: entry.size,
|
|
4558
|
+
sha256: entry.sha256,
|
|
4559
|
+
}));
|
|
4560
|
+
if (JSON.stringify(comparable(imported)) !== JSON.stringify(comparable(snapshot)))
|
|
4561
|
+
throw new Error("artifact_destination_mismatch");
|
|
4562
|
+
destination.fsyncTree();
|
|
4563
|
+
};
|
|
4116
4564
|
this.#disposeResidentTextBlobStore();
|
|
4117
|
-
|
|
4118
4565
|
try {
|
|
4119
|
-
|
|
4120
|
-
|
|
4566
|
+
if (managedMove) {
|
|
4567
|
+
if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
|
|
4568
|
+
throw new Error("managed_move_destination_unavailable");
|
|
4569
|
+
managedSourceStore = this.#managedTranscriptStore(oldSessionFile);
|
|
4570
|
+
managedDestinationStore = managedStoreFromContext(nextDestination.securityContext, newSessionDir);
|
|
4571
|
+
rollbackManagedMove = async () => {
|
|
4572
|
+
if (!managedSourceStore || !managedDestinationStore)
|
|
4573
|
+
throw new Error("managed_move_authority_unavailable");
|
|
4574
|
+
if (managedArtifacts && managedPublishedArtifacts) {
|
|
4575
|
+
await copyManagedTree(
|
|
4576
|
+
managedDestinationStore,
|
|
4577
|
+
path.basename(newArtifactDir),
|
|
4578
|
+
managedSourceStore,
|
|
4579
|
+
path.basename(oldArtifactDir),
|
|
4580
|
+
managedPublishedArtifacts,
|
|
4581
|
+
);
|
|
4582
|
+
}
|
|
4583
|
+
if (managedTranscript)
|
|
4584
|
+
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4585
|
+
if (managedPublishedArtifacts)
|
|
4586
|
+
managedDestinationStore.removeTreeExpected(
|
|
4587
|
+
path.basename(newArtifactDir),
|
|
4588
|
+
managedPublishedArtifacts,
|
|
4589
|
+
);
|
|
4590
|
+
if (managedPublishedTranscript)
|
|
4591
|
+
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4592
|
+
};
|
|
4593
|
+
managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
|
|
4594
|
+
hadSessionFile = managedTranscript !== null;
|
|
4595
|
+
if (managedTranscript) {
|
|
4596
|
+
await managedDestinationStore.publishNoReplace(
|
|
4597
|
+
path.basename(newSessionFile),
|
|
4598
|
+
managedTranscript.bytes,
|
|
4599
|
+
);
|
|
4600
|
+
managedPublishedTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
|
|
4601
|
+
if (!managedPublishedTranscript) throw new Error("managed_transcript_publish_missing");
|
|
4602
|
+
movedSessionFile = true;
|
|
4603
|
+
}
|
|
4121
4604
|
try {
|
|
4605
|
+
managedArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
|
|
4606
|
+
await copyManagedTree(
|
|
4607
|
+
managedSourceStore,
|
|
4608
|
+
path.basename(oldArtifactDir),
|
|
4609
|
+
managedDestinationStore,
|
|
4610
|
+
path.basename(newArtifactDir),
|
|
4611
|
+
managedArtifacts,
|
|
4612
|
+
);
|
|
4613
|
+
managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
|
|
4614
|
+
movedArtifactDir = true;
|
|
4615
|
+
} catch (error) {
|
|
4616
|
+
if (!(error instanceof Error) || error.message !== "not_found") throw error;
|
|
4617
|
+
}
|
|
4618
|
+
if (managedPublishedTranscript) {
|
|
4619
|
+
const terminalTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
|
|
4620
|
+
if (!managedFileSnapshotEquals(terminalTranscript, managedPublishedTranscript))
|
|
4621
|
+
throw new Error("managed_move_destination_transcript_changed");
|
|
4622
|
+
}
|
|
4623
|
+
if (managedPublishedArtifacts) {
|
|
4624
|
+
const terminalArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
|
|
4625
|
+
if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
|
|
4626
|
+
throw new Error("managed_move_destination_artifacts_changed");
|
|
4627
|
+
}
|
|
4628
|
+
if (managedArtifacts)
|
|
4629
|
+
managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
|
|
4630
|
+
if (managedTranscript)
|
|
4631
|
+
managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
|
|
4632
|
+
} else {
|
|
4633
|
+
if (hadSessionFile && oldSessionFile !== newSessionFile) {
|
|
4122
4634
|
await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
|
|
4123
4635
|
movedSessionFile = true;
|
|
4124
|
-
} catch (error) {
|
|
4125
|
-
if (error instanceof PathMoveCompletedWithSyncFailure) movedSessionFile = true;
|
|
4126
|
-
throw error;
|
|
4127
4636
|
}
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
const stat = await fs.promises.stat(oldArtifactDir);
|
|
4132
|
-
if (stat.isDirectory()) {
|
|
4133
|
-
try {
|
|
4637
|
+
try {
|
|
4638
|
+
const stat = await fs.promises.stat(oldArtifactDir);
|
|
4639
|
+
if (stat.isDirectory() && oldArtifactDir !== newArtifactDir) {
|
|
4134
4640
|
await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
|
|
4135
4641
|
movedArtifactDir = true;
|
|
4136
|
-
} catch (error) {
|
|
4137
|
-
if (error instanceof PathMoveCompletedWithSyncFailure) movedArtifactDir = true;
|
|
4138
|
-
throw error;
|
|
4139
4642
|
}
|
|
4643
|
+
} catch (err) {
|
|
4644
|
+
if (!isEnoent(err)) throw err;
|
|
4140
4645
|
}
|
|
4141
|
-
} catch (err) {
|
|
4142
|
-
if (!isEnoent(err)) throw err;
|
|
4143
4646
|
}
|
|
4144
4647
|
} catch (err) {
|
|
4145
|
-
if (
|
|
4146
|
-
try {
|
|
4147
|
-
await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
|
|
4148
|
-
} catch (rollbackErr) {
|
|
4149
|
-
restoreResidentStateAndThrow(
|
|
4150
|
-
new Error(
|
|
4151
|
-
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
4152
|
-
),
|
|
4153
|
-
);
|
|
4154
|
-
}
|
|
4155
|
-
}
|
|
4156
|
-
if (movedSessionFile) {
|
|
4648
|
+
if (managedMove && managedDestinationStore) {
|
|
4157
4649
|
try {
|
|
4158
|
-
|
|
4650
|
+
if (managedArtifacts && !managedPublishedArtifacts) {
|
|
4651
|
+
try {
|
|
4652
|
+
managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
|
|
4653
|
+
} catch (captureError) {
|
|
4654
|
+
if (!(captureError instanceof Error) || captureError.message !== "not_found")
|
|
4655
|
+
throw captureError;
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
if (managedSourceStore && managedArtifacts && managedPublishedArtifacts) {
|
|
4659
|
+
let sourceArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
|
|
4660
|
+
try {
|
|
4661
|
+
sourceArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
|
|
4662
|
+
} catch (captureError) {
|
|
4663
|
+
if (!(captureError instanceof Error) || captureError.message !== "not_found")
|
|
4664
|
+
throw captureError;
|
|
4665
|
+
}
|
|
4666
|
+
if (!sourceArtifacts || !retainedTreeSnapshotEquals(sourceArtifacts, managedArtifacts)) {
|
|
4667
|
+
if (sourceArtifacts)
|
|
4668
|
+
managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), sourceArtifacts);
|
|
4669
|
+
await copyManagedTree(
|
|
4670
|
+
managedDestinationStore,
|
|
4671
|
+
path.basename(newArtifactDir),
|
|
4672
|
+
managedSourceStore,
|
|
4673
|
+
path.basename(oldArtifactDir),
|
|
4674
|
+
managedPublishedArtifacts,
|
|
4675
|
+
);
|
|
4676
|
+
const restored = managedSourceStore.captureTree(path.basename(oldArtifactDir));
|
|
4677
|
+
if (!retainedTreeSnapshotEqualsAfterRename(restored, managedArtifacts))
|
|
4678
|
+
throw new Error("managed_move_source_restore_mismatch");
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4681
|
+
if (
|
|
4682
|
+
managedSourceStore &&
|
|
4683
|
+
managedTranscript &&
|
|
4684
|
+
!managedSourceStore.readExpected(path.basename(oldSessionFile))
|
|
4685
|
+
)
|
|
4686
|
+
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4687
|
+
if (managedPublishedArtifacts)
|
|
4688
|
+
managedDestinationStore.removeTreeExpected(
|
|
4689
|
+
path.basename(newArtifactDir),
|
|
4690
|
+
managedPublishedArtifacts,
|
|
4691
|
+
);
|
|
4692
|
+
if (managedPublishedTranscript)
|
|
4693
|
+
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4159
4694
|
} catch (rollbackErr) {
|
|
4160
4695
|
restoreResidentStateAndThrow(
|
|
4161
|
-
new Error(
|
|
4162
|
-
|
|
4163
|
-
),
|
|
4696
|
+
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
4697
|
+
cause: toError(err),
|
|
4698
|
+
}),
|
|
4164
4699
|
);
|
|
4165
4700
|
}
|
|
4701
|
+
} else {
|
|
4702
|
+
if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
|
|
4703
|
+
if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
|
|
4166
4704
|
}
|
|
4167
4705
|
restoreResidentStateAndThrow(err);
|
|
4168
4706
|
}
|
|
@@ -4176,6 +4714,11 @@ export class SessionManager {
|
|
|
4176
4714
|
// Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
|
|
4177
4715
|
this.cwd = resolvedCwd;
|
|
4178
4716
|
this.sessionDir = newSessionDir;
|
|
4717
|
+
this.destination = nextDestination;
|
|
4718
|
+
this.#managedTranscriptStoreCache =
|
|
4719
|
+
managedMove && managedDestinationStore
|
|
4720
|
+
? { directory: path.resolve(newSessionDir), store: managedDestinationStore }
|
|
4721
|
+
: null;
|
|
4179
4722
|
|
|
4180
4723
|
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
4181
4724
|
try {
|
|
@@ -4196,6 +4739,21 @@ export class SessionManager {
|
|
|
4196
4739
|
this.#persistChain = Promise.resolve();
|
|
4197
4740
|
this.#persistError = undefined;
|
|
4198
4741
|
this.#persistErrorReported = false;
|
|
4742
|
+
if (rollbackManagedMove) {
|
|
4743
|
+
await rollbackManagedMove();
|
|
4744
|
+
this.#sessionFile = previousSessionFile;
|
|
4745
|
+
this.cwd = previousCwd;
|
|
4746
|
+
this.sessionDir = previousSessionDir;
|
|
4747
|
+
this.destination = previousDestination;
|
|
4748
|
+
this.#managedTranscriptStoreCache =
|
|
4749
|
+
managedMove && managedSourceStore
|
|
4750
|
+
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4751
|
+
: null;
|
|
4752
|
+
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4753
|
+
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4754
|
+
this.#headerExportRevision++;
|
|
4755
|
+
throw error;
|
|
4756
|
+
}
|
|
4199
4757
|
if (
|
|
4200
4758
|
this.storage instanceof FileSessionStorage &&
|
|
4201
4759
|
failedArtifactDir &&
|
|
@@ -4217,6 +4775,8 @@ export class SessionManager {
|
|
|
4217
4775
|
this.#sessionFile = previousSessionFile;
|
|
4218
4776
|
this.cwd = previousCwd;
|
|
4219
4777
|
this.sessionDir = previousSessionDir;
|
|
4778
|
+
this.destination = previousDestination;
|
|
4779
|
+
this.#managedTranscriptStoreCache = null;
|
|
4220
4780
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4221
4781
|
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4222
4782
|
this.#headerExportRevision++;
|
|
@@ -4348,6 +4908,10 @@ export class SessionManager {
|
|
|
4348
4908
|
#ensurePersistWriter(): NdjsonFileWriter | undefined {
|
|
4349
4909
|
if (!this.persist || !this.#sessionFile) return undefined;
|
|
4350
4910
|
if (this.#persistError) throw this.#persistError;
|
|
4911
|
+
if (this.destination.kind === "managed") {
|
|
4912
|
+
this.#managedTranscriptStore();
|
|
4913
|
+
return undefined;
|
|
4914
|
+
}
|
|
4351
4915
|
if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
|
|
4352
4916
|
if (this.#persistWriter.isOpen()) return this.#persistWriter;
|
|
4353
4917
|
// Cached writer for the current file is mid-close (queued
|
|
@@ -4511,8 +5075,31 @@ export class SessionManager {
|
|
|
4511
5075
|
}
|
|
4512
5076
|
}
|
|
4513
5077
|
|
|
5078
|
+
#managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
|
|
5079
|
+
if (this.destination.kind !== "managed" || !sessionFile) {
|
|
5080
|
+
throw new Error("Managed transcript authority is unavailable");
|
|
5081
|
+
}
|
|
5082
|
+
const sessionDir = path.resolve(path.dirname(sessionFile));
|
|
5083
|
+
if (sessionDir !== path.resolve(this.destination.directory)) {
|
|
5084
|
+
throw new Error("Managed transcript escaped its session directory");
|
|
5085
|
+
}
|
|
5086
|
+
if (this.#managedTranscriptStoreCache) {
|
|
5087
|
+
if (this.#managedTranscriptStoreCache.directory !== sessionDir)
|
|
5088
|
+
throw new Error("Managed transcript store transition was not verified");
|
|
5089
|
+
return this.#managedTranscriptStoreCache.store;
|
|
5090
|
+
}
|
|
5091
|
+
const store = managedStoreFromContext(this.destination.securityContext, sessionDir);
|
|
5092
|
+
this.#managedTranscriptStoreCache = { directory: sessionDir, store };
|
|
5093
|
+
return store;
|
|
5094
|
+
}
|
|
5095
|
+
|
|
4514
5096
|
#writeEntriesAtomicallySync(entries: FileEntry[]): void {
|
|
4515
5097
|
if (!this.#sessionFile) return;
|
|
5098
|
+
if (this.destination.kind === "managed") {
|
|
5099
|
+
const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
5100
|
+
this.#managedTranscriptStore().replaceSync(path.basename(this.#sessionFile), bytes);
|
|
5101
|
+
return;
|
|
5102
|
+
}
|
|
4516
5103
|
const dir = path.resolve(this.#sessionFile, "..");
|
|
4517
5104
|
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
4518
5105
|
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
@@ -4539,6 +5126,11 @@ export class SessionManager {
|
|
|
4539
5126
|
}
|
|
4540
5127
|
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
4541
5128
|
if (!this.#sessionFile) return;
|
|
5129
|
+
if (this.destination.kind === "managed") {
|
|
5130
|
+
const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
|
5131
|
+
await this.#managedTranscriptStore().replace(path.basename(this.#sessionFile), bytes);
|
|
5132
|
+
return;
|
|
5133
|
+
}
|
|
4542
5134
|
const dir = path.resolve(this.#sessionFile, "..");
|
|
4543
5135
|
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
4544
5136
|
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
@@ -4569,10 +5161,13 @@ export class SessionManager {
|
|
|
4569
5161
|
entries: readonly FileEntry[],
|
|
4570
5162
|
sessionFile: string,
|
|
4571
5163
|
): Promise<string | undefined> {
|
|
5164
|
+
if (this.destination.kind === "managed") return undefined;
|
|
4572
5165
|
if (!this.storage.existsSync(sessionFile)) return undefined;
|
|
4573
5166
|
const dir = path.resolve(sessionFile, "..");
|
|
4574
5167
|
const tempPath = path.join(dir, `.${path.basename(sessionFile)}.${Snowflake.next()}.default-selection.tmp`);
|
|
4575
|
-
const writer = new NdjsonFileWriter(this.storage, tempPath, {
|
|
5168
|
+
const writer = new NdjsonFileWriter(this.storage, tempPath, {
|
|
5169
|
+
flags: "w",
|
|
5170
|
+
});
|
|
4576
5171
|
try {
|
|
4577
5172
|
const persistedEntries = await Promise.all(
|
|
4578
5173
|
materializeResidentEntriesForPersistenceSync([...entries], this.#residentBlobStores()).map(entry =>
|
|
@@ -4702,19 +5297,31 @@ export class SessionManager {
|
|
|
4702
5297
|
return { kind: "not_promoted" };
|
|
4703
5298
|
}
|
|
4704
5299
|
if (stage.persistsToExistingFile) {
|
|
4705
|
-
if (!
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
5300
|
+
if (!this.#sessionFile) return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
5301
|
+
if (this.destination.kind === "managed") {
|
|
5302
|
+
try {
|
|
5303
|
+
const persistedEntries = materializeResidentEntriesForPersistenceSync(
|
|
5304
|
+
[...stage.entries],
|
|
5305
|
+
this.#residentBlobStores(),
|
|
5306
|
+
).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
|
|
5307
|
+
this.#writeEntriesAtomicallySync(persistedEntries);
|
|
5308
|
+
} catch (error) {
|
|
5309
|
+
return { kind: "unknown", error: toError(error) };
|
|
4712
5310
|
}
|
|
4713
|
-
}
|
|
4714
|
-
if (
|
|
4715
|
-
|
|
5311
|
+
} else {
|
|
5312
|
+
if (!stage.tempPath) return { kind: "unknown", error: new Error("Missing staged session replacement") };
|
|
5313
|
+
try {
|
|
5314
|
+
this.#closePersistWriterInternalSync();
|
|
5315
|
+
const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
|
|
5316
|
+
if (replacement.kind === "restored_previous") {
|
|
5317
|
+
return { kind: "not_promoted", error: replacement.error };
|
|
5318
|
+
}
|
|
5319
|
+
} catch (error) {
|
|
5320
|
+
if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
|
|
5321
|
+
return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
|
|
5322
|
+
}
|
|
5323
|
+
return { kind: "unknown", error: toError(error) };
|
|
4716
5324
|
}
|
|
4717
|
-
return { kind: "unknown", error: toError(error) };
|
|
4718
5325
|
}
|
|
4719
5326
|
}
|
|
4720
5327
|
this.#fileEntries = [...stage.entries];
|
|
@@ -4862,15 +5469,62 @@ export class SessionManager {
|
|
|
4862
5469
|
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
4863
5470
|
* Returns null when the session is not persisted to a file.
|
|
4864
5471
|
* When this session has adopted an external ArtifactManager (subagent case),
|
|
4865
|
-
*
|
|
4866
|
-
*
|
|
5472
|
+
* never exposes that managed directory as a pathname. Reads and writes use the
|
|
5473
|
+
* adopted manager capability directly.
|
|
4867
5474
|
*/
|
|
4868
5475
|
getArtifactsDir(): string | null {
|
|
4869
|
-
if (this.#adoptedArtifactManager) return
|
|
5476
|
+
if (this.#adoptedArtifactManager) return null;
|
|
4870
5477
|
const sessionFile = this.#sessionFile;
|
|
4871
5478
|
return sessionFile ? sessionFile.slice(0, -6) : null;
|
|
4872
5479
|
}
|
|
4873
5480
|
|
|
5481
|
+
isManagedDestination(): boolean {
|
|
5482
|
+
return this.destination.kind === "managed";
|
|
5483
|
+
}
|
|
5484
|
+
|
|
5485
|
+
/** Supplies opaque retained authority for mandatory managed legacy local migration. */
|
|
5486
|
+
getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
|
|
5487
|
+
if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
|
|
5488
|
+
const store = this.#managedTranscriptStore();
|
|
5489
|
+
return {
|
|
5490
|
+
capture: async () => {
|
|
5491
|
+
let snapshot: native.NativeDirectoryTreeSnapshot;
|
|
5492
|
+
try {
|
|
5493
|
+
snapshot = store.captureTree("local");
|
|
5494
|
+
} catch (error) {
|
|
5495
|
+
if (error instanceof Error && error.message === "not_found") return null;
|
|
5496
|
+
throw error;
|
|
5497
|
+
}
|
|
5498
|
+
let totalBytes = 0;
|
|
5499
|
+
for (const entry of snapshot.entries) if (entry.kind === "file") totalBytes += Number(entry.size);
|
|
5500
|
+
if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
|
|
5501
|
+
const entries = snapshot.entries.map(entry => {
|
|
5502
|
+
if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
|
|
5503
|
+
const captured = store.readExpected(path.posix.join("local", entry.relativePath));
|
|
5504
|
+
if (
|
|
5505
|
+
!captured ||
|
|
5506
|
+
captured.identity.dev.toString() !== entry.dev ||
|
|
5507
|
+
captured.identity.ino.toString() !== entry.ino ||
|
|
5508
|
+
captured.identity.size.toString() !== entry.size ||
|
|
5509
|
+
crypto.createHash("sha256").update(captured.bytes).digest("hex") !== entry.sha256
|
|
5510
|
+
)
|
|
5511
|
+
throw new Error("Legacy local:// migration source changed during capture");
|
|
5512
|
+
return {
|
|
5513
|
+
relativePath: entry.relativePath,
|
|
5514
|
+
kind: "file" as const,
|
|
5515
|
+
bytes: captured.bytes,
|
|
5516
|
+
sha256: entry.sha256,
|
|
5517
|
+
};
|
|
5518
|
+
});
|
|
5519
|
+
const verified = store.captureTree("local");
|
|
5520
|
+
if (JSON.stringify(verified) !== JSON.stringify(snapshot))
|
|
5521
|
+
throw new Error("Legacy local:// migration source changed during capture");
|
|
5522
|
+
return { snapshot, entries };
|
|
5523
|
+
},
|
|
5524
|
+
retire: snapshot => store.removeTreeExpected("local", snapshot),
|
|
5525
|
+
};
|
|
5526
|
+
}
|
|
5527
|
+
|
|
4874
5528
|
/**
|
|
4875
5529
|
* Adopt an externally-owned ArtifactManager. Used by subagents to share
|
|
4876
5530
|
* the parent session's artifact directory and ID counter.
|
|
@@ -4906,7 +5560,12 @@ export class SessionManager {
|
|
|
4906
5560
|
return this.#artifactManager;
|
|
4907
5561
|
}
|
|
4908
5562
|
|
|
4909
|
-
const
|
|
5563
|
+
const artifactDir = sessionFile.slice(0, -6);
|
|
5564
|
+
let artifactStorage: string | ManagedSessionDescendantStore = artifactDir;
|
|
5565
|
+
if (this.destination.kind === "managed") {
|
|
5566
|
+
artifactStorage = this.#managedTranscriptStore(sessionFile).deriveSubtree(path.basename(artifactDir));
|
|
5567
|
+
}
|
|
5568
|
+
const manager = new ArtifactManager(artifactStorage);
|
|
4910
5569
|
this.#artifactManager = manager;
|
|
4911
5570
|
this.#artifactManagerSessionFile = sessionFile;
|
|
4912
5571
|
return manager;
|
|
@@ -4947,21 +5606,43 @@ export class SessionManager {
|
|
|
4947
5606
|
}
|
|
4948
5607
|
|
|
4949
5608
|
/**
|
|
4950
|
-
* Path to the unsent-input draft sidecar for
|
|
4951
|
-
* the artifacts directory so
|
|
4952
|
-
*
|
|
5609
|
+
* Path to the unsent-input draft sidecar for an explicit session. It lives
|
|
5610
|
+
* inside the artifacts directory so `dropSession` removes it with its owner.
|
|
5611
|
+
* Managed drafts use a retained descendant-store capability instead.
|
|
4953
5612
|
*/
|
|
4954
5613
|
#getDraftPath(): string | null {
|
|
5614
|
+
if (this.destination.kind === "managed") return null;
|
|
4955
5615
|
const dir = this.getArtifactsDir();
|
|
4956
5616
|
return dir ? path.join(dir, "draft.txt") : null;
|
|
4957
5617
|
}
|
|
4958
5618
|
|
|
5619
|
+
/** Retained authority for this managed session's private draft sidecar. */
|
|
5620
|
+
#getManagedDraftStore(): ManagedSessionDescendantStore | null {
|
|
5621
|
+
if (this.destination.kind !== "managed" || !this.#sessionFile) return null;
|
|
5622
|
+
return this.#managedTranscriptStore().deriveSubtree(path.basename(this.#sessionFile.slice(0, -6)));
|
|
5623
|
+
}
|
|
5624
|
+
|
|
4959
5625
|
/**
|
|
4960
5626
|
* Persist (or clear) the current editor draft so the next resume of this
|
|
4961
5627
|
* session can restore it. Empty text deletes any stale draft. No-op when the
|
|
4962
5628
|
* session is not persisted.
|
|
4963
5629
|
*/
|
|
4964
5630
|
async saveDraft(text: string): Promise<void> {
|
|
5631
|
+
if (this.destination.kind === "managed") {
|
|
5632
|
+
if (text.length === 0) {
|
|
5633
|
+
await this.#getManagedDraftStore()?.remove("draft.txt");
|
|
5634
|
+
return;
|
|
5635
|
+
}
|
|
5636
|
+
// Force the session header onto disk so resume can find the file we are
|
|
5637
|
+
// attaching this draft to. Without this, a session whose first message
|
|
5638
|
+
// never produced an assistant reply would persist a draft next to a
|
|
5639
|
+
// session file that does not exist on disk.
|
|
5640
|
+
await this.ensureOnDisk();
|
|
5641
|
+
const store = this.#getManagedDraftStore();
|
|
5642
|
+
if (!store) return;
|
|
5643
|
+
await store.replace("draft.txt", Buffer.from(text, "utf8"));
|
|
5644
|
+
return;
|
|
5645
|
+
}
|
|
4965
5646
|
const draftPath = this.#getDraftPath();
|
|
4966
5647
|
if (!draftPath || !this.persist) return;
|
|
4967
5648
|
if (text.length === 0) {
|
|
@@ -4977,7 +5658,9 @@ export class SessionManager {
|
|
|
4977
5658
|
// never produced an assistant reply would persist a draft next to a
|
|
4978
5659
|
// session file that does not exist on disk.
|
|
4979
5660
|
await this.ensureOnDisk();
|
|
4980
|
-
|
|
5661
|
+
const artifactManager = this.#getOrCreateArtifactManager();
|
|
5662
|
+
if (!artifactManager) return;
|
|
5663
|
+
await artifactManager.replaceNamed("draft.txt", text);
|
|
4981
5664
|
}
|
|
4982
5665
|
|
|
4983
5666
|
/**
|
|
@@ -4986,6 +5669,10 @@ export class SessionManager {
|
|
|
4986
5669
|
* sidecar so a subsequent resume does not re-restore the same text.
|
|
4987
5670
|
*/
|
|
4988
5671
|
async consumeDraft(): Promise<string | null> {
|
|
5672
|
+
if (this.destination.kind === "managed") {
|
|
5673
|
+
const draft = await this.#getManagedDraftStore()?.consume("draft.txt");
|
|
5674
|
+
return draft ? Buffer.from(draft).toString("utf8") : null;
|
|
5675
|
+
}
|
|
4989
5676
|
const draftPath = this.#getDraftPath();
|
|
4990
5677
|
if (!draftPath) return null;
|
|
4991
5678
|
let text: string;
|
|
@@ -5047,14 +5734,19 @@ export class SessionManager {
|
|
|
5047
5734
|
await this.#persistPatch({ type: "header_patch", patch });
|
|
5048
5735
|
}
|
|
5049
5736
|
|
|
5737
|
+
#appendManagedRecordSync(record: FileEntry | SessionPatchRecord): void {
|
|
5738
|
+
if (!this.#sessionFile) throw new Error("Managed transcript path is unavailable");
|
|
5739
|
+
const store = this.#managedTranscriptStore();
|
|
5740
|
+
const relativePath = path.basename(this.#sessionFile);
|
|
5741
|
+
store.appendSync(relativePath, Buffer.from(`${JSON.stringify(record)}\n`, "utf8"));
|
|
5742
|
+
}
|
|
5743
|
+
|
|
5050
5744
|
async #persistPatch(record: SessionPatchRecord): Promise<void> {
|
|
5051
5745
|
if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
|
|
5052
5746
|
await this.#queuePersistTask(async () => {
|
|
5053
5747
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
5054
5748
|
if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
|
|
5055
5749
|
return this.#rewriteFileContents();
|
|
5056
|
-
const writer = this.#ensurePersistWriter();
|
|
5057
|
-
if (!writer) return this.#rewriteFileContents();
|
|
5058
5750
|
const persistedRecord =
|
|
5059
5751
|
record.type === "entry_patch"
|
|
5060
5752
|
? (prepareEntryForPersistenceSync(
|
|
@@ -5066,6 +5758,12 @@ export class SessionManager {
|
|
|
5066
5758
|
this.#blobStore,
|
|
5067
5759
|
) as unknown as SessionPatchRecord)
|
|
5068
5760
|
: record;
|
|
5761
|
+
if (this.destination.kind === "managed") {
|
|
5762
|
+
this.#appendManagedRecordSync(persistedRecord);
|
|
5763
|
+
return;
|
|
5764
|
+
}
|
|
5765
|
+
const writer = this.#ensurePersistWriter();
|
|
5766
|
+
if (!writer) return this.#rewriteFileContents();
|
|
5069
5767
|
await writer.write(persistedRecord);
|
|
5070
5768
|
});
|
|
5071
5769
|
}
|
|
@@ -5108,6 +5806,16 @@ export class SessionManager {
|
|
|
5108
5806
|
// the synchronous blob-store path so blob bytes are durable before the JSONL
|
|
5109
5807
|
// line referencing them is written.
|
|
5110
5808
|
try {
|
|
5809
|
+
if (this.destination.kind === "managed") {
|
|
5810
|
+
const materializedEntry = materializeResidentEntryForPersistenceSync(
|
|
5811
|
+
entry,
|
|
5812
|
+
this.#residentBlobStores(),
|
|
5813
|
+
new Map(),
|
|
5814
|
+
);
|
|
5815
|
+
const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
|
|
5816
|
+
this.#appendManagedRecordSync(persistedEntry);
|
|
5817
|
+
return;
|
|
5818
|
+
}
|
|
5111
5819
|
const writer = this.#ensurePersistWriter();
|
|
5112
5820
|
if (!writer) {
|
|
5113
5821
|
// `#ensurePersistWriter` returns undefined here only when the cached
|
|
@@ -5446,18 +6154,29 @@ export class SessionManager {
|
|
|
5446
6154
|
// TTSR (Time Traveling Stream Rules)
|
|
5447
6155
|
// =========================================================================
|
|
5448
6156
|
|
|
5449
|
-
/**
|
|
5450
|
-
|
|
5451
|
-
* @param selectedToolNames MCP tool names selected for this branch
|
|
5452
|
-
* @returns Entry id
|
|
5453
|
-
*/
|
|
5454
|
-
appendMCPToolSelection(selectedToolNames: string[]): string {
|
|
6157
|
+
/** Append MCP discovery selection authority without altering discovered built-in authority. */
|
|
6158
|
+
appendMCPToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
|
|
5455
6159
|
const entry: MCPToolSelectionEntry = {
|
|
5456
6160
|
type: "mcp_tool_selection",
|
|
5457
6161
|
id: generateId(this.#byId),
|
|
5458
6162
|
parentId: this.#leafId,
|
|
5459
6163
|
timestamp: new Date().toISOString(),
|
|
5460
6164
|
selectedToolNames: [...selectedToolNames],
|
|
6165
|
+
mutationCorrelationId,
|
|
6166
|
+
};
|
|
6167
|
+
this.#appendEntry(entry);
|
|
6168
|
+
return entry.id;
|
|
6169
|
+
}
|
|
6170
|
+
|
|
6171
|
+
/** Append discovered built-in selection authority without altering MCP authority. */
|
|
6172
|
+
appendDiscoveredBuiltinToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
|
|
6173
|
+
const entry: DiscoveredBuiltinToolSelectionEntry = {
|
|
6174
|
+
type: "discovered_builtin_tool_selection",
|
|
6175
|
+
id: generateId(this.#byId),
|
|
6176
|
+
parentId: this.#leafId,
|
|
6177
|
+
timestamp: new Date().toISOString(),
|
|
6178
|
+
selectedToolNames: [...selectedToolNames],
|
|
6179
|
+
mutationCorrelationId,
|
|
5461
6180
|
};
|
|
5462
6181
|
this.#appendEntry(entry);
|
|
5463
6182
|
return entry.id;
|
|
@@ -6204,7 +6923,14 @@ export class SessionManager {
|
|
|
6204
6923
|
labelEntries.push(labelEntry);
|
|
6205
6924
|
parentId = labelEntry.id;
|
|
6206
6925
|
}
|
|
6207
|
-
this.
|
|
6926
|
+
if (this.destination.kind === "managed") {
|
|
6927
|
+
this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
|
|
6928
|
+
path.basename(newSessionFile),
|
|
6929
|
+
Buffer.from(`${lines.join("\n")}\n`, "utf8"),
|
|
6930
|
+
);
|
|
6931
|
+
} else {
|
|
6932
|
+
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
6933
|
+
}
|
|
6208
6934
|
this.#sessionId = newSessionId;
|
|
6209
6935
|
this.#sessionFile = newSessionFile;
|
|
6210
6936
|
this.#resetResidentTextBlobStore();
|
|
@@ -6278,9 +7004,47 @@ export class SessionManager {
|
|
|
6278
7004
|
* @param cwd Working directory (stored in session header)
|
|
6279
7005
|
* @param sessionDir Optional session directory. If omitted, uses default (~/.gjc/agent/sessions/<encoded-cwd>/).
|
|
6280
7006
|
*/
|
|
6281
|
-
static
|
|
6282
|
-
|
|
6283
|
-
|
|
7007
|
+
static nestedManagedDestination(
|
|
7008
|
+
authority: ManagedDirectoryRoot | ManagedSessionDescendantStore,
|
|
7009
|
+
directory: string,
|
|
7010
|
+
): SessionDestination {
|
|
7011
|
+
const rootAuthority =
|
|
7012
|
+
authority instanceof ManagedSessionDescendantStore ? authority.subtreeRootAuthority : authority;
|
|
7013
|
+
const retainedAuthority =
|
|
7014
|
+
authority instanceof ManagedSessionDescendantStore
|
|
7015
|
+
? authority.retainAuthority()
|
|
7016
|
+
: retainManagedDirectoryAuthority(rootAuthority, directory);
|
|
7017
|
+
const securityContext = createManagedSessionSecurityContext({
|
|
7018
|
+
agentDir: rootAuthority.canonicalPath,
|
|
7019
|
+
sessionsRoot: rootAuthority.canonicalPath,
|
|
7020
|
+
sessionDir: directory,
|
|
7021
|
+
rootAuthority,
|
|
7022
|
+
retainedAuthority,
|
|
7023
|
+
});
|
|
7024
|
+
const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
|
|
7025
|
+
trustedSessionDestinations.add(destination);
|
|
7026
|
+
return destination;
|
|
7027
|
+
}
|
|
7028
|
+
|
|
7029
|
+
static managedDestination(
|
|
7030
|
+
cwd: string,
|
|
7031
|
+
agentDir?: string,
|
|
7032
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7033
|
+
): SessionDestination {
|
|
7034
|
+
return managedDestination(cwd, storage, agentDir);
|
|
7035
|
+
}
|
|
7036
|
+
|
|
7037
|
+
static explicitDestination(directory: string): SessionDestination {
|
|
7038
|
+
return explicitDestination(directory);
|
|
7039
|
+
}
|
|
7040
|
+
|
|
7041
|
+
static create(
|
|
7042
|
+
cwd: string,
|
|
7043
|
+
destinationInput?: SessionDestinationInput,
|
|
7044
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7045
|
+
): SessionManager {
|
|
7046
|
+
const destination = destinationFor(cwd, destinationInput, storage);
|
|
7047
|
+
const manager = new SessionManager(cwd, destination.directory, true, storage, destination);
|
|
6284
7048
|
manager.#initNewSession();
|
|
6285
7049
|
return manager;
|
|
6286
7050
|
}
|
|
@@ -6345,16 +7109,17 @@ export class SessionManager {
|
|
|
6345
7109
|
static async forkFrom(
|
|
6346
7110
|
sourcePath: string,
|
|
6347
7111
|
cwd: string,
|
|
6348
|
-
|
|
7112
|
+
destinationInput?: SessionDestinationInput,
|
|
6349
7113
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6350
7114
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6351
7115
|
): Promise<SessionManager> {
|
|
7116
|
+
const destination = destinationFor(cwd, destinationInput, storage);
|
|
6352
7117
|
const managedSourcePath =
|
|
6353
|
-
|
|
7118
|
+
destination.kind === "managed" && storage instanceof FileSessionStorage
|
|
6354
7119
|
? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
|
|
6355
7120
|
: sourcePath;
|
|
6356
|
-
const dir =
|
|
6357
|
-
const manager = new SessionManager(cwd, dir, true, storage);
|
|
7121
|
+
const dir = destination.directory;
|
|
7122
|
+
const manager = new SessionManager(cwd, dir, true, storage, destination);
|
|
6358
7123
|
const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
|
|
6359
7124
|
migrateToCurrentVersion(forkEntries);
|
|
6360
7125
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
@@ -6388,52 +7153,103 @@ export class SessionManager {
|
|
|
6388
7153
|
*/
|
|
6389
7154
|
static async open(
|
|
6390
7155
|
filePath: string,
|
|
6391
|
-
|
|
7156
|
+
destinationInput?: SessionDestinationInput,
|
|
6392
7157
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6393
7158
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6394
7159
|
): Promise<SessionManager> {
|
|
7160
|
+
const destination =
|
|
7161
|
+
destinationInput === undefined
|
|
7162
|
+
? explicitDestination(path.dirname(filePath))
|
|
7163
|
+
: destinationFor(getProjectDir(), destinationInput, storage);
|
|
6395
7164
|
// Canonicalize benign ancestor symlinks in the caller-supplied path (e.g.
|
|
6396
7165
|
// macOS `/var -> /private/var`, a symlinked `$HOME`/project) so explicit
|
|
6397
7166
|
// session files land under a symlink-free trusted root that the strict
|
|
6398
7167
|
// owner-only and reparse guards accept.
|
|
6399
7168
|
if (storage instanceof FileSessionStorage) filePath = canonicalizeTrustedPath(filePath);
|
|
6400
|
-
if (
|
|
7169
|
+
if (destination.kind === "explicit" || !(storage instanceof FileSessionStorage)) {
|
|
6401
7170
|
const entries = await loadEntriesFromFile(filePath, storage);
|
|
6402
7171
|
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
6403
7172
|
const manager = new SessionManager(
|
|
6404
7173
|
header?.cwd ?? getProjectDir(),
|
|
6405
|
-
|
|
6406
|
-
(storage instanceof FileSessionStorage ? path.resolve(filePath, "..") : path.dirname(filePath)),
|
|
7174
|
+
destination.directory,
|
|
6407
7175
|
true,
|
|
6408
7176
|
storage,
|
|
7177
|
+
destination,
|
|
6409
7178
|
);
|
|
6410
7179
|
await manager.#initSessionFile(filePath);
|
|
6411
7180
|
return manager;
|
|
6412
7181
|
}
|
|
6413
7182
|
|
|
6414
|
-
const managedPath =
|
|
6415
|
-
!sessionDir && storage instanceof FileSessionStorage
|
|
6416
|
-
? await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy)
|
|
6417
|
-
: filePath;
|
|
7183
|
+
const managedPath = await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy);
|
|
6418
7184
|
const inspected = inspectResumeSessionFile(managedPath, storage);
|
|
6419
7185
|
if ("kind" in inspected) {
|
|
6420
7186
|
if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
|
|
6421
|
-
const manager = new SessionManager(getProjectDir(),
|
|
7187
|
+
const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
|
|
6422
7188
|
await manager.#initSessionFile(filePath);
|
|
6423
7189
|
return manager;
|
|
6424
7190
|
}
|
|
6425
7191
|
throw new Error(`Could not open session: ${inspected.reason}`);
|
|
6426
7192
|
}
|
|
6427
|
-
const opened = await SessionManager.openExistingStrict(
|
|
6428
|
-
inspected.identity,
|
|
6429
|
-
sessionDir ?? path.resolve(managedPath, ".."),
|
|
6430
|
-
storage,
|
|
6431
|
-
migrationPolicy,
|
|
6432
|
-
);
|
|
7193
|
+
const opened = await SessionManager.openExistingStrict(inspected.identity, destination, storage, migrationPolicy);
|
|
6433
7194
|
if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`);
|
|
6434
7195
|
return opened.manager;
|
|
6435
7196
|
}
|
|
6436
7197
|
|
|
7198
|
+
static async openNestedManaged(
|
|
7199
|
+
filePath: string,
|
|
7200
|
+
destination: SessionDestination,
|
|
7201
|
+
store: ManagedSessionDescendantStore,
|
|
7202
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7203
|
+
): Promise<SessionManager> {
|
|
7204
|
+
if (destination.kind !== "managed" || !trustedSessionDestinations.has(destination))
|
|
7205
|
+
throw new Error("Nested managed session authority is unavailable");
|
|
7206
|
+
const resolved = path.resolve(filePath);
|
|
7207
|
+
if (
|
|
7208
|
+
path.dirname(resolved) !== path.resolve(store.dir) ||
|
|
7209
|
+
path.dirname(resolved) !== path.resolve(destination.directory)
|
|
7210
|
+
)
|
|
7211
|
+
throw new Error("Nested managed session escaped retained authority");
|
|
7212
|
+
store.assertBound();
|
|
7213
|
+
const captured = store.readExpected(path.basename(resolved));
|
|
7214
|
+
const entries = captured ? parseSessionEntries(captured.bytes.toString("utf8")) : [];
|
|
7215
|
+
const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
7216
|
+
const manager = new SessionManager(
|
|
7217
|
+
header?.cwd ?? getProjectDir(),
|
|
7218
|
+
destination.directory,
|
|
7219
|
+
true,
|
|
7220
|
+
storage,
|
|
7221
|
+
destination,
|
|
7222
|
+
);
|
|
7223
|
+
manager.#sessionFile = resolved;
|
|
7224
|
+
writeTerminalBreadcrumb(manager.cwd, resolved);
|
|
7225
|
+
manager.#fileEntries = entries;
|
|
7226
|
+
if (entries.length > 0) {
|
|
7227
|
+
manager.#sessionId = header?.id ?? createSessionId();
|
|
7228
|
+
manager.#sessionName = header?.title;
|
|
7229
|
+
manager.#titleSource = header?.titleSource;
|
|
7230
|
+
manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
|
|
7231
|
+
await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
|
|
7232
|
+
manager.#resetResidentTextBlobStore();
|
|
7233
|
+
manager.#fileEntries = manager.#fileEntries.map(entry =>
|
|
7234
|
+
prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
|
|
7235
|
+
);
|
|
7236
|
+
manager.#buildIndex();
|
|
7237
|
+
manager.#bumpAllRevisions();
|
|
7238
|
+
manager.#flushed = true;
|
|
7239
|
+
manager.#ensuredOnDisk = true;
|
|
7240
|
+
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7241
|
+
} else {
|
|
7242
|
+
manager.#newSessionSync();
|
|
7243
|
+
manager.#sessionFile = resolved;
|
|
7244
|
+
manager.#resetResidentTextBlobStore();
|
|
7245
|
+
await manager.#rewriteFile();
|
|
7246
|
+
manager.#flushed = true;
|
|
7247
|
+
manager.#ensuredOnDisk = true;
|
|
7248
|
+
manager.#bumpAllRevisions();
|
|
7249
|
+
}
|
|
7250
|
+
store.assertBound();
|
|
7251
|
+
return manager;
|
|
7252
|
+
}
|
|
6437
7253
|
/**
|
|
6438
7254
|
* List sessions for the resume picker without recovery or other maintenance writes.
|
|
6439
7255
|
*/
|
|
@@ -6524,9 +7340,10 @@ export class SessionManager {
|
|
|
6524
7340
|
static async forkFromCaptured(
|
|
6525
7341
|
snapshot: CapturedSessionTranscriptSnapshot,
|
|
6526
7342
|
cwd: string,
|
|
6527
|
-
|
|
7343
|
+
destinationInput?: SessionDestinationInput,
|
|
6528
7344
|
_migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6529
7345
|
): Promise<StrictSessionForkResult> {
|
|
7346
|
+
const destination = destinationFor(cwd, destinationInput, snapshot.storage);
|
|
6530
7347
|
if (crypto.createHash("sha256").update(snapshot.content).digest("hex") !== snapshot.identity.sha256) {
|
|
6531
7348
|
return { kind: "error", reason: "identity-mismatch" };
|
|
6532
7349
|
}
|
|
@@ -6553,13 +7370,17 @@ export class SessionManager {
|
|
|
6553
7370
|
return { kind: "error", reason: "identity-mismatch" };
|
|
6554
7371
|
}
|
|
6555
7372
|
|
|
6556
|
-
const dir =
|
|
7373
|
+
const dir = destination.directory;
|
|
6557
7374
|
const removeSessionDirOnFailure =
|
|
6558
|
-
|
|
7375
|
+
destination.kind !== "managed" &&
|
|
7376
|
+
snapshot.storage instanceof FileSessionStorage &&
|
|
7377
|
+
!snapshot.storage.existsSync(dir);
|
|
7378
|
+
let managedForkStore: ManagedSessionDescendantStore | undefined;
|
|
7379
|
+
let managedForkTranscript: ManagedFileSnapshot | null = null;
|
|
6559
7380
|
let manager: SessionManager | undefined;
|
|
6560
7381
|
let authorityFailure: StrictSessionOpenFailure | undefined;
|
|
6561
7382
|
try {
|
|
6562
|
-
manager = new SessionManager(cwd, dir, true, snapshot.storage);
|
|
7383
|
+
manager = new SessionManager(cwd, dir, true, snapshot.storage, destination);
|
|
6563
7384
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
6564
7385
|
manager.#fileEntries = forkEntries;
|
|
6565
7386
|
const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
@@ -6589,6 +7410,11 @@ export class SessionManager {
|
|
|
6589
7410
|
throw new Error("Captured fork source authority changed before destination write.");
|
|
6590
7411
|
}
|
|
6591
7412
|
await manager.#rewriteFile();
|
|
7413
|
+
if (destination.kind === "managed" && manager.#sessionFile) {
|
|
7414
|
+
managedForkStore = manager.#managedTranscriptStore();
|
|
7415
|
+
managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
7416
|
+
if (!managedForkTranscript) throw new Error("managed_fork_transcript_publish_missing");
|
|
7417
|
+
}
|
|
6592
7418
|
const afterWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
|
|
6593
7419
|
if ("kind" in afterWrite) {
|
|
6594
7420
|
authorityFailure = afterWrite;
|
|
@@ -6598,6 +7424,11 @@ export class SessionManager {
|
|
|
6598
7424
|
authorityFailure = { kind: "error", reason: "identity-mismatch" };
|
|
6599
7425
|
throw new Error("Captured fork source authority changed during destination write.");
|
|
6600
7426
|
}
|
|
7427
|
+
if (managedForkStore && managedForkTranscript && manager.#sessionFile) {
|
|
7428
|
+
const terminalTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
7429
|
+
if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
|
|
7430
|
+
throw new Error("managed_fork_transcript_changed");
|
|
7431
|
+
}
|
|
6601
7432
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
6602
7433
|
return { kind: "forked", manager };
|
|
6603
7434
|
} catch (error) {
|
|
@@ -6610,35 +7441,44 @@ export class SessionManager {
|
|
|
6610
7441
|
cleanupErrors.push(toError(cleanupError));
|
|
6611
7442
|
}
|
|
6612
7443
|
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) {
|
|
7444
|
+
if (destination.kind === "managed") {
|
|
6619
7445
|
try {
|
|
6620
|
-
|
|
7446
|
+
if (managedForkStore && managedForkTranscript)
|
|
7447
|
+
managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
|
|
6621
7448
|
} catch (cleanupError) {
|
|
6622
7449
|
cleanupErrors.push(toError(cleanupError));
|
|
6623
7450
|
}
|
|
7451
|
+
} else {
|
|
6624
7452
|
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
|
-
}
|
|
7453
|
+
await manager.storage.unlink(sessionFile);
|
|
6639
7454
|
} catch (cleanupError) {
|
|
6640
7455
|
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
6641
7456
|
}
|
|
7457
|
+
if (manager.storage instanceof FileSessionStorage) {
|
|
7458
|
+
try {
|
|
7459
|
+
await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
|
|
7460
|
+
} catch (cleanupError) {
|
|
7461
|
+
cleanupErrors.push(toError(cleanupError));
|
|
7462
|
+
}
|
|
7463
|
+
try {
|
|
7464
|
+
const sessionFileName = path.basename(sessionFile);
|
|
7465
|
+
const transientPrefix = `.${sessionFileName}.`;
|
|
7466
|
+
const backupPrefix = `${sessionFileName}.`;
|
|
7467
|
+
for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
|
|
7468
|
+
if (
|
|
7469
|
+
(name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
|
|
7470
|
+
(name.startsWith(backupPrefix) && name.endsWith(".bak"))
|
|
7471
|
+
) {
|
|
7472
|
+
await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
|
|
7473
|
+
recursive: true,
|
|
7474
|
+
force: true,
|
|
7475
|
+
});
|
|
7476
|
+
}
|
|
7477
|
+
}
|
|
7478
|
+
} catch (cleanupError) {
|
|
7479
|
+
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7480
|
+
}
|
|
7481
|
+
}
|
|
6642
7482
|
}
|
|
6643
7483
|
}
|
|
6644
7484
|
if (removeSessionDirOnFailure) {
|
|
@@ -6673,6 +7513,67 @@ export class SessionManager {
|
|
|
6673
7513
|
: { kind: "terminal", identity: inspected.identity };
|
|
6674
7514
|
}
|
|
6675
7515
|
|
|
7516
|
+
/**
|
|
7517
|
+
* Hydrate an existing predecessor transcript for recovery without taking any
|
|
7518
|
+
* write-capable action. The caller must retain the returned context and use
|
|
7519
|
+
* the explicit promotion seam only after its ownership-ready fence and writer
|
|
7520
|
+
* lease are durable.
|
|
7521
|
+
*/
|
|
7522
|
+
static async openExistingForRecoveryHydrationStrict(
|
|
7523
|
+
identity: ResumeSessionIdentity,
|
|
7524
|
+
destinationInput?: SessionDestinationInput,
|
|
7525
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7526
|
+
): Promise<RecoveryHydrationOpenResult> {
|
|
7527
|
+
const destination =
|
|
7528
|
+
destinationInput === undefined
|
|
7529
|
+
? explicitDestination(path.dirname(identity.canonicalPath))
|
|
7530
|
+
: destinationFor(getProjectDir(), destinationInput, storage);
|
|
7531
|
+
const inspected = inspectResumeSessionFile(identity.canonicalPath, storage);
|
|
7532
|
+
if ("kind" in inspected) return inspected;
|
|
7533
|
+
if (!sameResumeIdentity(identity, inspected.identity)) return { kind: "error", reason: "identity-mismatch" };
|
|
7534
|
+
if (inspected.migrationApplied) return { kind: "error", reason: "migration-required" };
|
|
7535
|
+
|
|
7536
|
+
const header = inspected.entries[0] as SessionHeader;
|
|
7537
|
+
const manager = new SessionManager(
|
|
7538
|
+
header.cwd || getProjectDir(),
|
|
7539
|
+
destination.directory,
|
|
7540
|
+
true,
|
|
7541
|
+
storage,
|
|
7542
|
+
destination,
|
|
7543
|
+
);
|
|
7544
|
+
await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
|
|
7545
|
+
const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
|
|
7546
|
+
if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
|
|
7547
|
+
await manager.close();
|
|
7548
|
+
return "kind" in revalidated ? revalidated : { kind: "error", reason: "identity-mismatch" };
|
|
7549
|
+
}
|
|
7550
|
+
const context: RecoveryHydrationContext = Object.freeze({
|
|
7551
|
+
identity: Object.freeze({ ...identity }),
|
|
7552
|
+
});
|
|
7553
|
+
manager.#recoveryHydrationContext = context;
|
|
7554
|
+
return { kind: "hydrated", manager, context };
|
|
7555
|
+
}
|
|
7556
|
+
|
|
7557
|
+
/**
|
|
7558
|
+
* Allows the normal post-open metadata sanitation only after the caller has
|
|
7559
|
+
* fsynced its external ownership-ready fence and acquired the writer lease.
|
|
7560
|
+
*/
|
|
7561
|
+
async promoteRecoveryHydrationAfterOwnershipReadyFence(
|
|
7562
|
+
context: RecoveryHydrationContext,
|
|
7563
|
+
fence: RecoveryHydrationPromotionFence,
|
|
7564
|
+
): Promise<void> {
|
|
7565
|
+
if (fence.ownershipReady !== true || this.#recoveryHydrationContext !== context) {
|
|
7566
|
+
throw new Error("Recovery hydration promotion requires the original ownership-ready context.");
|
|
7567
|
+
}
|
|
7568
|
+
const inspected = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
|
|
7569
|
+
if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
|
|
7570
|
+
throw new Error("Recovery transcript authority changed before promotion.");
|
|
7571
|
+
}
|
|
7572
|
+
await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7573
|
+
writeTerminalBreadcrumb(this.cwd, context.identity.canonicalPath);
|
|
7574
|
+
this.#recoveryHydrationContext = undefined;
|
|
7575
|
+
}
|
|
7576
|
+
|
|
6676
7577
|
/**
|
|
6677
7578
|
* Main startup code MUST retain the consented inspection identity and branch on
|
|
6678
7579
|
* the returned discriminant; an error result never creates, rewrites, or adopts
|
|
@@ -6680,12 +7581,16 @@ export class SessionManager {
|
|
|
6680
7581
|
*/
|
|
6681
7582
|
static async openExistingStrict(
|
|
6682
7583
|
identity: ResumeSessionIdentity,
|
|
6683
|
-
|
|
7584
|
+
destinationInput?: SessionDestinationInput,
|
|
6684
7585
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6685
7586
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6686
7587
|
): Promise<StrictSessionOpenResult> {
|
|
7588
|
+
const destination =
|
|
7589
|
+
destinationInput === undefined
|
|
7590
|
+
? explicitDestination(path.dirname(identity.canonicalPath))
|
|
7591
|
+
: destinationFor(getProjectDir(), destinationInput, storage);
|
|
6687
7592
|
let sessionPath = identity.canonicalPath;
|
|
6688
|
-
if (
|
|
7593
|
+
if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
|
|
6689
7594
|
try {
|
|
6690
7595
|
sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
|
|
6691
7596
|
} catch (error) {
|
|
@@ -6705,8 +7610,8 @@ export class SessionManager {
|
|
|
6705
7610
|
}
|
|
6706
7611
|
const entries = inspected.entries;
|
|
6707
7612
|
const header = entries[0] as SessionHeader;
|
|
6708
|
-
const dir =
|
|
6709
|
-
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage);
|
|
7613
|
+
const dir = destination.directory;
|
|
7614
|
+
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
|
|
6710
7615
|
await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
|
|
6711
7616
|
const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
|
|
6712
7617
|
if ("kind" in ownershipInspection) {
|
|
@@ -6734,17 +7639,18 @@ export class SessionManager {
|
|
|
6734
7639
|
*/
|
|
6735
7640
|
static async continueRecent(
|
|
6736
7641
|
cwd: string,
|
|
6737
|
-
|
|
7642
|
+
destinationInput?: SessionDestinationInput,
|
|
6738
7643
|
storage: SessionStorage = new FileSessionStorage(),
|
|
6739
7644
|
migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
|
|
6740
7645
|
): Promise<SessionManager> {
|
|
6741
|
-
const
|
|
7646
|
+
const destination = destinationFor(cwd, destinationInput, storage);
|
|
7647
|
+
const dir = destination.directory;
|
|
6742
7648
|
const openSelectedStrictly = async (selectedPath: string): Promise<SessionManager | undefined> => {
|
|
6743
7649
|
const inspected = await SessionManager.inspectSessionTailReadOnly(selectedPath, storage);
|
|
6744
7650
|
if (inspected.kind === "error") return undefined;
|
|
6745
7651
|
const opened = await SessionManager.openExistingStrict(
|
|
6746
7652
|
inspected.identity,
|
|
6747
|
-
|
|
7653
|
+
destination,
|
|
6748
7654
|
storage,
|
|
6749
7655
|
migrationPolicy,
|
|
6750
7656
|
);
|
|
@@ -6756,9 +7662,10 @@ export class SessionManager {
|
|
|
6756
7662
|
};
|
|
6757
7663
|
// Legacy paths are candidates only: the managed scan validates each transcript
|
|
6758
7664
|
// header cwd before it can affect default resume authority.
|
|
6759
|
-
const managedCandidates =
|
|
6760
|
-
|
|
6761
|
-
|
|
7665
|
+
const managedCandidates =
|
|
7666
|
+
destination.kind === "explicit"
|
|
7667
|
+
? []
|
|
7668
|
+
: await SessionManager.listForResumePickerReadOnly(cwd, destination.directory, storage);
|
|
6762
7669
|
const terminalSession = await readTerminalBreadcrumb(cwd);
|
|
6763
7670
|
if (terminalSession) {
|
|
6764
7671
|
const opened = await openSelectedStrictly(terminalSession);
|
|
@@ -6773,7 +7680,7 @@ export class SessionManager {
|
|
|
6773
7680
|
const opened = await openSelectedStrictly(mostRecent);
|
|
6774
7681
|
if (opened) return opened;
|
|
6775
7682
|
}
|
|
6776
|
-
const manager = new SessionManager(cwd, dir, true, storage);
|
|
7683
|
+
const manager = new SessionManager(cwd, dir, true, storage, destination);
|
|
6777
7684
|
manager.#initNewSession();
|
|
6778
7685
|
return manager;
|
|
6779
7686
|
}
|