@oar-sh/oar 0.9.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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
|
|
3
|
+
// Anthropic rejects very large base64 image blocks; larger images fall back to
|
|
4
|
+
// a path reference so Claude can Read them from disk instead.
|
|
5
|
+
export const MAX_INLINE_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
// The Anthropic image content block accepts exactly these media types; an
|
|
8
|
+
// svg/bmp/heic block is a 400 that fails the whole turn, so anything else
|
|
9
|
+
// falls back to a path reference like an oversized image.
|
|
10
|
+
export const ANTHROPIC_IMAGE_MEDIA_TYPES = new Set([
|
|
11
|
+
'image/jpeg',
|
|
12
|
+
'image/png',
|
|
13
|
+
'image/gif',
|
|
14
|
+
'image/webp',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function isImageAttachment(att) {
|
|
18
|
+
return String(att?.type || '').toLowerCase().startsWith('image/');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isEmbeddableImageMediaType(mediaType) {
|
|
22
|
+
return ANTHROPIC_IMAGE_MEDIA_TYPES.has(String(mediaType || '').trim().toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function imageBlockFromDataUrl(att, maxInlineImageBytes) {
|
|
26
|
+
const dataUrl = String(att?.dataUrl || '').trim();
|
|
27
|
+
if (!dataUrl.startsWith('data:')) return null;
|
|
28
|
+
const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/i);
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
const mediaType = String(match[1] || '').trim().toLowerCase();
|
|
31
|
+
const data = String(match[2] || '').trim();
|
|
32
|
+
if (!isEmbeddableImageMediaType(mediaType) || !data) return null;
|
|
33
|
+
// The same size guard as the file-read branch: base64 is 4/3 the raw size,
|
|
34
|
+
// and an oversized data URL would defeat the cap the guard exists for.
|
|
35
|
+
if (Math.floor((data.length * 3) / 4) > maxInlineImageBytes) return null;
|
|
36
|
+
return {
|
|
37
|
+
type: 'image',
|
|
38
|
+
source: { type: 'base64', media_type: mediaType, data },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Map hydrated relay attachments to Anthropic user-message content.
|
|
44
|
+
*
|
|
45
|
+
* Returns `{ imageBlocks, noteLines }`:
|
|
46
|
+
* - `imageBlocks` are Anthropic `image` content blocks (base64) for images
|
|
47
|
+
* small enough to embed inline.
|
|
48
|
+
* - `noteLines` are prompt text lines describing every attachment, including
|
|
49
|
+
* absolute paths so Claude's Read tool can open non-image files (and
|
|
50
|
+
* oversized images) directly.
|
|
51
|
+
*/
|
|
52
|
+
export function buildClaudeAttachmentContent(rawAttachments, {
|
|
53
|
+
fsImpl = fs,
|
|
54
|
+
maxInlineImageBytes = MAX_INLINE_IMAGE_BYTES,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const input = Array.isArray(rawAttachments) ? rawAttachments : [];
|
|
57
|
+
const imageBlocks = [];
|
|
58
|
+
const noteLines = [];
|
|
59
|
+
for (const att of input) {
|
|
60
|
+
if (!att || typeof att !== 'object') continue;
|
|
61
|
+
const name = String(att.name || 'attachment').trim() || 'attachment';
|
|
62
|
+
const mime = String(att.type || 'application/octet-stream').trim() || 'application/octet-stream';
|
|
63
|
+
const filePath = String(att.path || '').trim();
|
|
64
|
+
|
|
65
|
+
if (isImageAttachment(att)) {
|
|
66
|
+
let embedded = false;
|
|
67
|
+
// Only media types the Anthropic image block accepts are embedded;
|
|
68
|
+
// everything else (svg, bmp, heic, …) would 400 the whole turn and
|
|
69
|
+
// falls back to a path reference instead.
|
|
70
|
+
if (filePath && isEmbeddableImageMediaType(mime) && fsImpl.existsSync(filePath)) {
|
|
71
|
+
try {
|
|
72
|
+
const bytes = fsImpl.readFileSync(filePath);
|
|
73
|
+
if (Buffer.isBuffer(bytes) && bytes.length && bytes.length <= maxInlineImageBytes) {
|
|
74
|
+
imageBlocks.push({
|
|
75
|
+
type: 'image',
|
|
76
|
+
source: {
|
|
77
|
+
type: 'base64',
|
|
78
|
+
media_type: mime.toLowerCase(),
|
|
79
|
+
data: bytes.toString('base64'),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
embedded = true;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
embedded = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!embedded) {
|
|
89
|
+
const blockFromDataUrl = imageBlockFromDataUrl(att, maxInlineImageBytes);
|
|
90
|
+
if (blockFromDataUrl) {
|
|
91
|
+
imageBlocks.push(blockFromDataUrl);
|
|
92
|
+
embedded = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (embedded) {
|
|
96
|
+
noteLines.push(`Attached image "${name}" (${mime}) is embedded in this message.`);
|
|
97
|
+
} else if (filePath) {
|
|
98
|
+
noteLines.push(`Attached image "${name}" (${mime}): ${filePath}`);
|
|
99
|
+
} else {
|
|
100
|
+
noteLines.push(`Attached image "${name}" (${mime}) could not be embedded or resolved to a readable path.`);
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (filePath && fsImpl.existsSync(filePath)) {
|
|
106
|
+
noteLines.push(`Attached file "${name}" (${mime}): ${filePath}`);
|
|
107
|
+
} else {
|
|
108
|
+
// A file whose hydration failed must still be visible to the model —
|
|
109
|
+
// silently dropping it reads as "the user attached nothing".
|
|
110
|
+
noteLines.push(`Attached file "${name}" (${mime}) could not be resolved to a readable path.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { imageBlocks, noteLines };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the full Anthropic user-message content array for a relay message.
|
|
118
|
+
* Text first (user text + attachment notes + server-provided attachment
|
|
119
|
+
* context), then inline image blocks.
|
|
120
|
+
*/
|
|
121
|
+
export function buildClaudeUserContent(message, { fsImpl = fs, maxInlineImageBytes = MAX_INLINE_IMAGE_BYTES } = {}) {
|
|
122
|
+
const text = String(message?.text || '').trim();
|
|
123
|
+
const { imageBlocks, noteLines } = buildClaudeAttachmentContent(message?.attachments, { fsImpl, maxInlineImageBytes });
|
|
124
|
+
const attachmentPromptContext = String(message?.attachmentPromptContext || '').trim();
|
|
125
|
+
const textParts = [text];
|
|
126
|
+
if (noteLines.length) textParts.push(noteLines.join('\n'));
|
|
127
|
+
if (attachmentPromptContext) textParts.push(attachmentPromptContext);
|
|
128
|
+
const combinedText = textParts.filter(Boolean).join('\n\n');
|
|
129
|
+
const content = [];
|
|
130
|
+
if (combinedText) content.push({ type: 'text', text: combinedText });
|
|
131
|
+
content.push(...imageBlocks);
|
|
132
|
+
if (!content.length) content.push({ type: 'text', text: '' });
|
|
133
|
+
return content;
|
|
134
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay's `preview` tool for the Claude worker, shaped as an SDK MCP tool
|
|
3
|
+
* definition (`{ name, description, inputSchema, handler }`) so the adapter can
|
|
4
|
+
* hand it to `createSdkMcpServer` and this module never imports the agent SDK.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as z from 'zod';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
PREVIEW_TOOL_DESCRIPTION,
|
|
11
|
+
PREVIEW_TOOL_INPUT_SCHEMA,
|
|
12
|
+
PREVIEW_TOOL_NAME,
|
|
13
|
+
executePreviewTool,
|
|
14
|
+
} from '../../shared/preview-tool-core.mjs';
|
|
15
|
+
|
|
16
|
+
const FIELDS = PREVIEW_TOOL_INPUT_SCHEMA.properties;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The shared JSON schema mirrored as a zod raw shape: the SDK's MCP layer
|
|
20
|
+
* accepts zod only and throws on a plain JSON Schema object, so the two
|
|
21
|
+
* spellings have to coexist. Descriptions are read from the shared schema
|
|
22
|
+
* rather than retyped, and the value constraints (port range, token pattern,
|
|
23
|
+
* label length) are deliberately left off — a value rejected here never
|
|
24
|
+
* reaches the handler, and the core's validator returns a message written for
|
|
25
|
+
* the model instead of an MCP schema error.
|
|
26
|
+
*/
|
|
27
|
+
export const PREVIEW_TOOL_ZOD_SHAPE = {
|
|
28
|
+
action: z.enum(['create', 'list', 'close']).describe(FIELDS.action.description),
|
|
29
|
+
port: z.number().optional().describe(FIELDS.port.description),
|
|
30
|
+
dir: z.string().optional().describe(FIELDS.dir.description),
|
|
31
|
+
label: z.string().optional().describe(FIELDS.label.description),
|
|
32
|
+
token: z.string().optional().describe(FIELDS.token.description),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createPreviewToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
|
|
36
|
+
return {
|
|
37
|
+
name: PREVIEW_TOOL_NAME,
|
|
38
|
+
description: PREVIEW_TOOL_DESCRIPTION,
|
|
39
|
+
inputSchema: PREVIEW_TOOL_ZOD_SHAPE,
|
|
40
|
+
handler: async (args) => {
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
result = await executePreviewTool(args, {
|
|
44
|
+
api,
|
|
45
|
+
conversationId: String(getConversationId() || ''),
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// A tool exception would surface as a failed MCP call and can abort
|
|
49
|
+
// the turn; a refusal the model can read cannot.
|
|
50
|
+
const message = error?.message || String(error);
|
|
51
|
+
dbg('preview tool failed', message);
|
|
52
|
+
result = { ok: false, error: message };
|
|
53
|
+
}
|
|
54
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
|
|
3
|
+
import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
|
|
4
|
+
import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
|
|
5
|
+
|
|
6
|
+
// The name the in-process MCP server is registered under; it prefixes every
|
|
7
|
+
// tool it carries on the wire (`mcp__relay__preview`).
|
|
8
|
+
export const RELAY_MCP_SERVER_NAME = 'relay';
|
|
9
|
+
|
|
10
|
+
const MODE_SYSTEM_PROMPT_APPEND = {
|
|
11
|
+
ask: 'Prioritize clarification questions (AskUserQuestion) before implementation work; do not make broad assumptions when a question would materially change the result.',
|
|
12
|
+
autopilot: 'Keep moving unless user input is truly blocking; avoid unnecessary questions.',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const CLAUDE_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
16
|
+
|
|
17
|
+
// Relay sentinel for the SDK's session-scoped `ultracode` settings flag
|
|
18
|
+
// (xhigh effort plus standing workflow orchestration). It arrives on the same
|
|
19
|
+
// `reasoningEffort` wire field as real effort levels but must never be sent
|
|
20
|
+
// as an `effort`/`effortLevel` value — the CLI's schema silently discards
|
|
21
|
+
// unknown levels. `claudeUltracodeFlagSettings` is the translation.
|
|
22
|
+
export const CLAUDE_ULTRACODE_EFFORT = 'ultracode';
|
|
23
|
+
|
|
24
|
+
export function normalizeClaudeEffort(value) {
|
|
25
|
+
const effort = String(value || '').trim().toLowerCase();
|
|
26
|
+
if (effort === CLAUDE_ULTRACODE_EFFORT) return effort;
|
|
27
|
+
return CLAUDE_EFFORT_LEVELS.has(effort) ? effort : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The flag-settings payload that moves a live session into or out of
|
|
32
|
+
* ultracode. `enableWorkflows` is explicit because the worker loads no
|
|
33
|
+
* filesystem settings, so nothing else would switch the feature on; the
|
|
34
|
+
* effort is pinned to xhigh (what the flag implies) rather than left to the
|
|
35
|
+
* flag layer's previous `effortLevel`, so enabling ultracode always lands on
|
|
36
|
+
* the same tier regardless of adapt history. Leaving ultracode clears both
|
|
37
|
+
* flags back to their defaults alongside the newly requested effort.
|
|
38
|
+
*/
|
|
39
|
+
export function claudeUltracodeFlagSettings(effort) {
|
|
40
|
+
if (effort === CLAUDE_ULTRACODE_EFFORT) {
|
|
41
|
+
return { ultracode: true, enableWorkflows: true, effortLevel: 'xhigh' };
|
|
42
|
+
}
|
|
43
|
+
return { ultracode: null, enableWorkflows: null, effortLevel: effort || null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The flag-settings payload that moves a live session onto another auto-compact
|
|
48
|
+
* window. `null` clears the flag layer back to the CLI's model-tuned default,
|
|
49
|
+
* which is exactly what "Auto" means — leaving the previous window pinned would
|
|
50
|
+
* make the setting one-way.
|
|
51
|
+
*/
|
|
52
|
+
export function claudeAutoCompactFlagSettings(autoCompactWindow) {
|
|
53
|
+
const window = normalizeAutoCompactWindow(autoCompactWindow);
|
|
54
|
+
return { autoCompactWindow: window };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The flag-settings payload that pins (or clears) `alwaysThinkingEnabled` on a
|
|
59
|
+
* live session.
|
|
60
|
+
*
|
|
61
|
+
* Probed 2026-08-26 (CLI 2.1.226): the CLI honors this key at SPAWN in both
|
|
62
|
+
* directions, and mid-session only in the ENABLING direction — a mid-session
|
|
63
|
+
* `false` is accepted and **silently ignored**; thinking keeps running and
|
|
64
|
+
* stays visible. `adaptProcess` therefore only calls this for `true`, and
|
|
65
|
+
* leaves a disable for the next spawn to apply. Do not "fix" that guard:
|
|
66
|
+
* calling this with `false` doesn't fail, it lies.
|
|
67
|
+
*/
|
|
68
|
+
export function claudeThinkingFlagSettings(thinkingEnabled) {
|
|
69
|
+
return { alwaysThinkingEnabled: strictThinkingEnabled(thinkingEnabled) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* true / false / null(=say nothing), with NO relay default applied.
|
|
74
|
+
*
|
|
75
|
+
* The adapter stays mechanical on purpose: the "unset means on" policy lives
|
|
76
|
+
* in `shared/claude-thinking.mjs` and is resolved by the relay before the
|
|
77
|
+
* value ever gets here. If this used `parseThinkingEnabled` instead, its
|
|
78
|
+
* default would fire for a caller that passed nothing and pin
|
|
79
|
+
* `alwaysThinkingEnabled` on every spawn — including the ones that mean to
|
|
80
|
+
* leave the key out entirely.
|
|
81
|
+
*/
|
|
82
|
+
function strictThinkingEnabled(value) {
|
|
83
|
+
if (value === true) return true;
|
|
84
|
+
if (value === false) return false;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A token count, or null for Auto. Junk is Auto, never a pinned window. */
|
|
89
|
+
export function normalizeAutoCompactWindow(value) {
|
|
90
|
+
if (value === null || value === undefined || value === '') return null;
|
|
91
|
+
const numeric = Number(value);
|
|
92
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return null;
|
|
93
|
+
return Math.round(numeric);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The single `settings` object a spawn gets. Both the ultracode flags and the
|
|
98
|
+
* auto-compact window live in `Settings`, so they must be merged here — two
|
|
99
|
+
* spreads of `settings` in the options literal would silently clobber each
|
|
100
|
+
* other. Returns null when nothing needs to be set.
|
|
101
|
+
*/
|
|
102
|
+
export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = null, thinkingEnabled = null } = {}) {
|
|
103
|
+
const window = normalizeAutoCompactWindow(autoCompactWindow);
|
|
104
|
+
const thinking = strictThinkingEnabled(thinkingEnabled);
|
|
105
|
+
const settings = {
|
|
106
|
+
...(ultracode ? { ultracode: true, enableWorkflows: true } : {}),
|
|
107
|
+
...(window !== null ? { autoCompactWindow: window } : {}),
|
|
108
|
+
// null means OMIT the key. The relay always resolves to true/false
|
|
109
|
+
// before spawning (its default is on), so in practice the key is always
|
|
110
|
+
// present; the omit path exists for direct callers that mean to say
|
|
111
|
+
// nothing.
|
|
112
|
+
...(thinking !== null ? { alwaysThinkingEnabled: thinking } : {}),
|
|
113
|
+
};
|
|
114
|
+
return Object.keys(settings).length ? settings : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function permissionModeForRelayMode(relayMode) {
|
|
118
|
+
const mode = String(relayMode || 'agent').trim().toLowerCase();
|
|
119
|
+
if (mode === 'plan') return 'plan';
|
|
120
|
+
return 'default';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function systemPromptForRelayMode(relayMode) {
|
|
124
|
+
const mode = String(relayMode || 'agent').trim().toLowerCase();
|
|
125
|
+
const append = MODE_SYSTEM_PROMPT_APPEND[mode];
|
|
126
|
+
return {
|
|
127
|
+
type: 'preset',
|
|
128
|
+
preset: 'claude_code',
|
|
129
|
+
...(append ? { append } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The in-process MCP server carrying the relay's own tools, running inside the
|
|
135
|
+
* worker rather than as a child process. It is registered on every session:
|
|
136
|
+
* the worker cannot see whether the relay's preview lane is enabled, and a
|
|
137
|
+
* disabled lane answers the tool call with a refusal the model can relay,
|
|
138
|
+
* which beats inventing a capability flag to plumb through the delivery
|
|
139
|
+
* payload. `getConversationId` is called per tool call because a process
|
|
140
|
+
* outlives the turn it was spawned for.
|
|
141
|
+
*/
|
|
142
|
+
export function createRelayMcpServer({ api, getConversationId, dbg } = {}) {
|
|
143
|
+
return createSdkMcpServer({
|
|
144
|
+
name: RELAY_MCP_SERVER_NAME,
|
|
145
|
+
tools: [createPreviewToolDefinition({ api, getConversationId, dbg })],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A user-message stream the session process feeds for the whole life of the
|
|
151
|
+
* CLI process: each `push(content)` becomes one user turn, and the stream
|
|
152
|
+
* only ends on `end()` — which is what lets the CLI exit. The CLI begins
|
|
153
|
+
* shutting down as soon as the input stream ends and the pending result is
|
|
154
|
+
* emitted, which also closes the control transport (canUseTool, context
|
|
155
|
+
* usage), so the session process keeps this open while background tasks or
|
|
156
|
+
* queued continuations still need the process alive.
|
|
157
|
+
*/
|
|
158
|
+
function createPushableUserMessageStream() {
|
|
159
|
+
const queued = [];
|
|
160
|
+
let wake = null;
|
|
161
|
+
let ended = false;
|
|
162
|
+
async function* stream() {
|
|
163
|
+
for (;;) {
|
|
164
|
+
while (queued.length) yield queued.shift();
|
|
165
|
+
if (ended) return;
|
|
166
|
+
await new Promise((resolve) => { wake = resolve; });
|
|
167
|
+
wake = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
stream: stream(),
|
|
172
|
+
push(content) {
|
|
173
|
+
if (ended) throw new Error('user message stream already ended');
|
|
174
|
+
queued.push({
|
|
175
|
+
type: 'user',
|
|
176
|
+
message: { role: 'user', content },
|
|
177
|
+
parent_tool_use_id: null,
|
|
178
|
+
});
|
|
179
|
+
wake?.();
|
|
180
|
+
},
|
|
181
|
+
end() {
|
|
182
|
+
ended = true;
|
|
183
|
+
wake?.();
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The only module that imports `@anthropic-ai/claude-agent-sdk`.
|
|
190
|
+
*
|
|
191
|
+
* Starts one persistent streaming-input `query()` — the CLI process that
|
|
192
|
+
* carries a whole relay session: the first user turn, any user turns pushed
|
|
193
|
+
* later, and the background-task continuation turns the CLI dequeues on its
|
|
194
|
+
* own. Returns the SDK `Query` async iterable with two extra methods:
|
|
195
|
+
* `pushUserMessage(content)` to feed another turn and `endInput()` to let the
|
|
196
|
+
* process exit once its current work drains.
|
|
197
|
+
*/
|
|
198
|
+
export function startClaudeSession({
|
|
199
|
+
content,
|
|
200
|
+
cwd,
|
|
201
|
+
model = '',
|
|
202
|
+
resume = '',
|
|
203
|
+
relayMode = 'agent',
|
|
204
|
+
reasoningEffort = '',
|
|
205
|
+
autoCompactWindow = null,
|
|
206
|
+
thinkingEnabled = null,
|
|
207
|
+
thinkingDisplay = '',
|
|
208
|
+
abortController,
|
|
209
|
+
canUseTool,
|
|
210
|
+
pathToClaudeCodeExecutable = '',
|
|
211
|
+
api = null,
|
|
212
|
+
getConversationId = () => '',
|
|
213
|
+
queryImpl = query,
|
|
214
|
+
dbg = () => {},
|
|
215
|
+
} = {}) {
|
|
216
|
+
const normalizedModel = String(model || '').trim().toLowerCase();
|
|
217
|
+
const effort = normalizeClaudeEffort(reasoningEffort);
|
|
218
|
+
const ultracode = effort === CLAUDE_ULTRACODE_EFFORT;
|
|
219
|
+
const effortOption = ultracode ? 'xhigh' : effort;
|
|
220
|
+
// Spawn-time twin of the flag-settings helpers: the settings layer is the
|
|
221
|
+
// only way to hand a fresh CLI the session-scoped ultracode flag, the
|
|
222
|
+
// auto-compact window and the thinking pin, and all three share one
|
|
223
|
+
// `settings` object.
|
|
224
|
+
const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled });
|
|
225
|
+
// Relay tools need the worker's authenticated API helper; a caller that
|
|
226
|
+
// passes none (tests, probes) gets a session without them.
|
|
227
|
+
const relayMcpServer = typeof api === 'function'
|
|
228
|
+
? createRelayMcpServer({ api, getConversationId, dbg })
|
|
229
|
+
: null;
|
|
230
|
+
const options = {
|
|
231
|
+
cwd,
|
|
232
|
+
permissionMode: permissionModeForRelayMode(relayMode),
|
|
233
|
+
systemPrompt: systemPromptForRelayMode(relayMode),
|
|
234
|
+
includePartialMessages: true,
|
|
235
|
+
forwardSubagentText: true,
|
|
236
|
+
...(effortOption ? { effort: effortOption } : {}),
|
|
237
|
+
...(spawnSettings ? { settings: spawnSettings } : {}),
|
|
238
|
+
...(normalizedModel && normalizedModel !== 'auto' ? { model: String(model).trim() } : {}),
|
|
239
|
+
...(String(resume || '').trim() ? { resume: String(resume).trim() } : {}),
|
|
240
|
+
...(abortController ? { abortController } : {}),
|
|
241
|
+
...(typeof canUseTool === 'function' ? { canUseTool } : {}),
|
|
242
|
+
...(relayMcpServer ? { mcpServers: { [RELAY_MCP_SERVER_NAME]: relayMcpServer } } : {}),
|
|
243
|
+
...(String(pathToClaudeCodeExecutable || '').trim()
|
|
244
|
+
? { pathToClaudeCodeExecutable: String(pathToClaudeCodeExecutable).trim() }
|
|
245
|
+
: {}),
|
|
246
|
+
};
|
|
247
|
+
const { stream, push, end } = createPushableUserMessageStream();
|
|
248
|
+
const turn = queryImpl({ prompt: stream, options });
|
|
249
|
+
applyThinkingDisplay(turn, thinkingDisplay, { dbg });
|
|
250
|
+
turn.pushUserMessage = push;
|
|
251
|
+
// The session process MUST call this on every teardown path or the CLI
|
|
252
|
+
// process lingers waiting for more input.
|
|
253
|
+
turn.endInput = end;
|
|
254
|
+
if (content !== undefined && content !== null) push(content);
|
|
255
|
+
return turn;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Set the session's thinking display mode, without changing how much Claude
|
|
260
|
+
* thinks.
|
|
261
|
+
*
|
|
262
|
+
* No `thinking` option is passed to `query()` on purpose: every variant of
|
|
263
|
+
* that option forces a `type`, which would override the host's configuration
|
|
264
|
+
* — the on/off axis goes through `Settings.alwaysThinkingEnabled` instead
|
|
265
|
+
* (spawn settings / `claudeThinkingFlagSettings`). `setMaxThinkingTokens` is
|
|
266
|
+
* deprecated on its FIRST argument only; the second (`thinkingDisplay`) has
|
|
267
|
+
* no replacement on a live session and is the sole live lever for display.
|
|
268
|
+
* Measured 2026-08-26: without this call the API default hides thinking text
|
|
269
|
+
* (blocks arrive with empty `thinking`), so the spawn-time call is
|
|
270
|
+
* load-bearing — it is what makes thought bubbles exist at all.
|
|
271
|
+
*
|
|
272
|
+
* `display` is a relay state ('summarized' | 'omitted' | 'host'); 'host'
|
|
273
|
+
* maps to the wire's `null`, an ACTIVE instruction that clears the session
|
|
274
|
+
* display mode back to the API default — not a skip.
|
|
275
|
+
*
|
|
276
|
+
* The budget argument is positional and re-sent on every call: hardcoding
|
|
277
|
+
* `null` while a budget pin existed would silently clear it, so the caller's
|
|
278
|
+
* tracked budget (always null today — the relay pins none) is threaded
|
|
279
|
+
* through rather than assumed.
|
|
280
|
+
*
|
|
281
|
+
* Best-effort: the method can be missing on older CLIs. When the call fails,
|
|
282
|
+
* complete assistant messages still carry whatever thinking the session's
|
|
283
|
+
* settings allow.
|
|
284
|
+
*/
|
|
285
|
+
export function applyThinkingDisplay(turn, display, { budget = null, dbg = () => {} } = {}) {
|
|
286
|
+
if (typeof turn?.setMaxThinkingTokens !== 'function') return Promise.resolve();
|
|
287
|
+
const mode = parseThinkingDisplay(display);
|
|
288
|
+
const wireDisplay = mode === 'host' ? null : mode;
|
|
289
|
+
return Promise.resolve()
|
|
290
|
+
.then(() => turn.setMaxThinkingTokens(budget ?? null, wireDisplay))
|
|
291
|
+
.catch((error) => {
|
|
292
|
+
dbg('thinking display request failed', error?.message || String(error));
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Read the session's live context-window breakdown (the data behind `/context`).
|
|
298
|
+
*
|
|
299
|
+
* Must be called while the turn's `Query` is still open: the control request
|
|
300
|
+
* travels over the same transport as the message stream, which the SDK tears
|
|
301
|
+
* down once the async iterator returns. In practice that means calling this
|
|
302
|
+
* from inside the `for await` loop, when the `result` message arrives.
|
|
303
|
+
*
|
|
304
|
+
* Best-effort like `applyThinkingDisplay`: the control request is
|
|
305
|
+
* unavailable on older CLIs, and losing a context snapshot must never fail the
|
|
306
|
+
* turn that produced it.
|
|
307
|
+
*/
|
|
308
|
+
export async function readContextUsage(turn, dbg = () => {}, { timeoutMs = 10000 } = {}) {
|
|
309
|
+
if (typeof turn?.getContextUsage !== 'function') return null;
|
|
310
|
+
let timer = null;
|
|
311
|
+
try {
|
|
312
|
+
// The input gate is still closed while this runs, so a CLI that never
|
|
313
|
+
// answers would otherwise stall the turn indefinitely.
|
|
314
|
+
return await Promise.race([
|
|
315
|
+
turn.getContextUsage(),
|
|
316
|
+
new Promise((_, reject) => {
|
|
317
|
+
timer = setTimeout(() => reject(new Error(`context usage timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
318
|
+
}),
|
|
319
|
+
]);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
dbg('context usage request failed', error?.message || String(error));
|
|
322
|
+
return null;
|
|
323
|
+
} finally {
|
|
324
|
+
if (timer) clearTimeout(timer);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Read the session's structured `/usage` data: plan rate-limit windows plus
|
|
330
|
+
* session cost/token totals — the same data Claude Code's `/usage` dialog
|
|
331
|
+
* renders.
|
|
332
|
+
*
|
|
333
|
+
* Same transport constraint as `readContextUsage`: the control request only
|
|
334
|
+
* works while the turn's `Query` is open, so this is called from the same
|
|
335
|
+
* finalize step, before the input gate releases.
|
|
336
|
+
*
|
|
337
|
+
* The SDK marks this API EXPERIMENTAL and says the method name will change
|
|
338
|
+
* when it stabilizes, so the call is feature-detected and every failure is
|
|
339
|
+
* swallowed — plan limits are a nice-to-have, never a reason to fail a turn.
|
|
340
|
+
*/
|
|
341
|
+
const CLAUDE_USAGE_METHOD = 'usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET';
|
|
342
|
+
|
|
343
|
+
export async function readPlanUsage(turn, dbg = () => {}, { timeoutMs = 10000 } = {}) {
|
|
344
|
+
if (typeof turn?.[CLAUDE_USAGE_METHOD] !== 'function') return null;
|
|
345
|
+
let timer = null;
|
|
346
|
+
try {
|
|
347
|
+
const request = turn[CLAUDE_USAGE_METHOD]();
|
|
348
|
+
// The race leaves the losing promise unobserved on timeout; swallow its
|
|
349
|
+
// rejection so it cannot surface as an unhandledRejection.
|
|
350
|
+
request.catch?.(() => {});
|
|
351
|
+
return await Promise.race([
|
|
352
|
+
request,
|
|
353
|
+
new Promise((_, reject) => {
|
|
354
|
+
timer = setTimeout(() => reject(new Error(`plan usage timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
355
|
+
}),
|
|
356
|
+
]);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
dbg('plan usage request failed', error?.message || String(error));
|
|
359
|
+
return null;
|
|
360
|
+
} finally {
|
|
361
|
+
if (timer) clearTimeout(timer);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Build the `canUseTool` callback for one turn. Everything is auto-allowed
|
|
367
|
+
* (parity with the Copilot workers' `--allow-all` posture); the only
|
|
368
|
+
* interceptions are AskUserQuestion (bridged to relay question cards) and
|
|
369
|
+
* ExitPlanMode (surfaces the plan_ready board).
|
|
370
|
+
*
|
|
371
|
+
* ExitPlanMode is DENIED after the board is posted: allowing it tells the CLI
|
|
372
|
+
* the user approved the plan, and the same turn rolls straight into
|
|
373
|
+
* implementation while the board is still waiting for a choice (verified
|
|
374
|
+
* against the live SDK). Denying with a review note ends the turn cleanly —
|
|
375
|
+
* the board action then starts the next turn in the chosen mode via resume,
|
|
376
|
+
* which matches direct Claude Code UX.
|
|
377
|
+
*/
|
|
378
|
+
const EXIT_PLAN_DENY_MESSAGE = 'Plan received — it is shown to the user in the relay for review. '
|
|
379
|
+
+ 'End the turn now with a short closing message and do not start implementing; '
|
|
380
|
+
+ 'the user\'s decision will arrive as a new message.';
|
|
381
|
+
// When the handler could not surface a board (no plan text reached it), the
|
|
382
|
+
// model must restate the plan as its final message: plan-shaped final text
|
|
383
|
+
// triggers the runner's fallback board, so the user still gets the review UI.
|
|
384
|
+
const EXIT_PLAN_DENY_NO_BOARD_MESSAGE = 'Plan received, but it could not be shown for review. '
|
|
385
|
+
+ 'Restate the complete plan as your final message, then end the turn without implementing; '
|
|
386
|
+
+ 'the user\'s decision will arrive as a new message.';
|
|
387
|
+
export function createCanUseTool({
|
|
388
|
+
askUserBridge,
|
|
389
|
+
onExitPlanMode,
|
|
390
|
+
dbg = () => {},
|
|
391
|
+
} = {}) {
|
|
392
|
+
return async function canUseTool(toolName, input, { signal } = {}) {
|
|
393
|
+
const normalizedToolName = String(toolName || '').trim();
|
|
394
|
+
try {
|
|
395
|
+
if (normalizedToolName === 'AskUserQuestion' && askUserBridge) {
|
|
396
|
+
const { answers } = await askUserBridge.handleAskUserQuestion(input, { signal });
|
|
397
|
+
return {
|
|
398
|
+
behavior: 'allow',
|
|
399
|
+
updatedInput: { ...input, answers },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (normalizedToolName === 'ExitPlanMode' && typeof onExitPlanMode === 'function') {
|
|
403
|
+
// The handler reports whether a review board was actually surfaced;
|
|
404
|
+
// treat a bare undefined (legacy handlers) as posted.
|
|
405
|
+
const posted = await onExitPlanMode(input);
|
|
406
|
+
return {
|
|
407
|
+
behavior: 'deny',
|
|
408
|
+
message: posted === false ? EXIT_PLAN_DENY_NO_BOARD_MESSAGE : EXIT_PLAN_DENY_MESSAGE,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
dbg('canUseTool bridge failed', normalizedToolName, error?.message || String(error));
|
|
413
|
+
return {
|
|
414
|
+
behavior: 'deny',
|
|
415
|
+
message: `Relay bridge failed for ${normalizedToolName}: ${error?.message || error}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return { behavior: 'allow', updatedInput: input };
|
|
419
|
+
};
|
|
420
|
+
}
|