@oar-sh/oar 0.9.18 → 0.9.20
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/server-lifecycle/managed-server.mjs +13 -1
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +359 -0
- package/README.md +10 -10
- package/bin/oar.js +391 -97
- 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 +27 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -2
- package/server/claude-worker/claude-session-process.mjs +3 -0
- 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 +57 -4
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +44 -6
- 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-view.mjs +87 -3
- package/server/public/app/api-client.js +2 -1
- package/server/public/app/bootstrap.js +55 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-view.js +17 -2
- package/server/public/app/inbox-ui.js +32 -0
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- 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-view.mjs +4 -0
- package/server/public/app/socket-handlers.js +10 -2
- package/server/public/index.html +14 -0
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +351 -0
- package/server/relay-self-restart.mjs +116 -9
- package/server/relay-tools.md +5 -4
- package/server/repositories/message-repository.mjs +31 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/messages-routes.mjs +120 -4
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +141 -31
- package/server/server-runtime.mjs +142 -11
- package/server/services/about-service.mjs +59 -0
- package/server/services/cache-rebuild-service.mjs +7 -0
- 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 +68 -8
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-stop-service.mjs +27 -6
- 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-scope.mjs +77 -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 +98 -21
- package/shared/turn-token-usage.mjs +144 -0
|
@@ -75,6 +75,12 @@ export function createRemoteRelayRepository(db) {
|
|
|
75
75
|
const getMayCreateSessions = hasMayCreateColumn
|
|
76
76
|
? db.prepare(`SELECT may_create_sessions FROM conversations WHERE id = ?`)
|
|
77
77
|
: null;
|
|
78
|
+
// Plan-only (migration 0035). Without the column no session is.
|
|
79
|
+
const hasPlanOnlyColumn = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
80
|
+
.some((column) => column.name === 'plan_only');
|
|
81
|
+
const getPlanOnly = hasPlanOnlyColumn
|
|
82
|
+
? db.prepare(`SELECT plan_only FROM conversations WHERE id = ?`)
|
|
83
|
+
: null;
|
|
78
84
|
|
|
79
85
|
return {
|
|
80
86
|
hasUnlock(conversationId, remoteRelayId) {
|
|
@@ -161,6 +167,10 @@ export function createRemoteRelayRepository(db) {
|
|
|
161
167
|
mayCreateSessions(conversationId) {
|
|
162
168
|
return Number(getMayCreateSessions?.get(String(conversationId || ''))?.may_create_sessions) === 1;
|
|
163
169
|
},
|
|
170
|
+
/** Is this conversation plan-only (agents cannot take it out of plan mode)? */
|
|
171
|
+
planOnly(conversationId) {
|
|
172
|
+
return Number(getPlanOnly?.get(String(conversationId || ''))?.plan_only) === 1;
|
|
173
|
+
},
|
|
164
174
|
};
|
|
165
175
|
}
|
|
166
176
|
|
|
@@ -45,6 +45,10 @@ export function createSessionRepository(db) {
|
|
|
45
45
|
const conversationsHaveMayCreateSessions = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
46
46
|
.some((column) => String(column?.name || '').trim() === 'may_create_sessions');
|
|
47
47
|
const conversationMayCreateSelect = conversationsHaveMayCreateSessions ? 'c.may_create_sessions' : 'NULL AS may_create_sessions';
|
|
48
|
+
// Plan-only (migration 0035), the same way.
|
|
49
|
+
const conversationsHavePlanOnly = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
50
|
+
.some((column) => String(column?.name || '').trim() === 'plan_only');
|
|
51
|
+
const conversationPlanOnlySelect = conversationsHavePlanOnly ? 'c.plan_only' : 'NULL AS plan_only';
|
|
48
52
|
return {
|
|
49
53
|
// conversations
|
|
50
54
|
getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
|
|
@@ -53,7 +57,8 @@ export function createSessionRepository(db) {
|
|
|
53
57
|
listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
|
|
54
58
|
runtimeSessionsSupportProviders,
|
|
55
59
|
conversationsHaveMayCreateSessions,
|
|
56
|
-
|
|
60
|
+
conversationsHavePlanOnly,
|
|
61
|
+
listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, ${conversationMayCreateSelect}, ${conversationPlanOnlySelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
|
|
57
62
|
insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
|
|
58
63
|
updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
|
|
59
64
|
updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
|
|
@@ -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';
|
|
@@ -56,7 +57,11 @@ import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../servic
|
|
|
56
57
|
import { taskKindOf } from '../services/session-activity-tracker.mjs';
|
|
57
58
|
import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
58
59
|
import { isLiveResend } from '../services/steer-resend.mjs';
|
|
59
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
checkRequestedProviderModel,
|
|
62
|
+
resolveProviderModelSelection,
|
|
63
|
+
unknownProviderModelText,
|
|
64
|
+
} from '../services/provider-model-selection.mjs';
|
|
60
65
|
import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
|
|
61
66
|
import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
|
|
62
67
|
import {
|
|
@@ -82,8 +87,11 @@ import {
|
|
|
82
87
|
import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
|
|
83
88
|
import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
|
|
84
89
|
import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
|
|
90
|
+
import { normalizeTurnTokenUsageReport } from '../../shared/turn-token-usage.mjs';
|
|
85
91
|
import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
|
|
86
92
|
import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
|
|
93
|
+
import { planBoundaryHoldSql } from '../repositories/message-repository.mjs';
|
|
94
|
+
import { PLAN_ONLY_REFUSAL, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
|
|
87
95
|
import {
|
|
88
96
|
admitRemoteRelayRequest,
|
|
89
97
|
readRemoteRelayRequest,
|
|
@@ -917,6 +925,23 @@ export function resolveResponderConversationMismatch({
|
|
|
917
925
|
};
|
|
918
926
|
}
|
|
919
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Whether a pending row must wait for its conversation's live turn to end
|
|
930
|
+
* instead of being handed to the worker, which would steer it into that turn.
|
|
931
|
+
* A steer switches the live turn's mode (the Claude worker re-applies the
|
|
932
|
+
* permission mode on every delivery), so across plan mode it would change
|
|
933
|
+
* what the running turn may do: an "Approve plan" follow-up would give a
|
|
934
|
+
* running plan turn write access, and a plan message would take it from a
|
|
935
|
+
* running write turn. Rows on the same side of plan mode steer as before.
|
|
936
|
+
*/
|
|
937
|
+
export function crossesPlanBoundaryOfLiveTurn({ stmts, row } = {}) {
|
|
938
|
+
const conversationId = String(row?.conversation_id || '').trim();
|
|
939
|
+
if (!conversationId || typeof stmts?.listProcessingQueueByConversation?.all !== 'function') return false;
|
|
940
|
+
const isPlan = (mode) => String(mode || '').trim().toLowerCase() === 'plan';
|
|
941
|
+
const pendingIsPlan = isPlan(row.relay_mode);
|
|
942
|
+
return stmts.listProcessingQueueByConversation.all(conversationId)
|
|
943
|
+
.some((live) => String(live?.id || '') !== String(row.id || '') && isPlan(live?.relay_mode) !== pendingIsPlan);
|
|
944
|
+
}
|
|
920
945
|
export function dequeuePendingMessage({
|
|
921
946
|
db,
|
|
922
947
|
stmts,
|
|
@@ -966,6 +991,10 @@ export function dequeuePendingMessage({
|
|
|
966
991
|
: stmts.findPending.get(currentIso);
|
|
967
992
|
}
|
|
968
993
|
if (!next) return null;
|
|
994
|
+
// Across plan mode, a row waits for the live turn instead of steering in.
|
|
995
|
+
// The claim statements leave such rows out (planBoundaryHoldSql); this is
|
|
996
|
+
// the fail-closed backstop for one that does not.
|
|
997
|
+
if (crossesPlanBoundaryOfLiveTurn({ stmts, row: next })) return null;
|
|
969
998
|
// One processing attempt = one attempt_id. Minted here for every claim —
|
|
970
999
|
// even for callers that never echo it back — so finalization, requeue, and
|
|
971
1000
|
// question writes can be fenced to the attempt that actually owns the row.
|
|
@@ -2045,6 +2074,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2045
2074
|
claudeBillingSwitchService = null,
|
|
2046
2075
|
// The Claude API booking (claude-api-usage-service.mjs).
|
|
2047
2076
|
claudeApiUsageService = null,
|
|
2077
|
+
// Tokens per turn for every provider (token-usage-service.mjs).
|
|
2078
|
+
tokenUsageService = null,
|
|
2048
2079
|
// The delivery fence's runtime half for GET /api/pending, as on the
|
|
2049
2080
|
// socket: the Claude API claim check (null: no refusal) and what a fenced
|
|
2050
2081
|
// row does (the swap, or fail closed).
|
|
@@ -2636,6 +2667,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2636
2667
|
AND rs.sdk_session_id = ?
|
|
2637
2668
|
)
|
|
2638
2669
|
AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
|
|
2670
|
+
AND ${planBoundaryHoldSql('q')}
|
|
2639
2671
|
ORDER BY q.timestamp ASC, q.rowid ASC
|
|
2640
2672
|
LIMIT 1
|
|
2641
2673
|
`);
|
|
@@ -4800,6 +4832,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4800
4832
|
? normalizeRelayMode(existingConversation.preferred_relay_mode)
|
|
4801
4833
|
: null;
|
|
4802
4834
|
const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
|
|
4835
|
+
// Plan-only: an agent's send that names another mode is refused
|
|
4836
|
+
// and nothing is queued (nor a billing switched). Without a mode it runs
|
|
4837
|
+
// in the session's own. The owner's own sends are not limited.
|
|
4838
|
+
if (remoteRequest.remote && readPlanOnly(existingConversation) && planOnlyRefusesMode(relayMode || mode)) {
|
|
4839
|
+
return res.status(409).json({ ...PLAN_ONLY_REFUSAL, conversationId });
|
|
4840
|
+
}
|
|
4803
4841
|
const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
|
|
4804
4842
|
const normalizedAttachments = normalizeAttachments(rawAttachments);
|
|
4805
4843
|
const requestedAttachmentCount = Math.min(
|
|
@@ -5019,6 +5057,13 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5019
5057
|
code: 'OPENAI_MODEL_REQUIRES_NEW_CONVERSATION',
|
|
5020
5058
|
});
|
|
5021
5059
|
}
|
|
5060
|
+
// A Copilot conversation is bound too, for the model-id guards below only:
|
|
5061
|
+
// Copilot resells Claude and GPT models under ids of its own
|
|
5062
|
+
// ("claude-opus-5.5"), so a Claude-form id another provider lists
|
|
5063
|
+
// ("claude-opus-5-5") is a model Copilot lacks, answered by the Copilot
|
|
5064
|
+
// check further down, never a switch to Cursor, Grok or DeepSeek. (It is
|
|
5065
|
+
// not in runtimeBoundToExplicitProvider: that one also picks the catalog.)
|
|
5066
|
+
const runtimeBoundForModelGuards = runtimeBoundToExplicitProvider || runtimeProviderType === 'github';
|
|
5022
5067
|
// Cursor conversations are bootstrap-created only, so an existing
|
|
5023
5068
|
// github/openai/claude conversation cannot cross to the Cursor provider
|
|
5024
5069
|
// mid-conversation. Cursor settings are read lazily so the hot path stays
|
|
@@ -5028,7 +5073,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5028
5073
|
let requestTargetsCursor = requestedProviderType === 'cursor';
|
|
5029
5074
|
if (
|
|
5030
5075
|
!requestTargetsCursor
|
|
5031
|
-
&& !
|
|
5076
|
+
&& !runtimeBoundForModelGuards
|
|
5032
5077
|
&& requestedOpenAIModel
|
|
5033
5078
|
&& !requestedModelAvailableInGitHub
|
|
5034
5079
|
) {
|
|
@@ -5053,7 +5098,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5053
5098
|
let requestTargetsGrok = requestedProviderType === 'grok';
|
|
5054
5099
|
if (
|
|
5055
5100
|
!requestTargetsGrok
|
|
5056
|
-
&& !
|
|
5101
|
+
&& !runtimeBoundForModelGuards
|
|
5057
5102
|
&& requestedOpenAIModel
|
|
5058
5103
|
&& !requestedModelAvailableInGitHub
|
|
5059
5104
|
) {
|
|
@@ -5079,7 +5124,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5079
5124
|
let requestTargetsDeepSeek = requestedProviderType === 'deepseek';
|
|
5080
5125
|
if (
|
|
5081
5126
|
!requestTargetsDeepSeek
|
|
5082
|
-
&& !
|
|
5127
|
+
&& !runtimeBoundForModelGuards
|
|
5083
5128
|
&& requestedOpenAIModel
|
|
5084
5129
|
&& !requestedModelAvailableInGitHub
|
|
5085
5130
|
) {
|
|
@@ -5397,6 +5442,24 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5397
5442
|
error: deepseekModel ? null : 'DeepSeek model is not configured',
|
|
5398
5443
|
}
|
|
5399
5444
|
: githubModelResolution)))));
|
|
5445
|
+
// A model Copilot does not list is named with its close matches, as the
|
|
5446
|
+
// bootstrap names it: "claude-opus-5-5" (the Claude form) gets "did you
|
|
5447
|
+
// mean 'claude-opus-5.5'?". Refused before anything is stored.
|
|
5448
|
+
if (!modelResolution.ok && modelResolution === githubModelResolution && explicitModelGiven) {
|
|
5449
|
+
const copilotCheck = checkRequestedProviderModel({
|
|
5450
|
+
requestedModel: requestedOpenAIModel,
|
|
5451
|
+
provider: 'github',
|
|
5452
|
+
models: Array.isArray(githubModelResolution.available) ? githubModelResolution.available : [],
|
|
5453
|
+
});
|
|
5454
|
+
if (copilotCheck.status === 'unknown') {
|
|
5455
|
+
return res.status(400).json({
|
|
5456
|
+
error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
|
|
5457
|
+
code: 'COPILOT_MODEL_UNAVAILABLE',
|
|
5458
|
+
supportedModels: copilotCheck.models,
|
|
5459
|
+
...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
|
|
5460
|
+
});
|
|
5461
|
+
}
|
|
5462
|
+
}
|
|
5400
5463
|
if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
|
|
5401
5464
|
const requestedModel = String(modelResolution.model || '').trim();
|
|
5402
5465
|
const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
|
|
@@ -6582,6 +6645,59 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6582
6645
|
res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
|
|
6583
6646
|
});
|
|
6584
6647
|
|
|
6648
|
+
// POST /api/turn-token-usage — every worker reports, after a turn, the
|
|
6649
|
+
// turn's tokens per model (`models`), or a Claude cloud worker the
|
|
6650
|
+
// session's running totals (`sessionTotals`), keyed by turn, run and model
|
|
6651
|
+
// (shared/turn-token-usage.mjs). Advisory and idempotent: a retried or
|
|
6652
|
+
// reordered report books only what it adds (token-usage-service.mjs).
|
|
6653
|
+
// Who ran the turn is the relay's to say: the chat's provider, and for a
|
|
6654
|
+
// Claude chat the posting worker's registered kind (the billing can switch
|
|
6655
|
+
// between turns). A body that says otherwise is logged. A deleted chat
|
|
6656
|
+
// takes no report, so a late retry cannot book its turn a second time; an
|
|
6657
|
+
// archived one does.
|
|
6658
|
+
app.post('/api/turn-token-usage', auth, (req, res) => {
|
|
6659
|
+
touchCli();
|
|
6660
|
+
if (!tokenUsageService) return res.status(501).json({ error: 'Token usage storage is unavailable' });
|
|
6661
|
+
const parsed = normalizeTurnTokenUsageReport(req.body);
|
|
6662
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
6663
|
+
const { report } = parsed;
|
|
6664
|
+
const conversationId = report.conversationId;
|
|
6665
|
+
if (!stmts.getConv?.get?.(conversationId)) return res.status(404).json({ error: 'Conversation not found' });
|
|
6666
|
+
// The binding's provider wins over the report's. A chat whose binding is
|
|
6667
|
+
// gone for a moment (a cache rebuild) still books, as the provider the
|
|
6668
|
+
// report states.
|
|
6669
|
+
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
|
|
6670
|
+
const chatProvider = String(runtimeSession?.provider_type || report.provider || '').trim().toLowerCase();
|
|
6671
|
+
let provider = chatProvider;
|
|
6672
|
+
let billing = '';
|
|
6673
|
+
if (chatProvider === 'openai') {
|
|
6674
|
+
// A chat on the user's own OpenAI key runs on the Copilot SDK.
|
|
6675
|
+
provider = 'github';
|
|
6676
|
+
billing = 'byok-openai';
|
|
6677
|
+
} else if (chatProvider === 'github') {
|
|
6678
|
+
billing = report.billing;
|
|
6679
|
+
} else if (normalizeClaudeBilling(chatProvider)) {
|
|
6680
|
+
const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
|
|
6681
|
+
const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
|
|
6682
|
+
// The worker that ran the turn, then the chat's billing; the report's
|
|
6683
|
+
// own word only when the chat has no binding.
|
|
6684
|
+
provider = registeredKind || normalizeClaudeBilling(chatProvider);
|
|
6685
|
+
} else if (!['claude-cloud', 'deepseek', 'cursor', 'grok'].includes(chatProvider)) {
|
|
6686
|
+
return res.status(409).json({ error: 'Conversation keeps no token usage' });
|
|
6687
|
+
}
|
|
6688
|
+
if (report.provider && report.provider !== provider) {
|
|
6689
|
+
console.warn(`[${ts()}] token usage conv=${conversationId.slice(0, 8)} stated=${report.provider} booked=${provider}`);
|
|
6690
|
+
}
|
|
6691
|
+
if (report.sessionTotals && provider !== 'claude-cloud') {
|
|
6692
|
+
return res.status(400).json({ error: 'sessionTotals is for Claude cloud only' });
|
|
6693
|
+
}
|
|
6694
|
+
const booked = report.sessionTotals
|
|
6695
|
+
? tokenUsageService.recordCloudSessionTotals({ conversationId, provider, billing, report })
|
|
6696
|
+
: tokenUsageService.recordTurn({ conversationId, provider, billing, report });
|
|
6697
|
+
if (!booked) return res.status(501).json({ error: 'Token usage storage is unavailable' });
|
|
6698
|
+
res.json({ ok: true, provider, billing, models: booked.map((entry) => entry.model) });
|
|
6699
|
+
});
|
|
6700
|
+
|
|
6585
6701
|
// POST /api/claude-plan-usage — Claude worker reports the session's
|
|
6586
6702
|
// structured /usage data (plan rate-limit windows + session cost totals).
|
|
6587
6703
|
//
|
|
@@ -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({
|