@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,717 @@
|
|
|
1
|
+
import { buildCursorUserMessage } from './cursor-attachments.mjs';
|
|
2
|
+
import { buildCursorContextUsage } from './cursor-context-usage.mjs';
|
|
3
|
+
import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
|
|
4
|
+
import {
|
|
5
|
+
createCursorAgentHandle,
|
|
6
|
+
startCursorRun,
|
|
7
|
+
classifyCursorError,
|
|
8
|
+
isCursorAuthErrorMessage,
|
|
9
|
+
readModelContextWindow,
|
|
10
|
+
readAgentUsage,
|
|
11
|
+
resolveCursorReasoningParams,
|
|
12
|
+
} from './cursor-sdk-adapter.mjs';
|
|
13
|
+
import { createAskUserTool } from './cursor-ask-user-tool.mjs';
|
|
14
|
+
import { createPreviewTool } from './cursor-preview-tool.mjs';
|
|
15
|
+
import {
|
|
16
|
+
buildCursorSubagentAgents,
|
|
17
|
+
cursorSubagentRosterFingerprint,
|
|
18
|
+
} from './cursor-subagent-roster.mjs';
|
|
19
|
+
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
20
|
+
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
21
|
+
import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
|
|
22
|
+
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
23
|
+
|
|
24
|
+
// Stale-auth recovery budget per turn: expiry is routine on long-lived
|
|
25
|
+
// handles, and one retry proved too small in practice (an orchestrator turn
|
|
26
|
+
// re-authenticated once, then died terminally on the second expiry hours
|
|
27
|
+
// later).
|
|
28
|
+
const MAX_STALE_AUTH_RETRIES = 2;
|
|
29
|
+
// Recreate a handle proactively once it has sat idle past this window; the
|
|
30
|
+
// exchanged auth goes stale around the 4h mark while the API key stays valid.
|
|
31
|
+
const STALE_HANDLE_IDLE_MS = 30 * 60_000;
|
|
32
|
+
|
|
33
|
+
const PLAN_BOARD_ACTIONS = [
|
|
34
|
+
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
35
|
+
{ id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
|
|
36
|
+
{ id: 'exit_only', label: 'Stop here', mode: 'agent' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// The Cursor SDK's SendOptions carries only model/mode/callbacks — there is
|
|
40
|
+
// no per-turn instruction channel — so ask/autopilot steering rides on the
|
|
41
|
+
// user message text, mirroring the Copilot extension's prompt-context
|
|
42
|
+
// injection. Injected only when the mode changes so the session transcript
|
|
43
|
+
// is not spammed with repeated instructions.
|
|
44
|
+
const MODE_NUDGES = {
|
|
45
|
+
ask: '[Relay mode: ask] Prioritize clarification questions before implementation work; '
|
|
46
|
+
+ 'do not make broad assumptions when a question would materially change the result.',
|
|
47
|
+
autopilot: '[Relay mode: autopilot] Keep moving unless user input is truly blocking; avoid unnecessary questions.',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function cursorModeNudge(relayMode, previousMode = '') {
|
|
51
|
+
const mode = String(relayMode || 'agent').trim().toLowerCase();
|
|
52
|
+
const previous = String(previousMode || '').trim().toLowerCase();
|
|
53
|
+
if (mode === previous) return '';
|
|
54
|
+
if (MODE_NUDGES[mode]) return MODE_NUDGES[mode];
|
|
55
|
+
// Leaving a nudged mode must cancel the standing instruction; before any
|
|
56
|
+
// nudge was sent there is nothing to cancel.
|
|
57
|
+
if (MODE_NUDGES[previous]) return `[Relay mode: ${mode}] Previous relay-mode instructions no longer apply.`;
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Cursor has no exit-plan tool, so the only board source is the plan-mode
|
|
62
|
+
// text-shape fallback; the payload otherwise matches the Claude worker's.
|
|
63
|
+
export function buildCursorPlanReadyBoardPayload({
|
|
64
|
+
message,
|
|
65
|
+
planText = '',
|
|
66
|
+
source = 'plan-mode-fallback',
|
|
67
|
+
} = {}) {
|
|
68
|
+
const summary = String(planText || '').trim();
|
|
69
|
+
if (!summary) return null;
|
|
70
|
+
return {
|
|
71
|
+
queueId: message?.id,
|
|
72
|
+
messageId: message?.id,
|
|
73
|
+
conversationId: message?.conversationId,
|
|
74
|
+
mode: message?.relayMode || 'agent',
|
|
75
|
+
boardType: 'plan_ready',
|
|
76
|
+
title: 'Plan ready for review',
|
|
77
|
+
body: summary,
|
|
78
|
+
actions: PLAN_BOARD_ACTIONS,
|
|
79
|
+
recommendedAction: null,
|
|
80
|
+
context: {
|
|
81
|
+
source,
|
|
82
|
+
queueMessageId: message?.id || null,
|
|
83
|
+
conversationId: message?.conversationId || null,
|
|
84
|
+
relayMode: message?.relayMode || 'agent',
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Execute one delivered relay turn against the Cursor SDK, streaming
|
|
91
|
+
* normalized events into the relay's activity channels and publishing the
|
|
92
|
+
* final response. Mirrors the Claude worker's `handlePendingPayload`
|
|
93
|
+
* contract (response / requeue / abort semantics).
|
|
94
|
+
*/
|
|
95
|
+
export function createCursorTurnRunner({
|
|
96
|
+
api,
|
|
97
|
+
sdkSessionId,
|
|
98
|
+
cwd,
|
|
99
|
+
defaultModel = '',
|
|
100
|
+
apiKey = '',
|
|
101
|
+
storeDir = '',
|
|
102
|
+
controlPoller,
|
|
103
|
+
createAgentHandleImpl = createCursorAgentHandle,
|
|
104
|
+
startCursorRunImpl = startCursorRun,
|
|
105
|
+
classifyErrorImpl = classifyCursorError,
|
|
106
|
+
readContextWindowImpl = readModelContextWindow,
|
|
107
|
+
readAgentUsageImpl = readAgentUsage,
|
|
108
|
+
resolveModelParamsImpl = resolveCursorReasoningParams,
|
|
109
|
+
createNormalizerImpl = createSdkMessageNormalizer,
|
|
110
|
+
buildUserMessageImpl = buildCursorUserMessage,
|
|
111
|
+
dbg = () => {},
|
|
112
|
+
} = {}) {
|
|
113
|
+
// Unlike the Claude worker, the agent handle (and the custom tools bound
|
|
114
|
+
// into it) outlives a single turn, so the ask_user tool is built once here
|
|
115
|
+
// and reaches the active turn's state through these closures.
|
|
116
|
+
let agentHandle = null;
|
|
117
|
+
let cursorAgentId = '';
|
|
118
|
+
// The subagent roster is fixed when the handle is created, so the handle has
|
|
119
|
+
// to be rebuilt when the user's model selection changes mid-conversation.
|
|
120
|
+
let agentRosterFingerprint = null;
|
|
121
|
+
let activeMessage = null;
|
|
122
|
+
let waitingForTurn = false;
|
|
123
|
+
let currentAbortController = null;
|
|
124
|
+
// Handle staleness is the norm, not the exception (~4h idle expires the
|
|
125
|
+
// exchanged auth while the API key stays valid); recreate proactively after
|
|
126
|
+
// a long idle instead of paying a failed attempt to find out.
|
|
127
|
+
let agentHandleLastUsedAt = 0;
|
|
128
|
+
// Open ask_user round-trips: the merged-stream stall watchdog defers while
|
|
129
|
+
// a human is thinking about a question card.
|
|
130
|
+
let pendingAskUserCalls = 0;
|
|
131
|
+
// Mirrors the Copilot extension's lastPromptedRelayMode: full mode
|
|
132
|
+
// instructions ride on the first message of a mode and on mode changes only.
|
|
133
|
+
let lastNudgedRelayMode = '';
|
|
134
|
+
|
|
135
|
+
const bridge = createAskUserBridge({
|
|
136
|
+
api,
|
|
137
|
+
getActiveMessage: () => activeMessage,
|
|
138
|
+
sdkSessionId,
|
|
139
|
+
questionSource: 'ask_user',
|
|
140
|
+
questionRationale: 'Cursor requested clarification to continue this turn.',
|
|
141
|
+
dbg,
|
|
142
|
+
});
|
|
143
|
+
const askUserTool = createAskUserTool({
|
|
144
|
+
bridge,
|
|
145
|
+
getAbortSignal: () => currentAbortController?.signal || null,
|
|
146
|
+
dbg,
|
|
147
|
+
});
|
|
148
|
+
// Registered even when the relay's preview lane is disabled: the worker
|
|
149
|
+
// cannot see that configuration, and the API answers a disabled lane with a
|
|
150
|
+
// plain refusal the model can relay instead of a failed turn.
|
|
151
|
+
const previewTool = createPreviewTool({
|
|
152
|
+
api,
|
|
153
|
+
getConversationId: () => activeMessage?.conversationId || '',
|
|
154
|
+
dbg,
|
|
155
|
+
});
|
|
156
|
+
const customTools = {
|
|
157
|
+
ask_user: {
|
|
158
|
+
description: askUserTool.description,
|
|
159
|
+
inputSchema: askUserTool.inputSchema,
|
|
160
|
+
execute: async (...args) => {
|
|
161
|
+
pendingAskUserCalls += 1;
|
|
162
|
+
try {
|
|
163
|
+
return await askUserTool.execute(...args);
|
|
164
|
+
} finally {
|
|
165
|
+
pendingAskUserCalls -= 1;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
[previewTool.name]: {
|
|
170
|
+
description: previewTool.description,
|
|
171
|
+
inputSchema: previewTool.inputSchema,
|
|
172
|
+
execute: previewTool.execute,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function getActiveQueueMessageId() {
|
|
177
|
+
return waitingForTurn ? String(activeMessage?.id || '') : '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isTurnActive() {
|
|
181
|
+
return waitingForTurn;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function dispose() {
|
|
185
|
+
try {
|
|
186
|
+
await agentHandle?.close?.();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
dbg('cursor agent close failed on dispose', error?.message || String(error));
|
|
189
|
+
}
|
|
190
|
+
agentHandle = null;
|
|
191
|
+
agentRosterFingerprint = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function persistAgentId(message, agentId) {
|
|
195
|
+
const normalized = String(agentId || '').trim();
|
|
196
|
+
if (!normalized || normalized === cursorAgentId) return;
|
|
197
|
+
try {
|
|
198
|
+
await api('POST', '/api/cursor-agent-id', {
|
|
199
|
+
conversationId: message.conversationId,
|
|
200
|
+
cursorAgentId: normalized,
|
|
201
|
+
});
|
|
202
|
+
// Only cache after the server accepted it, so a failed persist is
|
|
203
|
+
// retried on the next turn — resume across worker restarts depends on
|
|
204
|
+
// the server-side copy.
|
|
205
|
+
cursorAgentId = normalized;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
dbg('cursor agent id persist failed', error?.message || String(error));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function ensureAgentHandle(message, model) {
|
|
212
|
+
const rosterModels = Array.isArray(message.cursorSubagentModels) ? message.cursorSubagentModels : [];
|
|
213
|
+
const rosterFingerprint = cursorSubagentRosterFingerprint(rosterModels);
|
|
214
|
+
if (
|
|
215
|
+
agentHandle
|
|
216
|
+
&& agentHandleLastUsedAt
|
|
217
|
+
&& Date.now() - agentHandleLastUsedAt > STALE_HANDLE_IDLE_MS
|
|
218
|
+
) {
|
|
219
|
+
dbg('cursor agent handle idle past the staleness window, recreating proactively');
|
|
220
|
+
try {
|
|
221
|
+
await agentHandle.close?.();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
dbg('cursor agent close failed on staleness refresh', error?.message || String(error));
|
|
224
|
+
}
|
|
225
|
+
agentHandle = null;
|
|
226
|
+
}
|
|
227
|
+
if (agentHandle && agentRosterFingerprint !== rosterFingerprint) {
|
|
228
|
+
// Closing drops only the local handle; the durable Cursor agent id is
|
|
229
|
+
// persisted, so the rebuild below resumes the same conversation with the
|
|
230
|
+
// new roster rather than starting a fresh one.
|
|
231
|
+
dbg('cursor subagent roster changed, rebuilding agent handle');
|
|
232
|
+
try {
|
|
233
|
+
await agentHandle.close?.();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
dbg('cursor agent close failed on roster change', error?.message || String(error));
|
|
236
|
+
}
|
|
237
|
+
agentHandle = null;
|
|
238
|
+
}
|
|
239
|
+
if (!agentHandle) {
|
|
240
|
+
const durableId = String(message.cursorAgentId || '').trim() || cursorAgentId;
|
|
241
|
+
const agents = buildCursorSubagentAgents(rosterModels);
|
|
242
|
+
const options = { apiKey, model, cwd, storeDir, sdkSessionId, customTools, agents, dbg };
|
|
243
|
+
try {
|
|
244
|
+
agentHandle = await createAgentHandleImpl({ ...options, agentId: durableId });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!durableId) throw error;
|
|
247
|
+
// The durable agent may have been pruned server-side; fall back to a
|
|
248
|
+
// fresh agent rather than wedging the conversation.
|
|
249
|
+
dbg('cursor agent resume failed, creating fresh agent', error?.message || String(error));
|
|
250
|
+
agentHandle = await createAgentHandleImpl({ ...options, agentId: '' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Only recorded once a handle exists: a create that threw leaves the
|
|
254
|
+
// fingerprint stale, and the null handle makes the next turn rebuild.
|
|
255
|
+
agentRosterFingerprint = rosterFingerprint;
|
|
256
|
+
// Runs even when the handle is reused, so a persist that failed on an
|
|
257
|
+
// earlier turn is retried here.
|
|
258
|
+
if (agentHandle.agentId && agentHandle.agentId !== cursorAgentId) {
|
|
259
|
+
await persistAgentId(message, agentHandle.agentId);
|
|
260
|
+
}
|
|
261
|
+
return agentHandle;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function postActivity(message, text, subagentRunId = null) {
|
|
265
|
+
if (!text) return;
|
|
266
|
+
await api('POST', '/api/activity', {
|
|
267
|
+
messageId: message.id,
|
|
268
|
+
conversationId: message.conversationId,
|
|
269
|
+
mode: message.relayMode || 'agent',
|
|
270
|
+
text,
|
|
271
|
+
...(subagentRunId ? { subagentRunId } : {}),
|
|
272
|
+
}).catch(() => {});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function dispatchAction(message, action, state, normalizer) {
|
|
276
|
+
const { channel, payload } = action;
|
|
277
|
+
if (channel === 'init') {
|
|
278
|
+
// The durable agent id is persisted at handle creation, not from init.
|
|
279
|
+
state.responseModel = payload.model || state.responseModel;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (channel === 'stream') {
|
|
283
|
+
// Only the main thread's text can stand in for the answer: subagent
|
|
284
|
+
// text landing here would publish a subagent's prose as the reply on
|
|
285
|
+
// the abort / error / no-result fallback paths below.
|
|
286
|
+
if (!payload.subagentRunId) state.lastStreamedText = payload.text;
|
|
287
|
+
await api('POST', '/api/stream', {
|
|
288
|
+
messageId: message.id,
|
|
289
|
+
conversationId: message.conversationId,
|
|
290
|
+
mode: message.relayMode || 'agent',
|
|
291
|
+
text: payload.text,
|
|
292
|
+
done: payload.done === true,
|
|
293
|
+
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
294
|
+
}).catch(() => {});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (channel === 'thought') {
|
|
298
|
+
await api('POST', '/api/thought', {
|
|
299
|
+
messageId: message.id,
|
|
300
|
+
conversationId: message.conversationId,
|
|
301
|
+
mode: message.relayMode || 'agent',
|
|
302
|
+
reasoningId: payload.reasoningId,
|
|
303
|
+
text: payload.text,
|
|
304
|
+
done: payload.done === true,
|
|
305
|
+
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
306
|
+
}).catch(() => {});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (channel === 'activity') {
|
|
310
|
+
await postActivity(message, payload.text, payload.subagentRunId);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (channel === 'subagent') {
|
|
314
|
+
await api('POST', '/api/subagent-run', {
|
|
315
|
+
messageId: message.id,
|
|
316
|
+
conversationId: message.conversationId,
|
|
317
|
+
subagentRunId: payload.subagentRunId,
|
|
318
|
+
parentSubagentId: payload.parentSubagentId || undefined,
|
|
319
|
+
displayName: payload.displayName || undefined,
|
|
320
|
+
status: payload.status,
|
|
321
|
+
}).catch(() => {});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (channel === 'result') {
|
|
325
|
+
state.result = payload;
|
|
326
|
+
state.lastUsage = payload.usage || state.lastUsage;
|
|
327
|
+
state.responseModel = state.responseModel || normalizer.model || '';
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Ship the turn's token usage to the relay as a context-window breakdown.
|
|
333
|
+
* Advisory data: a failure here must not disturb the turn's response.
|
|
334
|
+
*/
|
|
335
|
+
async function publishContextUsage(message, state, model, normalizer) {
|
|
336
|
+
// The result payload is the primary carrier, but a run can end without a
|
|
337
|
+
// terminal status message (or get aborted); the normalizer still saw the
|
|
338
|
+
// turn's usage events, so fall back to its copy rather than dropping them.
|
|
339
|
+
const usage = state.lastUsage || normalizer?.lastUsage || null;
|
|
340
|
+
if (!usage) return;
|
|
341
|
+
const usageModel = state.responseModel || model || '';
|
|
342
|
+
// Provider-advertised window first; the shared static table covers models
|
|
343
|
+
// whose `models.list()` entry has no `context` parameter, so the gauge is
|
|
344
|
+
// not totals-only for them.
|
|
345
|
+
const contextWindow = (await readContextWindowImpl({ apiKey, model: usageModel, dbg })
|
|
346
|
+
.catch(() => null))
|
|
347
|
+
?? resolveFallbackContextLimitTokens(usageModel);
|
|
348
|
+
const built = buildCursorContextUsage({
|
|
349
|
+
usage,
|
|
350
|
+
model: usageModel,
|
|
351
|
+
contextWindow,
|
|
352
|
+
modelCallCount: normalizer?.modelCallCount,
|
|
353
|
+
});
|
|
354
|
+
if (!built) return;
|
|
355
|
+
await api('POST', '/api/cursor-context-usage', {
|
|
356
|
+
conversationId: message.conversationId,
|
|
357
|
+
sdkSessionId,
|
|
358
|
+
model: usageModel,
|
|
359
|
+
contextUsage: built.contextUsage,
|
|
360
|
+
modelUsage: built.modelUsage,
|
|
361
|
+
}).catch((error) => {
|
|
362
|
+
dbg('context usage publish failed', error?.message || String(error));
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Report the agent's cumulative billed usage so the relay can book this
|
|
368
|
+
* turn's increase against the configured monthly allowance. The model is
|
|
369
|
+
* carried along because it is the only signal available for deciding which
|
|
370
|
+
* Cursor billing pool the spend belongs to — the SDK's per-run entries do
|
|
371
|
+
* not identify a model.
|
|
372
|
+
*/
|
|
373
|
+
async function publishPlanUsage(message, state, model) {
|
|
374
|
+
if (!agentHandle?.agent || !agentHandle.agentId) return;
|
|
375
|
+
const usage = await readAgentUsageImpl({ agent: agentHandle.agent, dbg });
|
|
376
|
+
if (!usage) return;
|
|
377
|
+
await api('POST', '/api/cursor-plan-usage', {
|
|
378
|
+
conversationId: message.conversationId,
|
|
379
|
+
agentId: agentHandle.agentId,
|
|
380
|
+
model: state.responseModel || model || '',
|
|
381
|
+
// Lets the ledger tell a brand-new agent (book its lifetime — it IS
|
|
382
|
+
// this conversation) from one resumed without a checkpoint (seed the
|
|
383
|
+
// baseline; booking would dump historic spend into today's cycle).
|
|
384
|
+
agentCreated: agentHandle.created === true,
|
|
385
|
+
...usage,
|
|
386
|
+
capturedAt: new Date().toISOString(),
|
|
387
|
+
}).catch((error) => {
|
|
388
|
+
dbg('plan usage publish failed', error?.message || String(error));
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function publishFinalStream(message, text) {
|
|
393
|
+
await api('POST', '/api/stream', {
|
|
394
|
+
messageId: message.id,
|
|
395
|
+
conversationId: message.conversationId,
|
|
396
|
+
mode: message.relayMode || 'agent',
|
|
397
|
+
text: String(text || ''),
|
|
398
|
+
done: true,
|
|
399
|
+
}).catch(() => {});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
|
|
403
|
+
await api('POST', '/api/response', {
|
|
404
|
+
messageId: message.id,
|
|
405
|
+
conversationId: message.conversationId,
|
|
406
|
+
text: String(text || ''),
|
|
407
|
+
model: model || null,
|
|
408
|
+
modelOrigin: modelOrigin
|
|
409
|
+
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
410
|
+
...(terminalError ? { terminalError } : {}),
|
|
411
|
+
}).catch(async () => {
|
|
412
|
+
await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function runTurn(message) {
|
|
417
|
+
currentAbortController = new AbortController();
|
|
418
|
+
// `let`: the auth-retry path burns the old signal to unblock a parked
|
|
419
|
+
// ask_user wait, then arms a fresh one for the retry attempt.
|
|
420
|
+
let abortController = currentAbortController;
|
|
421
|
+
let normalizer = createNormalizerImpl();
|
|
422
|
+
const state = {
|
|
423
|
+
result: null,
|
|
424
|
+
lastStreamedText: '',
|
|
425
|
+
responseModel: '',
|
|
426
|
+
lastUsage: null,
|
|
427
|
+
};
|
|
428
|
+
let aborted = false;
|
|
429
|
+
let planBoardPosted = false;
|
|
430
|
+
|
|
431
|
+
// Per-message model wins so the composer can switch models between turns.
|
|
432
|
+
// Cursor model ids are unprefixed, so any non-auto value is accepted.
|
|
433
|
+
const requestedModel = String(message.model || '').trim();
|
|
434
|
+
const perTurnModel = requestedModel && requestedModel.toLowerCase() !== 'auto'
|
|
435
|
+
? requestedModel
|
|
436
|
+
: '';
|
|
437
|
+
const model = perTurnModel
|
|
438
|
+
|| String(message.providerModel || '').trim()
|
|
439
|
+
|| defaultModel;
|
|
440
|
+
|
|
441
|
+
let turn = null;
|
|
442
|
+
// Started before agent/params setup: Agent.resume and the model-params
|
|
443
|
+
// lookup can take a while (or hang on a slow API), and the user's Stop
|
|
444
|
+
// must not be inert during that window. The whole setup lives inside the
|
|
445
|
+
// try so the finally can never leak a running poller.
|
|
446
|
+
const controlState = controlPoller?.start?.({
|
|
447
|
+
queueMessageId: message.id,
|
|
448
|
+
onAbortTurn: async () => {
|
|
449
|
+
aborted = true;
|
|
450
|
+
abortController.abort();
|
|
451
|
+
await turn?.cancel?.();
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
await ensureAgentHandle(message, model);
|
|
457
|
+
const userMessage = buildUserMessageImpl(message);
|
|
458
|
+
const modeNudge = cursorModeNudge(message.relayMode, lastNudgedRelayMode);
|
|
459
|
+
if (modeNudge) userMessage.text = [modeNudge, userMessage.text].filter(Boolean).join('\n\n');
|
|
460
|
+
// Committed only once the send reached the transport (before the loop's
|
|
461
|
+
// break below) — a turn that dies before delivery must re-inject the
|
|
462
|
+
// nudge next time, or a mode change would be silently swallowed.
|
|
463
|
+
const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
|
|
464
|
+
// Per-turn like the model: the composer's effort maps onto Cursor model
|
|
465
|
+
// params, and null (no mapping / lookup failure) sends the model default.
|
|
466
|
+
const modelParams = await resolveModelParamsImpl({
|
|
467
|
+
apiKey,
|
|
468
|
+
model,
|
|
469
|
+
reasoningEffort: message.reasoningEffort || '',
|
|
470
|
+
dbg,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
let busyRetries = 0;
|
|
474
|
+
let staleAuthRetries = 0;
|
|
475
|
+
let forceNextSend = false;
|
|
476
|
+
|
|
477
|
+
// A stale handle's auth expiry is the norm on long conversations
|
|
478
|
+
// (~4h idle), so the budget is 2 recreate-and-retry attempts with a
|
|
479
|
+
// short backoff before the failure goes terminal. Shared by the
|
|
480
|
+
// result-shaped and the thrown flavor of the same failure.
|
|
481
|
+
const recreateHandleForAuthRetry = async (flavor) => {
|
|
482
|
+
staleAuthRetries += 1;
|
|
483
|
+
dbg(`cursor auth error (${flavor}), recreating handle for retry ${staleAuthRetries}/${MAX_STALE_AUTH_RETRIES}`);
|
|
484
|
+
// A question card parked on the dead attempt would poll forever:
|
|
485
|
+
// burn the old signal to settle its wait, then arm a fresh one for
|
|
486
|
+
// the retry.
|
|
487
|
+
try { abortController.abort(); } catch {}
|
|
488
|
+
abortController = new AbortController();
|
|
489
|
+
currentAbortController = abortController;
|
|
490
|
+
// Subagent runs the failed attempt already announced can never be
|
|
491
|
+
// terminated by the fresh normalizer (it has no memory of them), so
|
|
492
|
+
// close their relay rows out now instead of leaving them 'running'.
|
|
493
|
+
for (const run of normalizer?.activeSubagentRuns?.() || []) {
|
|
494
|
+
await dispatchAction(message, {
|
|
495
|
+
channel: 'subagent',
|
|
496
|
+
payload: { ...run, parentSubagentId: null, status: 'failed' },
|
|
497
|
+
}, state, normalizer);
|
|
498
|
+
}
|
|
499
|
+
await agentHandle?.close?.();
|
|
500
|
+
agentHandle = null;
|
|
501
|
+
await ensureAgentHandle(message, model);
|
|
502
|
+
normalizer = createNormalizerImpl();
|
|
503
|
+
state.result = null;
|
|
504
|
+
state.lastStreamedText = '';
|
|
505
|
+
state.responseModel = '';
|
|
506
|
+
state.lastUsage = null;
|
|
507
|
+
// The failed attempt already streamed partial text to the relay;
|
|
508
|
+
// clear it and leave a trace so the retry doesn't read as the
|
|
509
|
+
// same turn silently rewriting itself.
|
|
510
|
+
await publishFinalStream(message, '');
|
|
511
|
+
await postActivity(message, 'Cursor session re-authenticated; retrying this message on a fresh agent handle.');
|
|
512
|
+
await new Promise((resolve) => setTimeout(resolve, 400 * staleAuthRetries));
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
while (true) {
|
|
516
|
+
try {
|
|
517
|
+
turn = startCursorRunImpl({
|
|
518
|
+
agent: agentHandle.agent,
|
|
519
|
+
message: userMessage,
|
|
520
|
+
model,
|
|
521
|
+
modelParams,
|
|
522
|
+
relayMode: message.relayMode,
|
|
523
|
+
abortSignal: abortController.signal,
|
|
524
|
+
// The SDK's documented recovery for a wedged persisted run —
|
|
525
|
+
// armed by the second busy retry below.
|
|
526
|
+
sendLocal: forceNextSend ? { force: true } : null,
|
|
527
|
+
hasPendingClientWork: () => pendingAskUserCalls > 0,
|
|
528
|
+
dbg,
|
|
529
|
+
});
|
|
530
|
+
forceNextSend = false;
|
|
531
|
+
for await (const event of turn) {
|
|
532
|
+
const actions = normalizer.normalize(event);
|
|
533
|
+
for (const action of actions) {
|
|
534
|
+
await dispatchAction(message, action, state, normalizer);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// A long-lived handle whose exchanged auth expired fails as a
|
|
538
|
+
// terminal ERROR result, not a thrown AuthenticationError, and the
|
|
539
|
+
// API key itself is usually still valid — so recreate the handle
|
|
540
|
+
// and retry. Exhausting the budget falls through to the isError
|
|
541
|
+
// publish below.
|
|
542
|
+
if (
|
|
543
|
+
staleAuthRetries < MAX_STALE_AUTH_RETRIES
|
|
544
|
+
&& !aborted
|
|
545
|
+
&& !abortController.signal.aborted
|
|
546
|
+
&& state.result?.isError
|
|
547
|
+
// Classify on the SDK status message only: result.text is the
|
|
548
|
+
// model's own prose and mentioning "invalid api key" in an answer
|
|
549
|
+
// must never trigger a silent re-run.
|
|
550
|
+
&& isCursorAuthErrorMessage(state.result.errorMessage)
|
|
551
|
+
) {
|
|
552
|
+
await recreateHandleForAuthRetry('result');
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
// An abort can end the iterator cleanly before the send ever
|
|
556
|
+
// reached the transport; committing the nudge then would swallow a
|
|
557
|
+
// mode change. Re-injecting on a delivered-then-aborted turn is a
|
|
558
|
+
// harmless duplicate, so under-committing is the safe direction.
|
|
559
|
+
if (!aborted && !abortController.signal.aborted) {
|
|
560
|
+
lastNudgedRelayMode = pendingNudgedRelayMode;
|
|
561
|
+
}
|
|
562
|
+
break;
|
|
563
|
+
} catch (error) {
|
|
564
|
+
const classified = classifyErrorImpl(error);
|
|
565
|
+
// A stale handle whose previous run never settled server-side
|
|
566
|
+
// reports busy; one close-and-resume usually clears it, and the
|
|
567
|
+
// second attempt additionally expires the wedged persisted run via
|
|
568
|
+
// LocalSendOptions.force. A third busy is terminal.
|
|
569
|
+
if (busyRetries < 2 && classified.isBusy) {
|
|
570
|
+
busyRetries += 1;
|
|
571
|
+
forceNextSend = busyRetries >= 2;
|
|
572
|
+
dbg(`cursor agent busy, recreating handle for retry ${busyRetries}/2${forceNextSend ? ' (forcing run expiry)' : ''}`);
|
|
573
|
+
await agentHandle?.close?.();
|
|
574
|
+
agentHandle = null;
|
|
575
|
+
await ensureAgentHandle(message, model);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
// The thrown flavor of the same stale-auth failure (an
|
|
579
|
+
// AuthenticationError from agent.send or from the handle rebuild)
|
|
580
|
+
// deserves the same recreate-and-retry as the result-shaped one.
|
|
581
|
+
if (
|
|
582
|
+
staleAuthRetries < MAX_STALE_AUTH_RETRIES
|
|
583
|
+
&& !aborted
|
|
584
|
+
&& !abortController.signal.aborted
|
|
585
|
+
&& classified.isAuth
|
|
586
|
+
) {
|
|
587
|
+
await recreateHandleForAuthRetry('thrown');
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (aborted || abortController.signal.aborted) {
|
|
595
|
+
dbg('turn aborted', message.id);
|
|
596
|
+
await publishFinalStream(message, state.lastStreamedText);
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
throw error;
|
|
600
|
+
} finally {
|
|
601
|
+
controlPoller?.stop?.(controlState);
|
|
602
|
+
agentHandleLastUsedAt = Date.now();
|
|
603
|
+
// Runs on every exit path so usage captured before the turn went
|
|
604
|
+
// sideways still reaches the relay. `normalizer` is the live instance
|
|
605
|
+
// (the auth retry swaps it), so a discarded attempt's usage is not
|
|
606
|
+
// republished over the retry's.
|
|
607
|
+
await publishContextUsage(message, state, model, normalizer);
|
|
608
|
+
await publishPlanUsage(message, state, model);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (aborted) {
|
|
612
|
+
await publishFinalStream(message, state.lastStreamedText);
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const result = state.result;
|
|
617
|
+
const responseModel = state.responseModel || model || null;
|
|
618
|
+
if (!result) {
|
|
619
|
+
// The run ended without a terminal status message; surface what streamed.
|
|
620
|
+
const fallbackText = String(state.lastStreamedText || normalizer.finalStreamText() || '').trim();
|
|
621
|
+
if (fallbackText) {
|
|
622
|
+
await publishFinalStream(message, fallbackText);
|
|
623
|
+
await publishResponse(message, { text: fallbackText, model: responseModel });
|
|
624
|
+
} else {
|
|
625
|
+
await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (result.isError) {
|
|
631
|
+
// Reaching here with an auth error means the fresh-handle retry above
|
|
632
|
+
// already failed too, so the key itself needs renewing. Only the SDK
|
|
633
|
+
// status message is auth-classified — result.text is model prose.
|
|
634
|
+
const authMessage = String(result.errorMessage || '').trim();
|
|
635
|
+
const isAuthFailure = isCursorAuthErrorMessage(authMessage);
|
|
636
|
+
const errorText = isAuthFailure
|
|
637
|
+
? `System note: the Cursor runtime could not authenticate (${authMessage}). Set or renew the Cursor API key in provider settings, then retry.`
|
|
638
|
+
: result.text || `Cursor turn failed (${result.subtype || 'unknown error'}).`;
|
|
639
|
+
const code = isAuthFailure ? 'authentication_failed' : result.subtype || 'unknown';
|
|
640
|
+
await publishFinalStream(message, state.lastStreamedText);
|
|
641
|
+
await publishResponse(message, {
|
|
642
|
+
text: errorText,
|
|
643
|
+
model: responseModel,
|
|
644
|
+
terminalError: {
|
|
645
|
+
kind: 'cursor-turn-failed',
|
|
646
|
+
code,
|
|
647
|
+
stableCode: `cursor.${code}`,
|
|
648
|
+
message: errorText,
|
|
649
|
+
failedAt: new Date().toISOString(),
|
|
650
|
+
queueMessageId: String(message.id || '') || null,
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const finalText = String(result.text || state.lastStreamedText || '').trim();
|
|
657
|
+
if (
|
|
658
|
+
!planBoardPosted
|
|
659
|
+
&& String(message.relayMode || '').trim().toLowerCase() === 'plan'
|
|
660
|
+
&& countPlanLikeLines(finalText) >= 2
|
|
661
|
+
) {
|
|
662
|
+
const boardPayload = buildCursorPlanReadyBoardPayload({ message, planText: finalText });
|
|
663
|
+
if (boardPayload) {
|
|
664
|
+
await api('POST', '/api/relay-board', boardPayload).catch(() => {});
|
|
665
|
+
planBoardPosted = true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// A terminal, non-error result carrying no prose is a COMPLETED turn — not
|
|
669
|
+
// a failed delivery — so it publishes rather than requeues. The requeue
|
|
670
|
+
// that used to live here re-ran a turn whose emptiness was deterministic,
|
|
671
|
+
// re-billing the provider and re-spawning its subagents on every attempt
|
|
672
|
+
// before the retry cap failed the message with a "Relay timeout" that
|
|
673
|
+
// misreported a turn which had succeeded.
|
|
674
|
+
const publishedText = finalText || EMPTY_TURN_COMPLETION_NOTE;
|
|
675
|
+
await publishFinalStream(message, publishedText);
|
|
676
|
+
await publishResponse(message, { text: publishedText, model: responseModel });
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function handlePendingPayload(pending) {
|
|
681
|
+
const message = pending?.message || null;
|
|
682
|
+
if (!message) return false;
|
|
683
|
+
activeMessage = message;
|
|
684
|
+
waitingForTurn = true;
|
|
685
|
+
try {
|
|
686
|
+
return await runTurn(message);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
const classified = classifyErrorImpl(error);
|
|
689
|
+
dbg('cursor turn failed', message.id, classified.message);
|
|
690
|
+
await publishResponse(message, {
|
|
691
|
+
text: classified.isAuth
|
|
692
|
+
? `System note: the Cursor runtime could not authenticate (${classified.message}). Set or renew the Cursor API key in provider settings, then retry.`
|
|
693
|
+
: `System note: the Cursor turn failed (${classified.message}).`,
|
|
694
|
+
model: null,
|
|
695
|
+
terminalError: {
|
|
696
|
+
kind: 'cursor-turn-failed',
|
|
697
|
+
code: classified.code,
|
|
698
|
+
stableCode: classified.stableCode,
|
|
699
|
+
message: classified.message,
|
|
700
|
+
failedAt: new Date().toISOString(),
|
|
701
|
+
queueMessageId: String(message.id || '') || null,
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
return true;
|
|
705
|
+
} finally {
|
|
706
|
+
waitingForTurn = false;
|
|
707
|
+
activeMessage = null;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
handlePendingPayload,
|
|
713
|
+
getActiveQueueMessageId,
|
|
714
|
+
isTurnActive,
|
|
715
|
+
dispose,
|
|
716
|
+
};
|
|
717
|
+
}
|