@gajae-code/coding-agent 0.11.6 → 0.11.8
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 +92 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +34 -4
- package/dist/types/config/model-profile-activation.d.ts +4 -3
- package/dist/types/config/model-profile-contract.d.ts +38 -0
- package/dist/types/config/settings-schema.d.ts +70 -7
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
- package/dist/types/main.d.ts +32 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +4 -2
- package/dist/types/modes/components/welcome.d.ts +2 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +7 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +4 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +26 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +1 -1
- package/dist/types/sdk/host/query/handlers.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/startup-capability.d.ts +3 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +30 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
- package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-manager.d.ts +25 -4
- package/dist/types/session/session-storage.d.ts +18 -0
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +94 -0
- package/dist/types/tools/ask.d.ts +454 -174
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +63 -1
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/prompt-suggestion.d.ts +33 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/args.ts +2 -92
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +52 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +44 -11
- package/src/config/keybindings.ts +158 -25
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-profiles.ts +18 -0
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +73 -7
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
- package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/py/executor.ts +30 -11
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/launch-tmux.ts +1 -1
- package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/gjc-runtime/ralplan-runtime.ts +75 -3
- package/src/gjc-runtime/repository-binding.ts +267 -0
- package/src/gjc-runtime/session-state-sidecar.ts +25 -9
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
- package/src/gjc-runtime/tmux-sessions.ts +45 -6
- package/src/gjc-runtime/ultragoal-guard.ts +93 -15
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +12 -10
- package/src/internal-urls/local-protocol.ts +64 -21
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +111 -37
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +73 -11
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/tool-status-header.ts +25 -4
- package/src/modes/components/welcome.ts +22 -18
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +97 -54
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/input-controller.ts +38 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +149 -63
- package/src/modes/interactive-mode.ts +57 -34
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +4 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/prompts/agents/architect.md +0 -1
- package/src/prompts/agents/critic.md +0 -1
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +6 -4
- package/src/prompts/tools/cron.md +3 -1
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/manager.ts +51 -53
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/transports/http.ts +63 -22
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/bus/chat-daemon-control.ts +19 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +9 -1
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/index.ts +244 -70
- package/src/sdk/bus/lifecycle-commands.ts +63 -10
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +68 -20
- package/src/sdk/bus/prompt-reconciliation.ts +239 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
- package/src/sdk/bus/telegram-daemon-control.ts +147 -35
- package/src/sdk/bus/telegram-daemon.ts +2463 -450
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +1 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +2 -6
- package/src/sdk/host/query/handlers.ts +70 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +58 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +31 -5
- package/src/sdk/startup-capability.ts +9 -1
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +292 -154
- package/src/session/blob-store.ts +25 -7
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +1163 -137
- package/src/session/internal/managed-session-storage.ts +397 -234
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +620 -403
- package/src/session/session-storage.ts +128 -11
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/task/gjc-command.ts +2 -2
- package/src/task/index.ts +121 -15
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +34 -3
- package/src/tools/ask.ts +199 -89
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +476 -23
- package/src/tools/index.ts +4 -0
- package/src/tools/read.ts +2 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +140 -27
- package/src/web/scrapers/docs-rs.ts +30 -5
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
|
@@ -18,7 +18,6 @@ import * as native from "@gajae-code/natives";
|
|
|
18
18
|
import { getTerminalId } from "@gajae-code/tui";
|
|
19
19
|
import {
|
|
20
20
|
getBlobsDir,
|
|
21
|
-
getAgentDir as getDefaultAgentDir,
|
|
22
21
|
getProjectDir,
|
|
23
22
|
getSessionsDir,
|
|
24
23
|
getTerminalSessionsDir,
|
|
@@ -58,20 +57,28 @@ import {
|
|
|
58
57
|
canonicalizeTrustedPath,
|
|
59
58
|
deleteManagedSessionCandidate,
|
|
60
59
|
listManagedCandidates,
|
|
60
|
+
type ManagedCandidateWriteAuthority,
|
|
61
61
|
type ManagedMigrationPolicy,
|
|
62
|
+
type ManagedOpenCandidateResult,
|
|
62
63
|
type ManagedScope,
|
|
63
64
|
managedDirectoryAuthorityForScope,
|
|
64
65
|
managedRootForScope,
|
|
65
66
|
openManagedCandidateForWrite,
|
|
66
67
|
prepareManagedSessionScopeForWriteSync,
|
|
67
68
|
resolveManagedScope,
|
|
69
|
+
resolveManagedScopeForWrite,
|
|
68
70
|
} from "./internal/managed-session-scope";
|
|
69
71
|
import {
|
|
72
|
+
assertManagedDirectoryRoot,
|
|
73
|
+
fsyncManagedArtifactTree,
|
|
70
74
|
type ManagedDirectoryRoot,
|
|
71
75
|
type ManagedFileSnapshot,
|
|
72
76
|
ManagedSessionDescendantStore,
|
|
77
|
+
type ManagedSessionSecurityPolicy,
|
|
78
|
+
mayCleanManagedTreeStaging,
|
|
73
79
|
retainManagedDirectoryAuthority,
|
|
74
80
|
} from "./internal/managed-session-storage";
|
|
81
|
+
import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
|
|
75
82
|
|
|
76
83
|
import {
|
|
77
84
|
type BashExecutionMessage,
|
|
@@ -1317,6 +1324,18 @@ function freezeInternalReadSnapshot<T>(value: T): T {
|
|
|
1317
1324
|
return copy;
|
|
1318
1325
|
}
|
|
1319
1326
|
|
|
1327
|
+
function managedScopeStartupError(
|
|
1328
|
+
action: "resolve" | "prepare",
|
|
1329
|
+
failure: Extract<ReturnType<typeof resolveManagedScopeForWrite>, { kind: "error" }>,
|
|
1330
|
+
): Error {
|
|
1331
|
+
return new Error(`Could not ${action} managed session scope.`, {
|
|
1332
|
+
cause: {
|
|
1333
|
+
classification: failure.cause?.classification ?? failure.code,
|
|
1334
|
+
...(failure.cause?.diagnostic === undefined ? {} : { diagnostic: failure.cause.diagnostic }),
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1320
1339
|
/** Resolve and prepare the default v2 session scope before any managed writer exists. */
|
|
1321
1340
|
function computeDefaultSessionDir(
|
|
1322
1341
|
cwd: string,
|
|
@@ -1324,10 +1343,13 @@ function computeDefaultSessionDir(
|
|
|
1324
1343
|
sessionsRoot: string = getSessionsDir(),
|
|
1325
1344
|
): string {
|
|
1326
1345
|
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1327
|
-
const resolved =
|
|
1328
|
-
if (resolved.kind === "error") throw
|
|
1329
|
-
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1330
|
-
|
|
1346
|
+
const resolved = resolveManagedScopeForWrite({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
1347
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1348
|
+
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1349
|
+
resolved.scope,
|
|
1350
|
+
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1351
|
+
);
|
|
1352
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1331
1353
|
return prepared.scope.directoryPath;
|
|
1332
1354
|
}
|
|
1333
1355
|
|
|
@@ -1408,6 +1430,13 @@ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: Mana
|
|
|
1408
1430
|
);
|
|
1409
1431
|
}
|
|
1410
1432
|
const trustedSessionDestinations = new WeakSet<SessionDestination>();
|
|
1433
|
+
const managedSecurityPolicies = new WeakMap<ManagedSessionSecurityContext, ManagedSessionSecurityPolicy>();
|
|
1434
|
+
|
|
1435
|
+
function managedSecurityPolicyForContext(securityContext: ManagedSessionSecurityContext): ManagedSessionSecurityPolicy {
|
|
1436
|
+
const policy = managedSecurityPolicies.get(securityContext);
|
|
1437
|
+
if (!policy) throw new Error("Managed session security policy unavailable");
|
|
1438
|
+
return policy;
|
|
1439
|
+
}
|
|
1411
1440
|
|
|
1412
1441
|
function managedStoreFromContext(
|
|
1413
1442
|
securityContext: ManagedSessionSecurityContext,
|
|
@@ -1419,6 +1448,7 @@ function managedStoreFromContext(
|
|
|
1419
1448
|
securityContext.retainedAuthority
|
|
1420
1449
|
? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
|
|
1421
1450
|
: undefined,
|
|
1451
|
+
managedSecurityPolicyForContext(securityContext),
|
|
1422
1452
|
);
|
|
1423
1453
|
}
|
|
1424
1454
|
|
|
@@ -1431,6 +1461,10 @@ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
|
|
|
1431
1461
|
rootAuthority,
|
|
1432
1462
|
retainedAuthority: managedDirectoryAuthorityForScope(scope),
|
|
1433
1463
|
});
|
|
1464
|
+
managedSecurityPolicies.set(
|
|
1465
|
+
securityContext,
|
|
1466
|
+
scope.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1467
|
+
);
|
|
1434
1468
|
const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
|
|
1435
1469
|
trustedSessionDestinations.add(destination);
|
|
1436
1470
|
return destination;
|
|
@@ -1445,10 +1479,17 @@ function explicitDestination(directory: string): SessionDestination {
|
|
|
1445
1479
|
function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
|
|
1446
1480
|
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1447
1481
|
const sessionsRoot = getSessionsDir(agentDir);
|
|
1448
|
-
const resolved =
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1482
|
+
const resolved = resolveManagedScopeForWrite({
|
|
1483
|
+
cwd,
|
|
1484
|
+
agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
|
|
1485
|
+
sessionsRoot,
|
|
1486
|
+
});
|
|
1487
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1488
|
+
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1489
|
+
resolved.scope,
|
|
1490
|
+
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1491
|
+
);
|
|
1492
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1452
1493
|
return freezeManagedDestination(prepared.scope);
|
|
1453
1494
|
}
|
|
1454
1495
|
|
|
@@ -1814,12 +1855,46 @@ function inspectResumeSessionFile(
|
|
|
1814
1855
|
mtimeNs: snapshot.mtimeNs,
|
|
1815
1856
|
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
1816
1857
|
};
|
|
1817
|
-
return { identity, content:
|
|
1858
|
+
return { identity, content: bytes, entries, context, migrationApplied };
|
|
1818
1859
|
} catch {
|
|
1819
1860
|
return { kind: "error", reason: "malformed" };
|
|
1820
1861
|
}
|
|
1821
1862
|
}
|
|
1822
1863
|
|
|
1864
|
+
/** Revalidate previously parsed authority without another decode, parse, migration, or context build. */
|
|
1865
|
+
function revalidateResumeSessionIdentity(
|
|
1866
|
+
filePath: string,
|
|
1867
|
+
storage: SessionStorage,
|
|
1868
|
+
expected: ResumeSessionIdentity,
|
|
1869
|
+
): StrictSessionOpenFailure | { kind: "valid" } {
|
|
1870
|
+
const canonicalPath = resolveEquivalentPath(path.resolve(filePath));
|
|
1871
|
+
let before: SessionStorageStat;
|
|
1872
|
+
let snapshot: SessionStorageSnapshot;
|
|
1873
|
+
let after: SessionStorageStat;
|
|
1874
|
+
try {
|
|
1875
|
+
before = storage.statSync(canonicalPath);
|
|
1876
|
+
if (!before.isFile || !storage.readSnapshotSync) return { kind: "error", reason: "read-failed" };
|
|
1877
|
+
snapshot = storage.readSnapshotSync(canonicalPath);
|
|
1878
|
+
after = storage.statSync(canonicalPath);
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
return resumeReadFailure(error, storage, canonicalPath);
|
|
1881
|
+
}
|
|
1882
|
+
if (!sameResumeStat(before, snapshot.stat) || !sameResumeStat(snapshot.stat, after)) {
|
|
1883
|
+
return { kind: "error", reason: "unstable" };
|
|
1884
|
+
}
|
|
1885
|
+
const observed: ResumeSessionIdentity = {
|
|
1886
|
+
canonicalPath,
|
|
1887
|
+
sessionId: expected.sessionId,
|
|
1888
|
+
dev: snapshot.stat.dev,
|
|
1889
|
+
ino: snapshot.stat.ino,
|
|
1890
|
+
size: snapshot.stat.size,
|
|
1891
|
+
mtimeMs: snapshot.stat.mtimeMs,
|
|
1892
|
+
mtimeNs: snapshot.stat.mtimeNs,
|
|
1893
|
+
sha256: crypto.createHash("sha256").update(snapshot.bytes).digest("hex"),
|
|
1894
|
+
};
|
|
1895
|
+
return sameResumeIdentity(expected, observed) ? { kind: "valid" } : { kind: "error", reason: "identity-mismatch" };
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1823
1898
|
/**
|
|
1824
1899
|
* Convert legacy persisted blob references in loaded entries into resident sentinels.
|
|
1825
1900
|
* Images then materialize lazily at provider/display/export chokepoints instead of
|
|
@@ -2099,23 +2174,6 @@ function formatTimeAgo(date: Date): string {
|
|
|
2099
2174
|
return date.toLocaleDateString();
|
|
2100
2175
|
}
|
|
2101
2176
|
|
|
2102
|
-
async function syncPathAndParent(filePath: string): Promise<void> {
|
|
2103
|
-
const file = await fs.promises.open(filePath, "r");
|
|
2104
|
-
try {
|
|
2105
|
-
await file.sync();
|
|
2106
|
-
} finally {
|
|
2107
|
-
await file.close();
|
|
2108
|
-
}
|
|
2109
|
-
if (process.platform !== "win32") {
|
|
2110
|
-
const parent = await fs.promises.open(path.dirname(filePath), "r");
|
|
2111
|
-
try {
|
|
2112
|
-
await parent.sync();
|
|
2113
|
-
} finally {
|
|
2114
|
-
await parent.close();
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
2177
|
interface SessionMoveDirectoryHandle {
|
|
2120
2178
|
sync(): Promise<void>;
|
|
2121
2179
|
close(): Promise<void>;
|
|
@@ -2135,40 +2193,6 @@ export async function syncSessionMoveDirectory(
|
|
|
2135
2193
|
}
|
|
2136
2194
|
}
|
|
2137
2195
|
|
|
2138
|
-
/** Durably flush every copied file and directory before an EXDEV source removal. */
|
|
2139
|
-
async function syncCopiedTree(treePath: string): Promise<void> {
|
|
2140
|
-
const stat = await fs.promises.lstat(treePath);
|
|
2141
|
-
if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
|
|
2142
|
-
if (stat.isFile()) {
|
|
2143
|
-
const file = await fs.promises.open(treePath, "r");
|
|
2144
|
-
try {
|
|
2145
|
-
await file.sync();
|
|
2146
|
-
} finally {
|
|
2147
|
-
await file.close();
|
|
2148
|
-
}
|
|
2149
|
-
return;
|
|
2150
|
-
}
|
|
2151
|
-
if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
|
|
2152
|
-
const directory = await fs.promises.opendir(treePath);
|
|
2153
|
-
try {
|
|
2154
|
-
for (;;) {
|
|
2155
|
-
const entry = await directory.read();
|
|
2156
|
-
if (entry === null) break;
|
|
2157
|
-
await syncCopiedTree(path.join(treePath, entry.name));
|
|
2158
|
-
}
|
|
2159
|
-
} finally {
|
|
2160
|
-
await directory.close();
|
|
2161
|
-
}
|
|
2162
|
-
if (process.platform !== "win32") {
|
|
2163
|
-
const handle = await fs.promises.open(treePath, "r");
|
|
2164
|
-
try {
|
|
2165
|
-
await handle.sync();
|
|
2166
|
-
} finally {
|
|
2167
|
-
await handle.close();
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
2196
|
type CrossDeviceTreeIdentity = string;
|
|
2173
2197
|
|
|
2174
2198
|
/** Captures content plus topology; a later capture must match before source removal. */
|
|
@@ -2216,14 +2240,6 @@ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDe
|
|
|
2216
2240
|
return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
|
|
2217
2241
|
}
|
|
2218
2242
|
|
|
2219
|
-
async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
|
|
2220
|
-
if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
|
|
2221
|
-
throw new Error("Cross-device move source changed before removal");
|
|
2222
|
-
const stat = await fs.promises.lstat(source);
|
|
2223
|
-
if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
|
|
2224
|
-
else await fs.promises.unlink(source);
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
2243
|
/**
|
|
2228
2244
|
* An EXDEV retirement must be driven by a native descriptor/handle-bound
|
|
2229
2245
|
* snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
|
|
@@ -2239,83 +2255,25 @@ class CrossDeviceMoveUnsupportedError extends Error {
|
|
|
2239
2255
|
}
|
|
2240
2256
|
}
|
|
2241
2257
|
|
|
2242
|
-
class PathMoveCompletedWithSyncFailure extends Error {
|
|
2243
|
-
constructor(readonly cause: unknown) {
|
|
2244
|
-
super("Path move completed but final source-directory sync failed.", { cause });
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
2258
|
async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
|
|
2249
2259
|
const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
|
|
2250
|
-
const
|
|
2251
|
-
if (
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2257
|
-
} catch (error) {
|
|
2258
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2259
|
-
}
|
|
2260
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(source, destination));
|
|
2261
|
+
if (outcome.ok) {
|
|
2262
|
+
if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
|
|
2263
|
+
throw new Error("Atomic session rename did not preserve the captured source identity");
|
|
2264
|
+
await syncSessionMoveDirectory(path.dirname(destination));
|
|
2265
|
+
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2260
2266
|
return;
|
|
2261
2267
|
}
|
|
2262
|
-
if (
|
|
2268
|
+
if (outcome.reason === "destination_exists") {
|
|
2263
2269
|
const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
|
|
2264
2270
|
error.code = "EEXIST";
|
|
2265
2271
|
throw error;
|
|
2266
2272
|
}
|
|
2267
|
-
if (
|
|
2268
|
-
throw new
|
|
2269
|
-
const
|
|
2270
|
-
|
|
2271
|
-
let destinationCreated = false;
|
|
2272
|
-
let sourceRemoved = false;
|
|
2273
|
-
try {
|
|
2274
|
-
try {
|
|
2275
|
-
await fs.promises.link(source, destination);
|
|
2276
|
-
destinationCreated = true;
|
|
2277
|
-
} catch (error) {
|
|
2278
|
-
if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
|
|
2279
|
-
if (
|
|
2280
|
-
!hasFsCode(error, "EPERM") &&
|
|
2281
|
-
!hasFsCode(error, "EACCES") &&
|
|
2282
|
-
!hasFsCode(error, "ENOTSUP") &&
|
|
2283
|
-
!hasFsCode(error, "EOPNOTSUPP")
|
|
2284
|
-
)
|
|
2285
|
-
throw error;
|
|
2286
|
-
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
|
|
2287
|
-
destinationCreated = true;
|
|
2288
|
-
}
|
|
2289
|
-
await syncPathAndParent(destination);
|
|
2290
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2291
|
-
sourceRemoved = true;
|
|
2292
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2293
|
-
return;
|
|
2294
|
-
} catch (error) {
|
|
2295
|
-
if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
|
|
2296
|
-
if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2297
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2298
|
-
throw error;
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
const destinationParent = await fs.promises.lstat(path.dirname(destination));
|
|
2303
|
-
if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
|
|
2304
|
-
|
|
2305
|
-
try {
|
|
2306
|
-
await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
|
|
2307
|
-
await syncCopiedTree(destination);
|
|
2308
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2309
|
-
try {
|
|
2310
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2311
|
-
} catch (error) {
|
|
2312
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2313
|
-
}
|
|
2314
|
-
} catch (error) {
|
|
2315
|
-
if (!(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2316
|
-
await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
|
|
2317
|
-
throw error;
|
|
2318
|
-
}
|
|
2273
|
+
if (outcome.reason === "atomic_unavailable")
|
|
2274
|
+
throw new CrossDeviceMoveUnsupportedError(source, destination, new Error(formatNativePublishDiagnostic(outcome)));
|
|
2275
|
+
const message = `Atomic session rename failed: ${outcome.code ?? outcome.reason}`;
|
|
2276
|
+
throw new Error(message, { cause: new Error(formatNativePublishDiagnostic(outcome)) });
|
|
2319
2277
|
}
|
|
2320
2278
|
|
|
2321
2279
|
const MAX_PERSIST_CHARS = 500_000;
|
|
@@ -3501,6 +3459,64 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
|
3501
3459
|
return usage as Usage;
|
|
3502
3460
|
}
|
|
3503
3461
|
|
|
3462
|
+
interface ValidatedUsageTotals {
|
|
3463
|
+
input: number;
|
|
3464
|
+
output: number;
|
|
3465
|
+
cacheRead: number;
|
|
3466
|
+
cacheWrite: number;
|
|
3467
|
+
premiumRequests: number;
|
|
3468
|
+
cost: number;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/** Max malformed-usage entry ids sampled for a single resume-time report. */
|
|
3472
|
+
const MALFORMED_USAGE_SAMPLE_LIMIT = 8;
|
|
3473
|
+
|
|
3474
|
+
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
|
3475
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
/**
|
|
3479
|
+
* Validate an untrusted persisted `usage` record before it is aggregated into
|
|
3480
|
+
* session usage statistics. `parseSessionEntries` accepts any parseable JSON, so a
|
|
3481
|
+
* torn/corrupt record can carry a `usage` that is still valid JSON yet malformed:
|
|
3482
|
+
* absent (`{}` yields NaN), numeric strings (`"10"` coerces sums into strings), or
|
|
3483
|
+
* negative buckets that silently reduce totals. Returns the validated finite
|
|
3484
|
+
* non-negative totals, or null when any bucket or `cost.total` is malformed — the
|
|
3485
|
+
* caller then skips and reports that record rather than poisoning every
|
|
3486
|
+
* getUsageStatistics() consumer. Absent `premiumRequests`/`cost` default to 0
|
|
3487
|
+
* (backward-compatible with older transcripts); a present-but-invalid field is
|
|
3488
|
+
* rejected.
|
|
3489
|
+
*/
|
|
3490
|
+
function validatePersistedUsageTotals(usage: unknown): ValidatedUsageTotals | null {
|
|
3491
|
+
if (typeof usage !== "object" || usage === null) return null;
|
|
3492
|
+
const record = usage as Record<string, unknown>;
|
|
3493
|
+
const input = record.input;
|
|
3494
|
+
const output = record.output;
|
|
3495
|
+
const cacheRead = record.cacheRead;
|
|
3496
|
+
const cacheWrite = record.cacheWrite;
|
|
3497
|
+
if (
|
|
3498
|
+
!isFiniteNonNegativeNumber(input) ||
|
|
3499
|
+
!isFiniteNonNegativeNumber(output) ||
|
|
3500
|
+
!isFiniteNonNegativeNumber(cacheRead) ||
|
|
3501
|
+
!isFiniteNonNegativeNumber(cacheWrite)
|
|
3502
|
+
)
|
|
3503
|
+
return null;
|
|
3504
|
+
// Default only on truly absent (=== undefined) properties; a present-but-null/invalid
|
|
3505
|
+
// premiumRequests is malformed, not zero.
|
|
3506
|
+
const premiumRequests = record.premiumRequests === undefined ? 0 : record.premiumRequests;
|
|
3507
|
+
if (!isFiniteNonNegativeNumber(premiumRequests)) return null;
|
|
3508
|
+
const rawCost = record.cost;
|
|
3509
|
+
let cost: unknown = 0;
|
|
3510
|
+
if (rawCost !== undefined) {
|
|
3511
|
+
// A present `cost` must be a non-array record carrying a finite non-negative `total`;
|
|
3512
|
+
// null, arrays, and an absent/invalid `total` are malformed — never silently zero.
|
|
3513
|
+
if (typeof rawCost !== "object" || rawCost === null || Array.isArray(rawCost)) return null;
|
|
3514
|
+
cost = (rawCost as Record<string, unknown>).total;
|
|
3515
|
+
}
|
|
3516
|
+
if (!isFiniteNonNegativeNumber(cost)) return null;
|
|
3517
|
+
return { input, output, cacheRead, cacheWrite, premiumRequests, cost };
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3504
3520
|
function extractTextFromContent(content: Message["content"]): string {
|
|
3505
3521
|
if (typeof content === "string") return content;
|
|
3506
3522
|
return content
|
|
@@ -3867,9 +3883,11 @@ export async function resolveResumableSession(
|
|
|
3867
3883
|
cwd: string,
|
|
3868
3884
|
sessionDir?: string,
|
|
3869
3885
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3886
|
+
managedAgentDir?: string,
|
|
3870
3887
|
): Promise<ResolvedSessionMatch | undefined> {
|
|
3871
|
-
const
|
|
3872
|
-
|
|
3888
|
+
const localSessions = sessionDir
|
|
3889
|
+
? await SessionManager.list(cwd, sessionDir, storage)
|
|
3890
|
+
: await SessionManager.listManagedForResumePickerReadOnly(cwd, managedAgentDir, storage);
|
|
3873
3891
|
const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
3874
3892
|
if (localMatch) {
|
|
3875
3893
|
return { session: localMatch, scope: "local" };
|
|
@@ -3879,7 +3897,7 @@ export async function resolveResumableSession(
|
|
|
3879
3897
|
return undefined;
|
|
3880
3898
|
}
|
|
3881
3899
|
|
|
3882
|
-
const globalSessions = await SessionManager.listAll(storage);
|
|
3900
|
+
const globalSessions = await SessionManager.listAll(storage, managedAgentDir);
|
|
3883
3901
|
const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
3884
3902
|
if (!globalMatch) {
|
|
3885
3903
|
return undefined;
|
|
@@ -3911,6 +3929,7 @@ export class SessionManager {
|
|
|
3911
3929
|
#ensuredOnDisk: boolean = false;
|
|
3912
3930
|
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
3913
3931
|
#fileEntries: FileEntry[] = [];
|
|
3932
|
+
#pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
|
|
3914
3933
|
#byId: Map<string, SessionEntry> = new Map();
|
|
3915
3934
|
#labelsById: Map<string, string> = new Map();
|
|
3916
3935
|
#leafId: string | null = null;
|
|
@@ -4157,15 +4176,16 @@ export class SessionManager {
|
|
|
4157
4176
|
}
|
|
4158
4177
|
|
|
4159
4178
|
async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
|
|
4160
|
-
|
|
4161
|
-
|
|
4179
|
+
// Strict inspection creates these entries solely for this hydration path. Adopt that
|
|
4180
|
+
// final, validated representation instead of cloning the complete transcript again.
|
|
4181
|
+
const header = entries[0] as SessionHeader;
|
|
4162
4182
|
this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
4163
4183
|
this.#sessionId = header.id;
|
|
4164
4184
|
this.#sessionName = header.title;
|
|
4165
4185
|
this.#titleSource = header.titleSource;
|
|
4166
4186
|
this.#needsFullRewriteOnNextPersist = migrationApplied;
|
|
4167
|
-
await resolveBlobRefsInEntries(
|
|
4168
|
-
this.#fileEntries =
|
|
4187
|
+
await resolveBlobRefsInEntries(entries, this.#blobStore);
|
|
4188
|
+
this.#fileEntries = entries;
|
|
4169
4189
|
this.#resetResidentTextBlobStore();
|
|
4170
4190
|
this.#fileEntries = this.#fileEntries.map(entry =>
|
|
4171
4191
|
prepareEntryForResidentSync(entry, this.#residentBlobStores()),
|
|
@@ -4184,12 +4204,25 @@ export class SessionManager {
|
|
|
4184
4204
|
|
|
4185
4205
|
/** Switch to a different session file (used for resume and branching) */
|
|
4186
4206
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
4207
|
+
const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
|
|
4208
|
+
const strictAdoption = this.#pendingStrictAdoption;
|
|
4209
|
+
this.#pendingStrictAdoption = undefined;
|
|
4210
|
+
if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
|
|
4211
|
+
const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
|
|
4212
|
+
if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
|
|
4213
|
+
throw new Error("Prepared managed session changed before strict adoption.");
|
|
4214
|
+
}
|
|
4187
4215
|
await this.#closePersistWriter();
|
|
4188
4216
|
this.#persistError = undefined;
|
|
4189
4217
|
this.#persistErrorReported = false;
|
|
4190
|
-
this.#sessionFile =
|
|
4218
|
+
this.#sessionFile = resolvedSessionFile;
|
|
4191
4219
|
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
4192
4220
|
this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
|
|
4221
|
+
if (strictAdoption) {
|
|
4222
|
+
const inspected = inspectResumeSessionFile(this.#sessionFile, this.storage);
|
|
4223
|
+
if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
|
|
4224
|
+
throw new Error("Prepared managed session changed during strict adoption.");
|
|
4225
|
+
}
|
|
4193
4226
|
if (this.#fileEntries.length > 0) {
|
|
4194
4227
|
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
4195
4228
|
this.#sessionId = header?.id ?? createSessionId();
|
|
@@ -4255,7 +4288,8 @@ export class SessionManager {
|
|
|
4255
4288
|
const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
|
|
4256
4289
|
if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
|
|
4257
4290
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
4258
|
-
if (deleted.kind
|
|
4291
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
4292
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
4259
4293
|
} else {
|
|
4260
4294
|
await this.storage.deleteSessionWithArtifacts(sessionPath);
|
|
4261
4295
|
}
|
|
@@ -4384,27 +4418,20 @@ export class SessionManager {
|
|
|
4384
4418
|
throw new Error("managed_fork_transcript_changed");
|
|
4385
4419
|
}
|
|
4386
4420
|
} catch (error) {
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
if (this.destination.kind === "managed") {
|
|
4421
|
+
const failure = error;
|
|
4422
|
+
if (forkArtifactPublication) {
|
|
4390
4423
|
try {
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
forkArtifactPublication.cleanupRelativePath,
|
|
4396
|
-
forkArtifactPublication.snapshot,
|
|
4397
|
-
);
|
|
4424
|
+
forkArtifactPublication.cleanupStore.removeTreeExpected(
|
|
4425
|
+
forkArtifactPublication.cleanupRelativePath,
|
|
4426
|
+
forkArtifactPublication.snapshot,
|
|
4427
|
+
);
|
|
4398
4428
|
} catch (cleanupError) {
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
await this.storage.unlink(failedSessionFile);
|
|
4406
|
-
} catch {
|
|
4407
|
-
// Preserve ambiguous cleanup evidence while restoring the active manager.
|
|
4429
|
+
const cleanupMessage = toError(cleanupError).message;
|
|
4430
|
+
if (cleanupMessage !== "cleanup_pending") {
|
|
4431
|
+
throw new Error(`Failed to clean up fork artifacts: ${cleanupMessage}`, {
|
|
4432
|
+
cause: toError(failure),
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
4408
4435
|
}
|
|
4409
4436
|
}
|
|
4410
4437
|
this.#sessionId = oldSessionId;
|
|
@@ -4483,8 +4510,7 @@ export class SessionManager {
|
|
|
4483
4510
|
}
|
|
4484
4511
|
if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
|
|
4485
4512
|
throw new Error("artifact_source_changed");
|
|
4486
|
-
parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4487
|
-
publishedSnapshot = parentStore.captureTree(destinationName);
|
|
4513
|
+
publishedSnapshot = parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4488
4514
|
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4489
4515
|
tree.entries.map(entry => ({
|
|
4490
4516
|
relativePath: entry.relativePath,
|
|
@@ -4508,12 +4534,15 @@ export class SessionManager {
|
|
|
4508
4534
|
};
|
|
4509
4535
|
} catch (error) {
|
|
4510
4536
|
try {
|
|
4511
|
-
if (
|
|
4512
|
-
|
|
4537
|
+
if (stagingSnapshot && mayCleanManagedTreeStaging(error))
|
|
4538
|
+
parentStore.removeTreeExpected(stagingName, stagingSnapshot);
|
|
4513
4539
|
} catch (cleanupError) {
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4540
|
+
const cleanupMessage = toError(cleanupError).message;
|
|
4541
|
+
if (cleanupMessage !== "cleanup_pending") {
|
|
4542
|
+
throw new Error(`Failed to clean up managed fork artifacts: ${cleanupMessage}`, {
|
|
4543
|
+
cause: toError(error),
|
|
4544
|
+
});
|
|
4545
|
+
}
|
|
4517
4546
|
}
|
|
4518
4547
|
throw error;
|
|
4519
4548
|
}
|
|
@@ -4531,19 +4560,15 @@ export class SessionManager {
|
|
|
4531
4560
|
const previousSessionDir = this.sessionDir;
|
|
4532
4561
|
const previousSessionFile = this.#sessionFile;
|
|
4533
4562
|
const previousDestination = this.destination;
|
|
4534
|
-
const previousArtifactDir = previousSessionFile?.slice(0, -6);
|
|
4535
4563
|
|
|
4536
|
-
const nextDestination =
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
|
4545
|
-
return explicitDestination(directory);
|
|
4546
|
-
})();
|
|
4564
|
+
const nextDestination =
|
|
4565
|
+
this.storage instanceof FileSessionStorage
|
|
4566
|
+
? managedDestination(
|
|
4567
|
+
resolvedCwd,
|
|
4568
|
+
this.storage,
|
|
4569
|
+
this.destination.kind === "managed" ? this.destination.securityContext.agentDir : undefined,
|
|
4570
|
+
)
|
|
4571
|
+
: this.destination;
|
|
4547
4572
|
const newSessionDir = nextDestination.directory;
|
|
4548
4573
|
let hadSessionFile = false;
|
|
4549
4574
|
const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
|
|
@@ -4646,15 +4671,8 @@ export class SessionManager {
|
|
|
4646
4671
|
managedPublishedArtifacts,
|
|
4647
4672
|
);
|
|
4648
4673
|
}
|
|
4649
|
-
if (managedTranscript)
|
|
4674
|
+
if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
|
|
4650
4675
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4651
|
-
if (managedPublishedArtifacts)
|
|
4652
|
-
managedDestinationStore.removeTreeExpected(
|
|
4653
|
-
path.basename(newArtifactDir),
|
|
4654
|
-
managedPublishedArtifacts,
|
|
4655
|
-
);
|
|
4656
|
-
if (managedPublishedTranscript)
|
|
4657
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4658
4676
|
};
|
|
4659
4677
|
managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
|
|
4660
4678
|
hadSessionFile = managedTranscript !== null;
|
|
@@ -4691,10 +4709,20 @@ export class SessionManager {
|
|
|
4691
4709
|
if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
|
|
4692
4710
|
throw new Error("managed_move_destination_artifacts_changed");
|
|
4693
4711
|
}
|
|
4694
|
-
if (managedArtifacts)
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4712
|
+
if (managedArtifacts) {
|
|
4713
|
+
try {
|
|
4714
|
+
managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
|
|
4715
|
+
} catch (cleanupError) {
|
|
4716
|
+
if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
if (managedTranscript) {
|
|
4720
|
+
try {
|
|
4721
|
+
managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
|
|
4722
|
+
} catch (cleanupError) {
|
|
4723
|
+
if (toError(cleanupError).message !== "cleanup_pending") throw cleanupError;
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4698
4726
|
} else {
|
|
4699
4727
|
if (hadSessionFile && oldSessionFile !== newSessionFile) {
|
|
4700
4728
|
await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
|
|
@@ -4750,13 +4778,6 @@ export class SessionManager {
|
|
|
4750
4778
|
!managedSourceStore.readExpected(path.basename(oldSessionFile))
|
|
4751
4779
|
)
|
|
4752
4780
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4753
|
-
if (managedPublishedArtifacts)
|
|
4754
|
-
managedDestinationStore.removeTreeExpected(
|
|
4755
|
-
path.basename(newArtifactDir),
|
|
4756
|
-
managedPublishedArtifacts,
|
|
4757
|
-
);
|
|
4758
|
-
if (managedPublishedTranscript)
|
|
4759
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4760
4781
|
} catch (rollbackErr) {
|
|
4761
4782
|
restoreResidentStateAndThrow(
|
|
4762
4783
|
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
@@ -4777,7 +4798,8 @@ export class SessionManager {
|
|
|
4777
4798
|
this.#bumpAllRevisions();
|
|
4778
4799
|
}
|
|
4779
4800
|
|
|
4780
|
-
// Update cwd and sessionDir after
|
|
4801
|
+
// Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
|
|
4802
|
+
// authority but deliberately retain any destination publication evidence rather than deleting it.
|
|
4781
4803
|
this.cwd = resolvedCwd;
|
|
4782
4804
|
this.sessionDir = newSessionDir;
|
|
4783
4805
|
this.destination = nextDestination;
|
|
@@ -4798,60 +4820,37 @@ export class SessionManager {
|
|
|
4798
4820
|
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
4799
4821
|
}
|
|
4800
4822
|
} catch (error) {
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
this.#persistErrorReported = false;
|
|
4808
|
-
if (rollbackManagedMove) {
|
|
4823
|
+
await this.#closePersistWriter().catch(() => {});
|
|
4824
|
+
this.#persistChain = Promise.resolve();
|
|
4825
|
+
this.#persistError = undefined;
|
|
4826
|
+
this.#persistErrorReported = false;
|
|
4827
|
+
if (rollbackManagedMove) {
|
|
4828
|
+
try {
|
|
4809
4829
|
await rollbackManagedMove();
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4817
|
-
: null;
|
|
4818
|
-
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4819
|
-
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4820
|
-
this.#headerExportRevision++;
|
|
4821
|
-
throw error;
|
|
4822
|
-
}
|
|
4823
|
-
if (
|
|
4824
|
-
this.storage instanceof FileSessionStorage &&
|
|
4825
|
-
failedArtifactDir &&
|
|
4826
|
-
previousArtifactDir &&
|
|
4827
|
-
failedArtifactDir !== previousArtifactDir &&
|
|
4828
|
-
fs.existsSync(failedArtifactDir)
|
|
4829
|
-
) {
|
|
4830
|
-
await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
|
|
4831
|
-
}
|
|
4832
|
-
if (
|
|
4833
|
-
this.storage instanceof FileSessionStorage &&
|
|
4834
|
-
failedSessionFile &&
|
|
4835
|
-
previousSessionFile &&
|
|
4836
|
-
failedSessionFile !== previousSessionFile &&
|
|
4837
|
-
fs.existsSync(failedSessionFile)
|
|
4838
|
-
) {
|
|
4839
|
-
await movePathAcrossDevicesSafe(failedSessionFile, previousSessionFile);
|
|
4830
|
+
} catch (rollbackError) {
|
|
4831
|
+
if (toError(rollbackError).message !== "cleanup_pending") {
|
|
4832
|
+
throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
|
|
4833
|
+
cause: toError(error),
|
|
4834
|
+
});
|
|
4835
|
+
}
|
|
4840
4836
|
}
|
|
4841
4837
|
this.#sessionFile = previousSessionFile;
|
|
4842
4838
|
this.cwd = previousCwd;
|
|
4843
4839
|
this.sessionDir = previousSessionDir;
|
|
4844
4840
|
this.destination = previousDestination;
|
|
4845
|
-
this.#managedTranscriptStoreCache =
|
|
4841
|
+
this.#managedTranscriptStoreCache =
|
|
4842
|
+
managedMove && managedSourceStore
|
|
4843
|
+
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4844
|
+
: null;
|
|
4846
4845
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4847
4846
|
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4848
4847
|
this.#headerExportRevision++;
|
|
4849
|
-
|
|
4850
|
-
} catch (rollbackError) {
|
|
4851
|
-
throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
|
|
4852
|
-
cause: toError(error),
|
|
4853
|
-
});
|
|
4848
|
+
throw error;
|
|
4854
4849
|
}
|
|
4850
|
+
// The destination has already been published. Retain it rather than moving it
|
|
4851
|
+
// back over the source after a metadata failure.
|
|
4852
|
+
this.#managedTranscriptStoreCache = null;
|
|
4853
|
+
this.#headerExportRevision++;
|
|
4855
4854
|
throw error;
|
|
4856
4855
|
}
|
|
4857
4856
|
|
|
@@ -4911,6 +4910,8 @@ export class SessionManager {
|
|
|
4911
4910
|
this.#labelsById.clear();
|
|
4912
4911
|
this.#leafId = null;
|
|
4913
4912
|
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
4913
|
+
let malformedUsageRecords = 0;
|
|
4914
|
+
const malformedUsageSample: string[] = [];
|
|
4914
4915
|
for (const entry of this.#fileEntries) {
|
|
4915
4916
|
if (entry.type === "session") continue;
|
|
4916
4917
|
this.#byId.set(entry.id, entry);
|
|
@@ -4922,28 +4923,80 @@ export class SessionManager {
|
|
|
4922
4923
|
this.#labelsById.delete(entry.targetId);
|
|
4923
4924
|
}
|
|
4924
4925
|
}
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4926
|
+
// Aggregate this entry's persisted usage through the shared validated,
|
|
4927
|
+
// overflow-guarded path (see #accumulateEntryUsage). parseSessionEntries accepts
|
|
4928
|
+
// any parseable JSON, so a torn/corrupt record can carry a still-valid-JSON but
|
|
4929
|
+
// malformed `usage` (absent, {}, numeric strings, negatives, a non-record cost, or
|
|
4930
|
+
// a cumulative overflow); such a record is skipped and reported below instead of
|
|
4931
|
+
// poisoning every getUsageStatistics() consumer.
|
|
4932
|
+
if (this.#accumulateEntryUsage(entry)) {
|
|
4933
|
+
malformedUsageRecords++;
|
|
4934
|
+
if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
|
|
4933
4935
|
}
|
|
4936
|
+
}
|
|
4937
|
+
if (malformedUsageRecords > 0) {
|
|
4938
|
+
logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
|
|
4939
|
+
sessionFile: this.#sessionFile,
|
|
4940
|
+
count: malformedUsageRecords,
|
|
4941
|
+
sampleEntryIds: malformedUsageSample,
|
|
4942
|
+
});
|
|
4943
|
+
}
|
|
4944
|
+
}
|
|
4934
4945
|
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
+
/**
|
|
4947
|
+
* Commit a validated record's totals only if every cumulative sum stays finite.
|
|
4948
|
+
* Individually-finite buckets can still overflow to Infinity in aggregate (e.g. two
|
|
4949
|
+
* Number.MAX_VALUE records), which would poison getUsageStatistics(); such a record is
|
|
4950
|
+
* rejected atomically (no partial commit). Returns true when committed, false when
|
|
4951
|
+
* rejected for cumulative overflow.
|
|
4952
|
+
*/
|
|
4953
|
+
#addValidatedUsage(totals: ValidatedUsageTotals): boolean {
|
|
4954
|
+
const next = {
|
|
4955
|
+
input: this.#usageStatistics.input + totals.input,
|
|
4956
|
+
output: this.#usageStatistics.output + totals.output,
|
|
4957
|
+
cacheRead: this.#usageStatistics.cacheRead + totals.cacheRead,
|
|
4958
|
+
cacheWrite: this.#usageStatistics.cacheWrite + totals.cacheWrite,
|
|
4959
|
+
premiumRequests: this.#usageStatistics.premiumRequests + totals.premiumRequests,
|
|
4960
|
+
cost: this.#usageStatistics.cost + totals.cost,
|
|
4961
|
+
};
|
|
4962
|
+
if (
|
|
4963
|
+
!Number.isFinite(next.input) ||
|
|
4964
|
+
!Number.isFinite(next.output) ||
|
|
4965
|
+
!Number.isFinite(next.cacheRead) ||
|
|
4966
|
+
!Number.isFinite(next.cacheWrite) ||
|
|
4967
|
+
!Number.isFinite(next.premiumRequests) ||
|
|
4968
|
+
!Number.isFinite(next.cost)
|
|
4969
|
+
)
|
|
4970
|
+
return false;
|
|
4971
|
+
this.#usageStatistics.input = next.input;
|
|
4972
|
+
this.#usageStatistics.output = next.output;
|
|
4973
|
+
this.#usageStatistics.cacheRead = next.cacheRead;
|
|
4974
|
+
this.#usageStatistics.cacheWrite = next.cacheWrite;
|
|
4975
|
+
this.#usageStatistics.premiumRequests = next.premiumRequests;
|
|
4976
|
+
this.#usageStatistics.cost = next.cost;
|
|
4977
|
+
return true;
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
/**
|
|
4981
|
+
* Validate and accumulate one entry's persisted usage through the single shared,
|
|
4982
|
+
* overflow-guarded aggregation path used by both #buildIndex (resume) and #appendEntry
|
|
4983
|
+
* (runtime), covering the assistant and `task` tool-result shapes. Returns true when a
|
|
4984
|
+
* present usage record was skipped as malformed or overflowing (so the caller can report
|
|
4985
|
+
* it); false when the entry has no usage or was aggregated cleanly.
|
|
4986
|
+
*/
|
|
4987
|
+
#accumulateEntryUsage(entry: SessionEntry): boolean {
|
|
4988
|
+
if (entry.type !== "message") return false;
|
|
4989
|
+
if (entry.message.role === "assistant") {
|
|
4990
|
+
const totals = validatePersistedUsageTotals(entry.message.usage);
|
|
4991
|
+
return !(totals && this.#addValidatedUsage(totals));
|
|
4992
|
+
}
|
|
4993
|
+
if (entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
4994
|
+
const rawTaskUsage = getTaskToolUsage(entry.message.details);
|
|
4995
|
+
if (rawTaskUsage === undefined) return false;
|
|
4996
|
+
const totals = validatePersistedUsageTotals(rawTaskUsage);
|
|
4997
|
+
return !(totals && this.#addValidatedUsage(totals));
|
|
4946
4998
|
}
|
|
4999
|
+
return false;
|
|
4947
5000
|
}
|
|
4948
5001
|
|
|
4949
5002
|
#recordPersistError(err: unknown): Error {
|
|
@@ -5523,6 +5576,17 @@ export class SessionManager {
|
|
|
5523
5576
|
return this.sessionDir;
|
|
5524
5577
|
}
|
|
5525
5578
|
|
|
5579
|
+
/** Lists picker candidates within this manager's captured destination authority. */
|
|
5580
|
+
async listForResumePickerReadOnly(): Promise<SessionInfo[]> {
|
|
5581
|
+
return this.destination.kind === "managed"
|
|
5582
|
+
? await SessionManager.listManagedForResumePickerReadOnly(
|
|
5583
|
+
this.cwd,
|
|
5584
|
+
this.destination.securityContext.agentDir,
|
|
5585
|
+
this.storage,
|
|
5586
|
+
)
|
|
5587
|
+
: await SessionManager.listForResumePickerReadOnly(this.cwd, this.destination.directory, this.storage);
|
|
5588
|
+
}
|
|
5589
|
+
|
|
5526
5590
|
getSessionId(): string {
|
|
5527
5591
|
return this.#sessionId;
|
|
5528
5592
|
}
|
|
@@ -5552,13 +5616,22 @@ export class SessionManager {
|
|
|
5552
5616
|
getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
|
|
5553
5617
|
if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
|
|
5554
5618
|
const store = this.#managedTranscriptStore();
|
|
5619
|
+
const legacyArtifactsRoot = path.basename(this.#sessionFile.slice(0, -6));
|
|
5620
|
+
const legacyLocalRoot = path.posix.join(legacyArtifactsRoot, "local");
|
|
5555
5621
|
return {
|
|
5556
5622
|
capture: async () => {
|
|
5557
5623
|
let snapshot: native.NativeDirectoryTreeSnapshot;
|
|
5558
5624
|
try {
|
|
5559
|
-
snapshot = store.captureTree(
|
|
5625
|
+
snapshot = store.captureTree(legacyLocalRoot);
|
|
5560
5626
|
} catch (error) {
|
|
5561
5627
|
if (error instanceof Error && error.message === "not_found") return null;
|
|
5628
|
+
if (error instanceof Error && error.message === "reparse_point") {
|
|
5629
|
+
try {
|
|
5630
|
+
store.captureTree(legacyArtifactsRoot);
|
|
5631
|
+
} catch (artifactsError) {
|
|
5632
|
+
if (artifactsError instanceof Error && artifactsError.message === "not_found") return null;
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5562
5635
|
throw error;
|
|
5563
5636
|
}
|
|
5564
5637
|
let totalBytes = 0;
|
|
@@ -5566,7 +5639,7 @@ export class SessionManager {
|
|
|
5566
5639
|
if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
|
|
5567
5640
|
const entries = snapshot.entries.map(entry => {
|
|
5568
5641
|
if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
|
|
5569
|
-
const captured = store.readExpected(path.posix.join(
|
|
5642
|
+
const captured = store.readExpected(path.posix.join(legacyLocalRoot, entry.relativePath));
|
|
5570
5643
|
if (
|
|
5571
5644
|
!captured ||
|
|
5572
5645
|
captured.identity.dev.toString() !== entry.dev ||
|
|
@@ -5582,12 +5655,12 @@ export class SessionManager {
|
|
|
5582
5655
|
sha256: entry.sha256,
|
|
5583
5656
|
};
|
|
5584
5657
|
});
|
|
5585
|
-
const verified = store.captureTree(
|
|
5658
|
+
const verified = store.captureTree(legacyLocalRoot);
|
|
5586
5659
|
if (JSON.stringify(verified) !== JSON.stringify(snapshot))
|
|
5587
5660
|
throw new Error("Legacy local:// migration source changed during capture");
|
|
5588
5661
|
return { snapshot, entries };
|
|
5589
5662
|
},
|
|
5590
|
-
retire: snapshot => store.removeTreeExpected(
|
|
5663
|
+
retire: snapshot => store.removeTreeExpected(legacyLocalRoot, snapshot),
|
|
5591
5664
|
};
|
|
5592
5665
|
}
|
|
5593
5666
|
|
|
@@ -5914,26 +5987,14 @@ export class SessionManager {
|
|
|
5914
5987
|
this.#leafRevision++;
|
|
5915
5988
|
if (entry.type === "label") this.#labelRevision++;
|
|
5916
5989
|
this._persist(residentEntry);
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
}
|
|
5926
|
-
|
|
5927
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
5928
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
5929
|
-
if (usage) {
|
|
5930
|
-
this.#usageStatistics.input += usage.input;
|
|
5931
|
-
this.#usageStatistics.output += usage.output;
|
|
5932
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
5933
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
5934
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
5935
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
5936
|
-
}
|
|
5990
|
+
// Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
|
|
5991
|
+
// malformed task-result or assistant usage shape reaching the append path must not
|
|
5992
|
+
// crash or poison getUsageStatistics() either.
|
|
5993
|
+
if (this.#accumulateEntryUsage(entry)) {
|
|
5994
|
+
logger.warn("Skipped malformed or overflowing usage on appended entry", {
|
|
5995
|
+
sessionFile: this.#sessionFile,
|
|
5996
|
+
entryId: entry.id,
|
|
5997
|
+
});
|
|
5937
5998
|
}
|
|
5938
5999
|
}
|
|
5939
6000
|
|
|
@@ -7080,6 +7141,12 @@ export class SessionManager {
|
|
|
7080
7141
|
authority instanceof ManagedSessionDescendantStore
|
|
7081
7142
|
? authority.retainAuthority()
|
|
7082
7143
|
: retainManagedDirectoryAuthority(rootAuthority, directory);
|
|
7144
|
+
const policy =
|
|
7145
|
+
authority instanceof ManagedSessionDescendantStore
|
|
7146
|
+
? authority.securityPolicy
|
|
7147
|
+
: process.platform === "win32"
|
|
7148
|
+
? "windows-existing-verify-first"
|
|
7149
|
+
: "default";
|
|
7083
7150
|
const securityContext = createManagedSessionSecurityContext({
|
|
7084
7151
|
agentDir: rootAuthority.canonicalPath,
|
|
7085
7152
|
sessionsRoot: rootAuthority.canonicalPath,
|
|
@@ -7087,6 +7154,7 @@ export class SessionManager {
|
|
|
7087
7154
|
rootAuthority,
|
|
7088
7155
|
retainedAuthority,
|
|
7089
7156
|
});
|
|
7157
|
+
managedSecurityPolicies.set(securityContext, policy);
|
|
7090
7158
|
const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
|
|
7091
7159
|
trustedSessionDestinations.add(destination);
|
|
7092
7160
|
return destination;
|
|
@@ -7115,20 +7183,61 @@ export class SessionManager {
|
|
|
7115
7183
|
return manager;
|
|
7116
7184
|
}
|
|
7117
7185
|
|
|
7186
|
+
/** Prepare a selected candidate using this manager's captured managed destination authority. */
|
|
7187
|
+
async prepareManagedCandidateForWrite(
|
|
7188
|
+
filePath: string,
|
|
7189
|
+
migrationPolicy: SessionDirectoryMigrationPolicy,
|
|
7190
|
+
expectedIdentity?: ResumeSessionIdentity,
|
|
7191
|
+
): Promise<string> {
|
|
7192
|
+
return SessionManager.prepareManagedCandidateForWrite(
|
|
7193
|
+
filePath,
|
|
7194
|
+
migrationPolicy,
|
|
7195
|
+
this.destination,
|
|
7196
|
+
expectedIdentity,
|
|
7197
|
+
);
|
|
7198
|
+
}
|
|
7199
|
+
|
|
7200
|
+
/** Prepare a managed candidate and retain its exact post-preparation identity for the next adoption. */
|
|
7201
|
+
async prepareManagedCandidateForStrictAdoption(
|
|
7202
|
+
filePath: string,
|
|
7203
|
+
migrationPolicy: SessionDirectoryMigrationPolicy,
|
|
7204
|
+
expectedIdentity: ResumeSessionIdentity,
|
|
7205
|
+
): Promise<string> {
|
|
7206
|
+
const preparedPath = await this.prepareManagedCandidateForWrite(filePath, migrationPolicy, expectedIdentity);
|
|
7207
|
+
const inspected = inspectResumeSessionFile(preparedPath, this.storage);
|
|
7208
|
+
if ("kind" in inspected) throw new Error(`Could not inspect prepared managed session: ${inspected.reason}`);
|
|
7209
|
+
this.#pendingStrictAdoption = { canonicalPath: inspected.identity.canonicalPath, identity: inspected.identity };
|
|
7210
|
+
return preparedPath;
|
|
7211
|
+
}
|
|
7212
|
+
|
|
7118
7213
|
/** Resolve a default-managed candidate through binding validation and copy-retain migration before mutation. */
|
|
7119
7214
|
static async prepareManagedCandidateForWrite(
|
|
7120
7215
|
filePath: string,
|
|
7121
7216
|
migrationPolicy: SessionDirectoryMigrationPolicy,
|
|
7217
|
+
destination: SessionDestination,
|
|
7218
|
+
expectedIdentity?: ResumeSessionIdentity,
|
|
7122
7219
|
): Promise<string> {
|
|
7220
|
+
if (!trustedSessionDestinations.has(destination) || destination.kind !== "managed")
|
|
7221
|
+
throw new Error("Managed candidate preparation requires a trusted managed destination authority");
|
|
7123
7222
|
const storage = new FileSessionStorage();
|
|
7223
|
+
const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
|
|
7224
|
+
const assertManagedDestinationBound = (): void => {
|
|
7225
|
+
managedDestinationStore.assertBound();
|
|
7226
|
+
if (!destination.securityContext.retainedAuthority)
|
|
7227
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
7228
|
+
};
|
|
7229
|
+
|
|
7230
|
+
assertManagedDestinationBound();
|
|
7124
7231
|
const inspected = inspectResumeSessionFile(filePath, storage);
|
|
7125
7232
|
if ("kind" in inspected) {
|
|
7126
7233
|
if (inspected.reason === "missing") return filePath;
|
|
7127
7234
|
throw new Error(`Could not inspect managed session: ${inspected.reason}`);
|
|
7128
7235
|
}
|
|
7236
|
+
if (expectedIdentity && !sameResumeIdentity(expectedIdentity, inspected.identity))
|
|
7237
|
+
throw new Error("Managed session changed before migration authority was adopted.");
|
|
7129
7238
|
const header = inspected.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
7130
7239
|
if (!header?.cwd) return filePath;
|
|
7131
|
-
const sessionsRoot =
|
|
7240
|
+
const sessionsRoot = destination.securityContext.sessionsRoot;
|
|
7132
7241
|
const resolvedPath = path.resolve(filePath);
|
|
7133
7242
|
const relativeToManagedRoot = path.relative(sessionsRoot, resolvedPath);
|
|
7134
7243
|
const isManagedPath =
|
|
@@ -7136,14 +7245,17 @@ export class SessionManager {
|
|
|
7136
7245
|
(!relativeToManagedRoot.startsWith("..") && !path.isAbsolute(relativeToManagedRoot));
|
|
7137
7246
|
|
|
7138
7247
|
if (!isManagedPath) return filePath;
|
|
7248
|
+
assertManagedDestinationBound();
|
|
7139
7249
|
const resolved = resolveManagedScope({
|
|
7140
7250
|
cwd: header.cwd,
|
|
7141
|
-
agentDir:
|
|
7251
|
+
agentDir: destination.securityContext.agentDir,
|
|
7142
7252
|
sessionsRoot,
|
|
7143
7253
|
});
|
|
7144
7254
|
if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
|
|
7145
7255
|
|
|
7256
|
+
assertManagedDestinationBound();
|
|
7146
7257
|
const listing = listManagedCandidates(resolved.scope);
|
|
7258
|
+
assertManagedDestinationBound();
|
|
7147
7259
|
if (listing.kind !== "complete") throw new Error(`Managed session scan failed: ${listing.message}`);
|
|
7148
7260
|
|
|
7149
7261
|
const candidate = listing.owned.find(item => path.resolve(item.path) === resolvedPath);
|
|
@@ -7160,11 +7272,36 @@ export class SessionManager {
|
|
|
7160
7272
|
candidate.identity.sha256 !== inspected.identity.sha256
|
|
7161
7273
|
)
|
|
7162
7274
|
throw new Error("Managed session changed before migration authority was adopted.");
|
|
7163
|
-
|
|
7275
|
+
assertManagedDestinationBound();
|
|
7276
|
+
const authority: ManagedCandidateWriteAuthority = {
|
|
7277
|
+
rootAuthority: destination.securityContext.rootAuthority,
|
|
7278
|
+
...(path.resolve(destination.directory) === path.resolve(resolved.scope.directoryPath)
|
|
7279
|
+
? {
|
|
7280
|
+
retainedAuthority: destination.securityContext.retainedAuthority,
|
|
7281
|
+
retainedDirectory: destination.directory,
|
|
7282
|
+
}
|
|
7283
|
+
: {}),
|
|
7284
|
+
};
|
|
7285
|
+
let opened: ManagedOpenCandidateResult;
|
|
7286
|
+
try {
|
|
7287
|
+
opened = await openManagedCandidateForWrite(
|
|
7288
|
+
resolved.scope,
|
|
7289
|
+
candidate,
|
|
7290
|
+
expectedIdentity ?? inspected.identity,
|
|
7291
|
+
migrationPolicy,
|
|
7292
|
+
authority,
|
|
7293
|
+
);
|
|
7294
|
+
} catch (error) {
|
|
7295
|
+
if (error instanceof Error && error.message.startsWith("Managed root authority changed"))
|
|
7296
|
+
managedDestinationStore.assertBound();
|
|
7297
|
+
throw error;
|
|
7298
|
+
}
|
|
7164
7299
|
if (opened.kind === "error") {
|
|
7300
|
+
managedDestinationStore.assertBound();
|
|
7165
7301
|
if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
7166
7302
|
throw new Error(`Could not open managed session: ${opened.message}`);
|
|
7167
7303
|
}
|
|
7304
|
+
assertManagedDestinationBound();
|
|
7168
7305
|
return opened.path;
|
|
7169
7306
|
}
|
|
7170
7307
|
|
|
@@ -7182,7 +7319,7 @@ export class SessionManager {
|
|
|
7182
7319
|
const destination = destinationFor(cwd, destinationInput, storage);
|
|
7183
7320
|
const managedSourcePath =
|
|
7184
7321
|
destination.kind === "managed" && storage instanceof FileSessionStorage
|
|
7185
|
-
? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
|
|
7322
|
+
? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy, destination)
|
|
7186
7323
|
: sourcePath;
|
|
7187
7324
|
const dir = destination.directory;
|
|
7188
7325
|
const manager = new SessionManager(cwd, dir, true, storage, destination);
|
|
@@ -7246,10 +7383,9 @@ export class SessionManager {
|
|
|
7246
7383
|
return manager;
|
|
7247
7384
|
}
|
|
7248
7385
|
|
|
7249
|
-
const
|
|
7250
|
-
const inspected = inspectResumeSessionFile(managedPath, storage);
|
|
7386
|
+
const inspected = inspectResumeSessionFile(filePath, storage);
|
|
7251
7387
|
if ("kind" in inspected) {
|
|
7252
|
-
if (inspected.reason === "missing"
|
|
7388
|
+
if (inspected.reason === "missing") {
|
|
7253
7389
|
const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
|
|
7254
7390
|
await manager.#initSessionFile(filePath);
|
|
7255
7391
|
return manager;
|
|
@@ -7317,46 +7453,47 @@ export class SessionManager {
|
|
|
7317
7453
|
return manager;
|
|
7318
7454
|
}
|
|
7319
7455
|
/**
|
|
7320
|
-
* List sessions for the resume picker without recovery or other
|
|
7456
|
+
* List default-managed sessions for the resume picker without recovery or other
|
|
7457
|
+
* maintenance writes. This is the only picker inventory that includes legacy
|
|
7458
|
+
* sibling directories.
|
|
7459
|
+
*/
|
|
7460
|
+
static async listManagedForResumePickerReadOnly(
|
|
7461
|
+
cwd: string,
|
|
7462
|
+
managedAgentDir?: string,
|
|
7463
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7464
|
+
): Promise<SessionInfo[]> {
|
|
7465
|
+
if (!(storage instanceof FileSessionStorage)) return [];
|
|
7466
|
+
const sessionsRoot = getSessionsDir(managedAgentDir);
|
|
7467
|
+
const resolved = resolveManagedScope({
|
|
7468
|
+
cwd,
|
|
7469
|
+
agentDir: managedAgentDir ?? path.resolve(sessionsRoot, ".."),
|
|
7470
|
+
sessionsRoot,
|
|
7471
|
+
});
|
|
7472
|
+
if (resolved.kind === "error") return [];
|
|
7473
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
7474
|
+
if (listing.kind === "error") return [];
|
|
7475
|
+
return await collectSessionsFromFiles(
|
|
7476
|
+
listing.owned.map(candidate => candidate.path),
|
|
7477
|
+
storage,
|
|
7478
|
+
);
|
|
7479
|
+
}
|
|
7480
|
+
|
|
7481
|
+
/**
|
|
7482
|
+
* List sessions from an explicitly supplied picker directory without recovery
|
|
7483
|
+
* or other maintenance writes. Unlike managed inventory, this never scans
|
|
7484
|
+
* legacy sibling directories.
|
|
7321
7485
|
*/
|
|
7322
7486
|
static async listForResumePickerReadOnly(
|
|
7323
7487
|
cwd: string,
|
|
7324
7488
|
sessionDir?: string,
|
|
7325
7489
|
storage: SessionStorage = new FileSessionStorage(),
|
|
7326
7490
|
): Promise<SessionInfo[]> {
|
|
7327
|
-
if (!sessionDir
|
|
7328
|
-
if (sessionDir && !resolveManagedSessionRoot(sessionDir, cwd)) {
|
|
7329
|
-
try {
|
|
7330
|
-
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
7331
|
-
} catch {
|
|
7332
|
-
return [];
|
|
7333
|
-
}
|
|
7334
|
-
}
|
|
7335
|
-
const sessionsRoot = sessionDir
|
|
7336
|
-
? (resolveManagedSessionRoot(sessionDir, cwd) ?? getSessionsDir())
|
|
7337
|
-
: getSessionsDir();
|
|
7338
|
-
const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
7339
|
-
if (resolved.kind === "error") return [];
|
|
7340
|
-
if (storage instanceof FileSessionStorage) {
|
|
7341
|
-
const listing = listManagedCandidates(resolved.scope);
|
|
7342
|
-
if (listing.kind === "error") return [];
|
|
7343
|
-
return await collectSessionsFromFiles(
|
|
7344
|
-
listing.owned.map(candidate => candidate.path),
|
|
7345
|
-
storage,
|
|
7346
|
-
);
|
|
7347
|
-
}
|
|
7348
|
-
const legacyDirectory = `--${resolved.scope.canonicalCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
7349
|
-
const files = new Set<string>();
|
|
7491
|
+
if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
|
|
7350
7492
|
try {
|
|
7351
|
-
|
|
7352
|
-
for (const file of storage.listFilesSync(directory, "*.jsonl")) files.add(file);
|
|
7353
|
-
}
|
|
7493
|
+
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
7354
7494
|
} catch {
|
|
7355
7495
|
return [];
|
|
7356
7496
|
}
|
|
7357
|
-
return (await collectSessionsFromFiles([...files], storage)).filter(
|
|
7358
|
-
session => resolveEquivalentPath(session.cwd) === resolved.scope.canonicalCwd,
|
|
7359
|
-
);
|
|
7360
7497
|
}
|
|
7361
7498
|
|
|
7362
7499
|
/** Tombstone and exact-delete a default-managed candidate. */
|
|
@@ -7377,7 +7514,8 @@ export class SessionManager {
|
|
|
7377
7514
|
const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
|
|
7378
7515
|
if (!candidate) throw new Error("Session is not an authorized managed candidate.");
|
|
7379
7516
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
7380
|
-
if (deleted.kind
|
|
7517
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
7518
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
7381
7519
|
}
|
|
7382
7520
|
|
|
7383
7521
|
/** Capture exact source content for a strict fork without granting write ownership. */
|
|
@@ -7437,16 +7575,22 @@ export class SessionManager {
|
|
|
7437
7575
|
}
|
|
7438
7576
|
|
|
7439
7577
|
const dir = destination.directory;
|
|
7440
|
-
const
|
|
7578
|
+
const privateStagingDir =
|
|
7441
7579
|
destination.kind !== "managed" &&
|
|
7442
7580
|
snapshot.storage instanceof FileSessionStorage &&
|
|
7443
|
-
!snapshot.storage.existsSync(dir)
|
|
7581
|
+
!snapshot.storage.existsSync(dir)
|
|
7582
|
+
? fs.mkdtempSync(path.join(path.dirname(dir), `.${path.basename(dir)}.fork-staging-`))
|
|
7583
|
+
: undefined;
|
|
7584
|
+
const forkDestination = privateStagingDir ? explicitDestination(privateStagingDir) : destination;
|
|
7444
7585
|
let managedForkStore: ManagedSessionDescendantStore | undefined;
|
|
7445
7586
|
let managedForkTranscript: ManagedFileSnapshot | null = null;
|
|
7446
7587
|
let manager: SessionManager | undefined;
|
|
7447
7588
|
let authorityFailure: StrictSessionOpenFailure | undefined;
|
|
7589
|
+
let privateStagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
7590
|
+
let privateStagingPublished = false;
|
|
7591
|
+
|
|
7448
7592
|
try {
|
|
7449
|
-
manager = new SessionManager(cwd, dir, true, snapshot.storage,
|
|
7593
|
+
manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
|
|
7450
7594
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
7451
7595
|
manager.#fileEntries = forkEntries;
|
|
7452
7596
|
const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
@@ -7476,6 +7620,13 @@ export class SessionManager {
|
|
|
7476
7620
|
throw new Error("Captured fork source authority changed before destination write.");
|
|
7477
7621
|
}
|
|
7478
7622
|
await manager.#rewriteFile();
|
|
7623
|
+
if (privateStagingDir) {
|
|
7624
|
+
const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
|
|
7625
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
7626
|
+
throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
|
|
7627
|
+
privateStagingSnapshot = capturedStaging.snapshot;
|
|
7628
|
+
}
|
|
7629
|
+
|
|
7479
7630
|
if (destination.kind === "managed" && manager.#sessionFile) {
|
|
7480
7631
|
managedForkStore = manager.#managedTranscriptStore();
|
|
7481
7632
|
managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
@@ -7495,66 +7646,57 @@ export class SessionManager {
|
|
|
7495
7646
|
if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
|
|
7496
7647
|
throw new Error("managed_fork_transcript_changed");
|
|
7497
7648
|
}
|
|
7649
|
+
if (privateStagingDir && manager.#sessionFile) {
|
|
7650
|
+
const materializedEntries = materializeResidentEntriesForReadSync(
|
|
7651
|
+
manager.#fileEntries,
|
|
7652
|
+
manager.#residentBlobStores(),
|
|
7653
|
+
);
|
|
7654
|
+
manager.#fileEntries = materializedEntries;
|
|
7655
|
+
manager.#disposeResidentTextBlobStore();
|
|
7656
|
+
const capturedStaging = native.snapshotDirectoryTree(privateStagingDir);
|
|
7657
|
+
if (!capturedStaging.ok || !capturedStaging.snapshot)
|
|
7658
|
+
throw new Error(capturedStaging.code ?? "fork_staging_snapshot_failed");
|
|
7659
|
+
privateStagingSnapshot = capturedStaging.snapshot;
|
|
7660
|
+
fsyncManagedArtifactTree(privateStagingDir);
|
|
7661
|
+
const outcome = classifyNativePublishOutcome(native.renameNoReplacePath(privateStagingDir, dir));
|
|
7662
|
+
if (!outcome.ok) throw new Error(outcome.code ?? "fork_destination_publish_failed");
|
|
7663
|
+
privateStagingPublished = true;
|
|
7664
|
+
const terminal = native.snapshotDirectoryTree(dir);
|
|
7665
|
+
if (
|
|
7666
|
+
!terminal.ok ||
|
|
7667
|
+
!terminal.snapshot ||
|
|
7668
|
+
!retainedTreeSnapshotEqualsAfterRename(terminal.snapshot, privateStagingSnapshot)
|
|
7669
|
+
)
|
|
7670
|
+
throw new Error("fork_destination_terminal_identity_changed");
|
|
7671
|
+
await syncSessionMoveDirectory(path.dirname(dir));
|
|
7672
|
+
const durableTerminal = native.snapshotDirectoryTree(dir);
|
|
7673
|
+
if (
|
|
7674
|
+
!durableTerminal.ok ||
|
|
7675
|
+
!durableTerminal.snapshot ||
|
|
7676
|
+
!retainedTreeSnapshotEqualsAfterRename(durableTerminal.snapshot, privateStagingSnapshot)
|
|
7677
|
+
)
|
|
7678
|
+
throw new Error("fork_destination_durability_identity_changed");
|
|
7679
|
+
manager.sessionDir = dir;
|
|
7680
|
+
manager.destination = destination;
|
|
7681
|
+
manager.#sessionFile = path.join(dir, path.basename(manager.#sessionFile));
|
|
7682
|
+
manager.#resetResidentTextBlobStore();
|
|
7683
|
+
manager.#reexternalizeFileEntriesForResidentStore();
|
|
7684
|
+
manager.#bumpAllRevisions();
|
|
7685
|
+
}
|
|
7498
7686
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
7499
7687
|
return { kind: "forked", manager };
|
|
7500
7688
|
} catch (error) {
|
|
7501
7689
|
if (manager) {
|
|
7502
|
-
const sessionFile = manager.#sessionFile;
|
|
7503
7690
|
const cleanupErrors: Error[] = [];
|
|
7504
7691
|
try {
|
|
7505
7692
|
await manager.close();
|
|
7506
7693
|
} catch (cleanupError) {
|
|
7507
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7694
|
+
if (toError(cleanupError).message !== "cleanup_pending") cleanupErrors.push(toError(cleanupError));
|
|
7508
7695
|
}
|
|
7509
|
-
if (
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
|
|
7514
|
-
} catch (cleanupError) {
|
|
7515
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7516
|
-
}
|
|
7517
|
-
} else {
|
|
7518
|
-
try {
|
|
7519
|
-
await manager.storage.unlink(sessionFile);
|
|
7520
|
-
} catch (cleanupError) {
|
|
7521
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7522
|
-
}
|
|
7523
|
-
if (manager.storage instanceof FileSessionStorage) {
|
|
7524
|
-
try {
|
|
7525
|
-
await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
|
|
7526
|
-
} catch (cleanupError) {
|
|
7527
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7528
|
-
}
|
|
7529
|
-
try {
|
|
7530
|
-
const sessionFileName = path.basename(sessionFile);
|
|
7531
|
-
const transientPrefix = `.${sessionFileName}.`;
|
|
7532
|
-
const backupPrefix = `${sessionFileName}.`;
|
|
7533
|
-
for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
|
|
7534
|
-
if (
|
|
7535
|
-
(name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
|
|
7536
|
-
(name.startsWith(backupPrefix) && name.endsWith(".bak"))
|
|
7537
|
-
) {
|
|
7538
|
-
await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
|
|
7539
|
-
recursive: true,
|
|
7540
|
-
force: true,
|
|
7541
|
-
});
|
|
7542
|
-
}
|
|
7543
|
-
}
|
|
7544
|
-
} catch (cleanupError) {
|
|
7545
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7546
|
-
}
|
|
7547
|
-
}
|
|
7548
|
-
}
|
|
7549
|
-
}
|
|
7550
|
-
if (removeSessionDirOnFailure) {
|
|
7551
|
-
try {
|
|
7552
|
-
await fs.promises.rmdir(dir);
|
|
7553
|
-
} catch (cleanupError) {
|
|
7554
|
-
const code = (cleanupError as NodeJS.ErrnoException).code;
|
|
7555
|
-
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
7556
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7557
|
-
}
|
|
7696
|
+
if (!privateStagingPublished && privateStagingDir && privateStagingSnapshot) {
|
|
7697
|
+
const removed = native.exactRemoveDirectoryTree(privateStagingDir, privateStagingSnapshot);
|
|
7698
|
+
if (!removed.ok && removed.code !== "not_found" && removed.code !== "cleanup_pending")
|
|
7699
|
+
cleanupErrors.push(new Error(removed.code ?? "fork_staging_cleanup_failed"));
|
|
7558
7700
|
}
|
|
7559
7701
|
if (cleanupErrors.length > 0) {
|
|
7560
7702
|
throw new Error(`Failed to clean up fork destination: ${cleanupErrors[0]!.message}`, {
|
|
@@ -7658,36 +7800,39 @@ export class SessionManager {
|
|
|
7658
7800
|
let sessionPath = identity.canonicalPath;
|
|
7659
7801
|
if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
|
|
7660
7802
|
try {
|
|
7661
|
-
sessionPath = await SessionManager.prepareManagedCandidateForWrite(
|
|
7803
|
+
sessionPath = await SessionManager.prepareManagedCandidateForWrite(
|
|
7804
|
+
sessionPath,
|
|
7805
|
+
migrationPolicy,
|
|
7806
|
+
destination,
|
|
7807
|
+
identity,
|
|
7808
|
+
);
|
|
7662
7809
|
} catch (error) {
|
|
7663
7810
|
if (error instanceof SessionMigrationPolicyError)
|
|
7664
7811
|
return { kind: "error", reason: "legacy_migration_disabled" };
|
|
7812
|
+
if (
|
|
7813
|
+
error instanceof Error &&
|
|
7814
|
+
(error.message.includes("source_changed") || error.message.includes("changed before migration"))
|
|
7815
|
+
)
|
|
7816
|
+
return { kind: "error", reason: "identity-mismatch" };
|
|
7665
7817
|
throw error;
|
|
7666
7818
|
}
|
|
7667
7819
|
}
|
|
7668
7820
|
const inspected = inspectResumeSessionFile(sessionPath, storage);
|
|
7669
7821
|
if ("kind" in inspected) return inspected;
|
|
7670
|
-
if (
|
|
7671
|
-
(sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) ||
|
|
7672
|
-
(sessionPath !== identity.canonicalPath &&
|
|
7673
|
-
(identity.sessionId !== inspected.identity.sessionId || identity.sha256 !== inspected.identity.sha256))
|
|
7674
|
-
) {
|
|
7822
|
+
if (sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) {
|
|
7675
7823
|
return { kind: "error", reason: "identity-mismatch" };
|
|
7676
7824
|
}
|
|
7825
|
+
|
|
7677
7826
|
const entries = inspected.entries;
|
|
7678
7827
|
const header = entries[0] as SessionHeader;
|
|
7679
7828
|
const dir = destination.directory;
|
|
7680
7829
|
const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
|
|
7681
7830
|
await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
|
|
7682
|
-
const ownershipInspection =
|
|
7683
|
-
if (
|
|
7831
|
+
const ownershipInspection = revalidateResumeSessionIdentity(sessionPath, storage, inspected.identity);
|
|
7832
|
+
if (ownershipInspection.kind === "error") {
|
|
7684
7833
|
await manager.close();
|
|
7685
7834
|
return ownershipInspection;
|
|
7686
7835
|
}
|
|
7687
|
-
if (!sameResumeIdentity(inspected.identity, ownershipInspection.identity)) {
|
|
7688
|
-
await manager.close();
|
|
7689
|
-
return { kind: "error", reason: "identity-mismatch" };
|
|
7690
|
-
}
|
|
7691
7836
|
try {
|
|
7692
7837
|
await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
|
|
7693
7838
|
writeTerminalBreadcrumb(manager.cwd, sessionPath);
|
|
@@ -7731,9 +7876,20 @@ export class SessionManager {
|
|
|
7731
7876
|
const managedCandidates =
|
|
7732
7877
|
destination.kind === "explicit"
|
|
7733
7878
|
? []
|
|
7734
|
-
: await SessionManager.
|
|
7879
|
+
: await SessionManager.listManagedForResumePickerReadOnly(
|
|
7880
|
+
cwd,
|
|
7881
|
+
destination.securityContext.agentDir,
|
|
7882
|
+
storage,
|
|
7883
|
+
);
|
|
7884
|
+
|
|
7735
7885
|
const terminalSession = await readTerminalBreadcrumb(cwd);
|
|
7736
|
-
|
|
7886
|
+
const terminalSessionIsInExplicitRoot = (() => {
|
|
7887
|
+
if (destination.kind === "managed" || !terminalSession) return destination.kind === "managed";
|
|
7888
|
+
const canonicalRoot = canonicalizeTrustedPath(destination.directory);
|
|
7889
|
+
const canonicalSessionPath = canonicalizeTrustedPath(terminalSession);
|
|
7890
|
+
return pathIsWithin(canonicalRoot, canonicalSessionPath);
|
|
7891
|
+
})();
|
|
7892
|
+
if (terminalSession && terminalSessionIsInExplicitRoot) {
|
|
7737
7893
|
const opened = await openSelectedStrictly(terminalSession);
|
|
7738
7894
|
if (opened) return opened;
|
|
7739
7895
|
}
|
|
@@ -7771,7 +7927,7 @@ export class SessionManager {
|
|
|
7771
7927
|
sessionDir?: string,
|
|
7772
7928
|
storage: SessionStorage = new FileSessionStorage(),
|
|
7773
7929
|
): Promise<SessionInfo[]> {
|
|
7774
|
-
if (!sessionDir) return await SessionManager.
|
|
7930
|
+
if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
|
|
7775
7931
|
try {
|
|
7776
7932
|
await recoverOrphanedBackups(sessionDir, storage);
|
|
7777
7933
|
return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
|
|
@@ -7783,9 +7939,12 @@ export class SessionManager {
|
|
|
7783
7939
|
/**
|
|
7784
7940
|
* List all sessions across all project directories.
|
|
7785
7941
|
*/
|
|
7786
|
-
static async listAll(
|
|
7942
|
+
static async listAll(
|
|
7943
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
7944
|
+
managedAgentDir?: string,
|
|
7945
|
+
): Promise<SessionInfo[]> {
|
|
7787
7946
|
if (!(storage instanceof FileSessionStorage)) return [];
|
|
7788
|
-
const sessionsRoot =
|
|
7947
|
+
const sessionsRoot = getSessionsDir(managedAgentDir);
|
|
7789
7948
|
try {
|
|
7790
7949
|
const directories = await fs.promises.readdir(sessionsRoot, { withFileTypes: true });
|
|
7791
7950
|
const seedFiles = directories
|
|
@@ -7821,6 +7980,19 @@ export class SessionManager {
|
|
|
7821
7980
|
return [];
|
|
7822
7981
|
}
|
|
7823
7982
|
}
|
|
7983
|
+
/**
|
|
7984
|
+
* Strict inventory bound to this manager's captured session authority. Managed
|
|
7985
|
+
* managers include authorized legacy and current candidates; explicit managers
|
|
7986
|
+
* authorize only their exact directory.
|
|
7987
|
+
*/
|
|
7988
|
+
inventorySessionsStrict(): StrictInventoryResult {
|
|
7989
|
+
return SessionManager.inventorySessionsStrict(this.cwd, {
|
|
7990
|
+
sessionDir: this.destination.kind === "explicit" ? this.destination.directory : undefined,
|
|
7991
|
+
storage: this.storage,
|
|
7992
|
+
destination: this.destination,
|
|
7993
|
+
});
|
|
7994
|
+
}
|
|
7995
|
+
|
|
7824
7996
|
/**
|
|
7825
7997
|
* Strict raw scoped inventory for ACP authorization. Enumerates the scoped
|
|
7826
7998
|
* session directory without suppressing any root/scan/lstat/read/parse/stat/
|
|
@@ -7830,10 +8002,55 @@ export class SessionManager {
|
|
|
7830
8002
|
*/
|
|
7831
8003
|
static inventorySessionsStrict(
|
|
7832
8004
|
cwd: string,
|
|
7833
|
-
options?: { sessionDir?: string; storage?: SessionStorage },
|
|
8005
|
+
options?: { sessionDir?: string; storage?: SessionStorage; destination?: SessionDestination },
|
|
7834
8006
|
): StrictInventoryResult {
|
|
7835
8007
|
const storage = options?.storage ?? new FileSessionStorage();
|
|
7836
|
-
const
|
|
8008
|
+
const destination = options?.destination;
|
|
8009
|
+
const dir =
|
|
8010
|
+
options?.sessionDir ?? destination?.directory ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
8011
|
+
if (destination?.kind === "managed") {
|
|
8012
|
+
if (!trustedSessionDestinations.has(destination) || !(storage instanceof FileSessionStorage)) {
|
|
8013
|
+
return {
|
|
8014
|
+
kind: "failure",
|
|
8015
|
+
failures: [{ kind: "scan", message: "Managed strict inventory authority is unavailable", path: dir }],
|
|
8016
|
+
};
|
|
8017
|
+
}
|
|
8018
|
+
try {
|
|
8019
|
+
const store = managedStoreFromContext(destination.securityContext, destination.directory);
|
|
8020
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
8021
|
+
store.assertBound();
|
|
8022
|
+
const resolved = resolveManagedScope({
|
|
8023
|
+
cwd,
|
|
8024
|
+
agentDir: destination.securityContext.agentDir,
|
|
8025
|
+
sessionsRoot: destination.securityContext.sessionsRoot,
|
|
8026
|
+
});
|
|
8027
|
+
if (resolved.kind === "error")
|
|
8028
|
+
return { kind: "failure", failures: [{ kind: "root", message: resolved.message, path: dir }] };
|
|
8029
|
+
const listing = listManagedCandidates(resolved.scope);
|
|
8030
|
+
if (listing.kind === "error")
|
|
8031
|
+
return { kind: "failure", failures: [{ kind: "scan", message: listing.message, path: dir }] };
|
|
8032
|
+
const failures: StrictInventoryFailure[] = [];
|
|
8033
|
+
const candidates: StrictInventoryCandidate[] = [];
|
|
8034
|
+
for (const managedCandidate of listing.owned) {
|
|
8035
|
+
const candidate = inventoryReadCandidate(
|
|
8036
|
+
storage,
|
|
8037
|
+
managedCandidate.path,
|
|
8038
|
+
cwd,
|
|
8039
|
+
path.dirname(managedCandidate.path),
|
|
8040
|
+
);
|
|
8041
|
+
if ("failures" in candidate) failures.push(...candidate.failures);
|
|
8042
|
+
else candidates.push(candidate.candidate);
|
|
8043
|
+
}
|
|
8044
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
8045
|
+
store.assertBound();
|
|
8046
|
+
return failures.length > 0 ? { kind: "failure", failures } : { kind: "complete", candidates };
|
|
8047
|
+
} catch {
|
|
8048
|
+
return {
|
|
8049
|
+
kind: "failure",
|
|
8050
|
+
failures: [{ kind: "root", message: "Managed strict inventory authority changed", path: dir }],
|
|
8051
|
+
};
|
|
8052
|
+
}
|
|
8053
|
+
}
|
|
7837
8054
|
if (!storage.listFilesStrictSync) {
|
|
7838
8055
|
return {
|
|
7839
8056
|
kind: "failure",
|