@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
|
@@ -28,6 +28,8 @@ import { createDetachedWorkerWatch } from './services/detached-worker-watch-serv
|
|
|
28
28
|
import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
|
|
29
29
|
import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
|
|
30
30
|
import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
|
|
31
|
+
import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
|
|
32
|
+
import { carryPlanOnlyToCompacted, readPlanOnly } from './services/conversation-preferences-service.mjs';
|
|
31
33
|
import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
|
|
32
34
|
import { isRealPathWithinRoot } from './services/workspace-symlink-guard.mjs';
|
|
33
35
|
import { applySchema } from './db-schema.mjs';
|
|
@@ -74,6 +76,7 @@ import { createClaudeApiSettingsService } from './services/claude-api-settings-s
|
|
|
74
76
|
import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
|
|
75
77
|
import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
|
|
76
78
|
import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
|
|
79
|
+
import { createTokenUsageService } from './services/token-usage-service.mjs';
|
|
77
80
|
import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
|
|
78
81
|
import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
|
|
79
82
|
import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
|
|
@@ -101,6 +104,7 @@ import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
|
101
104
|
import { createRelayScheduleService } from './services/relay-schedule-service.mjs';
|
|
102
105
|
import { createRelayAskService } from './services/relay-ask-service.mjs';
|
|
103
106
|
import { createRelayAskLinkService } from './services/relay-ask-link-service.mjs';
|
|
107
|
+
import { createRemoteInboxWatchService } from './services/remote-inbox-watch-service.mjs';
|
|
104
108
|
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
105
109
|
import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
|
|
106
110
|
import {
|
|
@@ -141,7 +145,11 @@ import { createClaudeSessionRootResolver } from './services/claude-session-root-
|
|
|
141
145
|
import { createCursorSessionRootResolver } from './services/cursor-session-root-service.mjs';
|
|
142
146
|
import { createGitChangesService } from './services/git-changes-service.mjs';
|
|
143
147
|
import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
|
|
144
|
-
import {
|
|
148
|
+
import { startRelayMemoryLog } from './relay-heap-guard.mjs';
|
|
149
|
+
import { TAKEOVER_PROGRESS_FILE, adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
|
|
150
|
+
import { checkDatabaseFile, resolveDatabaseFile, settleDatabaseFile } from './services/database-file.mjs';
|
|
151
|
+
import { describeBackups, describeNextBackup, maybeBackupBeforeStart, writeDbLastRun } from './services/db-backup-service.mjs';
|
|
152
|
+
import { resolveStateLayout } from './services/oar-state-layout.mjs';
|
|
145
153
|
import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
|
|
146
154
|
import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
|
|
147
155
|
import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
|
|
@@ -218,7 +226,8 @@ import {
|
|
|
218
226
|
resolveRelayRestartMaxWaitOverrideMs,
|
|
219
227
|
RELAY_RESTART_MAX_WAIT_SETTING_KEY,
|
|
220
228
|
} from './services/relay-restart-queue.mjs';
|
|
221
|
-
import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
|
|
229
|
+
import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
|
|
230
|
+
import { createWorkerScope } from './services/worker-scope.mjs';
|
|
222
231
|
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
223
232
|
import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
|
|
224
233
|
import {
|
|
@@ -319,7 +328,9 @@ const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
|
|
|
319
328
|
// for why the config can carry it (a dev checkout pointing at a release
|
|
320
329
|
// install's data).
|
|
321
330
|
const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
|
|
322
|
-
|
|
331
|
+
// DB_PATH (oar.db, or the name of installs from before 0.9.20) is
|
|
332
|
+
// resolved under the lock, once an earlier database may have been brought
|
|
333
|
+
// over: see resolveDatabaseFile below.
|
|
323
334
|
const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
|
|
324
335
|
// Uploads follow the data dir whenever the env var or config.json chose it.
|
|
325
336
|
const UPLOAD_DIR = resolveUploadDir({ dataDir: DATA_DIR, env: process.env, serverDir: __dirname });
|
|
@@ -1440,7 +1451,16 @@ async function refreshGrokProviderModels() {
|
|
|
1440
1451
|
}
|
|
1441
1452
|
}
|
|
1442
1453
|
|
|
1443
|
-
const DEFAULT_CONFIG = {
|
|
1454
|
+
const DEFAULT_CONFIG = {
|
|
1455
|
+
authToken: '',
|
|
1456
|
+
port: 3333,
|
|
1457
|
+
pollIntervalMs: 3000,
|
|
1458
|
+
conversationSessionMode: 'isolated',
|
|
1459
|
+
localhostOnly: true,
|
|
1460
|
+
// A copy of the database at the first start of each new version, the last
|
|
1461
|
+
// `keep` kept (db-backup-service.mjs); `dir` (absolute) moves the folder.
|
|
1462
|
+
dbBackups: { enabled: true, keep: 2 },
|
|
1463
|
+
};
|
|
1444
1464
|
// How long a turn may go without any sign of life from its worker before the
|
|
1445
1465
|
// relay assumes the worker died. This is an inactivity window, not a cap on turn
|
|
1446
1466
|
// length — see recoverStaleMessages.
|
|
@@ -1867,8 +1887,28 @@ try {
|
|
|
1867
1887
|
console.error(`[server] ${error?.message || String(error)}`);
|
|
1868
1888
|
process.exit(error?.code === 'ERELAYLOCKED' ? RELAY_STOP_EXIT_CODE : 1);
|
|
1869
1889
|
}
|
|
1870
|
-
// Before the database opens, under the lock
|
|
1871
|
-
//
|
|
1890
|
+
// Before the database opens, under the lock: both names there are settled
|
|
1891
|
+
// first (the earlier name's database, not newer, is set aside, else the
|
|
1892
|
+
// relay stops), then adoptEarlierDatabase, whose result also feeds
|
|
1893
|
+
// lostPackageDatabaseWarning.
|
|
1894
|
+
{
|
|
1895
|
+
let settled;
|
|
1896
|
+
try {
|
|
1897
|
+
settled = settleDatabaseFile({
|
|
1898
|
+
dataDir: DATA_DIR,
|
|
1899
|
+
Database,
|
|
1900
|
+
progressFile: path.join(DATA_DIR, TAKEOVER_PROGRESS_FILE),
|
|
1901
|
+
logger: console,
|
|
1902
|
+
});
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
settled = { status: 'refused', error: `[data] Not starting: the database files in ${DATA_DIR} could not be looked at (${error?.message || error}).` };
|
|
1905
|
+
}
|
|
1906
|
+
if (settled.status === 'refused') {
|
|
1907
|
+
console.error(settled.error);
|
|
1908
|
+
try { relaySingletonGuard.release(); } catch {}
|
|
1909
|
+
process.exit(RELAY_STOP_EXIT_CODE);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1872
1912
|
let earlierDatabase;
|
|
1873
1913
|
{
|
|
1874
1914
|
try {
|
|
@@ -1891,6 +1931,21 @@ let earlierDatabase;
|
|
|
1891
1931
|
process.exit(RELAY_STOP_EXIT_CODE);
|
|
1892
1932
|
}
|
|
1893
1933
|
}
|
|
1934
|
+
// Resolved after the adoption, which may have placed the database: `created`
|
|
1935
|
+
// only when neither name is there now.
|
|
1936
|
+
const databaseFile = resolveDatabaseFile(DATA_DIR);
|
|
1937
|
+
const DB_PATH = databaseFile.path;
|
|
1938
|
+
{
|
|
1939
|
+
// A dangling link, or an oar.db-wal/-shm without oar.db (a later rename to
|
|
1940
|
+
// oar.db would take its pages in), stops the start; other strays are said.
|
|
1941
|
+
const check = checkDatabaseFile(databaseFile);
|
|
1942
|
+
for (const warning of check.warnings) console.warn(warning);
|
|
1943
|
+
if (check.refusal) {
|
|
1944
|
+
console.error(check.refusal);
|
|
1945
|
+
try { relaySingletonGuard.release(); } catch {}
|
|
1946
|
+
process.exit(RELAY_STOP_EXIT_CODE);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1894
1949
|
|
|
1895
1950
|
const MAX_REQUEUE_RETRIES = Number.isFinite(Number(config.maxRequeueRetries))
|
|
1896
1951
|
? Math.max(1, Number(config.maxRequeueRetries))
|
|
@@ -1949,6 +2004,8 @@ const runtimeTimers = {
|
|
|
1949
2004
|
sharedViewerPrune: null,
|
|
1950
2005
|
restoredTaskSettle: null,
|
|
1951
2006
|
claudeApiCapClock: null,
|
|
2007
|
+
memoryLog: null,
|
|
2008
|
+
memoryWatch: null,
|
|
1952
2009
|
};
|
|
1953
2010
|
|
|
1954
2011
|
function normalizeRemotePath(value) {
|
|
@@ -2734,7 +2791,7 @@ const configuredConversationSessionMode =
|
|
|
2734
2791
|
updateModelCatalog({ models: [DEFAULT_MODEL], currentModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL, source: 'bootstrap' });
|
|
2735
2792
|
|
|
2736
2793
|
// ─── SQLite Setup ─────────────────────────────────────────────────────────────
|
|
2737
|
-
const databaseCreatedAtStart =
|
|
2794
|
+
const databaseCreatedAtStart = databaseFile.created;
|
|
2738
2795
|
const db = new Database(DB_PATH);
|
|
2739
2796
|
if (process.platform !== 'win32') {
|
|
2740
2797
|
fs.chmodSync(DB_PATH, 0o600);
|
|
@@ -2747,11 +2804,39 @@ if (process.platform !== 'win32') {
|
|
|
2747
2804
|
}
|
|
2748
2805
|
}
|
|
2749
2806
|
|
|
2807
|
+
// Where the state root keeps backups (npm), or a checkout (server/backups).
|
|
2808
|
+
const STATE_LAYOUT = resolveStateLayout({ packageRoot: REPO_ROOT });
|
|
2809
|
+
// A copy of the database before this version's migrations, when another
|
|
2810
|
+
// version (on a checkout: another commit) ran on it last; before applySchema
|
|
2811
|
+
// and before reconcileUpdateAttempt, which removes the in-app update's
|
|
2812
|
+
// record that names the version before. Never stops the start.
|
|
2813
|
+
let dbBackupAtStart = null;
|
|
2814
|
+
try {
|
|
2815
|
+
dbBackupAtStart = await maybeBackupBeforeStart({
|
|
2816
|
+
db,
|
|
2817
|
+
Database,
|
|
2818
|
+
dbPath: DB_PATH,
|
|
2819
|
+
dataDir: DATA_DIR,
|
|
2820
|
+
databaseCreated: databaseCreatedAtStart,
|
|
2821
|
+
layout: STATE_LAYOUT,
|
|
2822
|
+
serverDir: __dirname,
|
|
2823
|
+
config,
|
|
2824
|
+
runningVersion: RUNNING_VERSION,
|
|
2825
|
+
runningCommit: BUILD_COMMIT,
|
|
2826
|
+
logger: console,
|
|
2827
|
+
});
|
|
2828
|
+
} catch (error) {
|
|
2829
|
+
console.warn(`[backup] failed: ${error?.message || error}; this start goes on without a backup of the version before.`);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2750
2832
|
// Base DDL plus the column-backfill/upgrade migrations, extracted verbatim to
|
|
2751
2833
|
// db-schema.mjs so tests can build the exact production schema. Runs the
|
|
2752
2834
|
// CREATE TABLE IF NOT EXISTS block, the FTS setup, and every ALTER TABLE
|
|
2753
2835
|
// upgrade in the original order.
|
|
2754
2836
|
applySchema(db);
|
|
2837
|
+
// A database that had no app_settings table before applySchema (a new one):
|
|
2838
|
+
// the version that runs on it now.
|
|
2839
|
+
if (dbBackupAtStart?.deferredLastRun) writeDbLastRun(db, dbBackupAtStart.deferredLastRun);
|
|
2755
2840
|
|
|
2756
2841
|
// ─── Prepared Statements ──────────────────────────────────────────────────────
|
|
2757
2842
|
// Owns its own schema (plan-usage tables) and the derived Cursor spend
|
|
@@ -3242,11 +3327,16 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
|
|
|
3242
3327
|
if (!copilotSdkEngine) return openAiEnv;
|
|
3243
3328
|
return applyCopilotSdkProviderEnvironment(openAiEnv, { enabled: true, model });
|
|
3244
3329
|
}
|
|
3330
|
+
// Under a systemd user service, session workers start in scopes of their
|
|
3331
|
+
// own (worker-scope.mjs); asked once, at the first worker start.
|
|
3332
|
+
const workerScope = createWorkerScope({ hosting: detectRelayHosting(), log: (message) => console.log(`${runtimeLogPrefix()}${message}`) });
|
|
3245
3333
|
const relayCliLauncherService = createRelayCliLauncherService({
|
|
3246
3334
|
cwd: (targetSessionId) => resolveLaunchWorkspaceRootForSession(targetSessionId),
|
|
3247
3335
|
env: sessionWorkerLaunchEnv,
|
|
3248
3336
|
log: (message) => console.log(`${runtimeLogPrefix()}${message}`),
|
|
3337
|
+
workerScope,
|
|
3249
3338
|
});
|
|
3339
|
+
|
|
3250
3340
|
async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
|
|
3251
3341
|
const normalizedTargetSessionId = String(targetSessionId || '').trim();
|
|
3252
3342
|
if (!normalizedTargetSessionId) {
|
|
@@ -3278,6 +3368,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
3278
3368
|
const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
|
|
3279
3369
|
const workerLaunchEnv = buildSessionWorkerLaunchEnvForSession(normalizedTargetSessionId);
|
|
3280
3370
|
const launched = await launchSessionCli({
|
|
3371
|
+
workerScope,
|
|
3281
3372
|
targetSessionId: normalizedTargetSessionId,
|
|
3282
3373
|
cwd: resolvedWorkspaceRoot,
|
|
3283
3374
|
env: workerLaunchEnv,
|
|
@@ -3356,6 +3447,7 @@ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
|
|
|
3356
3447
|
});
|
|
3357
3448
|
const updateInstallService = createUpdateInstallService({
|
|
3358
3449
|
runningVersion: RUNNING_VERSION,
|
|
3450
|
+
describeNextBackup: () => describeNextBackup({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, dbPath: DB_PATH, config }),
|
|
3359
3451
|
installMethod: INSTALL_METHOD,
|
|
3360
3452
|
packageRoot: PACKAGE_ROOT,
|
|
3361
3453
|
readSetting: readAppSettingValue,
|
|
@@ -3673,6 +3765,7 @@ function collectRestartPicture({ action = 'restart' } = {}) {
|
|
|
3673
3765
|
restartPictureCollector = createRestartPictureCollector({
|
|
3674
3766
|
db,
|
|
3675
3767
|
backgroundWorkView,
|
|
3768
|
+
workerScopes: () => workerScope.inUse(),
|
|
3676
3769
|
listHeldTasks: () => backgroundTaskStore.listRestored().map(({ key, task }) => ({
|
|
3677
3770
|
conversationId: resolveConversationRecord({ conversationId: key, sdkSessionId: key })?.id || key,
|
|
3678
3771
|
description: task?.description || task?.taskType || '',
|
|
@@ -4052,19 +4145,28 @@ function parseBoardContext(rawContext) {
|
|
|
4052
4145
|
|
|
4053
4146
|
function formatRelayBoardRow(row) {
|
|
4054
4147
|
if (!row) return null;
|
|
4148
|
+
const boardType = String(row.board_type || '').trim().toLowerCase() || 'generic';
|
|
4149
|
+
const status = String(row.status || 'pending').trim().toLowerCase() || 'pending';
|
|
4150
|
+
const planOnly = readPlanOnly(stmts.getConvAnyStatus.get(row.conversation_id));
|
|
4055
4151
|
return {
|
|
4056
4152
|
id: row.id,
|
|
4057
4153
|
queueId: row.queue_id,
|
|
4058
4154
|
conversationId: row.conversation_id,
|
|
4059
4155
|
messageId: row.message_id,
|
|
4060
|
-
boardType
|
|
4156
|
+
boardType,
|
|
4061
4157
|
mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
|
|
4062
4158
|
title: String(row.title || '').trim(),
|
|
4063
4159
|
body: String(row.body || '').trim(),
|
|
4064
|
-
|
|
4065
|
-
|
|
4160
|
+
// A pending plan card stored before "Approve plan" existed shows it too.
|
|
4161
|
+
actions: planBoardShownActions({ boardType, status }, parseBoardActions(row.actions_json)),
|
|
4162
|
+
// On a plan-only session a pending plan card recommends "Approve plan".
|
|
4163
|
+
recommendedAction: (planOnly && status === 'pending' && boardType === 'plan_ready')
|
|
4164
|
+
? PLAN_BOARD_APPROVE_ONLY
|
|
4165
|
+
: (String(row.recommended_action || '').trim() || null),
|
|
4166
|
+
// The UI asks before an action that leaves plan mode on such a session.
|
|
4167
|
+
planOnly,
|
|
4066
4168
|
context: parseBoardContext(row.context_json),
|
|
4067
|
-
status
|
|
4169
|
+
status,
|
|
4068
4170
|
selectedAction: String(row.selected_action || '').trim() || null,
|
|
4069
4171
|
actedAt: row.acted_at || null,
|
|
4070
4172
|
createdAt: row.created_at,
|
|
@@ -5246,6 +5348,8 @@ function createCompactedConversation(sourceConversationId) {
|
|
|
5246
5348
|
// The new conversation stays in the old one's session group, and heads
|
|
5247
5349
|
// it if the old one did; the archived source stays a member.
|
|
5248
5350
|
conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
|
|
5351
|
+
// A plan-only conversation's lock goes along, with its saved mode.
|
|
5352
|
+
carryPlanOnlyToCompacted({ db, stmts, source, targetConversationId, updatedAt: now });
|
|
5249
5353
|
// A Claude chat keeps its billing (the subscription or the API key, a
|
|
5250
5354
|
// pending swap's included): the new chat's first worker launches on it,
|
|
5251
5355
|
// and it is never guessed again from a Claude model id. Other providers
|
|
@@ -5547,6 +5651,9 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5547
5651
|
// workers' reports (services/claude-api-usage-service.mjs); the Claude API
|
|
5548
5652
|
// card and its cap read it.
|
|
5549
5653
|
const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console });
|
|
5654
|
+
// Tokens per turn and per day for every provider, booked from the
|
|
5655
|
+
// workers' reports; the Tokens sections in Check Usage and About read it.
|
|
5656
|
+
const tokenUsageService = createTokenUsageService({ db, logger: console });
|
|
5550
5657
|
// This month's spend on the key against the cap, and the Check Usage card
|
|
5551
5658
|
// (services/plan-usage-claude-api.mjs), over the same local days.
|
|
5552
5659
|
const claudeApiPlanUsage = createClaudeApiPlanUsage({
|
|
@@ -7359,6 +7466,16 @@ const relayAskLinkService = createRelayAskLinkService({
|
|
|
7359
7466
|
client: remoteRelayClient,
|
|
7360
7467
|
logger: console,
|
|
7361
7468
|
});
|
|
7469
|
+
// Paired relays' cards a turn waits on: the badge here stays current, and
|
|
7470
|
+
// one relay pushes a card its own relay reached no device for.
|
|
7471
|
+
const remoteInboxWatchService = createRemoteInboxWatchService({
|
|
7472
|
+
registry: remoteRelayRegistry,
|
|
7473
|
+
client: remoteRelayClient,
|
|
7474
|
+
push: pushDispatchService,
|
|
7475
|
+
hasSockets: () => io.of('/').sockets.size > 0,
|
|
7476
|
+
emit: (event) => io.emit(event),
|
|
7477
|
+
logger: console,
|
|
7478
|
+
});
|
|
7362
7479
|
const remoteRelayPairing = createRemoteRelayPairing({
|
|
7363
7480
|
registry: remoteRelayRegistry,
|
|
7364
7481
|
client: remoteRelayClient,
|
|
@@ -7644,6 +7761,7 @@ const sharedRouteDeps = {
|
|
|
7644
7761
|
usageLimitPauseService,
|
|
7645
7762
|
claudeBillingSwitchService,
|
|
7646
7763
|
claudeApiUsageService,
|
|
7764
|
+
tokenUsageService,
|
|
7647
7765
|
claudeApiClaimCheck,
|
|
7648
7766
|
onClaudeBillingFence: handleClaudeBillingFence,
|
|
7649
7767
|
claudeApiPlanUsage,
|
|
@@ -7716,11 +7834,13 @@ registerRelayAskRoutes(app, {
|
|
|
7716
7834
|
db,
|
|
7717
7835
|
stmts,
|
|
7718
7836
|
formatQuestionRow,
|
|
7837
|
+
formatRelayBoardRow,
|
|
7719
7838
|
relayAskService,
|
|
7720
7839
|
relayAskLinkService,
|
|
7721
7840
|
remoteRelayInbound,
|
|
7722
7841
|
remoteRelayRegistry,
|
|
7723
7842
|
remoteRelayClient,
|
|
7843
|
+
questionNotice: (questionId) => pushDispatchService.questionNotice(questionId),
|
|
7724
7844
|
});
|
|
7725
7845
|
registerRelayRestartRoutes(app, {
|
|
7726
7846
|
auth,
|
|
@@ -7755,6 +7875,7 @@ registerAboutRoutes(app, {
|
|
|
7755
7875
|
isRelayCliOnline: () => runtimeState.cliOnline === true,
|
|
7756
7876
|
listWorkers: () => sessionWorkerRegistry.listWorkers(),
|
|
7757
7877
|
getUpdateState: () => buildUpdateStatePayload(),
|
|
7878
|
+
describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
|
|
7758
7879
|
}),
|
|
7759
7880
|
});
|
|
7760
7881
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
@@ -7856,6 +7977,13 @@ runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
|
|
|
7856
7977
|
expirePendingQuestions();
|
|
7857
7978
|
runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
|
|
7858
7979
|
if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
|
|
7980
|
+
// Memory in the status log: now, every 10 minutes, and once per crossing of
|
|
7981
|
+
// 80 % of the heap limit (relay-heap-guard.mjs).
|
|
7982
|
+
{
|
|
7983
|
+
const memoryTimers = startRelayMemoryLog({ logger: console });
|
|
7984
|
+
runtimeTimers.memoryLog = memoryTimers.log;
|
|
7985
|
+
runtimeTimers.memoryWatch = memoryTimers.check;
|
|
7986
|
+
}
|
|
7859
7987
|
const sweptUploads = sweepUnreferencedUploadBlobs();
|
|
7860
7988
|
if (sweptUploads > 0) {
|
|
7861
7989
|
console.log(`${runtimeLogPrefix()}Reclaimed ${sweptUploads} unreferenced upload(s)`);
|
|
@@ -8099,6 +8227,8 @@ relayEventLinkService.start();
|
|
|
8099
8227
|
relayScheduleService.start();
|
|
8100
8228
|
// Asks on paired relays still open there: look for their answers again.
|
|
8101
8229
|
relayAskLinkService.start();
|
|
8230
|
+
// Paired relays' Inboxes, every ~20 s.
|
|
8231
|
+
remoteInboxWatchService.start();
|
|
8102
8232
|
runtimeTimers.pendingWorkerPrime = setInterval(() => {
|
|
8103
8233
|
void primePendingSessionWorkers('maintenance');
|
|
8104
8234
|
detachedWorkerWatch.sweep().catch((error) => {
|
|
@@ -8421,6 +8551,7 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
8421
8551
|
try { relayEventLinkService.stop(); } catch {}
|
|
8422
8552
|
try { relayScheduleService.stop(); } catch {}
|
|
8423
8553
|
try { relayAskLinkService.stop(); } catch {}
|
|
8554
|
+
try { remoteInboxWatchService.stop(); } catch {}
|
|
8424
8555
|
tmuxInspectorSocketService.stop();
|
|
8425
8556
|
stopWorkspaceFileWatcher();
|
|
8426
8557
|
sshTunnelManager.stop();
|
|
@@ -273,6 +273,42 @@ export function collectUsageTotals(db) {
|
|
|
273
273
|
usage['claude-cloud'] = { costUsd: toAmount(row.cost), sessions: toCount(row.sessions) };
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
+
// Every provider's tokens per turn, all time (token_usage_days, migration
|
|
277
|
+
// 0036): fresh input, output, cache writes and cache reads apart, each
|
|
278
|
+
// null while no turn reported it, the first day, and whether days copied
|
|
279
|
+
// at the upgrade are in it. An own key's turns (Copilot) are kept apart
|
|
280
|
+
// from the plan's.
|
|
281
|
+
if (tableExists(db, 'token_usage_days')) {
|
|
282
|
+
const rows = db.prepare(`
|
|
283
|
+
SELECT provider, billing, SUM(input_tokens) AS input, SUM(output_tokens) AS output,
|
|
284
|
+
SUM(cache_write_tokens) AS cache_write, SUM(cache_read_tokens) AS cache_read, MIN(day) AS since,
|
|
285
|
+
MIN(CASE WHEN source = 'turns' THEN day END) AS first_turns_day,
|
|
286
|
+
MAX(CASE WHEN source = 'backfill' THEN 1 ELSE 0 END) AS backfilled
|
|
287
|
+
FROM token_usage_days
|
|
288
|
+
GROUP BY provider, billing
|
|
289
|
+
ORDER BY provider, billing
|
|
290
|
+
`).all();
|
|
291
|
+
const nullable = (value) => (value === null || value === undefined ? null : toAmount(value));
|
|
292
|
+
for (const row of rows) {
|
|
293
|
+
const tokens = {
|
|
294
|
+
inputTokens: nullable(row.input),
|
|
295
|
+
outputTokens: nullable(row.output),
|
|
296
|
+
cacheWriteTokens: nullable(row.cache_write),
|
|
297
|
+
cacheReadTokens: nullable(row.cache_read),
|
|
298
|
+
since: row.since || null,
|
|
299
|
+
// Days copied at the upgrade (0036) hold cache writes inside input;
|
|
300
|
+
// the first counted turn's day is where that stops.
|
|
301
|
+
backfilled: Number(row.backfilled) === 1,
|
|
302
|
+
firstTurnsDay: row.first_turns_day || null,
|
|
303
|
+
};
|
|
304
|
+
const entry = usage[row.provider] || (usage[row.provider] = {});
|
|
305
|
+
if (row.billing) {
|
|
306
|
+
(entry.ownKeyTokens || (entry.ownKeyTokens = [])).push({ billing: row.billing, ...tokens });
|
|
307
|
+
} else {
|
|
308
|
+
entry.tokens = tokens;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
276
312
|
return usage;
|
|
277
313
|
}
|
|
278
314
|
|
|
@@ -310,10 +346,12 @@ async function fileSize(fsImpl, filePath) {
|
|
|
310
346
|
* @param {() => object[]} [deps.listWorkers] the session worker registry's entries
|
|
311
347
|
* @param {() => object|null} [deps.getUpdateState] buildUpdateStatePayload()
|
|
312
348
|
* @param {object} [deps.sdkVersions] package version by provider id; read from node_modules when absent
|
|
349
|
+
* @param {() => object|null} [deps.describeBackups] the database backups (db-backup-service.mjs describeBackups)
|
|
313
350
|
*/
|
|
314
351
|
export function createAboutService({
|
|
315
352
|
db,
|
|
316
353
|
dbPath = null,
|
|
354
|
+
describeBackups = null,
|
|
317
355
|
version = '0.0.0',
|
|
318
356
|
commit = null,
|
|
319
357
|
installMethod = null,
|
|
@@ -451,14 +489,35 @@ export function createAboutService({
|
|
|
451
489
|
usage: totals.usage,
|
|
452
490
|
relay: {
|
|
453
491
|
uptimeSeconds,
|
|
492
|
+
// The full path: which of the two database names this relay runs on.
|
|
493
|
+
dbPath: dbPath || null,
|
|
454
494
|
dbBytes: dbFileBytes + walBytes,
|
|
455
495
|
dbFileBytes,
|
|
456
496
|
walBytes,
|
|
457
497
|
runningWorkers: workers.total,
|
|
458
498
|
},
|
|
459
499
|
update: describeUpdate(),
|
|
500
|
+
backups: readBackups(),
|
|
460
501
|
};
|
|
461
502
|
}
|
|
462
503
|
|
|
504
|
+
// The last backup and how many are kept, for one row; never the listing.
|
|
505
|
+
function readBackups() {
|
|
506
|
+
if (!describeBackups) return null;
|
|
507
|
+
try {
|
|
508
|
+
const backups = describeBackups();
|
|
509
|
+
return {
|
|
510
|
+
enabled: backups.enabled,
|
|
511
|
+
keep: backups.keep,
|
|
512
|
+
dir: backups.dir,
|
|
513
|
+
problem: backups.problem,
|
|
514
|
+
kept: backups.own.length,
|
|
515
|
+
lastResult: backups.lastResult,
|
|
516
|
+
};
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
463
522
|
return { describe };
|
|
464
523
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
import { forgetTurnTokenRows } from './token-usage-service.mjs';
|
|
4
|
+
|
|
3
5
|
function normalizeId(value) {
|
|
4
6
|
return String(value || '').trim();
|
|
5
7
|
}
|
|
@@ -99,6 +101,8 @@ export function createCacheRebuildService({
|
|
|
99
101
|
db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(sid);
|
|
100
102
|
db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(sid);
|
|
101
103
|
db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(sid);
|
|
104
|
+
// Its turns' tokens go too; the day sums stay.
|
|
105
|
+
forgetTurnTokenRows(db, [sid]);
|
|
102
106
|
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
|
|
103
107
|
})();
|
|
104
108
|
closePreviews(sid);
|
|
@@ -164,6 +168,9 @@ export function createCacheRebuildService({
|
|
|
164
168
|
db.prepare(`DELETE FROM queue`).run();
|
|
165
169
|
db.prepare(`DELETE FROM messages`).run();
|
|
166
170
|
db.prepare(`DELETE FROM runtime_sessions`).run();
|
|
171
|
+
// The turn tokens of the chats removed for good; a hand-titled chat
|
|
172
|
+
// stays, and keeps them.
|
|
173
|
+
forgetTurnTokenRows(db, db.prepare(`SELECT id FROM conversations WHERE status = 'deleted' OR title_source != 'manual'`).all().map((row) => row.id));
|
|
167
174
|
db.prepare(`DELETE FROM conversations WHERE status = 'deleted' OR title_source != 'manual'`).run();
|
|
168
175
|
for (const conversationId of manualTitleConversations) {
|
|
169
176
|
db.prepare(`
|
|
@@ -44,6 +44,9 @@ export function persistConversationPreferences({
|
|
|
44
44
|
// The owner's "agents in this conversation may start sessions" (a boolean;
|
|
45
45
|
// `undefined` leaves it alone). The route decides who may write it.
|
|
46
46
|
mayCreateSessions = undefined,
|
|
47
|
+
// Plan-only: agents cannot take the session out of plan mode (a boolean;
|
|
48
|
+
// `undefined` leaves it alone). The routes decide who may write it.
|
|
49
|
+
planOnly = undefined,
|
|
47
50
|
updatedAt = new Date().toISOString(),
|
|
48
51
|
createIfMissing = false,
|
|
49
52
|
createTitle = 'Session',
|
|
@@ -60,6 +63,7 @@ export function persistConversationPreferences({
|
|
|
60
63
|
const writesThinkingDisplay = thinkingDisplay !== undefined;
|
|
61
64
|
const display = writesThinkingDisplay ? parseThinkingDisplay(thinkingDisplay) : null;
|
|
62
65
|
const writesMayCreateSessions = mayCreateSessions !== undefined;
|
|
66
|
+
const writesPlanOnly = planOnly !== undefined;
|
|
63
67
|
if (!db || !stmts || !convId || !mode) {
|
|
64
68
|
return {
|
|
65
69
|
ok: false,
|
|
@@ -71,6 +75,7 @@ export function persistConversationPreferences({
|
|
|
71
75
|
thinkingEnabled: DEFAULT_THINKING_ENABLED,
|
|
72
76
|
thinkingDisplay: DEFAULT_THINKING_DISPLAY,
|
|
73
77
|
mayCreateSessions: false,
|
|
78
|
+
planOnly: false,
|
|
74
79
|
updatedAt,
|
|
75
80
|
};
|
|
76
81
|
}
|
|
@@ -108,6 +113,10 @@ export function persistConversationPreferences({
|
|
|
108
113
|
extraSets.push('may_create_sessions = ?');
|
|
109
114
|
extraValues.push(mayCreateSessions === true ? 1 : 0);
|
|
110
115
|
}
|
|
116
|
+
if (writesPlanOnly) {
|
|
117
|
+
extraSets.push('plan_only = ?');
|
|
118
|
+
extraValues.push(planOnly === true ? 1 : 0);
|
|
119
|
+
}
|
|
111
120
|
try {
|
|
112
121
|
if (extraSets.length) {
|
|
113
122
|
db.prepare(`
|
|
@@ -148,6 +157,9 @@ export function persistConversationPreferences({
|
|
|
148
157
|
mayCreateSessions: writesMayCreateSessions
|
|
149
158
|
? mayCreateSessions === true
|
|
150
159
|
: readMayCreateSessions(existing),
|
|
160
|
+
planOnly: writesPlanOnly
|
|
161
|
+
? planOnly === true
|
|
162
|
+
: readPlanOnly(existing),
|
|
151
163
|
updatedAt,
|
|
152
164
|
};
|
|
153
165
|
});
|
|
@@ -159,6 +171,25 @@ export function readMayCreateSessions(row) {
|
|
|
159
171
|
return Number(row?.may_create_sessions) === 1;
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
/** Whether a conversations row is plan-only (migration 0035; NULL is not). */
|
|
175
|
+
export function readPlanOnly(row) {
|
|
176
|
+
return Number(row?.plan_only) === 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A compaction's new conversation keeps a plan-only source's lock and the
|
|
181
|
+
* mode it is saved in, so the work goes on as locked as it was. Nothing for
|
|
182
|
+
* any other source. Returns whether it carried the lock.
|
|
183
|
+
*/
|
|
184
|
+
export function carryPlanOnlyToCompacted({ db, stmts, source, targetConversationId, updatedAt = new Date().toISOString() } = {}) {
|
|
185
|
+
if (!db || !stmts?.conversationsHavePlanOnly || !readPlanOnly(source)) return false;
|
|
186
|
+
const target = String(targetConversationId || '').trim();
|
|
187
|
+
if (!target) return false;
|
|
188
|
+
const mode = String(source?.preferred_relay_mode || '').trim() || 'plan';
|
|
189
|
+
db.prepare(`UPDATE conversations SET plan_only = 1, preferred_relay_mode = ?, updated_at = ? WHERE id = ?`).run(mode, updatedAt, target);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
162
193
|
export function persistConversationModelPreference({
|
|
163
194
|
db,
|
|
164
195
|
stmts,
|