@oar-sh/oar 0.9.19 → 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/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +317 -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 +1 -0
- 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 +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +4 -3
- 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 +87 -0
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +103 -7
- package/server/server-runtime.mjs +132 -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 +40 -5
- 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 +66 -11
- package/shared/turn-token-usage.mjs +144 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: tokens per turn for every provider, and their daily sums
|
|
5
|
+
* (services/token-usage-service.mjs, the Tokens sections in Check Usage and
|
|
6
|
+
* the About panel).
|
|
7
|
+
*
|
|
8
|
+
* `turn_token_usage`: one row per turn, run and model, kept with the chat
|
|
9
|
+
* and deleted with it.
|
|
10
|
+
* - `conversation_id`; `turn_id`: the queue row id (the user message's id);
|
|
11
|
+
* no foreign key, as queue rows are pruned.
|
|
12
|
+
* - `run_id`: the worker process or attempt ('' for none), so a re-run after
|
|
13
|
+
* a crash keeps the first attempt's row.
|
|
14
|
+
* - `model`: lower case, without `[1m]`. `provider`: the relay's provider
|
|
15
|
+
* kind. `billing`: '' or `byok-<provider type>` (Copilot's own key).
|
|
16
|
+
* - `native_session_id`, `response_message_id`: NULL when not known.
|
|
17
|
+
* - `at`: the turn's first report (ISO); `day`: its relay local date
|
|
18
|
+
* (`YYYY-MM-DD`), fixed at the first report.
|
|
19
|
+
* - `input_tokens` (fresh input, no cache), `output_tokens`,
|
|
20
|
+
* `cache_write_tokens`, `cache_read_tokens`, `reasoning_tokens` (part of
|
|
21
|
+
* output): NULL when the provider did not report the field; 0 when it
|
|
22
|
+
* reported zero.
|
|
23
|
+
* - `cost_usd` (NULL when not known), `cost_source` (`claude-code`,
|
|
24
|
+
* `provider`, `oar-deepseek-table`), `cost_unknown`.
|
|
25
|
+
*
|
|
26
|
+
* `token_usage_days`: the sums per day, provider, billing, model and source
|
|
27
|
+
* (`turns`, or `backfill` for the copy below). Its rows survive the chats.
|
|
28
|
+
* A token field stays NULL until some turn reports it, then it is a sum.
|
|
29
|
+
*
|
|
30
|
+
* Backfill, in the transaction that creates the tables (a `token_usage_days`
|
|
31
|
+
* table that exists means it ran): the Claude API totals (0034, local days)
|
|
32
|
+
* and DeepSeek's days (0025, UTC days, kept as they are) are copied with
|
|
33
|
+
* `source='backfill'`. Their `miss_tokens` become `input_tokens` (cache
|
|
34
|
+
* writes included: the views say so), `hit_tokens` `cache_read_tokens`, and
|
|
35
|
+
* `cache_write_tokens` stays NULL. Model ids are normalised as above, and
|
|
36
|
+
* rows that meet on one key are summed. DeepSeek's `cost_usd` is the token
|
|
37
|
+
* estimate alone (its tools cost stays on the DeepSeek card; a NULL stays
|
|
38
|
+
* NULL), and its days keep no seen times, so the day's bounds stand in. A
|
|
39
|
+
* source that 0034's or 0025's own check finds missing or partial (its
|
|
40
|
+
* migration failed this boot) makes this one throw, so the schema boot logs
|
|
41
|
+
* it and retries on the next boot once the source is there.
|
|
42
|
+
*
|
|
43
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
44
|
+
*
|
|
45
|
+
* Usage: node server/migrations/0036-token-usage.mjs [path/to/oar.db]
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { fileURLToPath } from 'url';
|
|
49
|
+
import path from 'path';
|
|
50
|
+
import process from 'process';
|
|
51
|
+
import Database from 'better-sqlite3';
|
|
52
|
+
import { cliDbPath } from '../services/database-file.mjs';
|
|
53
|
+
|
|
54
|
+
import { CLAUDE_API_USAGE_TOTALS_TABLE, claudeApiUsageTotalsProblem } from './0034-claude-api-usage-totals.mjs';
|
|
55
|
+
import { DEEPSEEK_USAGE_DAYS_TABLE, deepseekUsageRequestsDailyProblem } from './0025-deepseek-usage-requests-daily.mjs';
|
|
56
|
+
|
|
57
|
+
export const TURN_TOKEN_USAGE_TABLE = 'turn_token_usage';
|
|
58
|
+
export const TOKEN_USAGE_DAYS_TABLE = 'token_usage_days';
|
|
59
|
+
export const TURN_TOKEN_USAGE_INDEXES = Object.freeze([
|
|
60
|
+
'idx_turn_token_usage_conversation',
|
|
61
|
+
'idx_turn_token_usage_native_session',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export const TURN_TOKEN_USAGE_COLUMNS = Object.freeze([
|
|
65
|
+
'conversation_id',
|
|
66
|
+
'turn_id',
|
|
67
|
+
'run_id',
|
|
68
|
+
'model',
|
|
69
|
+
'provider',
|
|
70
|
+
'billing',
|
|
71
|
+
'native_session_id',
|
|
72
|
+
'response_message_id',
|
|
73
|
+
'at',
|
|
74
|
+
'day',
|
|
75
|
+
'input_tokens',
|
|
76
|
+
'output_tokens',
|
|
77
|
+
'cache_write_tokens',
|
|
78
|
+
'cache_read_tokens',
|
|
79
|
+
'reasoning_tokens',
|
|
80
|
+
'cost_usd',
|
|
81
|
+
'cost_source',
|
|
82
|
+
'cost_unknown',
|
|
83
|
+
'updated_at',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
export const TOKEN_USAGE_DAYS_COLUMNS = Object.freeze([
|
|
87
|
+
'day',
|
|
88
|
+
'provider',
|
|
89
|
+
'billing',
|
|
90
|
+
'model',
|
|
91
|
+
'source',
|
|
92
|
+
'input_tokens',
|
|
93
|
+
'output_tokens',
|
|
94
|
+
'cache_write_tokens',
|
|
95
|
+
'cache_read_tokens',
|
|
96
|
+
'reasoning_tokens',
|
|
97
|
+
'cost_usd',
|
|
98
|
+
'unknown_cost_tokens',
|
|
99
|
+
'turn_count',
|
|
100
|
+
'first_seen_at',
|
|
101
|
+
'last_seen_at',
|
|
102
|
+
'updated_at',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function tableExists(db, name) {
|
|
106
|
+
return !!db
|
|
107
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
108
|
+
.get(name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function missingColumns(db, table, expected) {
|
|
112
|
+
const present = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
|
113
|
+
return expected.filter((name) => !present.has(name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What keeps the service from using the tables: '' when nothing does. */
|
|
117
|
+
export function tokenUsageProblem(db) {
|
|
118
|
+
for (const [table, expected] of [
|
|
119
|
+
[TURN_TOKEN_USAGE_TABLE, TURN_TOKEN_USAGE_COLUMNS],
|
|
120
|
+
[TOKEN_USAGE_DAYS_TABLE, TOKEN_USAGE_DAYS_COLUMNS],
|
|
121
|
+
]) {
|
|
122
|
+
if (!tableExists(db, table)) return `no ${table} table`;
|
|
123
|
+
const missing = missingColumns(db, table, expected);
|
|
124
|
+
if (missing.length) return `${table} lacks ${missing.join(', ')}`;
|
|
125
|
+
}
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The tables' model id: lower case, without the `[1m]` suffix
|
|
130
|
+
// (shared/turn-token-usage.mjs), in SQL.
|
|
131
|
+
const BOOKED_MODEL_SQL = `CASE WHEN LOWER(TRIM(model)) LIKE '%[1m]' THEN SUBSTR(LOWER(TRIM(model)), 1, LENGTH(TRIM(model)) - 4) ELSE LOWER(TRIM(model)) END`;
|
|
132
|
+
|
|
133
|
+
// Two source rows can land on one key once their model ids are normalised:
|
|
134
|
+
// they are summed, null-aware.
|
|
135
|
+
const BACKFILL_UPSERT = `
|
|
136
|
+
ON CONFLICT (day, provider, billing, model, source) DO UPDATE SET
|
|
137
|
+
input_tokens = CASE WHEN excluded.input_tokens IS NULL THEN input_tokens ELSE COALESCE(input_tokens, 0) + excluded.input_tokens END,
|
|
138
|
+
output_tokens = CASE WHEN excluded.output_tokens IS NULL THEN output_tokens ELSE COALESCE(output_tokens, 0) + excluded.output_tokens END,
|
|
139
|
+
cache_read_tokens = CASE WHEN excluded.cache_read_tokens IS NULL THEN cache_read_tokens ELSE COALESCE(cache_read_tokens, 0) + excluded.cache_read_tokens END,
|
|
140
|
+
cost_usd = CASE WHEN excluded.cost_usd IS NULL THEN cost_usd ELSE COALESCE(cost_usd, 0) + excluded.cost_usd END,
|
|
141
|
+
unknown_cost_tokens = unknown_cost_tokens + excluded.unknown_cost_tokens,
|
|
142
|
+
turn_count = turn_count + excluded.turn_count,
|
|
143
|
+
first_seen_at = MIN(first_seen_at, excluded.first_seen_at),
|
|
144
|
+
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
export function migrateTokenUsage(db, { now = () => new Date().toISOString() } = {}) {
|
|
148
|
+
return db.transaction(() => {
|
|
149
|
+
let tablesAdded = 0;
|
|
150
|
+
const backfilled = { claudeApi: 0, deepseek: 0 };
|
|
151
|
+
// Done means both tables exist, checked inside the transaction.
|
|
152
|
+
if (tableExists(db, TOKEN_USAGE_DAYS_TABLE) && tableExists(db, TURN_TOKEN_USAGE_TABLE)) {
|
|
153
|
+
return { applied: false, tablesAdded, backfilled };
|
|
154
|
+
}
|
|
155
|
+
if (!tableExists(db, TURN_TOKEN_USAGE_TABLE)) {
|
|
156
|
+
db.exec(`
|
|
157
|
+
CREATE TABLE ${TURN_TOKEN_USAGE_TABLE} (
|
|
158
|
+
conversation_id TEXT NOT NULL,
|
|
159
|
+
turn_id TEXT NOT NULL,
|
|
160
|
+
run_id TEXT NOT NULL DEFAULT '',
|
|
161
|
+
model TEXT NOT NULL,
|
|
162
|
+
provider TEXT NOT NULL,
|
|
163
|
+
billing TEXT NOT NULL DEFAULT '',
|
|
164
|
+
native_session_id TEXT,
|
|
165
|
+
response_message_id TEXT,
|
|
166
|
+
at TEXT NOT NULL,
|
|
167
|
+
day TEXT NOT NULL,
|
|
168
|
+
input_tokens INTEGER,
|
|
169
|
+
output_tokens INTEGER,
|
|
170
|
+
cache_write_tokens INTEGER,
|
|
171
|
+
cache_read_tokens INTEGER,
|
|
172
|
+
reasoning_tokens INTEGER,
|
|
173
|
+
cost_usd REAL,
|
|
174
|
+
cost_source TEXT,
|
|
175
|
+
cost_unknown INTEGER NOT NULL DEFAULT 0,
|
|
176
|
+
updated_at TEXT NOT NULL,
|
|
177
|
+
PRIMARY KEY (turn_id, run_id, model)
|
|
178
|
+
);
|
|
179
|
+
CREATE INDEX IF NOT EXISTS ${TURN_TOKEN_USAGE_INDEXES[0]} ON ${TURN_TOKEN_USAGE_TABLE} (conversation_id, at);
|
|
180
|
+
CREATE INDEX IF NOT EXISTS ${TURN_TOKEN_USAGE_INDEXES[1]} ON ${TURN_TOKEN_USAGE_TABLE} (native_session_id);
|
|
181
|
+
`);
|
|
182
|
+
tablesAdded += 1;
|
|
183
|
+
}
|
|
184
|
+
if (!tableExists(db, TOKEN_USAGE_DAYS_TABLE)) {
|
|
185
|
+
// The copy needs both sources as 0034 and 0025 leave them; a missing or
|
|
186
|
+
// partial one (its migration failed this boot) waits for the next boot.
|
|
187
|
+
for (const problem of [claudeApiUsageTotalsProblem(db), deepseekUsageRequestsDailyProblem(db)]) {
|
|
188
|
+
if (problem) throw new Error(`cannot backfill yet: ${problem}`);
|
|
189
|
+
}
|
|
190
|
+
db.exec(`
|
|
191
|
+
CREATE TABLE ${TOKEN_USAGE_DAYS_TABLE} (
|
|
192
|
+
day TEXT NOT NULL,
|
|
193
|
+
provider TEXT NOT NULL,
|
|
194
|
+
billing TEXT NOT NULL DEFAULT '',
|
|
195
|
+
model TEXT NOT NULL,
|
|
196
|
+
source TEXT NOT NULL,
|
|
197
|
+
input_tokens INTEGER,
|
|
198
|
+
output_tokens INTEGER,
|
|
199
|
+
cache_write_tokens INTEGER,
|
|
200
|
+
cache_read_tokens INTEGER,
|
|
201
|
+
reasoning_tokens INTEGER,
|
|
202
|
+
cost_usd REAL,
|
|
203
|
+
unknown_cost_tokens INTEGER NOT NULL DEFAULT 0,
|
|
204
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
205
|
+
first_seen_at TEXT NOT NULL,
|
|
206
|
+
last_seen_at TEXT NOT NULL,
|
|
207
|
+
updated_at TEXT NOT NULL,
|
|
208
|
+
PRIMARY KEY (day, provider, billing, model, source)
|
|
209
|
+
)
|
|
210
|
+
`);
|
|
211
|
+
tablesAdded += 1;
|
|
212
|
+
const at = now();
|
|
213
|
+
const columns = `
|
|
214
|
+
day, provider, billing, model, source,
|
|
215
|
+
input_tokens, output_tokens, cache_write_tokens, cache_read_tokens, reasoning_tokens,
|
|
216
|
+
cost_usd, unknown_cost_tokens, turn_count, first_seen_at, last_seen_at, updated_at
|
|
217
|
+
`;
|
|
218
|
+
// `WHERE true`: SQLite's parser needs it before an upsert on a SELECT.
|
|
219
|
+
backfilled.claudeApi = db.prepare(`
|
|
220
|
+
INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (${columns})
|
|
221
|
+
SELECT day, 'claude-api', '', ${BOOKED_MODEL_SQL}, 'backfill',
|
|
222
|
+
miss_tokens, output_tokens, NULL, hit_tokens, NULL,
|
|
223
|
+
cost_usd, COALESCE(unknown_cost_tokens, 0), COALESCE(turn_count, 0), first_seen_at, last_seen_at, ?
|
|
224
|
+
FROM ${CLAUDE_API_USAGE_TOTALS_TABLE} WHERE true
|
|
225
|
+
${BACKFILL_UPSERT}
|
|
226
|
+
`).run(at).changes;
|
|
227
|
+
// DeepSeek's days keep no seen times: the day's start and end stand in
|
|
228
|
+
// (UTC days). Its token estimate only, in dollars; no unknown tokens.
|
|
229
|
+
backfilled.deepseek = db.prepare(`
|
|
230
|
+
INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (${columns})
|
|
231
|
+
SELECT day, 'deepseek', '', ${BOOKED_MODEL_SQL}, 'backfill',
|
|
232
|
+
miss_tokens, output_tokens, NULL, hit_tokens, NULL,
|
|
233
|
+
cost_usd, 0, COALESCE(turn_count, 0), day || 'T00:00:00.000Z', day || 'T23:59:59.999Z', ?
|
|
234
|
+
FROM ${DEEPSEEK_USAGE_DAYS_TABLE} WHERE true
|
|
235
|
+
${BACKFILL_UPSERT}
|
|
236
|
+
`).run(at).changes;
|
|
237
|
+
}
|
|
238
|
+
return { applied: true, tablesAdded, backfilled };
|
|
239
|
+
})();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function migrate(dbPath, options = {}) {
|
|
243
|
+
const db = new Database(dbPath, options);
|
|
244
|
+
try {
|
|
245
|
+
return migrateTokenUsage(db);
|
|
246
|
+
} finally {
|
|
247
|
+
db.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
252
|
+
if (isMain) {
|
|
253
|
+
try {
|
|
254
|
+
// The given path, else the database in server/data (oar.db, else the
|
|
255
|
+
// name before 0.9.20); never a new one.
|
|
256
|
+
const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
|
|
257
|
+
const result = migrate(dbPath);
|
|
258
|
+
console.log(`Migration 0036 on ${dbPath}`);
|
|
259
|
+
console.log(result.applied
|
|
260
|
+
? ` token usage: tables added ${result.tablesAdded}, backfilled days: claude api ${result.backfilled.claudeApi}, deepseek ${result.backfilled.deepseek}`
|
|
261
|
+
: ' skipped: nothing to change');
|
|
262
|
+
process.exit(0);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.error(`Migration 0036 failed: ${err?.message || err}`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -13,9 +13,18 @@ const STATE_LABELS = Object.freeze({
|
|
|
13
13
|
|
|
14
14
|
const USAGE_LABELS = Object.freeze({
|
|
15
15
|
github: 'GitHub Copilot',
|
|
16
|
+
claude: 'Claude',
|
|
17
|
+
'claude-api': 'Claude API',
|
|
18
|
+
'claude-cloud': 'Claude Cloud',
|
|
16
19
|
cursor: 'Cursor',
|
|
17
20
|
grok: 'Grok',
|
|
18
|
-
|
|
21
|
+
deepseek: 'DeepSeek',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const OWN_KEY_LABELS = Object.freeze({
|
|
25
|
+
'byok-openai': 'OpenAI',
|
|
26
|
+
'byok-anthropic': 'Anthropic',
|
|
27
|
+
'byok-azure': 'Azure OpenAI',
|
|
19
28
|
});
|
|
20
29
|
|
|
21
30
|
function esc(value) {
|
|
@@ -133,11 +142,67 @@ function providerState(provider) {
|
|
|
133
142
|
return `<span class="about-state" data-state="${esc(state)}">${esc(STATE_LABELS[state])}</span>${esc(count)}`;
|
|
134
143
|
}
|
|
135
144
|
|
|
145
|
+
// A token count of the token booking: "—" where no turn reported it.
|
|
146
|
+
function knownCount(value) {
|
|
147
|
+
return value === null || value === undefined || value === '' ? null : finite(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tokenAmount(value) {
|
|
151
|
+
return knownCount(value) === null ? '—' : formatAmount(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* "Oct 29, 2026" for a `YYYY-MM-DD` day, as Check Usage writes it, joined
|
|
156
|
+
* by no-break spaces so a phone never wraps inside it.
|
|
157
|
+
*/
|
|
158
|
+
export function aboutDayLabel(day) {
|
|
159
|
+
const date = new Date(`${day}T00:00:00.000Z`);
|
|
160
|
+
if (!day || Number.isNaN(date.getTime())) return String(day || '');
|
|
161
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).replace(/ /g, '\u00a0');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The all-time tokens: their sum, and the four kinds with the first day.
|
|
166
|
+
* Days copied at the upgrade (Claude API, DeepSeek) hold cache writes inside
|
|
167
|
+
* input, and the hint says until when.
|
|
168
|
+
*/
|
|
169
|
+
function tokenRow(label, tokens) {
|
|
170
|
+
const fields = [tokens.inputTokens, tokens.outputTokens, tokens.cacheWriteTokens, tokens.cacheReadTokens];
|
|
171
|
+
const known = fields.map(knownCount).filter((value) => value !== null);
|
|
172
|
+
const total = known.length ? known.reduce((sum, value) => sum + value, 0) : null;
|
|
173
|
+
const parts = [`in ${tokenAmount(tokens.inputTokens)} · out ${tokenAmount(tokens.outputTokens)} · cache w ${tokenAmount(tokens.cacheWriteTokens)} · cache r ${tokenAmount(tokens.cacheReadTokens)}`];
|
|
174
|
+
if (tokens.since) parts.push(`since ${aboutDayLabel(tokens.since)}`);
|
|
175
|
+
if (tokens.backfilled) {
|
|
176
|
+
parts.push(tokens.firstTurnsDay
|
|
177
|
+
? `input includes cache writes before ${aboutDayLabel(tokens.firstTurnsDay)}`
|
|
178
|
+
: 'input includes cache writes (days from before the upgrade)');
|
|
179
|
+
}
|
|
180
|
+
// One unbroken part: on a phone the unit stays with its number (the
|
|
181
|
+
// section's values otherwise wrap anywhere, for paths).
|
|
182
|
+
return row(label, `<span class="plan-usage-detail-part">${esc(tokenAmount(total))}</span>`, { hint: parts.join(' · ') });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function tokenRows(usage) {
|
|
186
|
+
const rows = [];
|
|
187
|
+
if (usage.tokens) rows.push(tokenRow('Tokens (all time)', usage.tokens));
|
|
188
|
+
for (const entry of Array.isArray(usage.ownKeyTokens) ? usage.ownKeyTokens : []) {
|
|
189
|
+
const key = OWN_KEY_LABELS[entry.billing] || String(entry.billing || '').replace(/^byok-/, '') || 'own';
|
|
190
|
+
rows.push(tokenRow(`Tokens (all time), own ${key} key`, entry));
|
|
191
|
+
}
|
|
192
|
+
return rows;
|
|
193
|
+
}
|
|
194
|
+
|
|
136
195
|
function usageRows(id, usage) {
|
|
196
|
+
return [...providerUsageRows(id, usage), ...tokenRows(usage)];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function providerUsageRows(id, usage) {
|
|
137
200
|
if (id === 'github') {
|
|
201
|
+
if (usage.premiumRequests === undefined) return [];
|
|
138
202
|
return [row('Premium requests', esc(formatAmount(usage.premiumRequests)), { hint: `over ${formatCount(usage.turns)} replies` })];
|
|
139
203
|
}
|
|
140
204
|
if (id === 'cursor') {
|
|
205
|
+
if (usage.totalTokens === undefined) return [];
|
|
141
206
|
return [
|
|
142
207
|
row('Tokens', esc(formatAmount(usage.totalTokens)), {
|
|
143
208
|
hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out · ${formatAmount((finite(usage.cacheReadTokens) || 0) + (finite(usage.cacheWriteTokens) || 0))} cache`,
|
|
@@ -146,12 +211,14 @@ function usageRows(id, usage) {
|
|
|
146
211
|
];
|
|
147
212
|
}
|
|
148
213
|
if (id === 'grok') {
|
|
214
|
+
if (usage.totalTokens === undefined) return [];
|
|
149
215
|
return [
|
|
150
216
|
row('Tokens', esc(formatAmount(usage.totalTokens)), { hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out` }),
|
|
151
217
|
row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.turns)} turns` }),
|
|
152
218
|
];
|
|
153
219
|
}
|
|
154
220
|
if (id === 'claude-cloud') {
|
|
221
|
+
if (usage.costUsd === undefined) return [];
|
|
155
222
|
return [row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.sessions)} cloud sessions` })];
|
|
156
223
|
}
|
|
157
224
|
return [];
|
|
@@ -173,6 +240,22 @@ function buildUpdateSection(update, formatDateTime) {
|
|
|
173
240
|
));
|
|
174
241
|
}
|
|
175
242
|
|
|
243
|
+
// The database backups: the last result (time, size or why not), how many of
|
|
244
|
+
// how many are kept, and the folder.
|
|
245
|
+
function backupsRow(backups, formatDateTime) {
|
|
246
|
+
const last = backups.lastResult || null;
|
|
247
|
+
let value = 'none yet';
|
|
248
|
+
if (last?.status === 'written') value = `${formatDateTime(last.at)} · ${formatBytes(last.bytes)}`;
|
|
249
|
+
else if (last?.status === 'skipped') value = `skipped ${formatDateTime(last.at)}: ${last.reason || ''}`;
|
|
250
|
+
else if (last?.status === 'failed') value = `failed ${formatDateTime(last.at)}: ${last.reason || ''}`;
|
|
251
|
+
else if (last?.status === 'busy') value = `busy ${formatDateTime(last.at)}: ${last.reason || ''}`;
|
|
252
|
+
const kept = `${formatCount(backups.kept)} kept of ${formatCount(backups.keep)}`;
|
|
253
|
+
const hint = backups.enabled === false
|
|
254
|
+
? `at start: off (dbBackups.enabled); ${kept}`
|
|
255
|
+
: [kept, backups.problem || backups.dir].filter(Boolean).join(' · ');
|
|
256
|
+
return row('Backups', esc(value), { hint });
|
|
257
|
+
}
|
|
258
|
+
|
|
176
259
|
/**
|
|
177
260
|
* @param {object} about the GET /api/about payload
|
|
178
261
|
* @param {object} [options]
|
|
@@ -217,13 +300,14 @@ export function buildAboutModalHtml(about = {}, {
|
|
|
217
300
|
sections.push(section('usage', 'Usage', (usageIds.length
|
|
218
301
|
? usageIds.map((id) => `<div class="about-usage-provider">${esc(USAGE_LABELS[id])}</div>${table(usageRows(id, usage[id]))}`).join('')
|
|
219
302
|
: note('No provider has reported usage yet.'))
|
|
220
|
-
+ note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session. Claude
|
|
303
|
+
+ note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session, and every provider\'s tokens of all time: fresh input, output, cache writes and cache reads, "—" where no turn reported one. On Claude API and DeepSeek the days from before the upgrade have cache writes inside input. The tokens stay when a chat is deleted; Check Usage shows them by day and model.')));
|
|
221
304
|
|
|
222
305
|
const relay = about.relay || {};
|
|
223
306
|
sections.push(section('relay', 'Relay', table([
|
|
224
307
|
row('Uptime', esc(formatUptime(relay.uptimeSeconds))),
|
|
225
|
-
row('Database', esc(formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
|
|
308
|
+
row('Database', esc(relay.dbPath ? `${relay.dbPath} · ${formatBytes(relay.dbBytes)}` : formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
|
|
226
309
|
row('Running workers', esc(formatCount(relay.runningWorkers))),
|
|
310
|
+
...(about.backups ? [backupsRow(about.backups, formatDateTime)] : []),
|
|
227
311
|
])));
|
|
228
312
|
|
|
229
313
|
sections.push(buildUpdateSection(about.update || {}, formatDateTime));
|
|
@@ -1709,13 +1709,14 @@ export async function loadRelayBoard(boardId) {
|
|
|
1709
1709
|
return apiFetch(`/api/relay-board/${encodeURIComponent(id)}`);
|
|
1710
1710
|
}
|
|
1711
1711
|
|
|
1712
|
-
export async function submitRelayBoardAction(boardId, actionId) {
|
|
1712
|
+
export async function submitRelayBoardAction(boardId, actionId, { onErrorResponse } = {}) {
|
|
1713
1713
|
const id = String(boardId || '').trim();
|
|
1714
1714
|
const action = String(actionId || '').trim();
|
|
1715
1715
|
if (!id || !action) return null;
|
|
1716
1716
|
return apiFetch(`/api/relay-board/${encodeURIComponent(id)}/action`, {
|
|
1717
1717
|
method: 'POST',
|
|
1718
1718
|
body: JSON.stringify({ actionId: action }),
|
|
1719
|
+
onErrorResponse,
|
|
1719
1720
|
});
|
|
1720
1721
|
}
|
|
1721
1722
|
|
|
@@ -242,6 +242,7 @@ import {
|
|
|
242
242
|
autoCompactWindowNotes,
|
|
243
243
|
renderAutoCompactControlHtml,
|
|
244
244
|
renderAgentSessionsControlHtml,
|
|
245
|
+
renderPlanOnlyControlHtml,
|
|
245
246
|
renderAutoCompactNotesHtml,
|
|
246
247
|
renderContextUsageHtml,
|
|
247
248
|
renderThinkingControlHtml,
|
|
@@ -441,6 +442,7 @@ import {
|
|
|
441
442
|
dismissUpdateOutcome,
|
|
442
443
|
} from './update-ui.js';
|
|
443
444
|
import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
|
|
445
|
+
import { planOnlyModeHint } from './plan-board-shared.mjs';
|
|
444
446
|
|
|
445
447
|
const MODEL_STORAGE_KEY = 'copilot_selected_model';
|
|
446
448
|
// The New Chat modal used to keep its own model key, so a selection made there
|
|
@@ -2068,6 +2070,21 @@ function updateModeSelectorForProvider() {
|
|
|
2068
2070
|
select.value = modes.includes(selectedBefore)
|
|
2069
2071
|
? selectedBefore
|
|
2070
2072
|
: (modes.includes(FALLBACK_MODE) ? FALLBACK_MODE : modes[0]);
|
|
2073
|
+
syncModeSelectPlanOnlyHint();
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// A plan-only session's mode select says so; the owner may still
|
|
2077
|
+
// pick any mode.
|
|
2078
|
+
function syncModeSelectPlanOnlyHint() {
|
|
2079
|
+
const select = document.getElementById('mode-select');
|
|
2080
|
+
if (!select) return;
|
|
2081
|
+
const planOnly = conversations[String(currentConvId || '').trim()]?.planOnly === true;
|
|
2082
|
+
const hint = planOnlyModeHint(planOnly);
|
|
2083
|
+
select.title = hint.title;
|
|
2084
|
+
if (planOnly) select.dataset.planOnly = 'true';
|
|
2085
|
+
else delete select.dataset.planOnly;
|
|
2086
|
+
const planOption = Array.from(select.options).find((option) => option.value === 'plan');
|
|
2087
|
+
if (planOption) planOption.textContent = planOnly ? hint.planLabel : (RELAY_MODE_LABELS.plan || hint.planLabel);
|
|
2071
2088
|
}
|
|
2072
2089
|
|
|
2073
2090
|
function modeOptions() {
|
|
@@ -2086,6 +2103,8 @@ async function persistCurrentConversationPreferences() {
|
|
|
2086
2103
|
if (!modeSelect || !modelSelect) return;
|
|
2087
2104
|
const mode = String(modeSelect.value || '').trim() || FALLBACK_MODE;
|
|
2088
2105
|
const model = String(modelSelect.value || '').trim();
|
|
2106
|
+
// Steer or Queue: a mode across plan from the live turn's queues.
|
|
2107
|
+
syncComposerButtonState();
|
|
2089
2108
|
// The raw value, not selectedReasoningEffortValue(): a model with no reasoning
|
|
2090
2109
|
// options leaves the selector empty, and that helper's 'none' fallback would
|
|
2091
2110
|
// record an effort the user never picked when they only changed the mode.
|
|
@@ -2203,6 +2222,7 @@ function applyConversationPreferences({
|
|
|
2203
2222
|
} finally {
|
|
2204
2223
|
suppressConversationPreferenceSync = false;
|
|
2205
2224
|
}
|
|
2225
|
+
syncModeSelectPlanOnlyHint();
|
|
2206
2226
|
// Nothing is written to the shared "last used" storage here. Applying a
|
|
2207
2227
|
// conversation's stored preferences is not a choice: it runs on every open,
|
|
2208
2228
|
// on the ~1s live poll, on a catalog refresh and on another client's edit,
|
|
@@ -3536,6 +3556,12 @@ function initAgentSessionsControl() {
|
|
|
3536
3556
|
if (!body || body.dataset.agentSessionsControlBound === 'true') return;
|
|
3537
3557
|
body.dataset.agentSessionsControlBound = 'true';
|
|
3538
3558
|
body.addEventListener('change', async (event) => {
|
|
3559
|
+
// Plan-only, for every session.
|
|
3560
|
+
const planOnlyBox = event.target?.closest?.('#ctx-plan-only');
|
|
3561
|
+
if (planOnlyBox) {
|
|
3562
|
+
await changePlanOnly(planOnlyBox);
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3539
3565
|
const box = event.target?.closest?.('#ctx-may-create-sessions');
|
|
3540
3566
|
if (!box) return;
|
|
3541
3567
|
const convId = String(box.dataset.conversationId || '').trim();
|
|
@@ -3562,6 +3588,31 @@ function initAgentSessionsControl() {
|
|
|
3562
3588
|
});
|
|
3563
3589
|
}
|
|
3564
3590
|
|
|
3591
|
+
async function changePlanOnly(box) {
|
|
3592
|
+
const convId = String(box.dataset.conversationId || '').trim();
|
|
3593
|
+
if (!convId || agentSessionsControlUpdateInFlight) return;
|
|
3594
|
+
const wanted = box.checked === true;
|
|
3595
|
+
agentSessionsControlUpdateInFlight = true;
|
|
3596
|
+
box.disabled = true;
|
|
3597
|
+
try {
|
|
3598
|
+
const response = await updateConversationPreferences(convId, { clientId: CLIENT_ID, planOnly: wanted });
|
|
3599
|
+
if (!response) throw new Error('The relay refused the change');
|
|
3600
|
+
const stored = response.planOnly === true;
|
|
3601
|
+
if (conversations[convId]) conversations[convId] = { ...conversations[convId], planOnly: stored };
|
|
3602
|
+
box.checked = stored;
|
|
3603
|
+
if (convId === String(currentConvId || '').trim()) syncModeSelectPlanOnlyHint();
|
|
3604
|
+
showTransientRelayNotice(stored
|
|
3605
|
+
? 'Plan only: agents can no longer take this session out of plan mode.'
|
|
3606
|
+
: 'Plan only is off: agents may switch this session to another mode.');
|
|
3607
|
+
} catch (error) {
|
|
3608
|
+
box.checked = !wanted;
|
|
3609
|
+
showTransientRelayNotice(`Could not change the setting: ${error?.message || error}`);
|
|
3610
|
+
} finally {
|
|
3611
|
+
box.disabled = false;
|
|
3612
|
+
agentSessionsControlUpdateInFlight = false;
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3565
3616
|
function initThinkingControl() {
|
|
3566
3617
|
const body = document.getElementById('summary-modal-body');
|
|
3567
3618
|
if (!body || body.dataset.thinkingBound === '1') return;
|
|
@@ -3741,9 +3792,11 @@ async function loadContextSummaryAndRender(convId) {
|
|
|
3741
3792
|
// sessions? Only meaningful there, so only shown there.
|
|
3742
3793
|
const settingsConversationId = String(payload.resolvedConversationId || trimmedConvId || '').trim();
|
|
3743
3794
|
const settingsConversation = settingsConversationId ? conversations[settingsConversationId] : null;
|
|
3744
|
-
const agentSessionsHtml = settingsConversation?.origin
|
|
3795
|
+
const agentSessionsHtml = (settingsConversation?.origin
|
|
3745
3796
|
? renderAgentSessionsControlHtml({ conversationId: settingsConversationId, mayCreateSessions: settingsConversation.mayCreateSessions === true })
|
|
3746
|
-
: ''
|
|
3797
|
+
: '')
|
|
3798
|
+
// Plan-only, for every session.
|
|
3799
|
+
+ (settingsConversationId ? renderPlanOnlyControlHtml({ conversationId: settingsConversationId, planOnly: settingsConversation?.planOnly === true }) : '');
|
|
3747
3800
|
const detailText = String(payload.text || '').trim();
|
|
3748
3801
|
// The structured breakdown is the answer; the runtime's own text dump stays
|
|
3749
3802
|
// available underneath for the details it carries that categories don't.
|
|
@@ -19,6 +19,9 @@ export function hasUploadingAttachments(attachments = []) {
|
|
|
19
19
|
return countUploadingAttachments(attachments) > 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// A message whose mode is on the other side of plan mode from the live turn's.
|
|
23
|
+
export const PLAN_BOUNDARY_QUEUE_TITLE = 'Queued behind the running turn: plan mode and the writing modes do not steer into each other';
|
|
24
|
+
|
|
22
25
|
// Why a message sent right now queues instead of steering — worker-reported
|
|
23
26
|
// hold reasons mapped to a human title. Stopping a turn lives on the message
|
|
24
27
|
// bubbles, never here, so every state below is a Send/Steer/Queue shape.
|
|
@@ -49,6 +52,10 @@ export function deriveComposerControlState({
|
|
|
49
52
|
// the relay holds the message and it steers in once the hold clears.
|
|
50
53
|
steeringHeld = false,
|
|
51
54
|
steeringHoldReason = null,
|
|
55
|
+
// The composer's mode is on the other side of plan mode from the live turn's:
|
|
56
|
+
// the relay queues such a message behind the turn instead of steering it in
|
|
57
|
+
// (a steer would switch what the running turn may do).
|
|
58
|
+
crossesPlanMode = false,
|
|
52
59
|
// An archived conversation: its worker is stopped and it is out of the
|
|
53
60
|
// live list; sending would start it again behind the user's back.
|
|
54
61
|
archived = false,
|
|
@@ -83,10 +90,13 @@ export function deriveComposerControlState({
|
|
|
83
90
|
// The label/title/action for a draft typed during a live turn: steer into it
|
|
84
91
|
// where the provider supports that (queued while steering is held), queue
|
|
85
92
|
// behind it otherwise.
|
|
86
|
-
const
|
|
93
|
+
const crossing = active && !!crossesPlanMode;
|
|
94
|
+
const steerNow = !!steeringSupported && !held && !crossing;
|
|
87
95
|
const midTurnAction = steerNow ? 'steer' : 'queue';
|
|
88
96
|
const midTurnLabel = steerNow ? 'Steer' : 'Queue';
|
|
89
|
-
const midTurnTitle =
|
|
97
|
+
const midTurnTitle = crossing
|
|
98
|
+
? PLAN_BOUNDARY_QUEUE_TITLE
|
|
99
|
+
: held
|
|
90
100
|
? (STEERING_HOLD_TITLES[String(steeringHoldReason || '').trim()]
|
|
91
101
|
|| 'Will steer in once steering resumes')
|
|
92
102
|
: (steeringSupported
|
|
@@ -367,6 +367,22 @@ export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateS
|
|
|
367
367
|
`;
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* The owner's plan-only switch, for every session: agents cannot take it out
|
|
372
|
+
* of plan mode while it is on (the owner still changes the mode freely).
|
|
373
|
+
*/
|
|
374
|
+
export function renderPlanOnlyControlHtml({ conversationId = '', planOnly = false } = {}) {
|
|
375
|
+
return `
|
|
376
|
+
<div class="ctx-agent-sessions ctx-plan-only">
|
|
377
|
+
<label class="ctx-agent-sessions-row">
|
|
378
|
+
<input type="checkbox" id="ctx-plan-only" data-conversation-id="${escapeHtml(conversationId)}"${planOnly === true ? ' checked' : ''}>
|
|
379
|
+
<span>Plan only: agents can't take this session out of plan mode</span>
|
|
380
|
+
</label>
|
|
381
|
+
<div class="ctx-thinking-note">While this is on, an agent's message or settings change that names another mode is refused; you can still change the mode yourself. "Approve plan" runs one turn with write access and the session plans again after it. Agents may switch it off.</div>
|
|
382
|
+
</div>
|
|
383
|
+
`;
|
|
384
|
+
}
|
|
385
|
+
|
|
370
386
|
// A model limit is a round figure: 1M rather than 1.0M.
|
|
371
387
|
function formatModelLimit(value) {
|
|
372
388
|
const n = toNullableNumber(value);
|
|
@@ -612,6 +612,15 @@ function steeringHoldForConversation(conversationId) {
|
|
|
612
612
|
return { held: false, reason: null };
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
+
// Plan mode on one side, the writing modes on the other (a turn's mode not yet
|
|
616
|
+
// known crosses nothing).
|
|
617
|
+
function modesCrossPlan(turnMode, composerMode) {
|
|
618
|
+
const turn = String(turnMode || '').trim().toLowerCase();
|
|
619
|
+
const composer = String(composerMode || '').trim().toLowerCase();
|
|
620
|
+
if (!turn || !composer) return false;
|
|
621
|
+
return (turn === 'plan') !== (composer === 'plan');
|
|
622
|
+
}
|
|
623
|
+
|
|
615
624
|
function syncSendButtonState() {
|
|
616
625
|
const btn = document.getElementById('send-btn');
|
|
617
626
|
if (!btn) return;
|
|
@@ -631,6 +640,7 @@ function syncSendButtonState() {
|
|
|
631
640
|
steeringSupported: conversationSupportsSteering(currentConvId),
|
|
632
641
|
steeringHeld: steeringHold.held,
|
|
633
642
|
steeringHoldReason: steeringHold.reason,
|
|
643
|
+
crossesPlanMode: modesCrossPlan(currentTurn?.mode, document.getElementById('mode-select')?.value),
|
|
634
644
|
archived: conversations[String(currentConvId || '').trim()]?.archived === true,
|
|
635
645
|
setupRequired: !String(currentConvId || '').trim() && isProviderSetupRequired(),
|
|
636
646
|
});
|
|
@@ -1202,10 +1212,15 @@ function setConversationTurnState(conversationId, state = null) {
|
|
|
1202
1212
|
syncSendButtonState();
|
|
1203
1213
|
return;
|
|
1204
1214
|
}
|
|
1215
|
+
const messageId = String(state.messageId || '').trim();
|
|
1216
|
+
const previous = activeTurnsByConversation.get(conversationKey);
|
|
1205
1217
|
activeTurnsByConversation.set(conversationKey, {
|
|
1206
|
-
messageId
|
|
1218
|
+
messageId,
|
|
1207
1219
|
status: String(state.status || 'processing').trim().toLowerCase() || 'processing',
|
|
1208
1220
|
cancelRequested: state.cancelRequested === true,
|
|
1221
|
+
// The live turn's relay mode (the in-flight snapshot carries it); a status
|
|
1222
|
+
// update for the same turn keeps the one already known.
|
|
1223
|
+
mode: String(state.mode || (previous?.messageId === messageId ? previous.mode : '') || '').trim().toLowerCase(),
|
|
1209
1224
|
});
|
|
1210
1225
|
syncSendButtonState();
|
|
1211
1226
|
}
|
|
@@ -2293,7 +2308,7 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
2293
2308
|
const liveStreamSeq = relayStreamStateByMessageId.get(messageId)?.seq || 0;
|
|
2294
2309
|
const liveStreamText = relayStreamTextByMessageId.get(messageId);
|
|
2295
2310
|
clearRelayStreamState();
|
|
2296
|
-
setConversationTurnState(currentConvId, { messageId, status: 'processing' });
|
|
2311
|
+
setConversationTurnState(currentConvId, { messageId, status: 'processing', mode: inFlight?.mode });
|
|
2297
2312
|
const activities = mergeRelayActivityTexts(
|
|
2298
2313
|
relayActivities.get(messageId) || [],
|
|
2299
2314
|
Array.isArray(inFlight.activities) ? inFlight.activities : [],
|