@oar-sh/oar 0.9.15 → 0.9.17
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 +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// DeepSeek's published prices, for the usage card's estimate of what this
|
|
2
|
+
// relay's DeepSeek turns cost. DeepSeek reports no cost per request, and
|
|
3
|
+
// Claude Code's own `total_cost_usd` is computed at Claude's prices, so the
|
|
4
|
+
// estimate is tokens times this table.
|
|
5
|
+
//
|
|
6
|
+
// Source: api-docs.deepseek.com/quick_start/pricing (USD) and its zh-cn page
|
|
7
|
+
// (CNY), built 2026-10-19. Prices are per 1M tokens: a cache hit, a cache
|
|
8
|
+
// miss (input DeepSeek had not cached) and output. Peak hours cost exactly
|
|
9
|
+
// twice the off-peak price. Peak is Monday to Friday 01:00–04:00 and
|
|
10
|
+
// 06:00–10:00 UTC (09:00–12:00 and 14:00–18:00 Beijing time); everything else
|
|
11
|
+
// is off-peak. On Chinese public holidays DeepSeek charges off-peak prices all
|
|
12
|
+
// day; the estimate doesn't know those dates, so turns at peak hours on a
|
|
13
|
+
// holiday are counted at double the real price.
|
|
14
|
+
//
|
|
15
|
+
// A model the table does not know gets no estimate (null), never a guess.
|
|
16
|
+
//
|
|
17
|
+
// Claude Code's WebSearch runs on DeepSeek's Search API. Source:
|
|
18
|
+
// api-docs.deepseek.com/guides/search (USD) and its zh-cn page (CNY), read
|
|
19
|
+
// 2026-10-24: $0.75 / ¥5 per 1,000 successful requests for each of Search
|
|
20
|
+
// and Fetch, charged from 2026-11-01 00:00 Beijing time (2026-10-31 16:00
|
|
21
|
+
// UTC), no peak or off-peak. Requests before that are counted but cost 0.
|
|
22
|
+
// Claude Code's WebFetch is not a Fetch request: it reads the page on the
|
|
23
|
+
// relay host and sends the text to the model (plain tokens), and DeepSeek's
|
|
24
|
+
// API refuses the server-side fetch tool (422, checked 2026-10-25). The Fetch
|
|
25
|
+
// price stays for a fetch DeepSeek might report one day.
|
|
26
|
+
|
|
27
|
+
export const DEEPSEEK_PRICING_SOURCE = Object.freeze({
|
|
28
|
+
url: 'https://api-docs.deepseek.com/quick_start/pricing',
|
|
29
|
+
date: '2026-10-19',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const DEEPSEEK_SEARCH_PRICING = Object.freeze({
|
|
33
|
+
url: 'https://api-docs.deepseek.com/guides/search',
|
|
34
|
+
date: '2026-10-24',
|
|
35
|
+
chargedFrom: '2026-10-31T16:00:00.000Z',
|
|
36
|
+
// Per 1,000 successful requests, the same for a search and a fetch.
|
|
37
|
+
perThousand: Object.freeze({ USD: 0.75, CNY: 5 }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const DEEPSEEK_PRICE_CURRENCIES = Object.freeze(['USD', 'CNY']);
|
|
41
|
+
|
|
42
|
+
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
|
43
|
+
|
|
44
|
+
function prices(hit, miss, output) {
|
|
45
|
+
return Object.freeze({ hit, miss, output });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PRICE_TABLE = Object.freeze({
|
|
49
|
+
'deepseek-flash': Object.freeze({
|
|
50
|
+
offPeak: Object.freeze({ USD: prices(0.003, 0.15, 0.60), CNY: prices(0.02, 1, 4) }),
|
|
51
|
+
peak: Object.freeze({ USD: prices(0.006, 0.30, 1.20), CNY: prices(0.04, 2, 8) }),
|
|
52
|
+
}),
|
|
53
|
+
'deepseek-v4-pro': Object.freeze({
|
|
54
|
+
offPeak: Object.freeze({ USD: prices(0.022, 0.66, 1.98), CNY: prices(0.15, 4.5, 13.5) }),
|
|
55
|
+
peak: Object.freeze({ USD: prices(0.044, 1.32, 3.96), CNY: prices(0.30, 9, 27) }),
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Ids DeepSeek still accepts and serves with the Flash model.
|
|
60
|
+
const PRICE_ALIASES = Object.freeze({
|
|
61
|
+
'deepseek-v4-flash': 'deepseek-flash',
|
|
62
|
+
'deepseek-v4-flash-vision-exp': 'deepseek-flash',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// [start, end) hours in UTC, Monday to Friday. The card shows them in the
|
|
66
|
+
// browser's time zone (plan-usage-view.mjs).
|
|
67
|
+
export const DEEPSEEK_PEAK_HOURS_UTC = Object.freeze([Object.freeze([1, 4]), Object.freeze([6, 10])]);
|
|
68
|
+
const PEAK_HOURS_UTC = DEEPSEEK_PEAK_HOURS_UTC;
|
|
69
|
+
|
|
70
|
+
function toDate(value) {
|
|
71
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
72
|
+
if (typeof value === 'number') {
|
|
73
|
+
const date = new Date(value);
|
|
74
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
75
|
+
}
|
|
76
|
+
const text = String(value ?? '').trim();
|
|
77
|
+
if (!text) return null;
|
|
78
|
+
const date = new Date(text);
|
|
79
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The id the table prices a model under ("deepseek-v4-flash[1m]" → "deepseek-flash"), or '' when unknown. */
|
|
83
|
+
export function deepseekPricedModel(value) {
|
|
84
|
+
const id = String(value ?? '').trim().toLowerCase().replace(/\[1m\]$/, '');
|
|
85
|
+
if (PRICE_TABLE[id]) return id;
|
|
86
|
+
return PRICE_ALIASES[id] || '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether DeepSeek bills an instant at its peak price; null for an unreadable time. */
|
|
90
|
+
export function isDeepSeekPeakTime(at) {
|
|
91
|
+
const date = toDate(at);
|
|
92
|
+
if (!date) return null;
|
|
93
|
+
const day = date.getUTCDay();
|
|
94
|
+
if (day === 0 || day === 6) return false;
|
|
95
|
+
const minutes = date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
96
|
+
return PEAK_HOURS_UTC.some(([start, end]) => minutes >= start * 60 && minutes < end * 60);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The prices per 1M tokens for a model at an instant: `{ hit, miss, output,
|
|
101
|
+
* peak }`, or null for an unknown model, currency or time.
|
|
102
|
+
*/
|
|
103
|
+
export function deepseekPriceFor({ model, at, currency = 'USD' } = {}) {
|
|
104
|
+
const entry = PRICE_TABLE[deepseekPricedModel(model)];
|
|
105
|
+
const unit = String(currency ?? '').trim().toUpperCase();
|
|
106
|
+
const peak = isDeepSeekPeakTime(at);
|
|
107
|
+
if (!entry || !DEEPSEEK_PRICE_CURRENCIES.includes(unit) || peak === null) return null;
|
|
108
|
+
return { ...entry[peak ? 'peak' : 'offPeak'][unit], peak };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function tokenCount(value) {
|
|
112
|
+
const number = Number(value ?? 0);
|
|
113
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What the tokens of one request (or one turn) cost at DeepSeek's price for
|
|
118
|
+
* its time, in `currency` (USD or CNY). A missing count is 0. Null when the
|
|
119
|
+
* model, the currency or the time is unknown.
|
|
120
|
+
*/
|
|
121
|
+
export function estimateCost({ model, at, hitTokens = 0, missTokens = 0, outputTokens = 0, currency = 'USD' } = {}) {
|
|
122
|
+
const price = deepseekPriceFor({ model, at, currency });
|
|
123
|
+
if (!price) return null;
|
|
124
|
+
const cost = (
|
|
125
|
+
tokenCount(hitTokens) * price.hit
|
|
126
|
+
+ tokenCount(missTokens) * price.miss
|
|
127
|
+
+ tokenCount(outputTokens) * price.output
|
|
128
|
+
) / TOKENS_PER_PRICE_UNIT;
|
|
129
|
+
// Ten decimals: enough for a single token, and no binary-float tails.
|
|
130
|
+
return Math.round(cost * 1e10) / 1e10;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What the web searches and fetches of one turn cost at the time `at`, in
|
|
135
|
+
* `currency`: 0 before DeepSeek started charging for them, null for an
|
|
136
|
+
* unreadable time or an unknown currency. Flat per request, peak or not.
|
|
137
|
+
*/
|
|
138
|
+
export function estimateSearchCost({ at, searchRequests = 0, fetchRequests = 0, currency = 'USD' } = {}) {
|
|
139
|
+
const date = toDate(at);
|
|
140
|
+
const unit = String(currency ?? '').trim().toUpperCase();
|
|
141
|
+
const price = DEEPSEEK_SEARCH_PRICING.perThousand[unit];
|
|
142
|
+
if (!date || price === undefined) return null;
|
|
143
|
+
if (date.getTime() < Date.parse(DEEPSEEK_SEARCH_PRICING.chargedFrom)) return 0;
|
|
144
|
+
const cost = (tokenCount(searchRequests) + tokenCount(fetchRequests)) * price / 1000;
|
|
145
|
+
return Math.round(cost * 1e10) / 1e10;
|
|
146
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The variables of a Claude Code session the relay was started from.
|
|
2
|
+
//
|
|
3
|
+
// A relay started from inside a Claude Code session (an agent's shell, a
|
|
4
|
+
// `claude` run that starts it, a test relay an agent starts from a relay
|
|
5
|
+
// worker) carries that session's variables, and so would every worker: the
|
|
6
|
+
// Claude and DeepSeek workers' CLI would take itself for a child of the host's
|
|
7
|
+
// session (messaging socket and token, session id, entrypoint, effort), and a
|
|
8
|
+
// Copilot or Cursor worker has no use for them either. The launcher drops
|
|
9
|
+
// them for every kind and on every path (session-worker-launch-service.mjs),
|
|
10
|
+
// and the DeepSeek backend drops them again from its CLI's environment.
|
|
11
|
+
//
|
|
12
|
+
// A fixed list misses what the next CLI version adds, so the session's
|
|
13
|
+
// variables are matched by family as well:
|
|
14
|
+
// - the names Claude Code gives its child shells and MCP servers, those it
|
|
15
|
+
// sets on its own process (entrypoint, executable path), the markers an
|
|
16
|
+
// SDK host sets (its version and its app's name, which the CLI sends as
|
|
17
|
+
// `x-client-app`), and the per-spawn Bash cap a relay's Claude worker
|
|
18
|
+
// gives its CLI (HOST_CLAUDE_SESSION_ENV_VARS);
|
|
19
|
+
// - every `CLAUDE_CODE_SESSION_*` (id, attended, access token, kind, name,
|
|
20
|
+
// origin, ...), every `CLAUDE_CODE_*_SESSION_ID` (host, remote, bridge,
|
|
21
|
+
// cloud) and every `CLAUDE_CODE_MESSAGING_*`
|
|
22
|
+
// (HOST_CLAUDE_SESSION_ENV_PATTERNS).
|
|
23
|
+
// What stays is configuration: the relay's own (`CLAUDE_CONFIG_DIR`,
|
|
24
|
+
// `CLAUDE_CODE_EXECUTABLE`, a login token, which the launcher hands only to
|
|
25
|
+
// the Claude kinds) and the user's settings: `CLAUDE_CODE_*` ones (a
|
|
26
|
+
// provider such as `CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_GIT_BASH_PATH` on
|
|
27
|
+
// Windows, the `DISABLE_*`/`ENABLE_*` switches) and the SDK's
|
|
28
|
+
// `CLAUDE_AGENT_SDK_*` switches (`DISABLE_BUILTIN_AGENTS`, `MCP_NO_PREFIX`,
|
|
29
|
+
// `DISABLE_MCP_MANIFESTS`), which no family reaches. The settings that fall
|
|
30
|
+
// in a family stay through HOST_CLAUDE_SESSION_KEEP_ENV_VARS. Every
|
|
31
|
+
// `CLAUDE_AGENT_SDK_*` and family name the bundled CLI and SDK read was
|
|
32
|
+
// sorted this way (2026-10-25).
|
|
33
|
+
|
|
34
|
+
export const HOST_CLAUDE_SESSION_ENV_VARS = Object.freeze([
|
|
35
|
+
'CLAUDECODE',
|
|
36
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
37
|
+
'CLAUDE_CODE_MESSAGING_SOCKET',
|
|
38
|
+
'CLAUDE_CODE_MESSAGING_TOKEN',
|
|
39
|
+
'CLAUDE_CODE_CHILD_SESSION',
|
|
40
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
41
|
+
'CLAUDE_CODE_SESSION_ATTENDED',
|
|
42
|
+
'CLAUDE_CODE_EXECPATH',
|
|
43
|
+
'CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS',
|
|
44
|
+
'CLAUDE_AGENT_SDK_VERSION',
|
|
45
|
+
'CLAUDE_AGENT_SDK_CLIENT_APP',
|
|
46
|
+
'CLAUDE_PID',
|
|
47
|
+
'CLAUDE_EFFORT',
|
|
48
|
+
'CLAUDE_PROJECT_DIR',
|
|
49
|
+
'CLAUDE_ENV_FILE',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// Shell `case` patterns, matched against the upper-cased name in JavaScript.
|
|
53
|
+
export const HOST_CLAUDE_SESSION_ENV_PATTERNS = Object.freeze([
|
|
54
|
+
'CLAUDE_CODE_SESSION_*',
|
|
55
|
+
'CLAUDE_CODE_*_SESSION_ID',
|
|
56
|
+
'CLAUDE_CODE_MESSAGING_*',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Settings inside a family: a behaviour switch and a debug log switch.
|
|
60
|
+
export const HOST_CLAUDE_SESSION_KEEP_ENV_VARS = Object.freeze([
|
|
61
|
+
'CLAUDE_CODE_SESSION_START_ANNOUNCEMENTS_BEFORE_PROMPT',
|
|
62
|
+
'CLAUDE_CODE_SESSION_LOG',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const EXACT = new Set(HOST_CLAUDE_SESSION_ENV_VARS);
|
|
66
|
+
const KEEP = new Set(HOST_CLAUDE_SESSION_KEEP_ENV_VARS);
|
|
67
|
+
const PATTERNS = HOST_CLAUDE_SESSION_ENV_PATTERNS.map((pattern) => new RegExp(
|
|
68
|
+
`^${pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`,
|
|
69
|
+
));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a variable is the host Claude Code session's. Upper-cased first:
|
|
73
|
+
* Windows names are case-insensitive.
|
|
74
|
+
*/
|
|
75
|
+
export function isHostClaudeSessionVariable(name) {
|
|
76
|
+
const key = String(name ?? '').toUpperCase();
|
|
77
|
+
if (!key || KEEP.has(key)) return false;
|
|
78
|
+
return EXACT.has(key) || PATTERNS.some((pattern) => pattern.test(key));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The environment without the host Claude Code session's variables. */
|
|
82
|
+
export function withoutHostClaudeSessionVariables(env = {}) {
|
|
83
|
+
const next = {};
|
|
84
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
85
|
+
if (!isHostClaudeSessionVariable(key)) next[key] = value;
|
|
86
|
+
}
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A POSIX sh statement that unsets, in the shell it runs in, every variable
|
|
92
|
+
* of the host session the environment holds: the families as well as the
|
|
93
|
+
* names, for an environment the launcher never saw (a tmux server's). Names
|
|
94
|
+
* only; a value is never read.
|
|
95
|
+
*/
|
|
96
|
+
export function hostClaudeSessionUnsetStatement() {
|
|
97
|
+
const keep = HOST_CLAUDE_SESSION_KEEP_ENV_VARS.length ? `${HOST_CLAUDE_SESSION_KEEP_ENV_VARS.join('|')}) ;; ` : '';
|
|
98
|
+
const drop = [...HOST_CLAUDE_SESSION_ENV_VARS, ...HOST_CLAUDE_SESSION_ENV_PATTERNS].join('|');
|
|
99
|
+
return `for oar_host_var in $(env | sed -n 's/^\\([A-Za-z_][A-Za-z0-9_]*\\)=.*/\\1/p'); do `
|
|
100
|
+
+ `case "$oar_host_var" in ${keep}${drop}) unset "$oar_host_var";; esac; done; unset oar_host_var;`;
|
|
101
|
+
}
|
package/shared/model-id.mjs
CHANGED
|
@@ -77,6 +77,13 @@ export function isSafeGrokModelId(value) {
|
|
|
77
77
|
return isSafeProviderModelId(value);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// DeepSeek model ids ("deepseek-flash", "deepseek-v4-pro") follow the same
|
|
81
|
+
// safe-token rules; that they are DeepSeek's is checked by the provider
|
|
82
|
+
// (shared/deepseek-models.mjs).
|
|
83
|
+
export function isSafeDeepSeekModelId(value) {
|
|
84
|
+
return isSafeProviderModelId(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
// The "[1m]" suffix marks the 1M-context variant of a Claude model. The UI
|
|
81
88
|
// treats it as a context tier of the base model, not a separate model.
|
|
82
89
|
export const CLAUDE_LONG_CONTEXT_SUFFIX = '[1m]';
|
|
@@ -173,7 +173,7 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
173
173
|
const response = await api('POST', '/api/previews', body);
|
|
174
174
|
const existing = response?.existing === true;
|
|
175
175
|
const hint = parsed.port
|
|
176
|
-
? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Tell the user the URL and that the link is public.`
|
|
176
|
+
? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Bind the dev server to 127.0.0.1, not 0.0.0.0: the relay's proxy reaches it there and nothing else should. Tell the user the URL and that the link is public.`
|
|
177
177
|
: 'The directory is served as-is; relative asset paths work, root-absolute ones do not. Tell the user the URL and that the link is public.';
|
|
178
178
|
return {
|
|
179
179
|
ok: true,
|
|
@@ -13,7 +13,7 @@ export const LEGACY_RELAY_PROVIDER_TYPES = Object.freeze(['github', 'openai']);
|
|
|
13
13
|
|
|
14
14
|
// `claude-cloud` is its own provider, never an alias of `claude`: its worker
|
|
15
15
|
// drives a session in Anthropic's cloud and spawns no CLI.
|
|
16
|
-
export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud']);
|
|
16
|
+
export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud', 'deepseek']);
|
|
17
17
|
|
|
18
18
|
export function isLegacyRelayProviderType(value) {
|
|
19
19
|
const normalized = String(value || '').trim().toLowerCase();
|
|
@@ -25,7 +25,7 @@ export function isSessionWorkerProviderType(value) {
|
|
|
25
25
|
return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** `'claude', 'cursor', 'grok', 'claude-cloud'` — for interpolation into SQL NOT IN (...) lists. */
|
|
28
|
+
/** `'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek'` — for interpolation into SQL NOT IN (...) lists. */
|
|
29
29
|
export function sessionWorkerProviderSqlList() {
|
|
30
30
|
return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
|
|
31
31
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Questions that don't block: remote_relay's `ask` puts a question for the
|
|
2
|
+
// owner into a session (a card there and in the Inbox) and returns at once;
|
|
3
|
+
// the asking session keeps working and is told the answer later, as a relay
|
|
4
|
+
// event (question_answered).
|
|
5
|
+
//
|
|
6
|
+
// One ask is `{ id, kind: 'ask', session, question, options, recommendation,
|
|
7
|
+
// detail, urgency, status, answer, choice, createdBy, creator, ... }`. Its
|
|
8
|
+
// `status` is pending until the owner answers or dismisses it, or the agent
|
|
9
|
+
// that asked withdraws it.
|
|
10
|
+
//
|
|
11
|
+
// Every text of an ask is written by an agent, possibly on another relay:
|
|
12
|
+
// whoever shows it shows it as plain text.
|
|
13
|
+
//
|
|
14
|
+
// SDK-free and side-effect free: imported by the server, the remote_relay
|
|
15
|
+
// contract and the browser.
|
|
16
|
+
|
|
17
|
+
// The kind a question card carries in /api/relay-questions and the Inbox:
|
|
18
|
+
// `ask` (this feature), `approval` (a relay's Allow / Deny card on its own
|
|
19
|
+
// agent's call, context.source 'remote_relay'), `ask_user` (every other card a
|
|
20
|
+
// turn waits on).
|
|
21
|
+
export const RELAY_QUESTION_KINDS = Object.freeze(['ask', 'ask_user', 'approval']);
|
|
22
|
+
export const RELAY_ASK_KIND = 'ask';
|
|
23
|
+
|
|
24
|
+
export const RELAY_ASK_URGENCIES = Object.freeze(['normal', 'now']);
|
|
25
|
+
export const RELAY_ASK_STATUSES = Object.freeze(['pending', 'answered', 'dismissed', 'withdrawn', 'cancelled']);
|
|
26
|
+
// What the Inbox lists under its divider (for answeredKeepMs).
|
|
27
|
+
export const RELAY_ASK_CLOSED_STATUSES = Object.freeze(['answered', 'dismissed']);
|
|
28
|
+
|
|
29
|
+
// Socket event the relay sends on every change of an ask (`{ ask }`) and
|
|
30
|
+
// of the Inbox's count (`{ pending }`).
|
|
31
|
+
export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
|
|
32
|
+
|
|
33
|
+
export const RELAY_ASK_LIMITS = Object.freeze({
|
|
34
|
+
questionMax: 500,
|
|
35
|
+
optionsMax: 8,
|
|
36
|
+
optionMax: 120,
|
|
37
|
+
recommendationMax: 300,
|
|
38
|
+
detailMax: 2000,
|
|
39
|
+
// Pending asks one conversation may have asked (for itself and others), and
|
|
40
|
+
// pending asks one session may have open.
|
|
41
|
+
pendingPerConversation: 20,
|
|
42
|
+
// How long an answered or dismissed ask stays in the Inbox, under its divider.
|
|
43
|
+
answeredKeepMs: 24 * 60 * 60_000,
|
|
44
|
+
// Closed asks are kept this long, then pruned.
|
|
45
|
+
closedKeepMs: 7 * 24 * 60 * 60_000,
|
|
46
|
+
// The asking relay looks for answers to its asks on a paired relay this often.
|
|
47
|
+
remotePollMs: 5_000,
|
|
48
|
+
remotePollFailingMs: 30_000,
|
|
49
|
+
listMax: 200,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Ask ids carry this prefix: the question routes tell them from cards by it.
|
|
53
|
+
export const RELAY_ASK_ID_PREFIX = 'ask_';
|
|
54
|
+
|
|
55
|
+
export function isRelayAskId(value) {
|
|
56
|
+
return /^ask_[a-z0-9]{6,40}$/.test(String(value ?? '').trim());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toText(value) {
|
|
60
|
+
return String(value ?? '').trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Control characters of agent text go; line breaks stay in the detail only.
|
|
64
|
+
const CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]+/g;
|
|
65
|
+
|
|
66
|
+
function oneLine(value) {
|
|
67
|
+
return String(value ?? '').replace(CONTROLS, ' ').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function multiLine(value) {
|
|
71
|
+
return String(value ?? '').replace(/\r\n?/g, '\n').replace(CONTROLS, ' ').replace(/[ \t]+\n/g, '\n').trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function invalid(error) {
|
|
75
|
+
return { ok: false, error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* An ask as an agent writes it, checked: `{ ok, value: { question, options,
|
|
80
|
+
* recommendation, detail, urgency } }` or `{ ok:false, error }`. Too long is
|
|
81
|
+
* refused, never cut: the owner decides on the words the agent chose. The
|
|
82
|
+
* recommendation may name an option (matched case-insensitively, then given
|
|
83
|
+
* the option's spelling) or be free text.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeRelayAskInput(input = {}) {
|
|
86
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
87
|
+
const question = oneLine(source.question);
|
|
88
|
+
if (!question) return invalid('ask needs question (the question for the owner, at most 500 characters)');
|
|
89
|
+
if (question.length > RELAY_ASK_LIMITS.questionMax) {
|
|
90
|
+
return invalid(`question is longer than ${RELAY_ASK_LIMITS.questionMax} characters; put the background in detail`);
|
|
91
|
+
}
|
|
92
|
+
let rawOptions = source.options;
|
|
93
|
+
if (rawOptions === undefined || rawOptions === null || rawOptions === '') rawOptions = [];
|
|
94
|
+
if (typeof rawOptions === 'string') rawOptions = rawOptions.split(/\r?\n/);
|
|
95
|
+
if (!Array.isArray(rawOptions)) return invalid('options must be a list of short labels');
|
|
96
|
+
const options = [];
|
|
97
|
+
for (const raw of rawOptions) {
|
|
98
|
+
if (typeof raw !== 'string' && typeof raw !== 'number') return invalid('options must be a list of short labels');
|
|
99
|
+
const label = oneLine(raw);
|
|
100
|
+
if (!label) continue;
|
|
101
|
+
if (label.length > RELAY_ASK_LIMITS.optionMax) return invalid(`an option is longer than ${RELAY_ASK_LIMITS.optionMax} characters: "${label.slice(0, 40)}…"`);
|
|
102
|
+
if (options.some((option) => option.toLowerCase() === label.toLowerCase())) continue;
|
|
103
|
+
options.push(label);
|
|
104
|
+
}
|
|
105
|
+
if (options.length > RELAY_ASK_LIMITS.optionsMax) return invalid(`options takes at most ${RELAY_ASK_LIMITS.optionsMax} labels`);
|
|
106
|
+
let recommendation = oneLine(source.recommendation);
|
|
107
|
+
if (recommendation.length > RELAY_ASK_LIMITS.recommendationMax) {
|
|
108
|
+
return invalid(`recommendation is longer than ${RELAY_ASK_LIMITS.recommendationMax} characters`);
|
|
109
|
+
}
|
|
110
|
+
const named = options.find((option) => option.toLowerCase() === recommendation.toLowerCase());
|
|
111
|
+
if (named) recommendation = named;
|
|
112
|
+
const detail = multiLine(source.detail);
|
|
113
|
+
if (detail.length > RELAY_ASK_LIMITS.detailMax) return invalid(`detail is longer than ${RELAY_ASK_LIMITS.detailMax} characters`);
|
|
114
|
+
const urgency = toText(source.urgency).toLowerCase() || 'normal';
|
|
115
|
+
if (!RELAY_ASK_URGENCIES.includes(urgency)) return invalid(`urgency must be one of: ${RELAY_ASK_URGENCIES.join(', ')}`);
|
|
116
|
+
return { ok: true, value: { question, options, recommendation, detail, urgency } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The owner's answer to an ask: `{ ok, value: { answer, choice } }` or
|
|
121
|
+
* `{ ok:false, error }`. `choice` is the option picked (its own spelling),
|
|
122
|
+
* `answer` the text the asker reads: the typed text, else the choice.
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeRelayAskAnswer(ask, { answer = '', choice = '' } = {}) {
|
|
125
|
+
const options = Array.isArray(ask?.options) ? ask.options : [];
|
|
126
|
+
const wanted = oneLine(choice);
|
|
127
|
+
let picked = '';
|
|
128
|
+
if (wanted) {
|
|
129
|
+
picked = options.find((option) => option.toLowerCase() === wanted.toLowerCase()) || '';
|
|
130
|
+
if (!picked) return invalid(`"${wanted.slice(0, 80)}" is not an option of this question`);
|
|
131
|
+
}
|
|
132
|
+
const text = multiLine(answer).slice(0, RELAY_ASK_LIMITS.detailMax);
|
|
133
|
+
if (!picked && !text) return invalid('An answer needs a choice or some text');
|
|
134
|
+
// A typed text that is an option counts as that choice.
|
|
135
|
+
if (!picked) picked = options.find((option) => option.toLowerCase() === oneLine(text).toLowerCase()) || '';
|
|
136
|
+
return { ok: true, value: { answer: text || picked, choice: picked } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The kind of a formatted question card (a row of /api/relay-questions). */
|
|
140
|
+
export function relayQuestionKind(question) {
|
|
141
|
+
const kind = toText(question?.kind);
|
|
142
|
+
if (RELAY_QUESTION_KINDS.includes(kind)) return kind;
|
|
143
|
+
const context = question && typeof question.context === 'object' && question.context ? question.context : {};
|
|
144
|
+
return toText(context.source) === 'remote_relay' ? 'approval' : 'ask_user';
|
|
145
|
+
}
|