@gajae-code/coding-agent 0.11.7 → 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 +55 -0
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/config/keybindings.d.ts +33 -3
- 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 +15 -2
- 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/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 +1 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
- package/dist/types/main.d.ts +8 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +3 -2
- package/dist/types/modes/components/welcome.d.ts +2 -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 +5 -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 +3 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- 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 +5 -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 +25 -0
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -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/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +11 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +17 -2
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-storage.d.ts +18 -0
- 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/image-gen.d.ts +47 -1
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/utils/prompt-suggestion.d.ts +33 -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 +2 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/cli/args.ts +2 -92
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli.ts +18 -0
- package/src/commands/sdk.ts +2 -2
- package/src/config/keybindings.ts +157 -24
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +17 -2
- 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/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/prelude.py +2 -2
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- 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 +5 -5
- package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/internal-urls/local-protocol.ts +33 -16
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +25 -10
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/components/model-selector.ts +42 -8
- 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 +20 -4
- package/src/modes/components/welcome.ts +22 -18
- 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 +7 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +46 -54
- package/src/modes/interactive-mode.ts +41 -27
- 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 +3 -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/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/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 +13 -6
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/bus/chat-daemon-control.ts +18 -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 +240 -65
- package/src/sdk/bus/lifecycle-commands.ts +47 -3
- 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 +27 -2
- 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 +13 -3
- package/src/sdk/bus/telegram-daemon-control.ts +145 -33
- package/src/sdk/bus/telegram-daemon.ts +835 -110
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/cli/session-cli.ts +15 -2
- 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 +47 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +2 -0
- package/src/sdk/startup-capability.ts +9 -1
- package/src/session/agent-session.ts +52 -5
- package/src/session/blob-store.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +854 -84
- package/src/session/internal/managed-session-storage.ts +251 -199
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +310 -327
- package/src/session/session-storage.ts +128 -11
- 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 +198 -88
- 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/image-gen.ts +178 -6
- package/src/tools/index.ts +4 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/url-guard.ts +3 -1
- package/src/web/scrapers/docs-rs.ts +30 -5
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
listManagedCandidates,
|
|
60
60
|
type ManagedCandidateWriteAuthority,
|
|
61
61
|
type ManagedMigrationPolicy,
|
|
62
|
+
type ManagedOpenCandidateResult,
|
|
62
63
|
type ManagedScope,
|
|
63
64
|
managedDirectoryAuthorityForScope,
|
|
64
65
|
managedRootForScope,
|
|
@@ -69,12 +70,15 @@ import {
|
|
|
69
70
|
} from "./internal/managed-session-scope";
|
|
70
71
|
import {
|
|
71
72
|
assertManagedDirectoryRoot,
|
|
73
|
+
fsyncManagedArtifactTree,
|
|
72
74
|
type ManagedDirectoryRoot,
|
|
73
75
|
type ManagedFileSnapshot,
|
|
74
76
|
ManagedSessionDescendantStore,
|
|
75
77
|
type ManagedSessionSecurityPolicy,
|
|
78
|
+
mayCleanManagedTreeStaging,
|
|
76
79
|
retainManagedDirectoryAuthority,
|
|
77
80
|
} from "./internal/managed-session-storage";
|
|
81
|
+
import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
|
|
78
82
|
|
|
79
83
|
import {
|
|
80
84
|
type BashExecutionMessage,
|
|
@@ -1320,6 +1324,18 @@ function freezeInternalReadSnapshot<T>(value: T): T {
|
|
|
1320
1324
|
return copy;
|
|
1321
1325
|
}
|
|
1322
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
|
+
|
|
1323
1339
|
/** Resolve and prepare the default v2 session scope before any managed writer exists. */
|
|
1324
1340
|
function computeDefaultSessionDir(
|
|
1325
1341
|
cwd: string,
|
|
@@ -1328,12 +1344,12 @@ function computeDefaultSessionDir(
|
|
|
1328
1344
|
): string {
|
|
1329
1345
|
if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
|
|
1330
1346
|
const resolved = resolveManagedScopeForWrite({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
|
|
1331
|
-
if (resolved.kind === "error") throw
|
|
1347
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1332
1348
|
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1333
1349
|
resolved.scope,
|
|
1334
1350
|
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1335
1351
|
);
|
|
1336
|
-
if (prepared.kind === "error") throw
|
|
1352
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1337
1353
|
return prepared.scope.directoryPath;
|
|
1338
1354
|
}
|
|
1339
1355
|
|
|
@@ -1468,12 +1484,12 @@ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: str
|
|
|
1468
1484
|
agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
|
|
1469
1485
|
sessionsRoot,
|
|
1470
1486
|
});
|
|
1471
|
-
if (resolved.kind === "error") throw
|
|
1487
|
+
if (resolved.kind === "error") throw managedScopeStartupError("resolve", resolved);
|
|
1472
1488
|
const prepared = prepareManagedSessionScopeForWriteSync(
|
|
1473
1489
|
resolved.scope,
|
|
1474
1490
|
process.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
1475
1491
|
);
|
|
1476
|
-
if (prepared.kind === "error") throw
|
|
1492
|
+
if (prepared.kind === "error") throw managedScopeStartupError("prepare", prepared);
|
|
1477
1493
|
return freezeManagedDestination(prepared.scope);
|
|
1478
1494
|
}
|
|
1479
1495
|
|
|
@@ -2158,23 +2174,6 @@ function formatTimeAgo(date: Date): string {
|
|
|
2158
2174
|
return date.toLocaleDateString();
|
|
2159
2175
|
}
|
|
2160
2176
|
|
|
2161
|
-
async function syncPathAndParent(filePath: string): Promise<void> {
|
|
2162
|
-
const file = await fs.promises.open(filePath, "r");
|
|
2163
|
-
try {
|
|
2164
|
-
await file.sync();
|
|
2165
|
-
} finally {
|
|
2166
|
-
await file.close();
|
|
2167
|
-
}
|
|
2168
|
-
if (process.platform !== "win32") {
|
|
2169
|
-
const parent = await fs.promises.open(path.dirname(filePath), "r");
|
|
2170
|
-
try {
|
|
2171
|
-
await parent.sync();
|
|
2172
|
-
} finally {
|
|
2173
|
-
await parent.close();
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
2177
|
interface SessionMoveDirectoryHandle {
|
|
2179
2178
|
sync(): Promise<void>;
|
|
2180
2179
|
close(): Promise<void>;
|
|
@@ -2194,40 +2193,6 @@ export async function syncSessionMoveDirectory(
|
|
|
2194
2193
|
}
|
|
2195
2194
|
}
|
|
2196
2195
|
|
|
2197
|
-
/** Durably flush every copied file and directory before an EXDEV source removal. */
|
|
2198
|
-
async function syncCopiedTree(treePath: string): Promise<void> {
|
|
2199
|
-
const stat = await fs.promises.lstat(treePath);
|
|
2200
|
-
if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
|
|
2201
|
-
if (stat.isFile()) {
|
|
2202
|
-
const file = await fs.promises.open(treePath, "r");
|
|
2203
|
-
try {
|
|
2204
|
-
await file.sync();
|
|
2205
|
-
} finally {
|
|
2206
|
-
await file.close();
|
|
2207
|
-
}
|
|
2208
|
-
return;
|
|
2209
|
-
}
|
|
2210
|
-
if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
|
|
2211
|
-
const directory = await fs.promises.opendir(treePath);
|
|
2212
|
-
try {
|
|
2213
|
-
for (;;) {
|
|
2214
|
-
const entry = await directory.read();
|
|
2215
|
-
if (entry === null) break;
|
|
2216
|
-
await syncCopiedTree(path.join(treePath, entry.name));
|
|
2217
|
-
}
|
|
2218
|
-
} finally {
|
|
2219
|
-
await directory.close();
|
|
2220
|
-
}
|
|
2221
|
-
if (process.platform !== "win32") {
|
|
2222
|
-
const handle = await fs.promises.open(treePath, "r");
|
|
2223
|
-
try {
|
|
2224
|
-
await handle.sync();
|
|
2225
|
-
} finally {
|
|
2226
|
-
await handle.close();
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
2196
|
type CrossDeviceTreeIdentity = string;
|
|
2232
2197
|
|
|
2233
2198
|
/** Captures content plus topology; a later capture must match before source removal. */
|
|
@@ -2275,14 +2240,6 @@ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDe
|
|
|
2275
2240
|
return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
|
|
2276
2241
|
}
|
|
2277
2242
|
|
|
2278
|
-
async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
|
|
2279
|
-
if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
|
|
2280
|
-
throw new Error("Cross-device move source changed before removal");
|
|
2281
|
-
const stat = await fs.promises.lstat(source);
|
|
2282
|
-
if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
|
|
2283
|
-
else await fs.promises.unlink(source);
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
2243
|
/**
|
|
2287
2244
|
* An EXDEV retirement must be driven by a native descriptor/handle-bound
|
|
2288
2245
|
* snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
|
|
@@ -2298,83 +2255,25 @@ class CrossDeviceMoveUnsupportedError extends Error {
|
|
|
2298
2255
|
}
|
|
2299
2256
|
}
|
|
2300
2257
|
|
|
2301
|
-
class PathMoveCompletedWithSyncFailure extends Error {
|
|
2302
|
-
constructor(readonly cause: unknown) {
|
|
2303
|
-
super("Path move completed but final source-directory sync failed.", { cause });
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
2258
|
async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
|
|
2308
2259
|
const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
|
|
2309
|
-
const
|
|
2310
|
-
if (
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
|
|
2316
|
-
} catch (error) {
|
|
2317
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2318
|
-
}
|
|
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));
|
|
2319
2266
|
return;
|
|
2320
2267
|
}
|
|
2321
|
-
if (
|
|
2268
|
+
if (outcome.reason === "destination_exists") {
|
|
2322
2269
|
const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
|
|
2323
2270
|
error.code = "EEXIST";
|
|
2324
2271
|
throw error;
|
|
2325
2272
|
}
|
|
2326
|
-
if (
|
|
2327
|
-
throw new
|
|
2328
|
-
const
|
|
2329
|
-
|
|
2330
|
-
let destinationCreated = false;
|
|
2331
|
-
let sourceRemoved = false;
|
|
2332
|
-
try {
|
|
2333
|
-
try {
|
|
2334
|
-
await fs.promises.link(source, destination);
|
|
2335
|
-
destinationCreated = true;
|
|
2336
|
-
} catch (error) {
|
|
2337
|
-
if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
|
|
2338
|
-
if (
|
|
2339
|
-
!hasFsCode(error, "EPERM") &&
|
|
2340
|
-
!hasFsCode(error, "EACCES") &&
|
|
2341
|
-
!hasFsCode(error, "ENOTSUP") &&
|
|
2342
|
-
!hasFsCode(error, "EOPNOTSUPP")
|
|
2343
|
-
)
|
|
2344
|
-
throw error;
|
|
2345
|
-
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
|
|
2346
|
-
destinationCreated = true;
|
|
2347
|
-
}
|
|
2348
|
-
await syncPathAndParent(destination);
|
|
2349
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2350
|
-
sourceRemoved = true;
|
|
2351
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2352
|
-
return;
|
|
2353
|
-
} catch (error) {
|
|
2354
|
-
if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
|
|
2355
|
-
if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2356
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2357
|
-
throw error;
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
const destinationParent = await fs.promises.lstat(path.dirname(destination));
|
|
2362
|
-
if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
|
|
2363
|
-
|
|
2364
|
-
try {
|
|
2365
|
-
await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
|
|
2366
|
-
await syncCopiedTree(destination);
|
|
2367
|
-
await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
|
|
2368
|
-
try {
|
|
2369
|
-
await syncSessionMoveDirectory(path.dirname(source));
|
|
2370
|
-
} catch (error) {
|
|
2371
|
-
throw new PathMoveCompletedWithSyncFailure(error);
|
|
2372
|
-
}
|
|
2373
|
-
} catch (error) {
|
|
2374
|
-
if (!(error instanceof PathMoveCompletedWithSyncFailure))
|
|
2375
|
-
await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
|
|
2376
|
-
throw error;
|
|
2377
|
-
}
|
|
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)) });
|
|
2378
2277
|
}
|
|
2379
2278
|
|
|
2380
2279
|
const MAX_PERSIST_CHARS = 500_000;
|
|
@@ -3560,6 +3459,64 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
|
3560
3459
|
return usage as Usage;
|
|
3561
3460
|
}
|
|
3562
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
|
+
|
|
3563
3520
|
function extractTextFromContent(content: Message["content"]): string {
|
|
3564
3521
|
if (typeof content === "string") return content;
|
|
3565
3522
|
return content
|
|
@@ -4331,7 +4288,8 @@ export class SessionManager {
|
|
|
4331
4288
|
const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
|
|
4332
4289
|
if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
|
|
4333
4290
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
4334
|
-
if (deleted.kind
|
|
4291
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
4292
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
4335
4293
|
} else {
|
|
4336
4294
|
await this.storage.deleteSessionWithArtifacts(sessionPath);
|
|
4337
4295
|
}
|
|
@@ -4460,27 +4418,20 @@ export class SessionManager {
|
|
|
4460
4418
|
throw new Error("managed_fork_transcript_changed");
|
|
4461
4419
|
}
|
|
4462
4420
|
} catch (error) {
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
if (this.destination.kind === "managed") {
|
|
4421
|
+
const failure = error;
|
|
4422
|
+
if (forkArtifactPublication) {
|
|
4466
4423
|
try {
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
forkArtifactPublication.cleanupRelativePath,
|
|
4472
|
-
forkArtifactPublication.snapshot,
|
|
4473
|
-
);
|
|
4424
|
+
forkArtifactPublication.cleanupStore.removeTreeExpected(
|
|
4425
|
+
forkArtifactPublication.cleanupRelativePath,
|
|
4426
|
+
forkArtifactPublication.snapshot,
|
|
4427
|
+
);
|
|
4474
4428
|
} catch (cleanupError) {
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
await this.storage.unlink(failedSessionFile);
|
|
4482
|
-
} catch {
|
|
4483
|
-
// 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
|
+
}
|
|
4484
4435
|
}
|
|
4485
4436
|
}
|
|
4486
4437
|
this.#sessionId = oldSessionId;
|
|
@@ -4559,8 +4510,7 @@ export class SessionManager {
|
|
|
4559
4510
|
}
|
|
4560
4511
|
if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
|
|
4561
4512
|
throw new Error("artifact_source_changed");
|
|
4562
|
-
parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4563
|
-
publishedSnapshot = parentStore.captureTree(destinationName);
|
|
4513
|
+
publishedSnapshot = parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
|
|
4564
4514
|
const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
|
|
4565
4515
|
tree.entries.map(entry => ({
|
|
4566
4516
|
relativePath: entry.relativePath,
|
|
@@ -4584,12 +4534,15 @@ export class SessionManager {
|
|
|
4584
4534
|
};
|
|
4585
4535
|
} catch (error) {
|
|
4586
4536
|
try {
|
|
4587
|
-
if (
|
|
4588
|
-
|
|
4537
|
+
if (stagingSnapshot && mayCleanManagedTreeStaging(error))
|
|
4538
|
+
parentStore.removeTreeExpected(stagingName, stagingSnapshot);
|
|
4589
4539
|
} catch (cleanupError) {
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
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
|
+
}
|
|
4593
4546
|
}
|
|
4594
4547
|
throw error;
|
|
4595
4548
|
}
|
|
@@ -4607,7 +4560,6 @@ export class SessionManager {
|
|
|
4607
4560
|
const previousSessionDir = this.sessionDir;
|
|
4608
4561
|
const previousSessionFile = this.#sessionFile;
|
|
4609
4562
|
const previousDestination = this.destination;
|
|
4610
|
-
const previousArtifactDir = previousSessionFile?.slice(0, -6);
|
|
4611
4563
|
|
|
4612
4564
|
const nextDestination =
|
|
4613
4565
|
this.storage instanceof FileSessionStorage
|
|
@@ -4719,15 +4671,8 @@ export class SessionManager {
|
|
|
4719
4671
|
managedPublishedArtifacts,
|
|
4720
4672
|
);
|
|
4721
4673
|
}
|
|
4722
|
-
if (managedTranscript)
|
|
4674
|
+
if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
|
|
4723
4675
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4724
|
-
if (managedPublishedArtifacts)
|
|
4725
|
-
managedDestinationStore.removeTreeExpected(
|
|
4726
|
-
path.basename(newArtifactDir),
|
|
4727
|
-
managedPublishedArtifacts,
|
|
4728
|
-
);
|
|
4729
|
-
if (managedPublishedTranscript)
|
|
4730
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4731
4676
|
};
|
|
4732
4677
|
managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
|
|
4733
4678
|
hadSessionFile = managedTranscript !== null;
|
|
@@ -4764,10 +4709,20 @@ export class SessionManager {
|
|
|
4764
4709
|
if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
|
|
4765
4710
|
throw new Error("managed_move_destination_artifacts_changed");
|
|
4766
4711
|
}
|
|
4767
|
-
if (managedArtifacts)
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
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
|
+
}
|
|
4771
4726
|
} else {
|
|
4772
4727
|
if (hadSessionFile && oldSessionFile !== newSessionFile) {
|
|
4773
4728
|
await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
|
|
@@ -4823,13 +4778,6 @@ export class SessionManager {
|
|
|
4823
4778
|
!managedSourceStore.readExpected(path.basename(oldSessionFile))
|
|
4824
4779
|
)
|
|
4825
4780
|
await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
|
|
4826
|
-
if (managedPublishedArtifacts)
|
|
4827
|
-
managedDestinationStore.removeTreeExpected(
|
|
4828
|
-
path.basename(newArtifactDir),
|
|
4829
|
-
managedPublishedArtifacts,
|
|
4830
|
-
);
|
|
4831
|
-
if (managedPublishedTranscript)
|
|
4832
|
-
managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
|
|
4833
4781
|
} catch (rollbackErr) {
|
|
4834
4782
|
restoreResidentStateAndThrow(
|
|
4835
4783
|
new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
|
|
@@ -4850,7 +4798,8 @@ export class SessionManager {
|
|
|
4850
4798
|
this.#bumpAllRevisions();
|
|
4851
4799
|
}
|
|
4852
4800
|
|
|
4853
|
-
// 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.
|
|
4854
4803
|
this.cwd = resolvedCwd;
|
|
4855
4804
|
this.sessionDir = newSessionDir;
|
|
4856
4805
|
this.destination = nextDestination;
|
|
@@ -4871,60 +4820,37 @@ export class SessionManager {
|
|
|
4871
4820
|
await this.#appendHeaderPatch({ cwd: resolvedCwd });
|
|
4872
4821
|
}
|
|
4873
4822
|
} catch (error) {
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
this.#persistErrorReported = false;
|
|
4881
|
-
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 {
|
|
4882
4829
|
await rollbackManagedMove();
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4890
|
-
: null;
|
|
4891
|
-
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4892
|
-
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4893
|
-
this.#headerExportRevision++;
|
|
4894
|
-
throw error;
|
|
4895
|
-
}
|
|
4896
|
-
if (
|
|
4897
|
-
this.storage instanceof FileSessionStorage &&
|
|
4898
|
-
failedArtifactDir &&
|
|
4899
|
-
previousArtifactDir &&
|
|
4900
|
-
failedArtifactDir !== previousArtifactDir &&
|
|
4901
|
-
fs.existsSync(failedArtifactDir)
|
|
4902
|
-
) {
|
|
4903
|
-
await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
|
|
4904
|
-
}
|
|
4905
|
-
if (
|
|
4906
|
-
this.storage instanceof FileSessionStorage &&
|
|
4907
|
-
failedSessionFile &&
|
|
4908
|
-
previousSessionFile &&
|
|
4909
|
-
failedSessionFile !== previousSessionFile &&
|
|
4910
|
-
fs.existsSync(failedSessionFile)
|
|
4911
|
-
) {
|
|
4912
|
-
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
|
+
}
|
|
4913
4836
|
}
|
|
4914
4837
|
this.#sessionFile = previousSessionFile;
|
|
4915
4838
|
this.cwd = previousCwd;
|
|
4916
4839
|
this.sessionDir = previousSessionDir;
|
|
4917
4840
|
this.destination = previousDestination;
|
|
4918
|
-
this.#managedTranscriptStoreCache =
|
|
4841
|
+
this.#managedTranscriptStoreCache =
|
|
4842
|
+
managedMove && managedSourceStore
|
|
4843
|
+
? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
|
|
4844
|
+
: null;
|
|
4919
4845
|
const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
4920
4846
|
if (header) applyHeaderPatch(header, { cwd: previousCwd });
|
|
4921
4847
|
this.#headerExportRevision++;
|
|
4922
|
-
|
|
4923
|
-
} catch (rollbackError) {
|
|
4924
|
-
throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
|
|
4925
|
-
cause: toError(error),
|
|
4926
|
-
});
|
|
4848
|
+
throw error;
|
|
4927
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++;
|
|
4928
4854
|
throw error;
|
|
4929
4855
|
}
|
|
4930
4856
|
|
|
@@ -4984,6 +4910,8 @@ export class SessionManager {
|
|
|
4984
4910
|
this.#labelsById.clear();
|
|
4985
4911
|
this.#leafId = null;
|
|
4986
4912
|
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
4913
|
+
let malformedUsageRecords = 0;
|
|
4914
|
+
const malformedUsageSample: string[] = [];
|
|
4987
4915
|
for (const entry of this.#fileEntries) {
|
|
4988
4916
|
if (entry.type === "session") continue;
|
|
4989
4917
|
this.#byId.set(entry.id, entry);
|
|
@@ -4995,28 +4923,80 @@ export class SessionManager {
|
|
|
4995
4923
|
this.#labelsById.delete(entry.targetId);
|
|
4996
4924
|
}
|
|
4997
4925
|
}
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
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);
|
|
5006
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
|
+
}
|
|
5007
4945
|
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
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));
|
|
5019
4998
|
}
|
|
4999
|
+
return false;
|
|
5020
5000
|
}
|
|
5021
5001
|
|
|
5022
5002
|
#recordPersistError(err: unknown): Error {
|
|
@@ -6007,26 +5987,14 @@ export class SessionManager {
|
|
|
6007
5987
|
this.#leafRevision++;
|
|
6008
5988
|
if (entry.type === "label") this.#labelRevision++;
|
|
6009
5989
|
this._persist(residentEntry);
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
}
|
|
6019
|
-
|
|
6020
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
6021
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
6022
|
-
if (usage) {
|
|
6023
|
-
this.#usageStatistics.input += usage.input;
|
|
6024
|
-
this.#usageStatistics.output += usage.output;
|
|
6025
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
6026
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
6027
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
6028
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
6029
|
-
}
|
|
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
|
+
});
|
|
6030
5998
|
}
|
|
6031
5999
|
}
|
|
6032
6000
|
|
|
@@ -7254,9 +7222,11 @@ export class SessionManager {
|
|
|
7254
7222
|
const storage = new FileSessionStorage();
|
|
7255
7223
|
const managedDestinationStore = managedStoreFromContext(destination.securityContext, destination.directory);
|
|
7256
7224
|
const assertManagedDestinationBound = (): void => {
|
|
7257
|
-
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
7258
7225
|
managedDestinationStore.assertBound();
|
|
7226
|
+
if (!destination.securityContext.retainedAuthority)
|
|
7227
|
+
assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
|
|
7259
7228
|
};
|
|
7229
|
+
|
|
7260
7230
|
assertManagedDestinationBound();
|
|
7261
7231
|
const inspected = inspectResumeSessionFile(filePath, storage);
|
|
7262
7232
|
if ("kind" in inspected) {
|
|
@@ -7312,18 +7282,26 @@ export class SessionManager {
|
|
|
7312
7282
|
}
|
|
7313
7283
|
: {}),
|
|
7314
7284
|
};
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
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
|
+
}
|
|
7323
7299
|
if (opened.kind === "error") {
|
|
7300
|
+
managedDestinationStore.assertBound();
|
|
7324
7301
|
if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
|
|
7325
7302
|
throw new Error(`Could not open managed session: ${opened.message}`);
|
|
7326
7303
|
}
|
|
7304
|
+
assertManagedDestinationBound();
|
|
7327
7305
|
return opened.path;
|
|
7328
7306
|
}
|
|
7329
7307
|
|
|
@@ -7536,7 +7514,8 @@ export class SessionManager {
|
|
|
7536
7514
|
const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
|
|
7537
7515
|
if (!candidate) throw new Error("Session is not an authorized managed candidate.");
|
|
7538
7516
|
const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
|
|
7539
|
-
if (deleted.kind
|
|
7517
|
+
if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
|
|
7518
|
+
throw new Error(`Could not delete managed session: ${deleted.message}`);
|
|
7540
7519
|
}
|
|
7541
7520
|
|
|
7542
7521
|
/** Capture exact source content for a strict fork without granting write ownership. */
|
|
@@ -7596,16 +7575,22 @@ export class SessionManager {
|
|
|
7596
7575
|
}
|
|
7597
7576
|
|
|
7598
7577
|
const dir = destination.directory;
|
|
7599
|
-
const
|
|
7578
|
+
const privateStagingDir =
|
|
7600
7579
|
destination.kind !== "managed" &&
|
|
7601
7580
|
snapshot.storage instanceof FileSessionStorage &&
|
|
7602
|
-
!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;
|
|
7603
7585
|
let managedForkStore: ManagedSessionDescendantStore | undefined;
|
|
7604
7586
|
let managedForkTranscript: ManagedFileSnapshot | null = null;
|
|
7605
7587
|
let manager: SessionManager | undefined;
|
|
7606
7588
|
let authorityFailure: StrictSessionOpenFailure | undefined;
|
|
7589
|
+
let privateStagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
|
|
7590
|
+
let privateStagingPublished = false;
|
|
7591
|
+
|
|
7607
7592
|
try {
|
|
7608
|
-
manager = new SessionManager(cwd, dir, true, snapshot.storage,
|
|
7593
|
+
manager = new SessionManager(cwd, privateStagingDir ?? dir, true, snapshot.storage, forkDestination);
|
|
7609
7594
|
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
7610
7595
|
manager.#fileEntries = forkEntries;
|
|
7611
7596
|
const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
@@ -7635,6 +7620,13 @@ export class SessionManager {
|
|
|
7635
7620
|
throw new Error("Captured fork source authority changed before destination write.");
|
|
7636
7621
|
}
|
|
7637
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
|
+
|
|
7638
7630
|
if (destination.kind === "managed" && manager.#sessionFile) {
|
|
7639
7631
|
managedForkStore = manager.#managedTranscriptStore();
|
|
7640
7632
|
managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
|
|
@@ -7654,66 +7646,57 @@ export class SessionManager {
|
|
|
7654
7646
|
if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
|
|
7655
7647
|
throw new Error("managed_fork_transcript_changed");
|
|
7656
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
|
+
}
|
|
7657
7686
|
if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
|
|
7658
7687
|
return { kind: "forked", manager };
|
|
7659
7688
|
} catch (error) {
|
|
7660
7689
|
if (manager) {
|
|
7661
|
-
const sessionFile = manager.#sessionFile;
|
|
7662
7690
|
const cleanupErrors: Error[] = [];
|
|
7663
7691
|
try {
|
|
7664
7692
|
await manager.close();
|
|
7665
7693
|
} catch (cleanupError) {
|
|
7666
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7667
|
-
}
|
|
7668
|
-
if (sessionFile) {
|
|
7669
|
-
if (destination.kind === "managed") {
|
|
7670
|
-
try {
|
|
7671
|
-
if (managedForkStore && managedForkTranscript)
|
|
7672
|
-
managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
|
|
7673
|
-
} catch (cleanupError) {
|
|
7674
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7675
|
-
}
|
|
7676
|
-
} else {
|
|
7677
|
-
try {
|
|
7678
|
-
await manager.storage.unlink(sessionFile);
|
|
7679
|
-
} catch (cleanupError) {
|
|
7680
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7681
|
-
}
|
|
7682
|
-
if (manager.storage instanceof FileSessionStorage) {
|
|
7683
|
-
try {
|
|
7684
|
-
await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
|
|
7685
|
-
} catch (cleanupError) {
|
|
7686
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7687
|
-
}
|
|
7688
|
-
try {
|
|
7689
|
-
const sessionFileName = path.basename(sessionFile);
|
|
7690
|
-
const transientPrefix = `.${sessionFileName}.`;
|
|
7691
|
-
const backupPrefix = `${sessionFileName}.`;
|
|
7692
|
-
for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
|
|
7693
|
-
if (
|
|
7694
|
-
(name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
|
|
7695
|
-
(name.startsWith(backupPrefix) && name.endsWith(".bak"))
|
|
7696
|
-
) {
|
|
7697
|
-
await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
|
|
7698
|
-
recursive: true,
|
|
7699
|
-
force: true,
|
|
7700
|
-
});
|
|
7701
|
-
}
|
|
7702
|
-
}
|
|
7703
|
-
} catch (cleanupError) {
|
|
7704
|
-
if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
|
|
7705
|
-
}
|
|
7706
|
-
}
|
|
7707
|
-
}
|
|
7694
|
+
if (toError(cleanupError).message !== "cleanup_pending") cleanupErrors.push(toError(cleanupError));
|
|
7708
7695
|
}
|
|
7709
|
-
if (
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
const code = (cleanupError as NodeJS.ErrnoException).code;
|
|
7714
|
-
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST")
|
|
7715
|
-
cleanupErrors.push(toError(cleanupError));
|
|
7716
|
-
}
|
|
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"));
|
|
7717
7700
|
}
|
|
7718
7701
|
if (cleanupErrors.length > 0) {
|
|
7719
7702
|
throw new Error(`Failed to clean up fork destination: ${cleanupErrors[0]!.message}`, {
|