@gajae-code/coding-agent 0.18.12 → 0.19.0
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 +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import type { RunSettlementProof } from "@gajae-code/agent-core";
|
|
3
|
+
import type { ImageContent } from "@gajae-code/ai/core";
|
|
4
|
+
import { logger, parseImageMetadata } from "@gajae-code/utils";
|
|
5
|
+
import {
|
|
6
|
+
MAX_IMAGE_INPUT_BYTES,
|
|
7
|
+
MAX_PASTED_IMAGE_DIMENSION,
|
|
8
|
+
MAX_PASTED_IMAGE_PIXELS,
|
|
9
|
+
MAX_PASTED_IMAGE_SOURCE_BYTES,
|
|
10
|
+
MAX_STAGED_PROMPT_IMAGES,
|
|
11
|
+
} from "../../utils/image-limits";
|
|
12
|
+
import { isAnimatedImage } from "../../utils/pasted-image-loading";
|
|
13
|
+
import { TypedControlError } from "./control/dispatch";
|
|
14
|
+
|
|
15
|
+
const MAX_CHUNK_BYTES = 96 * 1024;
|
|
16
|
+
const MIN_RETAINED_BUFFER_BYTES = 4 * 1024;
|
|
17
|
+
const MAX_ACCEPTED_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_PROCESS_BYTES = 256 * 1024 * 1024;
|
|
19
|
+
// Each session gets one decoder slot; byte reservations remain process-wide.
|
|
20
|
+
const MAX_CONCURRENT_FINISHES = 1;
|
|
21
|
+
const LEASE_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
/** One definitive event-driven wait; teardown and elapsed time are not settlement proof. */
|
|
24
|
+
export function retirePromptImageReservation(
|
|
25
|
+
handle: string,
|
|
26
|
+
waitForRunSettlement: (handle: string) => Promise<RunSettlementProof>,
|
|
27
|
+
isCurrent: () => boolean,
|
|
28
|
+
release: () => void,
|
|
29
|
+
): void {
|
|
30
|
+
if (!isCurrent()) return;
|
|
31
|
+
void (async (): Promise<void> => {
|
|
32
|
+
try {
|
|
33
|
+
const proof = await waitForRunSettlement(handle);
|
|
34
|
+
if (!isCurrent()) return;
|
|
35
|
+
if (proof.status === "settled") {
|
|
36
|
+
release();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
logger.warn("SDK image settlement observer returned unfenced proof; capacity retained", {
|
|
40
|
+
handle,
|
|
41
|
+
reason: proof.reason,
|
|
42
|
+
contractViolation: proof.reason === "run_not_sealed" || proof.reason === "resources_pending",
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (isCurrent())
|
|
46
|
+
logger.warn("SDK image settlement observation failed; capacity retained without settlement proof", {
|
|
47
|
+
handle,
|
|
48
|
+
reason: "observation_failed",
|
|
49
|
+
error,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
}
|
|
54
|
+
const MAX_BATCH_ID_LENGTH = 128;
|
|
55
|
+
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
56
|
+
let processBytes = 0;
|
|
57
|
+
let transientBytes = 0;
|
|
58
|
+
let concurrentFinishes = 0;
|
|
59
|
+
|
|
60
|
+
export interface PromptImageProcessBudgetSnapshot {
|
|
61
|
+
/** Retained upload and accepted source bytes, including accepted base64 copies. */
|
|
62
|
+
readonly sourceBytes: number;
|
|
63
|
+
readonly transientBytes: number;
|
|
64
|
+
readonly concurrentFinishes: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Internal process diagnostics only; not an SDK operation or a release capability. */
|
|
68
|
+
export function getPromptImageProcessBudgetSnapshot(): PromptImageProcessBudgetSnapshot {
|
|
69
|
+
return Object.freeze({ sourceBytes: processBytes, transientBytes, concurrentFinishes });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Upload = {
|
|
73
|
+
owner: string;
|
|
74
|
+
batchId?: string;
|
|
75
|
+
mimeType: string;
|
|
76
|
+
byteLength: number;
|
|
77
|
+
sha256: string;
|
|
78
|
+
chunks: Buffer[];
|
|
79
|
+
reservedBytes: number;
|
|
80
|
+
length: number;
|
|
81
|
+
sequence: number;
|
|
82
|
+
finishing: boolean;
|
|
83
|
+
finished: boolean;
|
|
84
|
+
expiresAt: number;
|
|
85
|
+
timer: NodeJS.Timeout;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function invalid(message: string): never {
|
|
89
|
+
throw new TypedControlError("invalid_input", message);
|
|
90
|
+
}
|
|
91
|
+
function busy(message: string): never {
|
|
92
|
+
throw new TypedControlError("busy", message);
|
|
93
|
+
}
|
|
94
|
+
function object(value: unknown, fields: readonly string[]): Record<string, unknown> {
|
|
95
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) invalid("Image input must be an object.");
|
|
96
|
+
const input = value as Record<string, unknown>;
|
|
97
|
+
if (Object.keys(input).some(key => !fields.includes(key))) invalid("Unknown image input field.");
|
|
98
|
+
return input;
|
|
99
|
+
}
|
|
100
|
+
function ownerId(connectionId: string | undefined): string {
|
|
101
|
+
if (!connectionId)
|
|
102
|
+
throw new TypedControlError("operation_prohibited", "An authenticated SDK connection is required.");
|
|
103
|
+
return connectionId;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Session-scoped, connection-owned image leases; no client-supplied path or identity is trusted. */
|
|
107
|
+
export class PromptImageUploadStore {
|
|
108
|
+
readonly #uploads = new Map<string, Upload>();
|
|
109
|
+
readonly #isConnectionOpen: (connectionId: string) => boolean;
|
|
110
|
+
#closed = false;
|
|
111
|
+
#uploadBytes = 0;
|
|
112
|
+
#acceptedBytes = 0;
|
|
113
|
+
#concurrentFinishes = 0;
|
|
114
|
+
|
|
115
|
+
/** The host supplies live socket membership; standalone callers must choose an explicit policy. */
|
|
116
|
+
constructor(isConnectionOpen: (connectionId: string) => boolean) {
|
|
117
|
+
this.#isConnectionOpen = isConnectionOpen;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
begin(connectionId: string | undefined, value: unknown): { id: string; nextSequence: 0 } {
|
|
121
|
+
this.#assertOpen();
|
|
122
|
+
const owner = ownerId(connectionId);
|
|
123
|
+
this.#assertConnected(owner);
|
|
124
|
+
const input = object(value, ["mimeType", "byteLength", "sha256", "batchId"]);
|
|
125
|
+
if (
|
|
126
|
+
typeof input.mimeType !== "string" ||
|
|
127
|
+
!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(input.mimeType)
|
|
128
|
+
)
|
|
129
|
+
invalid("Unsupported image MIME type.");
|
|
130
|
+
if (
|
|
131
|
+
!Number.isSafeInteger(input.byteLength) ||
|
|
132
|
+
(input.byteLength as number) <= 0 ||
|
|
133
|
+
(input.byteLength as number) > MAX_IMAGE_INPUT_BYTES
|
|
134
|
+
)
|
|
135
|
+
invalid("Image byteLength exceeds the 20 MiB limit.");
|
|
136
|
+
if (typeof input.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(input.sha256))
|
|
137
|
+
invalid("Invalid image SHA-256 digest.");
|
|
138
|
+
if (
|
|
139
|
+
Object.hasOwn(input, "batchId") &&
|
|
140
|
+
(typeof input.batchId !== "string" || input.batchId.length === 0 || input.batchId.length > MAX_BATCH_ID_LENGTH)
|
|
141
|
+
)
|
|
142
|
+
invalid("Image batchId must be a nonempty string of at most 128 characters.");
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
for (const [uploadId, upload] of this.#uploads) this.#expire(uploadId, upload, now);
|
|
145
|
+
if (this.#uploads.size >= MAX_STAGED_PROMPT_IMAGES) busy("Too many concurrent image uploads.");
|
|
146
|
+
const id = crypto.randomUUID();
|
|
147
|
+
const timer = setTimeout(() => this.#remove(id), LEASE_MS);
|
|
148
|
+
timer.unref?.();
|
|
149
|
+
const upload: Upload = {
|
|
150
|
+
owner,
|
|
151
|
+
...(typeof input.batchId === "string" ? { batchId: input.batchId } : {}),
|
|
152
|
+
mimeType: input.mimeType,
|
|
153
|
+
byteLength: input.byteLength as number,
|
|
154
|
+
sha256: input.sha256,
|
|
155
|
+
chunks: [],
|
|
156
|
+
reservedBytes: 0,
|
|
157
|
+
length: 0,
|
|
158
|
+
sequence: 0,
|
|
159
|
+
finishing: false,
|
|
160
|
+
finished: false,
|
|
161
|
+
expiresAt: now + LEASE_MS,
|
|
162
|
+
timer,
|
|
163
|
+
};
|
|
164
|
+
this.#uploads.set(id, upload);
|
|
165
|
+
this.#renewBatch(owner, id, upload);
|
|
166
|
+
return { id, nextSequence: 0 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
append(
|
|
170
|
+
connectionId: string | undefined,
|
|
171
|
+
value: unknown,
|
|
172
|
+
): { id: string; nextSequence: number; receivedBytes: number } {
|
|
173
|
+
const input = object(value, ["id", "sequence", "data"]);
|
|
174
|
+
const upload = this.#owned(connectionId, input.id);
|
|
175
|
+
if (upload.finishing || upload.finished) invalid("Image upload is already finishing or finished.");
|
|
176
|
+
if (!Number.isSafeInteger(input.sequence) || input.sequence !== upload.sequence)
|
|
177
|
+
invalid("Image chunk sequence is out of order.");
|
|
178
|
+
if (
|
|
179
|
+
typeof input.data !== "string" ||
|
|
180
|
+
input.data.length === 0 ||
|
|
181
|
+
input.data.length > (MAX_CHUNK_BYTES * 4) / 3 ||
|
|
182
|
+
!CANONICAL_BASE64.test(input.data)
|
|
183
|
+
)
|
|
184
|
+
invalid("Image chunk must be canonical base64 within the frame limit.");
|
|
185
|
+
const bytes = Buffer.from(input.data, "base64");
|
|
186
|
+
if (bytes.length === 0 || bytes.length > MAX_CHUNK_BYTES || bytes.toString("base64") !== input.data)
|
|
187
|
+
invalid("Image chunk must be canonical base64 within the frame limit.");
|
|
188
|
+
if (upload.length + bytes.length > upload.byteLength) invalid("Image upload exceeds its declared size.");
|
|
189
|
+
const nextLength = upload.length + bytes.length;
|
|
190
|
+
const allocationLimit = Math.max(upload.byteLength, MIN_RETAINED_BUFFER_BYTES);
|
|
191
|
+
const requiredBytes = Math.min(Math.ceil(nextLength / MAX_CHUNK_BYTES) * MAX_CHUNK_BYTES, allocationLimit);
|
|
192
|
+
const addedBytes = requiredBytes - upload.reservedBytes;
|
|
193
|
+
if (
|
|
194
|
+
this.#uploadBytes + addedBytes > MAX_PASTED_IMAGE_SOURCE_BYTES ||
|
|
195
|
+
processBytes + transientBytes + addedBytes > MAX_PROCESS_BYTES
|
|
196
|
+
)
|
|
197
|
+
busy("Image staging capacity exceeded.");
|
|
198
|
+
|
|
199
|
+
// Reserve coalesced fixed-size slabs before allocating or retaining them. Tiny declared
|
|
200
|
+
// uploads reserve at least 4 KiB, avoiding one retained Buffer per admitted byte.
|
|
201
|
+
this.#uploadBytes += addedBytes;
|
|
202
|
+
processBytes += addedBytes;
|
|
203
|
+
const originalChunkCount = upload.chunks.length;
|
|
204
|
+
try {
|
|
205
|
+
const newChunks: Buffer[] = [];
|
|
206
|
+
for (let allocated = upload.reservedBytes; allocated < requiredBytes; ) {
|
|
207
|
+
const chunkBytes = Math.min(MAX_CHUNK_BYTES, requiredBytes - allocated);
|
|
208
|
+
newChunks.push(Buffer.allocUnsafeSlow(chunkBytes));
|
|
209
|
+
allocated += chunkBytes;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let sourceOffset = 0;
|
|
213
|
+
let targetOffset = upload.length;
|
|
214
|
+
while (sourceOffset < bytes.length) {
|
|
215
|
+
const chunkIndex = Math.floor(targetOffset / MAX_CHUNK_BYTES);
|
|
216
|
+
const chunk =
|
|
217
|
+
chunkIndex < upload.chunks.length
|
|
218
|
+
? upload.chunks[chunkIndex]!
|
|
219
|
+
: newChunks[chunkIndex - upload.chunks.length]!;
|
|
220
|
+
const chunkOffset = targetOffset % MAX_CHUNK_BYTES;
|
|
221
|
+
const copied = Math.min(bytes.length - sourceOffset, chunk.length - chunkOffset);
|
|
222
|
+
bytes.copy(chunk, chunkOffset, sourceOffset, sourceOffset + copied);
|
|
223
|
+
sourceOffset += copied;
|
|
224
|
+
targetOffset += copied;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (newChunks.length > 0) upload.chunks.push(...newChunks);
|
|
228
|
+
upload.reservedBytes = requiredBytes;
|
|
229
|
+
upload.length = nextLength;
|
|
230
|
+
upload.sequence++;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
upload.chunks.length = originalChunkCount;
|
|
233
|
+
this.#uploadBytes -= addedBytes;
|
|
234
|
+
processBytes -= addedBytes;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
this.#renewBatch(upload.owner, input.id as string, upload);
|
|
238
|
+
return { id: input.id as string, nextSequence: upload.sequence, receivedBytes: upload.length };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async finish(
|
|
242
|
+
connectionId: string | undefined,
|
|
243
|
+
value: unknown,
|
|
244
|
+
): Promise<{ id: string; byteLength: number; sha256: string; mimeType: string }> {
|
|
245
|
+
const input = object(value, ["id"]);
|
|
246
|
+
const upload = this.#owned(connectionId, input.id);
|
|
247
|
+
if (upload.finishing || upload.finished) invalid("Image upload is already finishing or finished.");
|
|
248
|
+
if (upload.length !== upload.byteLength) invalid("Image upload is incomplete.");
|
|
249
|
+
// Claim the lease, decoder slot and copy capacity before the first allocation or await.
|
|
250
|
+
if (
|
|
251
|
+
this.#concurrentFinishes >= MAX_CONCURRENT_FINISHES ||
|
|
252
|
+
processBytes + transientBytes + upload.length > MAX_PROCESS_BYTES
|
|
253
|
+
)
|
|
254
|
+
busy("Image finalization capacity exceeded.");
|
|
255
|
+
upload.finishing = true;
|
|
256
|
+
this.#concurrentFinishes++;
|
|
257
|
+
concurrentFinishes++;
|
|
258
|
+
transientBytes += upload.length;
|
|
259
|
+
let transientReservation = upload.length;
|
|
260
|
+
try {
|
|
261
|
+
const bytes = Buffer.concat(upload.chunks, upload.length);
|
|
262
|
+
if (crypto.createHash("sha256").update(bytes).digest("hex") !== upload.sha256)
|
|
263
|
+
invalid("Image SHA-256 digest mismatch.");
|
|
264
|
+
const metadata = parseImageMetadata(bytes);
|
|
265
|
+
if (
|
|
266
|
+
!metadata ||
|
|
267
|
+
metadata.mimeType !== upload.mimeType ||
|
|
268
|
+
!metadata.width ||
|
|
269
|
+
!metadata.height ||
|
|
270
|
+
metadata.width > MAX_PASTED_IMAGE_DIMENSION ||
|
|
271
|
+
metadata.height > MAX_PASTED_IMAGE_DIMENSION ||
|
|
272
|
+
metadata.width * metadata.height > MAX_PASTED_IMAGE_PIXELS
|
|
273
|
+
)
|
|
274
|
+
invalid("Image MIME type, structure or dimensions are invalid.");
|
|
275
|
+
if (isAnimatedImage(bytes, metadata.mimeType) !== false)
|
|
276
|
+
invalid("Animated or malformed images are not supported.");
|
|
277
|
+
const decodedBytes = metadata.width * metadata.height * 4;
|
|
278
|
+
if (processBytes + transientBytes + decodedBytes > MAX_PROCESS_BYTES)
|
|
279
|
+
busy("Image decoding capacity exceeded.");
|
|
280
|
+
transientBytes += decodedBytes;
|
|
281
|
+
transientReservation += decodedBytes;
|
|
282
|
+
try {
|
|
283
|
+
const decoded = await new Bun.Image(bytes).metadata();
|
|
284
|
+
if (decoded.width !== metadata.width || decoded.height !== metadata.height)
|
|
285
|
+
invalid("Image dimensions do not match decoded data.");
|
|
286
|
+
await new Bun.Image(bytes).resize(1, 1).png().bytes();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error instanceof TypedControlError) throw error;
|
|
289
|
+
invalid("Image cannot be decoded.");
|
|
290
|
+
}
|
|
291
|
+
// A custom transport may attest liveness without delivering a close
|
|
292
|
+
// callback. Decode completion must re-authorize, not renew a dead lease.
|
|
293
|
+
if (!this.#isConnectionOpen(upload.owner)) {
|
|
294
|
+
this.#remove(input.id as string);
|
|
295
|
+
throw new TypedControlError("resource_gone", "Image upload connection is closed.");
|
|
296
|
+
}
|
|
297
|
+
// The transport can disconnect while decoding. A removed lease cannot be resurrected.
|
|
298
|
+
if (this.#uploads.get(input.id as string) !== upload || this.#expire(input.id as string, upload))
|
|
299
|
+
throw new TypedControlError("resource_gone", "Image upload expired.");
|
|
300
|
+
const releasedBytes = upload.reservedBytes - upload.length;
|
|
301
|
+
this.#uploadBytes -= releasedBytes;
|
|
302
|
+
processBytes -= releasedBytes;
|
|
303
|
+
upload.chunks = [bytes];
|
|
304
|
+
upload.reservedBytes = upload.length;
|
|
305
|
+
upload.finished = true;
|
|
306
|
+
this.#renewBatch(upload.owner, input.id as string, upload);
|
|
307
|
+
return { id: input.id as string, byteLength: upload.length, sha256: upload.sha256, mimeType: upload.mimeType };
|
|
308
|
+
} finally {
|
|
309
|
+
// A discarded lease still owns its source chunks until this async decode settles.
|
|
310
|
+
if (this.#uploads.get(input.id as string) !== upload) {
|
|
311
|
+
this.#uploadBytes -= upload.reservedBytes;
|
|
312
|
+
processBytes -= upload.reservedBytes;
|
|
313
|
+
}
|
|
314
|
+
transientBytes -= transientReservation;
|
|
315
|
+
concurrentFinishes--;
|
|
316
|
+
this.#concurrentFinishes--;
|
|
317
|
+
upload.finishing = false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
discard(connectionId: string | undefined, value: unknown): { discarded: true } {
|
|
322
|
+
const input = object(value, ["id"]);
|
|
323
|
+
this.#owned(connectionId, input.id);
|
|
324
|
+
this.#remove(input.id as string);
|
|
325
|
+
return { discarded: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Synchronous reserve–validate–copy–consume transaction, called before prompt admission. */
|
|
329
|
+
redeem(connectionId: string | undefined, value: unknown): { images: ImageContent[]; release: () => void } {
|
|
330
|
+
this.#assertOpen();
|
|
331
|
+
const owner = ownerId(connectionId);
|
|
332
|
+
this.#assertConnected(owner);
|
|
333
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_STAGED_PROMPT_IMAGES)
|
|
334
|
+
invalid(`stagedImages must contain 1–${MAX_STAGED_PROMPT_IMAGES} image IDs.`);
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
const entries: Array<[string, Upload]> = [];
|
|
337
|
+
let bytes = 0;
|
|
338
|
+
for (const descriptor of value) {
|
|
339
|
+
const input = object(descriptor, ["id"]);
|
|
340
|
+
const id = input.id;
|
|
341
|
+
if (typeof id !== "string" || !id || seen.has(id))
|
|
342
|
+
invalid("stagedImages contains an invalid or duplicate ID.");
|
|
343
|
+
seen.add(id);
|
|
344
|
+
const entry = this.#uploads.get(id);
|
|
345
|
+
if (!entry || entry.owner !== owner || this.#expire(id, entry))
|
|
346
|
+
throw new TypedControlError("image_upload_unavailable", "Image upload is unavailable or already consumed.");
|
|
347
|
+
if (!entry.finished) invalid("Image upload is not finished.");
|
|
348
|
+
bytes += entry.length;
|
|
349
|
+
entries.push([id, entry]);
|
|
350
|
+
}
|
|
351
|
+
const encodedBytes = entries.reduce((total, [, entry]) => total + Math.ceil(entry.length / 3) * 4, 0);
|
|
352
|
+
if (
|
|
353
|
+
bytes > MAX_PASTED_IMAGE_SOURCE_BYTES ||
|
|
354
|
+
this.#acceptedBytes + bytes > MAX_ACCEPTED_BYTES ||
|
|
355
|
+
processBytes + transientBytes + bytes + encodedBytes > MAX_PROCESS_BYTES
|
|
356
|
+
)
|
|
357
|
+
busy("Accepted image capacity exceeded.");
|
|
358
|
+
this.#acceptedBytes += bytes;
|
|
359
|
+
processBytes += bytes + encodedBytes;
|
|
360
|
+
let released = false;
|
|
361
|
+
const release = () => {
|
|
362
|
+
if (released) return;
|
|
363
|
+
released = true;
|
|
364
|
+
this.#acceptedBytes -= bytes;
|
|
365
|
+
processBytes -= bytes + encodedBytes;
|
|
366
|
+
};
|
|
367
|
+
try {
|
|
368
|
+
const images: ImageContent[] = entries.map(([, entry]) => ({
|
|
369
|
+
type: "image",
|
|
370
|
+
data: entry.chunks[0]!.toString("base64"),
|
|
371
|
+
mimeType: entry.mimeType,
|
|
372
|
+
}));
|
|
373
|
+
for (const [id] of entries) this.#remove(id);
|
|
374
|
+
return { images, release };
|
|
375
|
+
} catch (error) {
|
|
376
|
+
release();
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
disconnect(connectionId: string): void {
|
|
382
|
+
for (const [id, upload] of this.#uploads) if (upload.owner === connectionId) this.#remove(id);
|
|
383
|
+
}
|
|
384
|
+
close(): void {
|
|
385
|
+
this.#closed = true;
|
|
386
|
+
for (const id of this.#uploads.keys()) this.#remove(id);
|
|
387
|
+
}
|
|
388
|
+
#owned(connectionId: string | undefined, value: unknown): Upload {
|
|
389
|
+
this.#assertOpen();
|
|
390
|
+
const owner = ownerId(connectionId);
|
|
391
|
+
this.#assertConnected(owner);
|
|
392
|
+
if (typeof value !== "string" || !value) invalid("Image upload ID is required.");
|
|
393
|
+
const upload = this.#uploads.get(value);
|
|
394
|
+
if (!upload || upload.owner !== owner || this.#expire(value, upload))
|
|
395
|
+
throw new TypedControlError("resource_gone", "Image upload is unavailable.");
|
|
396
|
+
return upload;
|
|
397
|
+
}
|
|
398
|
+
#assertOpen(): void {
|
|
399
|
+
if (this.#closed) throw new TypedControlError("resource_gone", "Image upload session is closed.");
|
|
400
|
+
}
|
|
401
|
+
#assertConnected(owner: string): void {
|
|
402
|
+
if (!this.#isConnectionOpen(owner))
|
|
403
|
+
throw new TypedControlError("resource_gone", "Image upload connection is closed.");
|
|
404
|
+
}
|
|
405
|
+
#expire(id: string, upload: Upload, now = Date.now()): boolean {
|
|
406
|
+
if (now < upload.expiresAt) return false;
|
|
407
|
+
this.#remove(id);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
#renewBatch(owner: string, id: string, current: Upload): void {
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
if (this.#uploads.get(id) !== current || this.#expire(id, current, now))
|
|
413
|
+
throw new TypedControlError("resource_gone", "Image upload expired.");
|
|
414
|
+
if (current.batchId === undefined) {
|
|
415
|
+
this.#resetLease(id, current, now);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
for (const [uploadId, upload] of this.#uploads)
|
|
419
|
+
if (upload.owner === owner && upload.batchId === current.batchId && !this.#expire(uploadId, upload, now))
|
|
420
|
+
this.#resetLease(uploadId, upload, now);
|
|
421
|
+
}
|
|
422
|
+
#resetLease(id: string, upload: Upload, now: number): void {
|
|
423
|
+
clearTimeout(upload.timer);
|
|
424
|
+
upload.expiresAt = now + LEASE_MS;
|
|
425
|
+
upload.timer = setTimeout(() => this.#remove(id), LEASE_MS);
|
|
426
|
+
upload.timer.unref?.();
|
|
427
|
+
}
|
|
428
|
+
#remove(id: string): void {
|
|
429
|
+
const upload = this.#uploads.get(id);
|
|
430
|
+
if (!upload) return;
|
|
431
|
+
this.#uploads.delete(id);
|
|
432
|
+
clearTimeout(upload.timer);
|
|
433
|
+
if (!upload.finishing) {
|
|
434
|
+
this.#uploadBytes -= upload.reservedBytes;
|
|
435
|
+
processBytes -= upload.reservedBytes;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -133,6 +133,21 @@ describe("SDK surface parity", () => {
|
|
|
133
133
|
expect(policy.installedQueries.has("skill.list/state")).toBe(false);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
test("private image operations are advertised only when the host installs staging", () => {
|
|
137
|
+
for (const available of [false, true]) {
|
|
138
|
+
const policy = createSdkSurfacePolicy({
|
|
139
|
+
bindings: [],
|
|
140
|
+
workflowGateAvailable: false,
|
|
141
|
+
imageUploadsAvailable: available,
|
|
142
|
+
});
|
|
143
|
+
const capabilities = createSdkCapabilities(policy);
|
|
144
|
+
expect(capabilities.operations.filter(operation => operation.startsWith("turn.image."))).toEqual(
|
|
145
|
+
available ? ["turn.image.begin", "turn.image.append", "turn.image.finish", "turn.image.discard"] : [],
|
|
146
|
+
);
|
|
147
|
+
expect(capabilities.operations).toContain("turn.prompt");
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
136
151
|
test("request results, replay order, and typed protocol errors match across transports", async () => {
|
|
137
152
|
const nativeTransport = memoryTransport();
|
|
138
153
|
const loopbackTransport = memoryTransport();
|