@oar-sh/oar 0.9.19 → 0.9.21
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 +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '../services/relay-queue-gate-service.mjs';
|
|
16
16
|
import {
|
|
17
17
|
persistConversationModelPreference as persistConversationModelPreferenceTx,
|
|
18
|
+
readPlanOnly,
|
|
18
19
|
} from '../services/conversation-preferences-service.mjs';
|
|
19
20
|
import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
|
|
20
21
|
import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
|
|
@@ -44,6 +45,7 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
|
|
|
44
45
|
import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
45
46
|
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
46
47
|
import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
48
|
+
import { normalizeBuildId } from '../../shared/build-id.mjs';
|
|
47
49
|
import {
|
|
48
50
|
DEFAULT_TASK_STALL_MINUTES,
|
|
49
51
|
TASK_OUTPUT_LIMITS,
|
|
@@ -86,8 +88,11 @@ import {
|
|
|
86
88
|
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
87
89
|
import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
|
|
88
90
|
import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
|
|
91
|
+
import { normalizeTurnTokenUsageReport } from '../../shared/turn-token-usage.mjs';
|
|
89
92
|
import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
|
|
90
93
|
import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
|
|
94
|
+
import { planBoundaryHoldSql } from '../repositories/message-repository.mjs';
|
|
95
|
+
import { PLAN_ONLY_REFUSAL, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
|
|
91
96
|
import {
|
|
92
97
|
admitRemoteRelayRequest,
|
|
93
98
|
readRemoteRelayRequest,
|
|
@@ -921,6 +926,23 @@ export function resolveResponderConversationMismatch({
|
|
|
921
926
|
};
|
|
922
927
|
}
|
|
923
928
|
|
|
929
|
+
/**
|
|
930
|
+
* Whether a pending row must wait for its conversation's live turn to end
|
|
931
|
+
* instead of being handed to the worker, which would steer it into that turn.
|
|
932
|
+
* A steer switches the live turn's mode (the Claude worker re-applies the
|
|
933
|
+
* permission mode on every delivery), so across plan mode it would change
|
|
934
|
+
* what the running turn may do: an "Approve plan" follow-up would give a
|
|
935
|
+
* running plan turn write access, and a plan message would take it from a
|
|
936
|
+
* running write turn. Rows on the same side of plan mode steer as before.
|
|
937
|
+
*/
|
|
938
|
+
export function crossesPlanBoundaryOfLiveTurn({ stmts, row } = {}) {
|
|
939
|
+
const conversationId = String(row?.conversation_id || '').trim();
|
|
940
|
+
if (!conversationId || typeof stmts?.listProcessingQueueByConversation?.all !== 'function') return false;
|
|
941
|
+
const isPlan = (mode) => String(mode || '').trim().toLowerCase() === 'plan';
|
|
942
|
+
const pendingIsPlan = isPlan(row.relay_mode);
|
|
943
|
+
return stmts.listProcessingQueueByConversation.all(conversationId)
|
|
944
|
+
.some((live) => String(live?.id || '') !== String(row.id || '') && isPlan(live?.relay_mode) !== pendingIsPlan);
|
|
945
|
+
}
|
|
924
946
|
export function dequeuePendingMessage({
|
|
925
947
|
db,
|
|
926
948
|
stmts,
|
|
@@ -970,6 +992,10 @@ export function dequeuePendingMessage({
|
|
|
970
992
|
: stmts.findPending.get(currentIso);
|
|
971
993
|
}
|
|
972
994
|
if (!next) return null;
|
|
995
|
+
// Across plan mode, a row waits for the live turn instead of steering in.
|
|
996
|
+
// The claim statements leave such rows out (planBoundaryHoldSql); this is
|
|
997
|
+
// the fail-closed backstop for one that does not.
|
|
998
|
+
if (crossesPlanBoundaryOfLiveTurn({ stmts, row: next })) return null;
|
|
973
999
|
// One processing attempt = one attempt_id. Minted here for every claim —
|
|
974
1000
|
// even for callers that never echo it back — so finalization, requeue, and
|
|
975
1001
|
// question writes can be fenced to the attempt that actually owns the row.
|
|
@@ -1176,6 +1202,12 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1176
1202
|
workerKind = null,
|
|
1177
1203
|
onBillingFence = null,
|
|
1178
1204
|
claudeApiClaimCheck = null,
|
|
1205
|
+
// () → true while the replacement fence (services/worker-replacement.mjs)
|
|
1206
|
+
// is up for the requesting process. The caller asks it before calling; it
|
|
1207
|
+
// is asked again before every claim, since ensureWorker and a transient
|
|
1208
|
+
// error's backoff both wait, and the worker recycle can raise the fence
|
|
1209
|
+
// meanwhile.
|
|
1210
|
+
isReplacementFenced = null,
|
|
1179
1211
|
} = {}) {
|
|
1180
1212
|
const requesterSid = normalizeSessionWorkerId(requesterSessionId);
|
|
1181
1213
|
const routingWithWorker = routingEnabled && requesterSid;
|
|
@@ -1250,6 +1282,18 @@ export async function dequeuePendingMessageForWorkerLoop({
|
|
|
1250
1282
|
|
|
1251
1283
|
let attempt = 0;
|
|
1252
1284
|
while (attempt <= retryLimit) {
|
|
1285
|
+
// The process asking is being replaced: no row is claimed for it, the
|
|
1286
|
+
// same answer as the caller's own fence check gives.
|
|
1287
|
+
if (routingWithWorker && typeof isReplacementFenced === 'function' && isReplacementFenced() === true) {
|
|
1288
|
+
return {
|
|
1289
|
+
message: null,
|
|
1290
|
+
blockedReason: 'worker-replacement',
|
|
1291
|
+
worker: supervisor?.getWorkerState?.(requesterSid) || null,
|
|
1292
|
+
lifecycle: supervisor?.getLifecycleState?.(requesterSid) || null,
|
|
1293
|
+
fallbackRestart: null,
|
|
1294
|
+
attempts: attempt,
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1253
1297
|
const currentIso = String(nowIso || '').trim() || new Date().toISOString();
|
|
1254
1298
|
try {
|
|
1255
1299
|
const message = dequeuePendingMessage({
|
|
@@ -2040,6 +2084,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2040
2084
|
// killTmuxSessionImpl). Tests must inject these instead of relying on the
|
|
2041
2085
|
// host OS, so the suite behaves identically on Windows and POSIX.
|
|
2042
2086
|
sessionWorkerStopOverrides = null,
|
|
2087
|
+
// The replacement fence (services/worker-replacement.mjs): a worker
|
|
2088
|
+
// process the recycle is replacing is handed no work by /api/pending.
|
|
2089
|
+
workerReplacementFence = null,
|
|
2043
2090
|
resolveSessionStateRoot,
|
|
2044
2091
|
fetchUsageSummary = null,
|
|
2045
2092
|
planUsageService = null,
|
|
@@ -2049,6 +2096,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2049
2096
|
claudeBillingSwitchService = null,
|
|
2050
2097
|
// The Claude API booking (claude-api-usage-service.mjs).
|
|
2051
2098
|
claudeApiUsageService = null,
|
|
2099
|
+
// Tokens per turn for every provider (token-usage-service.mjs).
|
|
2100
|
+
tokenUsageService = null,
|
|
2052
2101
|
// The delivery fence's runtime half for GET /api/pending, as on the
|
|
2053
2102
|
// socket: the Claude API claim check (null: no refusal) and what a fenced
|
|
2054
2103
|
// row does (the swap, or fail closed).
|
|
@@ -2640,6 +2689,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2640
2689
|
AND rs.sdk_session_id = ?
|
|
2641
2690
|
)
|
|
2642
2691
|
AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
|
|
2692
|
+
AND ${planBoundaryHoldSql('q')}
|
|
2643
2693
|
ORDER BY q.timestamp ASC, q.rowid ASC
|
|
2644
2694
|
LIMIT 1
|
|
2645
2695
|
`);
|
|
@@ -4804,6 +4854,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4804
4854
|
? normalizeRelayMode(existingConversation.preferred_relay_mode)
|
|
4805
4855
|
: null;
|
|
4806
4856
|
const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
|
|
4857
|
+
// Plan-only: an agent's send that names another mode is refused
|
|
4858
|
+
// and nothing is queued (nor a billing switched). Without a mode it runs
|
|
4859
|
+
// in the session's own. The owner's own sends are not limited.
|
|
4860
|
+
if (remoteRequest.remote && readPlanOnly(existingConversation) && planOnlyRefusesMode(relayMode || mode)) {
|
|
4861
|
+
return res.status(409).json({ ...PLAN_ONLY_REFUSAL, conversationId });
|
|
4862
|
+
}
|
|
4807
4863
|
const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
|
|
4808
4864
|
const normalizedAttachments = normalizeAttachments(rawAttachments);
|
|
4809
4865
|
const requestedAttachmentCount = Math.min(
|
|
@@ -6211,7 +6267,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6211
6267
|
// Whether a billing swap may replace the worker now, and why not.
|
|
6212
6268
|
|| (stored.releasable ?? null) !== (steering.releasable ?? null)
|
|
6213
6269
|
|| (stored.releaseHold || null) !== (steering.releaseHold || null)
|
|
6214
|
-
|| (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0)
|
|
6270
|
+
|| (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0)
|
|
6271
|
+
// Whether the worker's CLI child still runs: the worker recycle
|
|
6272
|
+
// waits for it to be gone, so a flip must be stored.
|
|
6273
|
+
|| (stored.cliAlive ?? null) !== (steering.cliAlive ?? null);
|
|
6215
6274
|
if (workerForSteering && changed) {
|
|
6216
6275
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
|
|
6217
6276
|
// Pushed rather than left for the clients' status poll: the
|
|
@@ -6229,6 +6288,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6229
6288
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
|
|
6230
6289
|
}
|
|
6231
6290
|
}
|
|
6291
|
+
// The build id of the code the worker started on, recorded the same way:
|
|
6292
|
+
// only when it says one and the registry has another, so quiet
|
|
6293
|
+
// heartbeats stay write-free.
|
|
6294
|
+
const heartbeatBuildId = normalizeBuildId(req.body?.buildId);
|
|
6295
|
+
if (heartbeatBuildId) {
|
|
6296
|
+
const workerForBuild = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
|
|
6297
|
+
if (workerForBuild && workerForBuild.buildId !== heartbeatBuildId) {
|
|
6298
|
+
sessionWorkerRegistry?.upsertWorker?.({ ...workerForBuild, buildId: heartbeatBuildId });
|
|
6299
|
+
}
|
|
6300
|
+
}
|
|
6232
6301
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
6233
6302
|
// The worker's digest of its live background-task ids against the set
|
|
6234
6303
|
// held for it (keyed as the worker posts, by its session id). They
|
|
@@ -6611,6 +6680,59 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6611
6680
|
res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
|
|
6612
6681
|
});
|
|
6613
6682
|
|
|
6683
|
+
// POST /api/turn-token-usage — every worker reports, after a turn, the
|
|
6684
|
+
// turn's tokens per model (`models`), or a Claude cloud worker the
|
|
6685
|
+
// session's running totals (`sessionTotals`), keyed by turn, run and model
|
|
6686
|
+
// (shared/turn-token-usage.mjs). Advisory and idempotent: a retried or
|
|
6687
|
+
// reordered report books only what it adds (token-usage-service.mjs).
|
|
6688
|
+
// Who ran the turn is the relay's to say: the chat's provider, and for a
|
|
6689
|
+
// Claude chat the posting worker's registered kind (the billing can switch
|
|
6690
|
+
// between turns). A body that says otherwise is logged. A deleted chat
|
|
6691
|
+
// takes no report, so a late retry cannot book its turn a second time; an
|
|
6692
|
+
// archived one does.
|
|
6693
|
+
app.post('/api/turn-token-usage', auth, (req, res) => {
|
|
6694
|
+
touchCli();
|
|
6695
|
+
if (!tokenUsageService) return res.status(501).json({ error: 'Token usage storage is unavailable' });
|
|
6696
|
+
const parsed = normalizeTurnTokenUsageReport(req.body);
|
|
6697
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
6698
|
+
const { report } = parsed;
|
|
6699
|
+
const conversationId = report.conversationId;
|
|
6700
|
+
if (!stmts.getConv?.get?.(conversationId)) return res.status(404).json({ error: 'Conversation not found' });
|
|
6701
|
+
// The binding's provider wins over the report's. A chat whose binding is
|
|
6702
|
+
// gone for a moment (a cache rebuild) still books, as the provider the
|
|
6703
|
+
// report states.
|
|
6704
|
+
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
|
|
6705
|
+
const chatProvider = String(runtimeSession?.provider_type || report.provider || '').trim().toLowerCase();
|
|
6706
|
+
let provider = chatProvider;
|
|
6707
|
+
let billing = '';
|
|
6708
|
+
if (chatProvider === 'openai') {
|
|
6709
|
+
// A chat on the user's own OpenAI key runs on the Copilot SDK.
|
|
6710
|
+
provider = 'github';
|
|
6711
|
+
billing = 'byok-openai';
|
|
6712
|
+
} else if (chatProvider === 'github') {
|
|
6713
|
+
billing = report.billing;
|
|
6714
|
+
} else if (normalizeClaudeBilling(chatProvider)) {
|
|
6715
|
+
const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
|
|
6716
|
+
const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
|
|
6717
|
+
// The worker that ran the turn, then the chat's billing; the report's
|
|
6718
|
+
// own word only when the chat has no binding.
|
|
6719
|
+
provider = registeredKind || normalizeClaudeBilling(chatProvider);
|
|
6720
|
+
} else if (!['claude-cloud', 'deepseek', 'cursor', 'grok'].includes(chatProvider)) {
|
|
6721
|
+
return res.status(409).json({ error: 'Conversation keeps no token usage' });
|
|
6722
|
+
}
|
|
6723
|
+
if (report.provider && report.provider !== provider) {
|
|
6724
|
+
console.warn(`[${ts()}] token usage conv=${conversationId.slice(0, 8)} stated=${report.provider} booked=${provider}`);
|
|
6725
|
+
}
|
|
6726
|
+
if (report.sessionTotals && provider !== 'claude-cloud') {
|
|
6727
|
+
return res.status(400).json({ error: 'sessionTotals is for Claude cloud only' });
|
|
6728
|
+
}
|
|
6729
|
+
const booked = report.sessionTotals
|
|
6730
|
+
? tokenUsageService.recordCloudSessionTotals({ conversationId, provider, billing, report })
|
|
6731
|
+
: tokenUsageService.recordTurn({ conversationId, provider, billing, report });
|
|
6732
|
+
if (!booked) return res.status(501).json({ error: 'Token usage storage is unavailable' });
|
|
6733
|
+
res.json({ ok: true, provider, billing, models: booked.map((entry) => entry.model) });
|
|
6734
|
+
});
|
|
6735
|
+
|
|
6614
6736
|
// POST /api/claude-plan-usage — Claude worker reports the session's
|
|
6615
6737
|
// structured /usage data (plan rate-limit windows + session cost totals).
|
|
6616
6738
|
//
|
|
@@ -6922,6 +7044,24 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6922
7044
|
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
6923
7045
|
});
|
|
6924
7046
|
}
|
|
7047
|
+
// The process asking is being replaced (the worker recycle): no work,
|
|
7048
|
+
// as for a killed session, so no turn starts on the pid being stopped.
|
|
7049
|
+
// Unlike the killed marker, the fence outlives the marker's grace and
|
|
7050
|
+
// every clearRestartSchedule; the new worker has another pid and takes
|
|
7051
|
+
// what waited meanwhile.
|
|
7052
|
+
if (workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true) {
|
|
7053
|
+
return res.json({
|
|
7054
|
+
message: null,
|
|
7055
|
+
routing: {
|
|
7056
|
+
enabled: true,
|
|
7057
|
+
requesterSessionId,
|
|
7058
|
+
blockedReason: 'worker-replacement',
|
|
7059
|
+
lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
|
|
7060
|
+
fallbackRestart: null,
|
|
7061
|
+
},
|
|
7062
|
+
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
7063
|
+
});
|
|
7064
|
+
}
|
|
6925
7065
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
6926
7066
|
}
|
|
6927
7067
|
if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
|
|
@@ -7016,6 +7156,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7016
7156
|
workerKind: httpPendingWorkerKind(requesterSessionId),
|
|
7017
7157
|
claudeApiClaimCheck,
|
|
7018
7158
|
onBillingFence: onClaudeBillingFence,
|
|
7159
|
+
// The fence check above, asked again before every claim.
|
|
7160
|
+
isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
|
|
7019
7161
|
telemetry: (payload = {}) => {
|
|
7020
7162
|
if (!sessionWorkerRoutingEnabled) return;
|
|
7021
7163
|
emitSessionWorkerTelemetry(payload.event, {
|
|
@@ -7236,6 +7378,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7236
7378
|
workerKind: httpPendingWorkerKind(requesterSessionId),
|
|
7237
7379
|
claudeApiClaimCheck,
|
|
7238
7380
|
onBillingFence: onClaudeBillingFence,
|
|
7381
|
+
isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
|
|
7239
7382
|
});
|
|
7240
7383
|
if (recoveryDequeue?.message) {
|
|
7241
7384
|
msg = recoveryDequeue.message;
|
|
@@ -27,12 +27,15 @@
|
|
|
27
27
|
// relay from its Inbox: asks only;
|
|
28
28
|
// an approval or any other card is
|
|
29
29
|
// never answered over the pairing
|
|
30
|
-
// GET /api/relay-asks/remote/inbox what its Inbox shows of this relay
|
|
30
|
+
// GET /api/relay-asks/remote/inbox what its Inbox shows of this relay;
|
|
31
|
+
// `?push=1`: it can push, and each
|
|
32
|
+
// card a turn waits on says who does
|
|
33
|
+
// (`notice`)
|
|
31
34
|
//
|
|
32
35
|
// Every text of an ask or card is an agent's: it goes out as data, and the
|
|
33
36
|
// browser shows it as plain text.
|
|
34
37
|
|
|
35
|
-
import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
|
|
38
|
+
import { REMOTE_INBOX_CLAIMS, REMOTE_INBOX_NOTICES, REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
|
|
36
39
|
import { RELAY_ASK_LIMITS, relayQuestionKind } from '../../shared/relay-asks.mjs';
|
|
37
40
|
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
38
41
|
import { findOriginRemoteRelay, admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
@@ -53,6 +56,8 @@ const RELAY_ASK_ERROR_STATUS = Object.freeze({
|
|
|
53
56
|
const INBOX_REMOTE_TIMEOUT_MS = 5_000;
|
|
54
57
|
// Cards answered in the last day are listed under the divider, at most these.
|
|
55
58
|
const INBOX_ANSWERED_CARDS_MAX = 50;
|
|
59
|
+
// Plan cards, pending (one per session) and settled in the last day, at most these each.
|
|
60
|
+
const INBOX_PLAN_CARDS_MAX = 50;
|
|
56
61
|
// Registry states that mean "do not even try" for the Inbox.
|
|
57
62
|
const UNREACHABLE_STATES = new Set(['offline', 'unauthorized']);
|
|
58
63
|
|
|
@@ -90,7 +95,15 @@ export function inboxItemOfCard(question, session, { forRemote = false } = {}) {
|
|
|
90
95
|
createdAt: question?.createdAt || null,
|
|
91
96
|
...(question?.status === 'answered' ? { answeredAt: question?.answeredAt || null } : {}),
|
|
92
97
|
};
|
|
93
|
-
|
|
98
|
+
// Its kind and summary say what it is about, never what it would send.
|
|
99
|
+
if (forRemote && kind === 'approval') {
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
question: '',
|
|
103
|
+
...(toText(context.variant) ? { variant: toText(context.variant) } : {}),
|
|
104
|
+
...(toText(context.summary) ? { summary: toText(context.summary) } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
94
107
|
const fields = schemaFields(question?.requestSchema);
|
|
95
108
|
return {
|
|
96
109
|
...base,
|
|
@@ -107,6 +120,37 @@ export function inboxItemOfCard(question, session, { forRemote = false } = {}) {
|
|
|
107
120
|
};
|
|
108
121
|
}
|
|
109
122
|
|
|
123
|
+
/**
|
|
124
|
+
* One Inbox item of a plan card (a formatted relay board). A paired
|
|
125
|
+
* relay's Inbox gets its title and status alone, "Open there": a card is
|
|
126
|
+
* never answered over the pairing. `question` repeats the title for one
|
|
127
|
+
* that does not know plan items (0.9.19 shows it as a card's question).
|
|
128
|
+
*/
|
|
129
|
+
export function inboxItemOfPlanBoard(board, session, { forRemote = false } = {}) {
|
|
130
|
+
const title = toText(board?.title) || 'Plan ready for review';
|
|
131
|
+
const status = toText(board?.status) || 'pending';
|
|
132
|
+
const base = {
|
|
133
|
+
id: toText(board?.id),
|
|
134
|
+
kind: 'plan',
|
|
135
|
+
session,
|
|
136
|
+
title,
|
|
137
|
+
status,
|
|
138
|
+
createdAt: board?.createdAt || null,
|
|
139
|
+
};
|
|
140
|
+
if (forRemote) return { ...base, question: title };
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
...(status !== 'pending' ? { actedAt: board?.actedAt || null } : {}),
|
|
144
|
+
body: String(board?.body || ''),
|
|
145
|
+
actions: (Array.isArray(board?.actions) ? board.actions : [])
|
|
146
|
+
.map((action) => ({ id: toText(action?.id), label: toText(action?.label) || toText(action?.id) }))
|
|
147
|
+
.filter((action) => action.id),
|
|
148
|
+
recommendedAction: toText(board?.recommendedAction) || null,
|
|
149
|
+
planOnly: board?.planOnly === true,
|
|
150
|
+
selectedAction: toText(board?.selectedAction) || null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
110
154
|
/** One Inbox item of an ask (relay-ask-service.mjs shape). */
|
|
111
155
|
export function inboxItemOfAsk(ask) {
|
|
112
156
|
const {
|
|
@@ -139,6 +183,8 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
139
183
|
db,
|
|
140
184
|
stmts,
|
|
141
185
|
formatQuestionRow,
|
|
186
|
+
// Plan cards (relay boards) in the Inbox, formatted as their routes do.
|
|
187
|
+
formatRelayBoardRow = null,
|
|
142
188
|
relayAskService = null,
|
|
143
189
|
relayAskLinkService = null,
|
|
144
190
|
remoteRelayInbound = null,
|
|
@@ -146,6 +192,9 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
146
192
|
// calls them (remote-relay-client.mjs): the Inbox of paired relays.
|
|
147
193
|
remoteRelayRegistry = null,
|
|
148
194
|
remoteRelayClient = null,
|
|
195
|
+
// push-dispatch-service.mjs questionNotice: whether this relay's own push
|
|
196
|
+
// of a card reached a device.
|
|
197
|
+
questionNotice = null,
|
|
149
198
|
now = () => Date.now(),
|
|
150
199
|
} = deps;
|
|
151
200
|
|
|
@@ -154,6 +203,26 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
154
203
|
SELECT * FROM relay_questions WHERE status = 'answered' AND answered_at >= ? ORDER BY answered_at DESC LIMIT ?
|
|
155
204
|
`) || null;
|
|
156
205
|
|
|
206
|
+
// Plan cards: of each session that is neither deleted nor
|
|
207
|
+
// archived the newest pending one (an older one stays in its session),
|
|
208
|
+
// and the ones settled in the last day.
|
|
209
|
+
const planBoardSessionFilter = `c.status != 'deleted' AND COALESCE(c.archived, 0) = 0`;
|
|
210
|
+
const pendingPlanBoardsStmt = db?.prepare?.(`
|
|
211
|
+
SELECT b.* FROM relay_boards b JOIN conversations c ON c.id = b.conversation_id
|
|
212
|
+
WHERE b.status = 'pending' AND b.board_type = 'plan_ready' AND ${planBoardSessionFilter}
|
|
213
|
+
AND NOT EXISTS (
|
|
214
|
+
SELECT 1 FROM relay_boards newer
|
|
215
|
+
WHERE newer.conversation_id = b.conversation_id AND newer.status = 'pending' AND newer.board_type = 'plan_ready'
|
|
216
|
+
AND (newer.created_at > b.created_at OR (newer.created_at = b.created_at AND newer.rowid > b.rowid))
|
|
217
|
+
)
|
|
218
|
+
ORDER BY b.created_at DESC LIMIT ?
|
|
219
|
+
`) || null;
|
|
220
|
+
const settledPlanBoardsStmt = db?.prepare?.(`
|
|
221
|
+
SELECT b.* FROM relay_boards b JOIN conversations c ON c.id = b.conversation_id
|
|
222
|
+
WHERE b.status IN ('acted', 'dismissed') AND b.board_type = 'plan_ready' AND b.acted_at >= ? AND ${planBoardSessionFilter}
|
|
223
|
+
ORDER BY b.acted_at DESC LIMIT ?
|
|
224
|
+
`) || null;
|
|
225
|
+
|
|
157
226
|
function sessionOf(id) {
|
|
158
227
|
let row = null;
|
|
159
228
|
try {
|
|
@@ -204,13 +273,20 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
204
273
|
return { relayId, name: toText(paired?.name) || claimed || toText(request.origin?.relayName) || relayId };
|
|
205
274
|
}
|
|
206
275
|
|
|
207
|
-
/**
|
|
208
|
-
|
|
276
|
+
/**
|
|
277
|
+
* What an Inbox shows of this relay: its asks, and the cards turns wait on.
|
|
278
|
+
* `read`, when given, learns whether the pending cards could be read
|
|
279
|
+
* (`read.pendingCards`): a failed read lists none, which is not "none pending".
|
|
280
|
+
*/
|
|
281
|
+
function localInboxItems({ forRemote = false, read = null } = {}) {
|
|
209
282
|
const items = (relayAskService?.isAvailable?.() ? relayAskService.listHere() : []).map(inboxItemOfAsk);
|
|
210
283
|
let pending = [];
|
|
211
284
|
try {
|
|
212
285
|
pending = stmts.listQuestions.all('pending', null, null).map(formatQuestionRow).filter(Boolean);
|
|
213
|
-
|
|
286
|
+
if (read) read.pendingCards = true;
|
|
287
|
+
} catch {
|
|
288
|
+
if (read) read.pendingCards = false;
|
|
289
|
+
}
|
|
214
290
|
let answered = [];
|
|
215
291
|
try {
|
|
216
292
|
const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
|
|
@@ -219,6 +295,19 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
219
295
|
for (const question of [...pending, ...answered]) {
|
|
220
296
|
items.push(inboxItemOfCard(question, sessionOf(question.conversationId), { forRemote }));
|
|
221
297
|
}
|
|
298
|
+
if (typeof formatRelayBoardRow === 'function') {
|
|
299
|
+
let boards = [];
|
|
300
|
+
try {
|
|
301
|
+
const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
|
|
302
|
+
boards = [
|
|
303
|
+
...(pendingPlanBoardsStmt?.all(INBOX_PLAN_CARDS_MAX) || []),
|
|
304
|
+
...(settledPlanBoardsStmt?.all(since, INBOX_PLAN_CARDS_MAX) || []),
|
|
305
|
+
].map(formatRelayBoardRow).filter(Boolean);
|
|
306
|
+
} catch {}
|
|
307
|
+
for (const board of boards) {
|
|
308
|
+
items.push(inboxItemOfPlanBoard(board, sessionOf(board.conversationId), { forRemote }));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
222
311
|
return items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
223
312
|
}
|
|
224
313
|
|
|
@@ -375,11 +464,52 @@ export function registerRelayAskRoutes(app, deps) {
|
|
|
375
464
|
}
|
|
376
465
|
});
|
|
377
466
|
|
|
378
|
-
//
|
|
467
|
+
// Who pushes a card a turn waits on: question id → the paired
|
|
468
|
+
// relay (its id) that claimed it. In memory: after a restart a
|
|
469
|
+
// card is just claimable again.
|
|
470
|
+
const claims = new Map();
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* `notice` (and with `you`, `claimed`) for each pending approval and
|
|
474
|
+
* ask_user item, for a caller that can push: this relay's own push
|
|
475
|
+
* decides first, then the first caller to find it missed claims it.
|
|
476
|
+
*/
|
|
477
|
+
function addNotices(items, callerRelayId) {
|
|
478
|
+
const pendingIds = new Set();
|
|
479
|
+
const noticed = items.map((item) => {
|
|
480
|
+
if (item.status !== 'pending' || (item.kind !== 'approval' && item.kind !== 'ask_user')) return item;
|
|
481
|
+
pendingIds.add(item.id);
|
|
482
|
+
let own = null;
|
|
483
|
+
try {
|
|
484
|
+
own = typeof questionNotice === 'function' ? questionNotice(item.id) : null;
|
|
485
|
+
} catch {}
|
|
486
|
+
if (own === 'reached') return { ...item, notice: REMOTE_INBOX_NOTICES.here };
|
|
487
|
+
if (own === 'pending') return { ...item, notice: REMOTE_INBOX_NOTICES.wait };
|
|
488
|
+
const claim = claims.get(item.id);
|
|
489
|
+
if (!claim) {
|
|
490
|
+
claims.set(item.id, callerRelayId);
|
|
491
|
+
return { ...item, notice: REMOTE_INBOX_NOTICES.you, claimed: REMOTE_INBOX_CLAIMS.now };
|
|
492
|
+
}
|
|
493
|
+
if (claim === callerRelayId) return { ...item, notice: REMOTE_INBOX_NOTICES.you, claimed: REMOTE_INBOX_CLAIMS.before };
|
|
494
|
+
return { ...item, notice: REMOTE_INBOX_NOTICES.other };
|
|
495
|
+
});
|
|
496
|
+
// A card no longer pending needs no claim.
|
|
497
|
+
for (const id of claims.keys()) {
|
|
498
|
+
if (!pendingIds.has(id)) claims.delete(id);
|
|
499
|
+
}
|
|
500
|
+
return noticed;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// GET /api/relay-asks/remote/inbox — what a paired relay's Inbox shows of
|
|
504
|
+
// this one. `?push=1`: the caller can push, and learns who pushes each card.
|
|
379
505
|
app.get('/api/relay-asks/remote/inbox', auth, (req, res) => {
|
|
380
506
|
const caller = admitPairedRelay(req, res);
|
|
381
507
|
if (!caller) return;
|
|
382
|
-
const
|
|
508
|
+
const read = {};
|
|
509
|
+
let items = localInboxItems({ forRemote: true, read });
|
|
510
|
+
// Pending cards that could not be read are not "no card pending": the
|
|
511
|
+
// claims stay, and no card is claimed or offered on this read.
|
|
512
|
+
if (toText(req.query?.push) === '1' && read.pendingCards) items = addNotices(items, caller.relayId);
|
|
383
513
|
res.json({ ok: true, items, pending: pendingOf(items) });
|
|
384
514
|
});
|
|
385
515
|
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
|
|
3
|
+
import { persistConversationPreferences, readPlanOnly } from '../services/conversation-preferences-service.mjs';
|
|
4
4
|
import { fallbackReasoningEffort } from '../services/conversation-model-fallback.mjs';
|
|
5
|
+
import {
|
|
6
|
+
PLAN_BOARD_APPROVE_ONLY,
|
|
7
|
+
isPendingPlanBoard,
|
|
8
|
+
planBoardActionLabel,
|
|
9
|
+
planBoardFollowupMode,
|
|
10
|
+
planBoardFollowupText,
|
|
11
|
+
} from '../../shared/plan-board-actions.mjs';
|
|
5
12
|
|
|
6
13
|
function normalizeId(value) {
|
|
7
14
|
const text = String(value || '').trim();
|
|
@@ -20,34 +27,6 @@ function normalizeBoardType(value) {
|
|
|
20
27
|
return text.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
function labelForAction(actionId) {
|
|
24
|
-
const id = String(actionId || '').trim().toLowerCase();
|
|
25
|
-
if (id === 'autopilot') return 'Implement in autopilot';
|
|
26
|
-
if (id === 'autopilot_fleet') return 'Implement with autopilot fleet';
|
|
27
|
-
if (id === 'interactive') return 'Stop here and prompt myself';
|
|
28
|
-
if (id === 'exit_only') return 'Stop here';
|
|
29
|
-
return id.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()) || 'Select';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function defaultPromptForAction(actionId, board = null) {
|
|
33
|
-
const summary = String(board?.body || '').trim();
|
|
34
|
-
if (actionId === 'autopilot') {
|
|
35
|
-
return `Please implement the approved plan now.\n\nPlan summary:\n${summary}`;
|
|
36
|
-
}
|
|
37
|
-
if (actionId === 'autopilot_fleet') {
|
|
38
|
-
return `Please implement the approved plan using autopilot_fleet where useful.\n\nPlan summary:\n${summary}`;
|
|
39
|
-
}
|
|
40
|
-
if (actionId === 'interactive') {
|
|
41
|
-
return `I approved the plan. Let's proceed interactively with implementation.\n\nPlan summary:\n${summary}`;
|
|
42
|
-
}
|
|
43
|
-
return '';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function modeForAction(actionId, fallbackMode) {
|
|
47
|
-
if (actionId === 'autopilot' || actionId === 'autopilot_fleet') return 'autopilot';
|
|
48
|
-
if (actionId === 'interactive') return 'agent';
|
|
49
|
-
return fallbackMode;
|
|
50
|
-
}
|
|
51
30
|
|
|
52
31
|
function normalizeBoardActions(rawActions) {
|
|
53
32
|
const input = Array.isArray(rawActions) ? rawActions : [];
|
|
@@ -61,7 +40,7 @@ function normalizeBoardActions(rawActions) {
|
|
|
61
40
|
const id = String(item.id || item.actionId || item.value || '').trim().toLowerCase();
|
|
62
41
|
if (!id || seen.has(id)) continue;
|
|
63
42
|
seen.add(id);
|
|
64
|
-
const label = String(item.label || item.title ||
|
|
43
|
+
const label = String(item.label || item.title || planBoardActionLabel(id)).trim().slice(0, 120) || planBoardActionLabel(id);
|
|
65
44
|
const mode = normalizeId(item.mode);
|
|
66
45
|
const prompt = normalizeBoardText(item.prompt || '', '');
|
|
67
46
|
normalized.push({
|
|
@@ -99,6 +78,7 @@ export function registerRelayBoardRoutes(app, deps) {
|
|
|
99
78
|
DEFAULT_MODEL,
|
|
100
79
|
formatRelayBoardRow,
|
|
101
80
|
pushDispatchService,
|
|
81
|
+
relayEventService,
|
|
102
82
|
} = deps;
|
|
103
83
|
|
|
104
84
|
/**
|
|
@@ -201,7 +181,12 @@ export function registerRelayBoardRoutes(app, deps) {
|
|
|
201
181
|
const boardId = uuidv4();
|
|
202
182
|
const relayMode = normalizeRelayMode(mode || q.relay_mode) || DEFAULT_RELAY_MODE;
|
|
203
183
|
const normalizedActions = normalizeBoardActions(actions);
|
|
204
|
-
|
|
184
|
+
// A plan-only session's plan card recommends "Approve plan", whatever the
|
|
185
|
+
// worker or the Copilot CLI recommended.
|
|
186
|
+
const boardConversationId = normalizeId(conversationId) || q.conversation_id;
|
|
187
|
+
const planOnly = normalizedBoardType === 'plan_ready'
|
|
188
|
+
&& readPlanOnly(stmts.getConvAnyStatus?.get?.(boardConversationId));
|
|
189
|
+
const normalizedRecommended = planOnly ? PLAN_BOARD_APPROVE_ONLY : normalizeId(recommendedAction);
|
|
205
190
|
const boardContext = parseContext(context);
|
|
206
191
|
|
|
207
192
|
stmts.insertBoard.run(
|
|
@@ -240,23 +225,43 @@ export function registerRelayBoardRoutes(app, deps) {
|
|
|
240
225
|
}
|
|
241
226
|
|
|
242
227
|
const board = formatRelayBoardRow(row);
|
|
228
|
+
const conversationRow = stmts.getConvAnyStatus?.get?.(String(board?.conversationId || '').trim()) || null;
|
|
229
|
+
if (conversationRow && String(conversationRow.status || '').trim() === 'deleted') {
|
|
230
|
+
return res.status(404).json({ error: 'Conversation not found' });
|
|
231
|
+
}
|
|
243
232
|
const actions = Array.isArray(board?.actions) ? board.actions : [];
|
|
244
|
-
const
|
|
233
|
+
const chosen = actionId.toLowerCase();
|
|
234
|
+
// "Approve plan" is taken on any pending plan card, also one stored
|
|
235
|
+
// before the button existed.
|
|
236
|
+
const selected = actions.find((item) => String(item?.id || '').trim().toLowerCase() === chosen)
|
|
237
|
+
|| (chosen === PLAN_BOARD_APPROVE_ONLY && isPendingPlanBoard(board)
|
|
238
|
+
? { id: PLAN_BOARD_APPROVE_ONLY, label: planBoardActionLabel(PLAN_BOARD_APPROVE_ONLY), mode: planBoardFollowupMode(PLAN_BOARD_APPROVE_ONLY) }
|
|
239
|
+
: null);
|
|
245
240
|
if (!selected) return res.status(400).json({ error: 'Unknown board action' });
|
|
246
241
|
|
|
247
242
|
const now = new Date().toISOString();
|
|
248
|
-
const shouldDismiss =
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
243
|
+
const shouldDismiss = chosen === 'exit_only';
|
|
244
|
+
const approveOnly = chosen === PLAN_BOARD_APPROVE_ONLY;
|
|
245
|
+
// Only a pending card is settled: a second press (here or in the Inbox)
|
|
246
|
+
// finds it settled already and queues nothing.
|
|
247
|
+
const settledRow = shouldDismiss
|
|
248
|
+
? stmts.dismissBoard.run(chosen, now, now, boardId)
|
|
249
|
+
: stmts.markBoardAction.run(chosen, now, now, boardId);
|
|
250
|
+
if (Number(settledRow?.changes) !== 1) {
|
|
251
|
+
const current = stmts.getBoard.get(boardId);
|
|
252
|
+
return res.status(409).json({ error: `Board already ${current?.status || 'settled'}`, board: formatRelayBoardRow(current) });
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
let queuedMessageId = null;
|
|
256
256
|
if (!shouldDismiss) {
|
|
257
|
-
|
|
257
|
+
// approve_only says exactly its line; the others may carry their own.
|
|
258
|
+
const followupPrompt = normalizeBoardText(approveOnly ? planBoardFollowupText(chosen) : (selected.prompt || planBoardFollowupText(chosen)), '');
|
|
258
259
|
if (followupPrompt) {
|
|
259
|
-
|
|
260
|
+
// approve_only runs this one turn in agent mode (plan mode blocks
|
|
261
|
+
// the writes its plan-only steps need); the session stays in plan.
|
|
262
|
+
const relayMode = normalizeRelayMode(approveOnly
|
|
263
|
+
? planBoardFollowupMode(chosen)
|
|
264
|
+
: (selected.mode || planBoardFollowupMode(chosen) || board.mode)) || DEFAULT_RELAY_MODE;
|
|
260
265
|
const convId = String(board?.conversationId || '').trim();
|
|
261
266
|
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(convId) || null;
|
|
262
267
|
const latestModel = stmts.getLatestConversationModel?.get?.(convId)?.model || null;
|
|
@@ -323,11 +328,21 @@ export function registerRelayBoardRoutes(app, deps) {
|
|
|
323
328
|
timestamp: now,
|
|
324
329
|
},
|
|
325
330
|
});
|
|
326
|
-
followChosenMode(convId, relayMode, now);
|
|
331
|
+
if (!approveOnly) followChosenMode(convId, relayMode, now);
|
|
327
332
|
}
|
|
328
333
|
}
|
|
329
334
|
|
|
330
335
|
const updated = formatRelayBoardRow(stmts.getBoard.get(boardId));
|
|
336
|
+
try {
|
|
337
|
+
relayEventService?.noteBoardSettled?.(String(updated?.conversationId || board?.conversationId || ''), {
|
|
338
|
+
boardId,
|
|
339
|
+
boardType: updated?.boardType || board?.boardType || null,
|
|
340
|
+
action: chosen,
|
|
341
|
+
actionLabel: String(selected.label || '').trim() || planBoardActionLabel(chosen),
|
|
342
|
+
title: updated?.title || board?.title || '',
|
|
343
|
+
queuedMessageId,
|
|
344
|
+
});
|
|
345
|
+
} catch {}
|
|
331
346
|
io.emit('relay_board_updated', { board: updated });
|
|
332
347
|
io.emit('relay_board_changed', { conversationId: updated?.conversationId || null, boardId });
|
|
333
348
|
return res.json({
|