@oar-sh/oar 0.9.5 → 0.9.7
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/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Cloud session worker: a per-conversation Node process that speaks
|
|
3
|
+
// the same relay contracts as the Claude/Cursor/Grok workers, but runs no
|
|
4
|
+
// agent itself. The agent and its clone live in a Claude Code cloud session;
|
|
5
|
+
// this process sends the user's messages there and follows the session's
|
|
6
|
+
// event stream (claude-cloud-session-process.mjs).
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
loadTokenFromConfig,
|
|
12
|
+
resolveRelayServerUrl,
|
|
13
|
+
} from '../../shared/worker-runtime/config-loader.mjs';
|
|
14
|
+
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
15
|
+
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
16
|
+
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
17
|
+
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
18
|
+
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
19
|
+
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
20
|
+
import { createClaudeCloudCredentials } from '../../shared/claude-cloud/credentials.mjs';
|
|
21
|
+
import { createClaudeCloudClient } from '../../shared/claude-cloud/api-client.mjs';
|
|
22
|
+
import { resolveClaudeCloudBaseUrl } from '../../shared/claude-cloud/base-url.mjs';
|
|
23
|
+
import { createClaudeCloudSessionRunner } from './claude-cloud-session-process.mjs';
|
|
24
|
+
|
|
25
|
+
// Windows console launch: the launcher names the worker log, this copies
|
|
26
|
+
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
27
|
+
installWorkerLogFile();
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const HEARTBEAT_MS = 10_000;
|
|
31
|
+
|
|
32
|
+
function parseSessionIdArg(argv = process.argv) {
|
|
33
|
+
const index = argv.indexOf('--session-id');
|
|
34
|
+
if (index !== -1 && argv[index + 1]) return String(argv[index + 1]).trim();
|
|
35
|
+
const inline = argv.find((arg) => String(arg || '').startsWith('--session-id='));
|
|
36
|
+
if (inline) return String(inline.split('=')[1] || '').trim();
|
|
37
|
+
return String(process.env.SESSION_ID || '').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dbg(...parts) {
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
console.log(`[claude-cloud-worker ${timestamp}]`, ...parts);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
const sdkSessionId = parseSessionIdArg();
|
|
47
|
+
if (!sdkSessionId) {
|
|
48
|
+
console.error('claude-cloud-session-worker: missing --session-id');
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
|
|
53
|
+
|| path.resolve(__dirname, '..', 'config.json');
|
|
54
|
+
const serverUrl = resolveRelayServerUrl({ configPath });
|
|
55
|
+
const token = loadTokenFromConfig(configPath);
|
|
56
|
+
const defaultModel = String(process.env.CLAUDE_CLOUD_RELAY_MODEL || '').trim();
|
|
57
|
+
|
|
58
|
+
const api = createApiClient({
|
|
59
|
+
serverUrl,
|
|
60
|
+
token,
|
|
61
|
+
getHeaders: () => ({
|
|
62
|
+
'X-Relay-Process-Pid': String(process.pid),
|
|
63
|
+
'X-Relay-Parent-Pid': String(process.ppid),
|
|
64
|
+
'X-Relay-Session-Id': sdkSessionId,
|
|
65
|
+
'X-Relay-Conversation-Id': sdkSessionId,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The Claude CLI's login, read by the client's own module (the credentials
|
|
70
|
+
// file, or CLAUDE_CODE_OAUTH_TOKEN where the relay runs on one): this
|
|
71
|
+
// process never logs it. A login that has run out is nudged through the
|
|
72
|
+
// relay, which can run the CLI (`claude auth status` refreshes a login
|
|
73
|
+
// whose refresh token is still good); the file is read again afterwards.
|
|
74
|
+
// The base URL is the Anthropic API unless a test names a fake one on this
|
|
75
|
+
// machine (base-url.mjs takes nothing else).
|
|
76
|
+
const cloud = createClaudeCloudClient({
|
|
77
|
+
credentials: createClaudeCloudCredentials({
|
|
78
|
+
nudge: async () => {
|
|
79
|
+
const answer = await api('POST', '/api/claude-cloud/login-nudge', {});
|
|
80
|
+
dbg('login nudge', answer?.nudged ? 'ran' : 'skipped', answer?.expired ? 'still expired' : `expires ${answer?.expiresAt || '?'}`);
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
baseUrl: resolveClaudeCloudBaseUrl(process.env),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const controlPoller = createControlPoller({
|
|
87
|
+
api,
|
|
88
|
+
sdkSessionId,
|
|
89
|
+
abortAckNote: 'cloud turn interrupted',
|
|
90
|
+
dbg,
|
|
91
|
+
});
|
|
92
|
+
const runner = createClaudeCloudSessionRunner({
|
|
93
|
+
api,
|
|
94
|
+
cloud,
|
|
95
|
+
sdkSessionId,
|
|
96
|
+
defaultModel,
|
|
97
|
+
controlPoller,
|
|
98
|
+
dbg,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let heartbeatTimer = null;
|
|
102
|
+
const heartbeat = createHeartbeatController({
|
|
103
|
+
api,
|
|
104
|
+
pollMs: HEARTBEAT_MS,
|
|
105
|
+
getSessionReady: () => true,
|
|
106
|
+
getHeartbeatTimer: () => heartbeatTimer,
|
|
107
|
+
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
108
|
+
getActiveQueueMessageId: () => runner.getActiveQueueMessageId(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const wsLink = createWorkerWebSocketLink({
|
|
112
|
+
serverUrl,
|
|
113
|
+
token,
|
|
114
|
+
dbg,
|
|
115
|
+
getSessionReady: () => true,
|
|
116
|
+
getSessionId: () => sdkSessionId,
|
|
117
|
+
getPid: () => process.pid,
|
|
118
|
+
onDeliver: async (pending, reason) => {
|
|
119
|
+
dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
|
|
120
|
+
try {
|
|
121
|
+
return await runner.handlePendingPayload(pending);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
dbg('turn handling failed', error?.message || String(error));
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const shutdown = async (signal) => {
|
|
130
|
+
dbg(`shutting down (${signal})`);
|
|
131
|
+
try { wsLink.stop(); } catch {}
|
|
132
|
+
try { heartbeat.stopHeartbeat(); } catch {}
|
|
133
|
+
try { controlPoller.stop(); } catch {}
|
|
134
|
+
// The cloud session is left as it is: a turn in flight keeps running
|
|
135
|
+
// there, and the next worker finds it through the stored sequence number.
|
|
136
|
+
try { await runner.dispose(); } catch {}
|
|
137
|
+
process.exit(0);
|
|
138
|
+
};
|
|
139
|
+
process.on('SIGTERM', () => { shutdown('SIGTERM'); });
|
|
140
|
+
process.on('SIGINT', () => { shutdown('SIGINT'); });
|
|
141
|
+
installWorkerCrashGuard({
|
|
142
|
+
api,
|
|
143
|
+
workerName: 'claude-cloud-session-worker',
|
|
144
|
+
getActiveQueueMessageIds: () => [runner.getActiveQueueAttempt()],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'}`);
|
|
148
|
+
heartbeat.startHeartbeat();
|
|
149
|
+
wsLink.start();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
main().catch((error) => {
|
|
153
|
+
console.error('claude-cloud-session-worker fatal:', error?.stack || error?.message || error);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
});
|
|
@@ -62,6 +62,8 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
|
|
|
62
62
|
provider: choice('provider', REMOTE_RELAY_PROVIDERS),
|
|
63
63
|
model: text('model'),
|
|
64
64
|
cwd: text('cwd'),
|
|
65
|
+
repo: text('repo'),
|
|
66
|
+
branch: text('branch'),
|
|
65
67
|
mode: choice('mode', REMOTE_RELAY_MODES),
|
|
66
68
|
effort: text('effort'),
|
|
67
69
|
title: text('title'),
|
|
@@ -111,7 +111,7 @@ export function normalizeAutoCompactWindow(value) {
|
|
|
111
111
|
* spreads of `settings` in the options literal would silently clobber each
|
|
112
112
|
* other. Returns null when nothing needs to be set.
|
|
113
113
|
*/
|
|
114
|
-
export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = null, thinkingEnabled = null } = {}) {
|
|
114
|
+
export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = null, thinkingEnabled = null, attribution = null } = {}) {
|
|
115
115
|
const window = normalizeAutoCompactWindow(autoCompactWindow);
|
|
116
116
|
const thinking = strictThinkingEnabled(thinkingEnabled);
|
|
117
117
|
const settings = {
|
|
@@ -122,10 +122,25 @@ export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = nul
|
|
|
122
122
|
// present; the omit path exists for direct callers that mean to say
|
|
123
123
|
// nothing.
|
|
124
124
|
...(thinking !== null ? { alwaysThinkingEnabled: thinking } : {}),
|
|
125
|
+
// What commits and PRs say (shared/claude-attribution.mjs). null is
|
|
126
|
+
// vanilla: the key is left out and the CLI uses its own.
|
|
127
|
+
...(attribution && typeof attribution === 'object' ? { attribution } : {}),
|
|
125
128
|
};
|
|
126
129
|
return Object.keys(settings).length ? settings : null;
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Live twin of the attribution spawn setting, for a value (OAR or off). A
|
|
134
|
+
* running CLI takes a new text through the flag layer (it arrives as a fresh
|
|
135
|
+
* system-reminder on the next user turn) but cannot UNSET a value it was
|
|
136
|
+
* spawned with — `{}`, `null` and `true` all leave the spawn-time text in
|
|
137
|
+
* force (wire-logged probe, 2026-10-01). Back to vanilla therefore means a
|
|
138
|
+
* fresh process without the key; the session runner does that.
|
|
139
|
+
*/
|
|
140
|
+
export function claudeAttributionFlagSettings(attribution) {
|
|
141
|
+
return { attribution: attribution && typeof attribution === 'object' ? attribution : { commit: '', pr: '', sessionUrl: false } };
|
|
142
|
+
}
|
|
143
|
+
|
|
129
144
|
export function permissionModeForRelayMode(relayMode) {
|
|
130
145
|
const mode = String(relayMode || 'agent').trim().toLowerCase();
|
|
131
146
|
if (mode === 'plan') return 'plan';
|
|
@@ -228,6 +243,7 @@ export function startClaudeSession({
|
|
|
228
243
|
reasoningEffort = '',
|
|
229
244
|
autoCompactWindow = null,
|
|
230
245
|
thinkingEnabled = null,
|
|
246
|
+
attribution = null,
|
|
231
247
|
thinkingDisplay = '',
|
|
232
248
|
abortController,
|
|
233
249
|
canUseTool,
|
|
@@ -248,7 +264,7 @@ export function startClaudeSession({
|
|
|
248
264
|
// only way to hand a fresh CLI the session-scoped ultracode flag, the
|
|
249
265
|
// auto-compact window and the thinking pin, and all three share one
|
|
250
266
|
// `settings` object.
|
|
251
|
-
const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled });
|
|
267
|
+
const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled, attribution });
|
|
252
268
|
// Relay tools need the worker's authenticated API helper; a caller that
|
|
253
269
|
// passes none (tests, probes) gets a session without them.
|
|
254
270
|
const relayMcpServer = typeof api === 'function'
|
|
@@ -21,12 +21,14 @@ import {
|
|
|
21
21
|
normalizeClaudeEffort,
|
|
22
22
|
claudeUltracodeFlagSettings,
|
|
23
23
|
claudeAutoCompactFlagSettings,
|
|
24
|
+
claudeAttributionFlagSettings,
|
|
24
25
|
claudeThinkingFlagSettings,
|
|
25
26
|
applyThinkingDisplay,
|
|
26
27
|
normalizeAutoCompactWindow,
|
|
27
28
|
permissionModeForRelayMode,
|
|
28
29
|
} from './claude-sdk-adapter.mjs';
|
|
29
30
|
import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
|
|
31
|
+
import { sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
|
|
30
32
|
import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
|
|
31
33
|
import {
|
|
32
34
|
attemptFields,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
38
40
|
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
39
41
|
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
42
|
+
import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
40
43
|
import {
|
|
41
44
|
ANSWERED_ELSEWHERE_KIND,
|
|
42
45
|
DELIVERY_ANSWERED_ELSEWHERE_TEXT,
|
|
@@ -49,6 +52,7 @@ import {
|
|
|
49
52
|
stripSlashCommandGuardContent,
|
|
50
53
|
withSlashCommandGuardContent,
|
|
51
54
|
} from '../../shared/slash-command-guard.mjs';
|
|
55
|
+
import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
58
|
* Which system-prompt append a relay mode gets (claude-sdk-adapter's
|
|
@@ -446,11 +450,19 @@ export function createClaudeSessionRunner({
|
|
|
446
450
|
// per turn rather than captured, so a slider change picked up on the next
|
|
447
451
|
// delivery reaches a process that is already running.
|
|
448
452
|
getAutoCompactWindow = () => null,
|
|
453
|
+
// What this session's commits say (shared/claude-attribution.mjs); null
|
|
454
|
+
// leaves the CLI's own attribution. Piggybacked on deliveries like the
|
|
455
|
+
// window above.
|
|
456
|
+
getAttribution = () => null,
|
|
449
457
|
// The per-conversation thinking state ({enabled, display}); read per turn
|
|
450
458
|
// like the window, so a settings change reaches a running process on its
|
|
451
459
|
// next delivery.
|
|
452
460
|
getThinking = () => ({ enabled: null, display: '' }),
|
|
453
461
|
lifecyclePollMs = 5_000,
|
|
462
|
+
// How long a RUNNING turn may go without a message from the CLI before
|
|
463
|
+
// that is written to the log. Nothing is failed over it: see
|
|
464
|
+
// `noteQuietTurn`. `{ model, tool }` in ms; 0 for either turns it off.
|
|
465
|
+
quietTurnWindowsMs = undefined,
|
|
454
466
|
// A settled task's continuation normally begins within ~1s; when nothing
|
|
455
467
|
// arrives inside this window the notification was silent (skip_transcript)
|
|
456
468
|
// and must stop pinning the process.
|
|
@@ -546,6 +558,9 @@ export function createClaudeSessionRunner({
|
|
|
546
558
|
// fixed per process, and the first delivery can beat the startup GET.
|
|
547
559
|
const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
|
|
548
560
|
void remoteRelayGate.refresh();
|
|
561
|
+
// The subscription's usage limit as the CLI reports it. Runner-level: the
|
|
562
|
+
// limit is the account's, not the CLI process's.
|
|
563
|
+
const usageLimit = createUsageLimitTracker();
|
|
549
564
|
const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
|
|
550
565
|
let claudeNativeSessionId = '';
|
|
551
566
|
let proc = null;
|
|
@@ -2475,6 +2490,90 @@ export function createClaudeSessionRunner({
|
|
|
2475
2490
|
return 'swallow';
|
|
2476
2491
|
}
|
|
2477
2492
|
|
|
2493
|
+
// ---------------------------------------------------------------------------
|
|
2494
|
+
// A running turn that went quiet
|
|
2495
|
+
//
|
|
2496
|
+
// The other workers fail a turn whose runtime has been silent for too long
|
|
2497
|
+
// (shared/worker-runtime/turn-liveness.mjs). This one only WRITES IT DOWN.
|
|
2498
|
+
// What the CLI sends is known (SDK 0.3.283, measured 2026-09-28): a
|
|
2499
|
+
// `tool_progress` heartbeat every 30 s while a tool runs in silence, for the
|
|
2500
|
+
// Agent call as a whole when the tool runs inside a subagent; a
|
|
2501
|
+
// `thinking_tokens` or stream frame about every second while the model
|
|
2502
|
+
// works. What is not known is how long its silent stretches get in real
|
|
2503
|
+
// sessions: a compaction (133 s seen), the backoff of an API retry, a
|
|
2504
|
+
// request it is waiting on. Failing a turn here also means killing the CLI
|
|
2505
|
+
// and its background tasks. So the windows are generous, the known silent
|
|
2506
|
+
// stretches are holds, and a turn that outlasts them is logged with what
|
|
2507
|
+
// the CLI was doing — the measurements a failing watchdog would need.
|
|
2508
|
+
|
|
2509
|
+
const quietWindows = (() => {
|
|
2510
|
+
const windows = resolveTurnStallWindows({
|
|
2511
|
+
// The CLI gives up on a silent model stream by itself after 300 s and
|
|
2512
|
+
// says so (`api_retry`); a window of the same length would race it.
|
|
2513
|
+
modelMs: quietTurnWindowsMs?.model ?? process.env.OAR_TURN_STALL_MODEL_MS ?? 600_000,
|
|
2514
|
+
toolMs: quietTurnWindowsMs?.tool,
|
|
2515
|
+
});
|
|
2516
|
+
return { idle: 0, model: windows.model, tool: windows.tool };
|
|
2517
|
+
})();
|
|
2518
|
+
|
|
2519
|
+
function observeQuietTurn(sdkMessage) {
|
|
2520
|
+
const liveness = proc?.liveness;
|
|
2521
|
+
if (!liveness) return;
|
|
2522
|
+
const type = String(sdkMessage?.type || '');
|
|
2523
|
+
const agent = String(sdkMessage?.parent_tool_use_id || '').trim() || 'root';
|
|
2524
|
+
if (proc.quietLoggedAt) {
|
|
2525
|
+
dbg(`the CLI spoke again after ${Math.round(liveness.quietMs() / 1000)}s of silence`, type);
|
|
2526
|
+
proc.quietLoggedAt = 0;
|
|
2527
|
+
}
|
|
2528
|
+
liveness.traffic(type === 'system' ? `system/${sdkMessage?.subtype || ''}` : type);
|
|
2529
|
+
const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
2530
|
+
if (type === 'assistant') {
|
|
2531
|
+
for (const block of blocks) {
|
|
2532
|
+
if (block?.type === 'tool_use' && block.id) liveness.begin('tool', `${agent}:${block.id}`, block.name);
|
|
2533
|
+
}
|
|
2534
|
+
} else if (type === 'user') {
|
|
2535
|
+
for (const block of blocks) {
|
|
2536
|
+
if (block?.type !== 'tool_result' || !block.tool_use_id) continue;
|
|
2537
|
+
liveness.end('tool', `${agent}:${block.tool_use_id}`);
|
|
2538
|
+
// The result of an Agent call: whatever that agent had open ended with it.
|
|
2539
|
+
liveness.endAgent(String(block.tool_use_id));
|
|
2540
|
+
}
|
|
2541
|
+
} else if (type === 'stream_event') {
|
|
2542
|
+
const event = String(sdkMessage?.event?.type || '');
|
|
2543
|
+
if (event === 'message_start') liveness.begin('model', agent);
|
|
2544
|
+
else if (event === 'message_stop') liveness.end('model', agent);
|
|
2545
|
+
} else if (type === 'result') {
|
|
2546
|
+
liveness.reset();
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* Log a running turn the CLI has said nothing in for longer than its phase
|
|
2552
|
+
* allows. Once per silence, and never while the silence has a known reason.
|
|
2553
|
+
*/
|
|
2554
|
+
function noteQuietTurn() {
|
|
2555
|
+
const liveness = proc?.liveness;
|
|
2556
|
+
const ctx = proc?.activeCtx;
|
|
2557
|
+
if (!liveness || !ctx || ctx.finalized || proc.closing || proc.quietLoggedAt) return;
|
|
2558
|
+
// A human is deciding, a compaction is running, the relay is slow to take
|
|
2559
|
+
// a publish (the stream is not read meanwhile), or the turn is being
|
|
2560
|
+
// stopped: silence with a reason.
|
|
2561
|
+
if (proc.pendingControlRequests > 0 || isCompacting() || proc.dispatching > 0 || ctx.interrupted) {
|
|
2562
|
+
liveness.traffic('held');
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
// Inside a turn the CLI is waiting for the model unless a tool is open.
|
|
2566
|
+
const phase = liveness.phase() === 'tool' ? 'tool' : 'model';
|
|
2567
|
+
const limitMs = quietWindows[phase];
|
|
2568
|
+
if (!(limitMs > 0) || liveness.quietMs() < limitMs) return;
|
|
2569
|
+
proc.quietLoggedAt = Date.now();
|
|
2570
|
+
dbg(
|
|
2571
|
+
`running turn quiet for ${Math.round(liveness.quietMs() / 1000)}s; left running (the quiet-turn watchdog only observes)`,
|
|
2572
|
+
ctx.message?.id || '(continuation)',
|
|
2573
|
+
liveness.describe(),
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2478
2577
|
async function onSdkMessage(processRef, sdkMessage) {
|
|
2479
2578
|
// A superseded process (push-race respawn, mode-recycle timeout) may still
|
|
2480
2579
|
// be draining its stream; its late messages must not mutate the state of
|
|
@@ -2482,6 +2581,21 @@ export function createClaudeSessionRunner({
|
|
|
2482
2581
|
// cleanupProcess when the old stream ends.
|
|
2483
2582
|
if (!proc || proc !== processRef) return;
|
|
2484
2583
|
proc.lastEventAt = Date.now();
|
|
2584
|
+
observeQuietTurn(sdkMessage);
|
|
2585
|
+
// Counted for as long as this message is being handled: the stream is
|
|
2586
|
+
// not read meanwhile, so the silence is this worker's, not the CLI's.
|
|
2587
|
+
processRef.dispatching += 1;
|
|
2588
|
+
try {
|
|
2589
|
+
await handleSdkMessage(sdkMessage);
|
|
2590
|
+
} finally {
|
|
2591
|
+
processRef.dispatching -= 1;
|
|
2592
|
+
if (proc === processRef) proc.liveness.traffic('handled');
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
async function handleSdkMessage(sdkMessage) {
|
|
2597
|
+
const usageLimitReport = usageLimit.observe(sdkMessage);
|
|
2598
|
+
if (usageLimitReport) publisher.publishUsageLimitReport({ conversationId: sdkSessionId, sdkSessionId, report: usageLimitReport });
|
|
2485
2599
|
const suppressed = maybeSuppressStopFollowUp(sdkMessage);
|
|
2486
2600
|
if (suppressed === 'swallow') return;
|
|
2487
2601
|
observeProcessLevel(sdkMessage);
|
|
@@ -2502,6 +2616,8 @@ export function createClaudeSessionRunner({
|
|
|
2502
2616
|
if (!ctx) return;
|
|
2503
2617
|
const actions = ctx.normalizer.normalize(sdkMessage);
|
|
2504
2618
|
for (const action of actions) {
|
|
2619
|
+
// Decided here, while the reports the turn ran under are the latest.
|
|
2620
|
+
if (action.channel === 'result') action.payload.usageLimit = usageLimit.classifyResult(sdkMessage);
|
|
2505
2621
|
await dispatchToContext(ctx, action);
|
|
2506
2622
|
}
|
|
2507
2623
|
}
|
|
@@ -2917,6 +3033,7 @@ export function createClaudeSessionRunner({
|
|
|
2917
3033
|
if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
|
|
2918
3034
|
proc.stopFollowUp = null;
|
|
2919
3035
|
}
|
|
3036
|
+
noteQuietTurn();
|
|
2920
3037
|
reapSilentProvisionalAdoption(now);
|
|
2921
3038
|
// Before the 5-minute watchdog: a folded steered entry settles as merged
|
|
2922
3039
|
// within the short fold grace instead of failing over.
|
|
@@ -3030,6 +3147,7 @@ export function createClaudeSessionRunner({
|
|
|
3030
3147
|
await publisher.publishTurnException({
|
|
3031
3148
|
message: ctx.message,
|
|
3032
3149
|
errorText: String(streamError?.message || streamError || 'unknown error'),
|
|
3150
|
+
usageLimit: usageLimit.classifyException(streamError),
|
|
3033
3151
|
}).catch(() => {});
|
|
3034
3152
|
}
|
|
3035
3153
|
continue;
|
|
@@ -3058,6 +3176,7 @@ export function createClaudeSessionRunner({
|
|
|
3058
3176
|
const thinkingState = getThinking() || {};
|
|
3059
3177
|
const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
|
|
3060
3178
|
const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
|
|
3179
|
+
const attribution = getAttribution() || null;
|
|
3061
3180
|
const abortController = new AbortController();
|
|
3062
3181
|
const processRef = {
|
|
3063
3182
|
turn: null,
|
|
@@ -3066,6 +3185,7 @@ export function createClaudeSessionRunner({
|
|
|
3066
3185
|
relayMode,
|
|
3067
3186
|
effort,
|
|
3068
3187
|
autoCompactWindow,
|
|
3188
|
+
attribution,
|
|
3069
3189
|
// Seeded from what the process is SPAWNED with, so the adapt diff is
|
|
3070
3190
|
// against reality rather than against a delivered setting the CLI may
|
|
3071
3191
|
// never have honored.
|
|
@@ -3132,6 +3252,11 @@ export function createClaudeSessionRunner({
|
|
|
3132
3252
|
settleSeqAtContinuationOpen: 0,
|
|
3133
3253
|
pendingControlRequests: 0,
|
|
3134
3254
|
lastEventAt: Date.now(),
|
|
3255
|
+
// The quiet-turn log (see noteQuietTurn): what the CLI has in flight,
|
|
3256
|
+
// messages being handled right now, and when the silence was logged.
|
|
3257
|
+
liveness: createTurnLiveness({ windows: quietWindows }),
|
|
3258
|
+
dispatching: 0,
|
|
3259
|
+
quietLoggedAt: 0,
|
|
3135
3260
|
heldForTasksSince: 0,
|
|
3136
3261
|
taskSettledAt: 0,
|
|
3137
3262
|
// Bumped by every task-settle signal; the Stop guard compares it.
|
|
@@ -3218,6 +3343,7 @@ export function createClaudeSessionRunner({
|
|
|
3218
3343
|
autoCompactWindow,
|
|
3219
3344
|
thinkingEnabled,
|
|
3220
3345
|
thinkingDisplay,
|
|
3346
|
+
attribution,
|
|
3221
3347
|
abortController,
|
|
3222
3348
|
canUseTool,
|
|
3223
3349
|
pathToClaudeCodeExecutable,
|
|
@@ -3326,6 +3452,30 @@ export function createClaudeSessionRunner({
|
|
|
3326
3452
|
});
|
|
3327
3453
|
proc.autoCompactWindow = autoCompactWindow;
|
|
3328
3454
|
}
|
|
3455
|
+
// Attribution follows the window's pattern: a changed value (a settings
|
|
3456
|
+
// change, a model switch that renames the trailer) reaches the live CLI
|
|
3457
|
+
// through the flag layer, before the turn that may commit.
|
|
3458
|
+
const attribution = getAttribution() || null;
|
|
3459
|
+
if (!sameClaudeAttribution(attribution, proc.attribution)) {
|
|
3460
|
+
if (attribution === null) {
|
|
3461
|
+
// Back to the CLI's own attribution. A value the process was spawned
|
|
3462
|
+
// with cannot be unset through the flag layer (probe 2026-10-01), so
|
|
3463
|
+
// the honest way is a fresh process without the key — taken when
|
|
3464
|
+
// nothing lives in the old one; a busy process keeps the old text and
|
|
3465
|
+
// the next delivery tries again.
|
|
3466
|
+
if (!hasLiveWork() && !proc.liveTasks.size) {
|
|
3467
|
+
gracefulShutdown('attribution-vanilla');
|
|
3468
|
+
await drainClosingProcess(10_000);
|
|
3469
|
+
return null;
|
|
3470
|
+
}
|
|
3471
|
+
} else {
|
|
3472
|
+
expectSettingsReinit();
|
|
3473
|
+
await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAttributionFlagSettings(attribution))).catch((error) => {
|
|
3474
|
+
dbg('applyFlagSettings attribution failed', error?.message || String(error));
|
|
3475
|
+
});
|
|
3476
|
+
proc.attribution = attribution;
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3329
3479
|
// Thinking is NOT a symmetric copy of the window block: the probe
|
|
3330
3480
|
// (2026-08-26, docs/plans/claude-thinking-control.md) showed the two
|
|
3331
3481
|
// directions of the on/off axis are not equally reachable mid-session.
|
|
@@ -3631,7 +3781,7 @@ export function createClaudeSessionRunner({
|
|
|
3631
3781
|
settingsWindowOpen = false;
|
|
3632
3782
|
const errorText = String(error?.message || error || 'unknown error');
|
|
3633
3783
|
dbg('claude turn failed', message.id, errorText);
|
|
3634
|
-
await publisher.publishTurnException({ message, errorText });
|
|
3784
|
+
await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
|
|
3635
3785
|
return true;
|
|
3636
3786
|
} finally {
|
|
3637
3787
|
endAdmission();
|
|
@@ -17,6 +17,7 @@ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-fil
|
|
|
17
17
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
18
18
|
import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
19
19
|
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
20
|
+
import { resolveDeliveredAttribution } from '../../shared/claude-attribution.mjs';
|
|
20
21
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
21
22
|
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
22
23
|
import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
|
|
@@ -66,6 +67,9 @@ async function main() {
|
|
|
66
67
|
// piggyback. enabled null = host default; display defaults to 'summarized'
|
|
67
68
|
// (today's behavior — visible thought bubbles).
|
|
68
69
|
let thinking = resolveDeliveredThinking(null, null);
|
|
70
|
+
// What this session's commits and PRs say; same piggyback. null = the
|
|
71
|
+
// CLI's own attribution (vanilla).
|
|
72
|
+
let attribution = null;
|
|
69
73
|
|
|
70
74
|
const api = createApiClient({
|
|
71
75
|
serverUrl,
|
|
@@ -111,6 +115,7 @@ async function main() {
|
|
|
111
115
|
getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
|
|
112
116
|
getAutoCompactWindow: () => autoCompactWindow,
|
|
113
117
|
getThinking: () => thinking,
|
|
118
|
+
getAttribution: () => attribution,
|
|
114
119
|
// A readiness flip is a hold starting or ending (question card,
|
|
115
120
|
// compaction, a turn-opening delivery): the steering snapshot changed
|
|
116
121
|
// with it, so it goes out now rather than on the next 10 s heartbeat.
|
|
@@ -176,6 +181,7 @@ async function main() {
|
|
|
176
181
|
// keep the last known state; an explicit null on thinkingEnabled is the
|
|
177
182
|
// user choosing Host default.
|
|
178
183
|
thinking = resolveDeliveredThinking(thinking, pending?.settings);
|
|
184
|
+
attribution = resolveDeliveredAttribution(attribution, pending?.settings);
|
|
179
185
|
try {
|
|
180
186
|
return await turnRunner.handlePendingPayload(pending);
|
|
181
187
|
} catch (error) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Why a Claude turn failed: the code the relay prints, and the advice that
|
|
2
|
+
// fits the failure.
|
|
3
|
+
//
|
|
4
|
+
// A turn the API refused ends with a `result` of subtype 'success' and
|
|
5
|
+
// `is_error: true`: the subtype says how the turn ended, not whether it
|
|
6
|
+
// worked, so it names nothing. What names the failure, in this order:
|
|
7
|
+
//
|
|
8
|
+
// 1. the `error` field of the turn's last assistant message (the SDK's
|
|
9
|
+
// SDKAssistantMessageError: 'billing_error', 'oauth_org_not_allowed', …);
|
|
10
|
+
// 2. the `api_error_status` of the result;
|
|
11
|
+
// 3. the words of the refusal, for an account failure that came with
|
|
12
|
+
// neither;
|
|
13
|
+
// 4. a subtype other than 'success' ('error_max_turns', …);
|
|
14
|
+
// 5. the `terminal_reason` of the result.
|
|
15
|
+
//
|
|
16
|
+
// The relay prints `claude.<code>` as `relay.claude-<code>`, dashes for
|
|
17
|
+
// underscores.
|
|
18
|
+
|
|
19
|
+
// The user has to sign in again: a retry cannot succeed before that.
|
|
20
|
+
const SIGN_IN_ERRORS = new Set(['authentication_failed']);
|
|
21
|
+
// The account itself refuses: billing, or access its organisation withholds.
|
|
22
|
+
const ACCOUNT_ERRORS = new Set([
|
|
23
|
+
'billing_error',
|
|
24
|
+
'oauth_org_not_allowed',
|
|
25
|
+
'account_on_hold',
|
|
26
|
+
'verification_required',
|
|
27
|
+
'account-access',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const STATUS_CODES = new Map([
|
|
31
|
+
[401, 'authentication_failed'],
|
|
32
|
+
[402, 'billing_error'],
|
|
33
|
+
[429, 'rate_limit'],
|
|
34
|
+
[529, 'overloaded'],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Checked before the sign-in words: the organisation's refusal mentions an
|
|
38
|
+
// API key too.
|
|
39
|
+
const ACCOUNT_ACCESS_TEXT = /organi[sz]ation has disabled|subscription access|does not have access to claude/i;
|
|
40
|
+
const BILLING_TEXT = /credit balance|billing|payment/i;
|
|
41
|
+
const SIGN_IN_TEXT = /failed to authenticate|authentication_error|invalid api key|not logged in|please run \/login/i;
|
|
42
|
+
|
|
43
|
+
export const CLAUDE_SIGN_IN_GUIDANCE =
|
|
44
|
+
'Sign in to Claude again in Settings → Providers → Claude → Relogin (or run `claude` on the relay host), '
|
|
45
|
+
+ 'then send the message again.';
|
|
46
|
+
export const CLAUDE_ACCOUNT_GUIDANCE =
|
|
47
|
+
'Check the Claude account the relay host is signed in to: its subscription, its payment method, and whether '
|
|
48
|
+
+ 'its organisation allows Claude Code. Settings → Providers → Claude shows the account and signs in to '
|
|
49
|
+
+ 'another one. Then send the message again.';
|
|
50
|
+
export const CLAUDE_MODEL_GUIDANCE = 'Choose another model for this conversation, then send the message again.';
|
|
51
|
+
|
|
52
|
+
function codeFromText(text) {
|
|
53
|
+
if (ACCOUNT_ACCESS_TEXT.test(text)) return 'account-access';
|
|
54
|
+
if (BILLING_TEXT.test(text)) return 'billing_error';
|
|
55
|
+
if (SIGN_IN_TEXT.test(text)) return 'authentication_failed';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The advice for a code, or null where the relay's own default fits. */
|
|
60
|
+
export function claudeFailureGuidance(code) {
|
|
61
|
+
const token = String(code || '').trim().toLowerCase();
|
|
62
|
+
if (SIGN_IN_ERRORS.has(token)) return CLAUDE_SIGN_IN_GUIDANCE;
|
|
63
|
+
if (ACCOUNT_ERRORS.has(token)) return CLAUDE_ACCOUNT_GUIDANCE;
|
|
64
|
+
if (token === 'model_not_found') return CLAUDE_MODEL_GUIDANCE;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `result` is the normalized result payload of the turn (sdk-message-
|
|
70
|
+
* normalizer): `{ text, subtype, assistantError, apiErrorStatus,
|
|
71
|
+
* terminalReason }`. Returns `{ code, stableCode, guidance }`; `guidance` is
|
|
72
|
+
* null where the relay's default fits.
|
|
73
|
+
*/
|
|
74
|
+
export function classifyClaudeResultFailure(result = {}) {
|
|
75
|
+
const subtype = String(result?.subtype || '').trim();
|
|
76
|
+
const assistantError = String(result?.assistantError || '').trim().toLowerCase();
|
|
77
|
+
const status = Number(result?.apiErrorStatus);
|
|
78
|
+
const hasStatus = Number.isFinite(status) && status >= 400;
|
|
79
|
+
const terminalReason = String(result?.terminalReason || '').trim().toLowerCase();
|
|
80
|
+
|
|
81
|
+
let code = null;
|
|
82
|
+
if (assistantError && assistantError !== 'unknown') code = assistantError;
|
|
83
|
+
else if (hasStatus && STATUS_CODES.has(status)) code = STATUS_CODES.get(status);
|
|
84
|
+
else code = codeFromText(String(result?.text || ''));
|
|
85
|
+
|
|
86
|
+
if (!code && subtype && subtype !== 'success') code = subtype;
|
|
87
|
+
if (!code && (hasStatus || assistantError === 'unknown' || terminalReason === 'api_error')) code = 'api-error';
|
|
88
|
+
if (!code && terminalReason && terminalReason !== 'completed') code = terminalReason;
|
|
89
|
+
if (!code) code = 'turn-error';
|
|
90
|
+
|
|
91
|
+
return { code, stableCode: `claude.${code}`, guidance: claudeFailureGuidance(code) };
|
|
92
|
+
}
|