@oar-sh/oar 0.9.2 → 0.9.3
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/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +25 -10
- package/package.json +7 -4
- package/server/README.md +1 -1
- package/server/claude-worker/claude-session-process.mjs +981 -44
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/db-schema.mjs +20 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/api-client.js +22 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +34 -8
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +919 -194
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +145 -5
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
- package/server/public/app/settings-modal.js +8 -6
- package/server/public/app/socket-handlers.js +19 -0
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +206 -9
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/repositories/message-repository.mjs +61 -18
- package/server/repositories/question-repository.mjs +15 -0
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/messages-routes.mjs +488 -46
- package/server/routes/sessions-routes.mjs +298 -55
- package/server/server-runtime.mjs +65 -25
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// control polling, activity channels) but executes turns through the Copilot
|
|
5
5
|
// SDK's headless runtime instead of the `copilot` TUI under a PTY.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
// a live relay with:
|
|
7
|
+
// Run it by hand against a live relay with:
|
|
9
8
|
//
|
|
10
9
|
// COPILOT_WEB_RELAY_CONFIG=server/config.json \
|
|
11
10
|
// COPILOT_WORKSPACE_ROOT=/path/to/workspace \
|
|
@@ -21,6 +20,10 @@ import {
|
|
|
21
20
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
22
21
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
23
22
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
23
|
+
import {
|
|
24
|
+
createRunnerLinkBridge,
|
|
25
|
+
failSettlingRowsOnShutdown,
|
|
26
|
+
} from '../../shared/worker-runtime/runner-link-wiring.mjs';
|
|
24
27
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
25
28
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
26
29
|
import {
|
|
@@ -69,8 +72,29 @@ async function main() {
|
|
|
69
72
|
api,
|
|
70
73
|
sdkSessionId,
|
|
71
74
|
abortAckNote: 'copilot session aborted',
|
|
75
|
+
// A subagent lane's Stop. Lanes are keyed on the envelope agentId, which
|
|
76
|
+
// is also the runtime's task id, so `rpc.tasks.cancel` is tried; the
|
|
77
|
+
// runtime refuses ids it does not track (then the control answers "not
|
|
78
|
+
// supported" and the whole-turn Stop remains). Background agents have no
|
|
79
|
+
// lane — their Stop is the task panel's. (Late-bound: controls only poll
|
|
80
|
+
// while a turn is live.)
|
|
81
|
+
onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTask(subagentRunId),
|
|
72
82
|
dbg,
|
|
73
83
|
});
|
|
84
|
+
// The relay's *Background task timeout* slider (0 = unlimited) rides every
|
|
85
|
+
// delivery payload, exactly as for the Claude worker: background agents and
|
|
86
|
+
// shells now have cards and a Stop, so the slider governs them too and a cap
|
|
87
|
+
// expiry CANCELS them rather than forgetting them. The env var is an
|
|
88
|
+
// emergency override only.
|
|
89
|
+
const backgroundTaskTimeoutOverrideMs = readOptionalMs('COPILOT_SDK_RELAY_BACKGROUND_TASK_TIMEOUT_MS');
|
|
90
|
+
let backgroundTaskTimeoutMs = backgroundTaskTimeoutOverrideMs ?? 0;
|
|
91
|
+
// The runner reports every flip of its delivery gate; the bridge turns them
|
|
92
|
+
// into the link's worker.unready / worker.ready frames, so a message queued
|
|
93
|
+
// while a question card is open is held in the relay queue and steers into
|
|
94
|
+
// the resumed turn one round trip after the answer.
|
|
95
|
+
const linkBridge = createRunnerLinkBridge();
|
|
96
|
+
// Late-bound: the heartbeat controller is built after the runner.
|
|
97
|
+
let heartbeat = null;
|
|
74
98
|
const turnRunner = createCopilotSdkSessionRunner({
|
|
75
99
|
api,
|
|
76
100
|
sdkSessionId,
|
|
@@ -82,23 +106,22 @@ async function main() {
|
|
|
82
106
|
// How long a deferred model switch may wait for its drain before the
|
|
83
107
|
// explicit selection fails the row (default 10s).
|
|
84
108
|
modelSwitchTimeoutMs: readOptionalMs('COPILOT_SDK_RELAY_MODEL_SWITCH_TIMEOUT_MS'),
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
})(),
|
|
109
|
+
getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
|
|
110
|
+
// A readiness flip is a hold starting or ending (question card, approval,
|
|
111
|
+
// compaction): the composer's steering snapshot changed with it.
|
|
112
|
+
onDeliveryReadinessChange: (ready) => {
|
|
113
|
+
linkBridge.onDeliveryReadinessChange(ready);
|
|
114
|
+
heartbeat?.requestPulse();
|
|
115
|
+
},
|
|
116
|
+
// A message became (or stopped being) cancellable: report it now rather
|
|
117
|
+
// than on the next 10 s heartbeat (coalesced by the controller).
|
|
118
|
+
onCancellableChange: () => heartbeat?.requestPulse(),
|
|
119
|
+
canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
|
|
97
120
|
dbg,
|
|
98
121
|
});
|
|
99
122
|
|
|
100
123
|
let heartbeatTimer = null;
|
|
101
|
-
|
|
124
|
+
heartbeat = createHeartbeatController({
|
|
102
125
|
api,
|
|
103
126
|
pollMs: HEARTBEAT_MS,
|
|
104
127
|
getSessionReady: () => true,
|
|
@@ -110,6 +133,12 @@ async function main() {
|
|
|
110
133
|
// the crash guard below takes the entries whole so its requeues stay
|
|
111
134
|
// fenced to this attempt.
|
|
112
135
|
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
|
|
136
|
+
// The composer's steering snapshot (Steer / Queue + the hold reason, the
|
|
137
|
+
// un-steerable rows) and the consumed steers whose settle the runner gave
|
|
138
|
+
// up on — the relay fails those itself, never re-running them.
|
|
139
|
+
getSteeringState: () => turnRunner.steeringState(),
|
|
140
|
+
getSettleFailed: () => turnRunner.getSettleFailed(),
|
|
141
|
+
onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
|
|
113
142
|
});
|
|
114
143
|
|
|
115
144
|
const wsLink = createWorkerWebSocketLink({
|
|
@@ -119,8 +148,22 @@ async function main() {
|
|
|
119
148
|
getSessionReady: () => true,
|
|
120
149
|
getSessionId: () => sdkSessionId,
|
|
121
150
|
getPid: () => process.pid,
|
|
151
|
+
// Mid-turn steering opt-in: the link keeps signalling readiness while a
|
|
152
|
+
// delivery is in flight as long as the runner can absorb another message
|
|
153
|
+
// into the live turn, and withdraws it while the runner holds.
|
|
154
|
+
getSteeringReady: () => turnRunner.canAcceptSteering(),
|
|
155
|
+
getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
|
|
156
|
+
// A relay that just (re)started knows nothing of this worker until it
|
|
157
|
+
// heartbeats; the composer's steering snapshot rides that beat.
|
|
158
|
+
onServerHello: () => heartbeat.requestPulse(),
|
|
122
159
|
onDeliver: async (pending, reason) => {
|
|
123
160
|
dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
|
|
161
|
+
// Slider changes reach a running worker on its next delivery; an
|
|
162
|
+
// explicit env override pins the value regardless.
|
|
163
|
+
const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
164
|
+
if (backgroundTaskTimeoutOverrideMs === undefined && Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
|
|
165
|
+
backgroundTaskTimeoutMs = deliveredTimeout;
|
|
166
|
+
}
|
|
124
167
|
try {
|
|
125
168
|
return await turnRunner.handlePendingPayload(pending);
|
|
126
169
|
} catch (error) {
|
|
@@ -129,19 +172,38 @@ async function main() {
|
|
|
129
172
|
}
|
|
130
173
|
},
|
|
131
174
|
onControl: (control) => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
175
|
+
const type = String(control?.type || '').trim();
|
|
176
|
+
if (type === 'cancel_pushed_message') {
|
|
177
|
+
// Un-steer: pull a pushed-but-unconsumed message back out of the
|
|
178
|
+
// runtime's queue and cancel its row.
|
|
179
|
+
void turnRunner.cancelPushedMessage(control?.messageId).catch((error) => {
|
|
180
|
+
dbg('un-steer failed', error?.message || String(error));
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (type === 'stop_background_task') {
|
|
185
|
+
// The task panel's per-card Stop → rpc.tasks.cancel.
|
|
186
|
+
const taskId = String(control?.taskId || '').trim();
|
|
187
|
+
dbg('stop background task control', taskId);
|
|
188
|
+
void turnRunner.stopBackgroundTask(taskId).catch((error) => {
|
|
189
|
+
dbg('stop background task failed', error?.message || String(error));
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
dbg('worker control ignored (unsupported by the SDK worker)', type || '(none)');
|
|
137
194
|
},
|
|
138
195
|
});
|
|
196
|
+
linkBridge.attach(wsLink);
|
|
139
197
|
|
|
140
198
|
const shutdown = async (signal) => {
|
|
141
199
|
dbg(`shutting down (${signal})`);
|
|
142
200
|
try { wsLink.stop(); } catch {}
|
|
143
201
|
try { heartbeat.stopHeartbeat(); } catch {}
|
|
144
202
|
try { controlPoller.stop(); } catch {}
|
|
203
|
+
// Rows whose prompt the runtime consumed and whose settle marker has not
|
|
204
|
+
// landed are failed terminally, never left for dead-worker recovery to
|
|
205
|
+
// requeue (that would run the prompt twice). Bounded.
|
|
206
|
+
try { await failSettlingRowsOnShutdown({ api, runner: turnRunner }); } catch {}
|
|
145
207
|
// Bounded, like the Cursor worker: stopping the runtime is an RPC, and a
|
|
146
208
|
// hung stop must never make the process ignore the supervisor's SIGTERM.
|
|
147
209
|
try {
|
package/server/db-schema.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// final schema either way.
|
|
10
10
|
import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-workspace-roots-path-key.mjs';
|
|
11
11
|
import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
|
|
12
|
+
import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
|
|
12
13
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
13
14
|
|
|
14
15
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -637,6 +638,13 @@ if (!queueColumns.includes('parked_at')) {
|
|
|
637
638
|
if (!queueColumns.includes('parked_target_session_id')) {
|
|
638
639
|
db.exec(`ALTER TABLE queue ADD COLUMN parked_target_session_id TEXT`);
|
|
639
640
|
}
|
|
641
|
+
if (!queueColumns.includes('consumed_at')) {
|
|
642
|
+
// Set when the Claude CLI has provably taken a row's prompt (a steer still
|
|
643
|
+
// pending when the turn it was pushed into finished). A consumed row is
|
|
644
|
+
// at-most-once: every recovery path fails it terminally instead of
|
|
645
|
+
// requeueing it, because a re-delivery would run the prompt a second time.
|
|
646
|
+
db.exec(`ALTER TABLE queue ADD COLUMN consumed_at TEXT`);
|
|
647
|
+
}
|
|
640
648
|
if (!queueColumns.includes('kind')) {
|
|
641
649
|
// 'continuation' rows are synthetic turns for the Claude worker's
|
|
642
650
|
// background-task continuations: born 'processing', never deliverable, and
|
|
@@ -682,6 +690,18 @@ db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_owner_pending ON queue(status, own
|
|
|
682
690
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_parked_release ON queue(status, parked_transaction_id, parked_target_session_id, parked_at, timestamp)`);
|
|
683
691
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_queue_continuation_op ON queue(continuation_op_id) WHERE continuation_op_id IS NOT NULL`);
|
|
684
692
|
migrateImageConversationSchema(db);
|
|
693
|
+
// After the queue's response_message_id exists: the source-link backfill
|
|
694
|
+
// reads it. Not boot-critical — every reader of its columns tolerates their
|
|
695
|
+
// absence — so a failure (e.g. SQLITE_BUSY) is logged and retried next boot
|
|
696
|
+
// instead of keeping the relay down.
|
|
697
|
+
try {
|
|
698
|
+
const steerSettleMarkersMigration = migrateSteerSettleMarkers(db);
|
|
699
|
+
if (steerSettleMarkersMigration.foldStubsRekinded) {
|
|
700
|
+
console.log(`[steering] re-kinded ${steerSettleMarkersMigration.foldStubsRekinded} fold stub(s) as 'folded'`);
|
|
701
|
+
}
|
|
702
|
+
} catch (error) {
|
|
703
|
+
console.warn(`[steering] migration 0004 failed; retrying on next boot: ${error?.message || error}`);
|
|
704
|
+
}
|
|
685
705
|
|
|
686
706
|
const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
|
|
687
707
|
if (runtimeSessionColumns.length) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: give steering settle stubs a durable shape the transcript can
|
|
5
|
+
* render on reload.
|
|
6
|
+
*
|
|
7
|
+
* 1. `messages.source_message_id` — the user message an assistant row answers.
|
|
8
|
+
* The link used to live only in `queue.response_message_id`, and the queue
|
|
9
|
+
* keeps just the newest 200 finished rows, so older assistant rows lost
|
|
10
|
+
* their anchor and fell back to response-time ordering: an absorbed
|
|
11
|
+
* (handed-off) reply then sorted below the steered message it hands off to,
|
|
12
|
+
* and the merge never applied. Backfilled from whatever queue rows remain.
|
|
13
|
+
* 2. `messages.resend_of_message_id` — on a user message sent with Resend,
|
|
14
|
+
* the stopped steer it re-sends. Durable, so "Resent" survives reloads and
|
|
15
|
+
* other devices, and the relay can refuse a second Resend of one original.
|
|
16
|
+
* 3. Fold stubs saved as kind='absorbed' become kind='folded'. 'absorbed'
|
|
17
|
+
* tells the client the reply continues through the NEXT user message, so
|
|
18
|
+
* the next ordinary message after a fold was styled as steered.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent: the column is added once, the backfill only fills NULLs, and the
|
|
21
|
+
* kind rewrite matches nothing once applied. Safe to run on every boot.
|
|
22
|
+
*
|
|
23
|
+
* Usage: node server/migrations/0004-steer-settle-markers.mjs [path/to/copilot.db]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import process from 'process';
|
|
29
|
+
import Database from 'better-sqlite3';
|
|
30
|
+
|
|
31
|
+
import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
32
|
+
|
|
33
|
+
function tableExists(db, name) {
|
|
34
|
+
return !!db
|
|
35
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
36
|
+
.get(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function columnNames(db, table) {
|
|
40
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function migrateSteerSettleMarkers(db) {
|
|
44
|
+
if (!tableExists(db, 'messages')) {
|
|
45
|
+
return { applied: false, reason: 'messages table not found', sourceLinksBackfilled: 0, foldStubsRekinded: 0 };
|
|
46
|
+
}
|
|
47
|
+
let columns = columnNames(db, 'messages');
|
|
48
|
+
let columnAdded = false;
|
|
49
|
+
if (!columns.has('source_message_id')) {
|
|
50
|
+
db.exec(`ALTER TABLE messages ADD COLUMN source_message_id TEXT`);
|
|
51
|
+
columnAdded = true;
|
|
52
|
+
}
|
|
53
|
+
if (!columns.has('resend_of_message_id')) {
|
|
54
|
+
db.exec(`ALTER TABLE messages ADD COLUMN resend_of_message_id TEXT`);
|
|
55
|
+
columnAdded = true;
|
|
56
|
+
}
|
|
57
|
+
if (columnAdded) columns = columnNames(db, 'messages');
|
|
58
|
+
let sourceLinksBackfilled = 0;
|
|
59
|
+
let foldStubsRekinded = 0;
|
|
60
|
+
db.transaction(() => {
|
|
61
|
+
// Walk the (small, pruned) queue rather than correlating every message
|
|
62
|
+
// against it.
|
|
63
|
+
if (tableExists(db, 'queue') && columnNames(db, 'queue').has('response_message_id')) {
|
|
64
|
+
const link = db.prepare(`
|
|
65
|
+
UPDATE messages SET source_message_id = ?
|
|
66
|
+
WHERE id = ? AND role = 'assistant' AND source_message_id IS NULL
|
|
67
|
+
`);
|
|
68
|
+
const rows = db.prepare(`
|
|
69
|
+
SELECT id, response_message_id FROM queue
|
|
70
|
+
WHERE response_message_id IS NOT NULL AND response_message_id != ''
|
|
71
|
+
`).all();
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
sourceLinksBackfilled += link.run(String(row.id), String(row.response_message_id)).changes;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (columns.has('kind')) {
|
|
77
|
+
foldStubsRekinded = db.prepare(`
|
|
78
|
+
UPDATE messages SET kind = 'folded'
|
|
79
|
+
WHERE role = 'assistant' AND kind = 'absorbed' AND TRIM(text) = ?
|
|
80
|
+
`).run(STEER_FOLDED_TEXT).changes;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return {
|
|
84
|
+
applied: columnAdded || sourceLinksBackfilled > 0 || foldStubsRekinded > 0,
|
|
85
|
+
columnAdded,
|
|
86
|
+
sourceLinksBackfilled,
|
|
87
|
+
foldStubsRekinded,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function migrate(dbPath) {
|
|
92
|
+
const db = new Database(dbPath);
|
|
93
|
+
try {
|
|
94
|
+
return migrateSteerSettleMarkers(db);
|
|
95
|
+
} finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultDbPath() {
|
|
101
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
106
|
+
if (isMain) {
|
|
107
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
108
|
+
try {
|
|
109
|
+
const result = migrate(dbPath);
|
|
110
|
+
console.log(`Migration 0004 on ${dbPath}`);
|
|
111
|
+
console.log(result.applied
|
|
112
|
+
? ` column added: ${result.columnAdded}; source links backfilled: ${result.sourceLinksBackfilled}; fold stubs re-kinded: ${result.foldStubsRekinded}`
|
|
113
|
+
: ` skipped: ${result.reason || 'nothing to change'}`);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`Migration 0004 failed: ${err?.message || err}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -655,10 +655,30 @@ export async function searchMessages(options = {}) {
|
|
|
655
655
|
return apiFetch(`/api/search/messages?${params.toString()}`);
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
+
// Raw fetch instead of apiFetch: a refused delete (the conversation is still
|
|
659
|
+
// working, or its session would not stop) must surface the server's reason.
|
|
660
|
+
// The long timeout covers stopping the session and deleting its CLI session.
|
|
658
661
|
export async function deleteConversation(id) {
|
|
659
662
|
const convId = String(id || '').trim();
|
|
660
|
-
if (!convId) return null;
|
|
661
|
-
|
|
663
|
+
if (!convId || !areNetworkRequestsEnabled()) return null;
|
|
664
|
+
const url = `/api/conversation/${encodeURIComponent(convId)}`;
|
|
665
|
+
try {
|
|
666
|
+
const response = await fetch(`${BASE}${url}`, {
|
|
667
|
+
signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
|
|
668
|
+
method: 'DELETE',
|
|
669
|
+
headers: {
|
|
670
|
+
'Content-Type': 'application/json',
|
|
671
|
+
...authHeaders(),
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
const payload = await response.json().catch(() => null);
|
|
675
|
+
if (!response.ok) return { ...(payload || {}), ok: false, status: response.status };
|
|
676
|
+
noteFetchSuccess();
|
|
677
|
+
return payload || { ok: true };
|
|
678
|
+
} catch (error) {
|
|
679
|
+
noteFetchFailure(url, error);
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
662
682
|
}
|
|
663
683
|
|
|
664
684
|
export async function updateMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
|
|
@@ -10,11 +10,23 @@ import { loadRelayQuestions as loadRelayQuestionsApi, answerRelayQuestion, answe
|
|
|
10
10
|
import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
|
|
11
11
|
import { renderLinkedPlainText } from './router.js';
|
|
12
12
|
import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
|
|
13
|
+
import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
|
|
13
14
|
import { isChatInteractionHeld } from './selection-guard.mjs';
|
|
15
|
+
import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
|
|
14
16
|
|
|
15
17
|
let relayQuestionRenderHash = '';
|
|
16
18
|
let renderedRelayQuestionIds = new Set();
|
|
17
19
|
const relayQuestionStructuredDrafts = new Map();
|
|
20
|
+
// Ticked choices of a multi-select card (question id → Set of labels), kept
|
|
21
|
+
// across re-renders until the card is answered.
|
|
22
|
+
const relayQuestionMultiDrafts = new Map();
|
|
23
|
+
// The "Select several" switch position per card (question id → boolean) on
|
|
24
|
+
// cards that offer it (Copilot); absent = the provider's flag decides.
|
|
25
|
+
const relayQuestionMultiMode = new Map();
|
|
26
|
+
|
|
27
|
+
function multiModeFor(question) {
|
|
28
|
+
return question ? relayQuestionMultiMode.get(question.id) : undefined;
|
|
29
|
+
}
|
|
18
30
|
const RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX = 80;
|
|
19
31
|
|
|
20
32
|
function distanceFromBottom(el) {
|
|
@@ -75,6 +87,8 @@ export function upsertRelayQuestion(question) {
|
|
|
75
87
|
relayQuestions.set(question.id, question);
|
|
76
88
|
updatePendingQuestionBanner();
|
|
77
89
|
window.renderConvList?.();
|
|
90
|
+
// A question opening or settling flips the composer's steering hold.
|
|
91
|
+
window.syncComposerButtonState?.();
|
|
78
92
|
renderRelayQuestions();
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -98,10 +112,19 @@ export async function loadRelayQuestions(conversationId) {
|
|
|
98
112
|
relayQuestionStructuredDrafts.delete(questionId);
|
|
99
113
|
}
|
|
100
114
|
}
|
|
115
|
+
// Multi-select ticks and the "Select several" switch position die with the
|
|
116
|
+
// card, whoever settled it (this device, another one, a timeout).
|
|
117
|
+
for (const draftMap of [relayQuestionMultiDrafts, relayQuestionMultiMode]) {
|
|
118
|
+
for (const questionId of draftMap.keys()) {
|
|
119
|
+
const question = next.get(questionId);
|
|
120
|
+
if (!question || question.status !== 'pending') draftMap.delete(questionId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
101
123
|
relayQuestions.clear();
|
|
102
124
|
for (const [id, q] of next.entries()) relayQuestions.set(id, q);
|
|
103
125
|
updatePendingQuestionBanner();
|
|
104
126
|
window.renderConvList?.();
|
|
127
|
+
window.syncComposerButtonState?.();
|
|
105
128
|
|
|
106
129
|
if (!conversationId && pending.length) {
|
|
107
130
|
const latest = pending
|
|
@@ -212,6 +235,7 @@ export function renderRelayQuestions() {
|
|
|
212
235
|
answeredAt: q.answeredAt || '',
|
|
213
236
|
createdAt: q.createdAt || '',
|
|
214
237
|
choices: Array.isArray(q.choices) ? q.choices : [],
|
|
238
|
+
multiSelect: isMultiSelectQuestion(q, multiModeFor(q)),
|
|
215
239
|
schema: q.requestSchema || null,
|
|
216
240
|
}))
|
|
217
241
|
);
|
|
@@ -249,24 +273,41 @@ export function renderRelayQuestions() {
|
|
|
249
273
|
interactiveHtml = renderMultiFieldForm(question);
|
|
250
274
|
} else {
|
|
251
275
|
const choices = Array.isArray(question.choices) ? question.choices : [];
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
// A multi-select card (the provider said several may be picked) offers
|
|
277
|
+
// checkmarks and ONE Reply; ticking never answers by itself.
|
|
278
|
+
const multiSelect = isMultiSelectQuestion(question, multiModeFor(question));
|
|
279
|
+
const ticked = relayQuestionMultiDrafts.get(question.id) || new Set();
|
|
280
|
+
// Copilot cards: a switch between one-click answers and checkmarks,
|
|
281
|
+
// pre-set from the model's flag or the question's wording.
|
|
282
|
+
const toggleHtml = question.status === 'pending' && offersMultiSelectToggle(question)
|
|
283
|
+
? `<label class="relay-question-multi-toggle"><input type="checkbox" data-relay-focus-key="multi-toggle"${multiSelect ? ' checked' : ''} onchange="setRelayQuestionMultiMode('${question.id}', this.checked)"> Select several</label>`
|
|
260
284
|
: '';
|
|
285
|
+
let choiceHtml = '';
|
|
286
|
+
if (choices.length && question.status === 'pending' && multiSelect) {
|
|
287
|
+
choiceHtml = `<div class="relay-question-choices relay-question-multi" id="relay-question-multi-${question.id}">${
|
|
288
|
+
choices.map((choice) => `<label class="relay-question-choice relay-question-choice-check"><input type="checkbox" data-choice-value="${escHtml(choice)}" data-relay-focus-key="multi-${escHtml(choice)}"${ticked.has(choice) ? ' checked' : ''} onchange="onRelayQuestionMultiSelectChange('${question.id}', this.dataset.choiceValue, this.checked)"> ${escHtml(choice)}</label>`).join('')
|
|
289
|
+
}</div>`;
|
|
290
|
+
} else if (choices.length && question.status === 'pending') {
|
|
291
|
+
choiceHtml = `<div class="relay-question-choices">${
|
|
292
|
+
choices.map((choice) => `<button class="relay-question-choice" data-choice="${escHtml(choice)}" data-relay-focus-key="choice-${escHtml(choice)}" onclick="submitRelayQuestionChoice('${question.id}', this.dataset.choice)">${escHtml(choice)}</button>`).join('')
|
|
293
|
+
}</div>`;
|
|
294
|
+
} else if (choices.length) {
|
|
295
|
+
choiceHtml = `<div class="relay-question-choices">${
|
|
296
|
+
choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
|
|
297
|
+
}</div>`;
|
|
298
|
+
}
|
|
261
299
|
const showReplyControls = question.status === 'pending';
|
|
262
300
|
const draftText = String(relayQuestionDrafts.get(question.id) || '');
|
|
301
|
+
const replyPlaceholder = multiSelect
|
|
302
|
+
? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
|
|
303
|
+
: 'Type a reply… (Ctrl/Cmd+Enter to send)';
|
|
263
304
|
const replyHtml = showReplyControls
|
|
264
305
|
? `<div class="relay-question-reply">
|
|
265
|
-
<textarea id="relay-question-input-${question.id}" placeholder="
|
|
266
|
-
<button class="relay-question-submit" onclick="submitRelayQuestionAnswer('${question.id}')"
|
|
306
|
+
<textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
|
|
307
|
+
<button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
|
|
267
308
|
</div>`
|
|
268
309
|
: '';
|
|
269
|
-
interactiveHtml = `${choiceHtml}${replyHtml}`;
|
|
310
|
+
interactiveHtml = `${choiceHtml}${toggleHtml}${replyHtml}`;
|
|
270
311
|
}
|
|
271
312
|
|
|
272
313
|
const answeredHtml = question.status === 'answered' && question.answer
|
|
@@ -278,7 +319,7 @@ export function renderRelayQuestions() {
|
|
|
278
319
|
|
|
279
320
|
wrapper.innerHTML = `
|
|
280
321
|
<div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
|
|
281
|
-
<div class="relay-question-head"
|
|
322
|
+
<div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
|
|
282
323
|
<div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
|
|
283
324
|
${contextHtml}
|
|
284
325
|
${answeredHtml}
|
|
@@ -467,6 +508,7 @@ export async function submitRelayStructuredAnswer(questionId) {
|
|
|
467
508
|
if (r.question) relayQuestions.set(questionId, r.question);
|
|
468
509
|
updatePendingQuestionBanner();
|
|
469
510
|
window.renderConvList?.();
|
|
511
|
+
window.syncComposerButtonState?.();
|
|
470
512
|
renderRelayQuestions();
|
|
471
513
|
window.showTransientRelayNotice?.(`✅ Answer received · Agent continuing…`, 7000);
|
|
472
514
|
} catch (e) {
|
|
@@ -480,6 +522,44 @@ export async function submitRelayQuestionChoice(questionId, choice) {
|
|
|
480
522
|
await submitRelayQuestionAnswer(questionId, choice);
|
|
481
523
|
}
|
|
482
524
|
|
|
525
|
+
/** Ticked labels of a multi-select card, in the card's own order. */
|
|
526
|
+
function tickedMultiSelectLabels(questionId) {
|
|
527
|
+
const container = document.getElementById(`relay-question-multi-${questionId}`);
|
|
528
|
+
if (!container) return [...(relayQuestionMultiDrafts.get(questionId) || [])];
|
|
529
|
+
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
|
530
|
+
.map((el) => String(el.getAttribute('data-choice-value') || ''));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The "Select several" switch: flip the card between one-click answers and
|
|
535
|
+
* checkmarks. Ticks made so far are kept, and the card re-renders in place.
|
|
536
|
+
*/
|
|
537
|
+
export function setRelayQuestionMultiMode(questionId, enabled) {
|
|
538
|
+
const id = String(questionId || '');
|
|
539
|
+
if (!id) return;
|
|
540
|
+
relayQuestionMultiMode.set(id, enabled === true);
|
|
541
|
+
renderRelayQuestions();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function onRelayQuestionMultiSelectChange(questionId, label, checked) {
|
|
545
|
+
const id = String(questionId || '');
|
|
546
|
+
const set = relayQuestionMultiDrafts.get(id) || new Set();
|
|
547
|
+
if (checked) set.add(String(label || ''));
|
|
548
|
+
else set.delete(String(label || ''));
|
|
549
|
+
relayQuestionMultiDrafts.set(id, set);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Answer a multi-select card: every ticked choice, plus whatever was typed as
|
|
554
|
+
* an extra answer, joined the way the provider's own UI would ("A, C").
|
|
555
|
+
*/
|
|
556
|
+
export async function submitRelayQuestionMultiSelect(questionId) {
|
|
557
|
+
const input = document.getElementById(`relay-question-input-${questionId}`);
|
|
558
|
+
const answer = composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '');
|
|
559
|
+
if (!answer) return;
|
|
560
|
+
await submitRelayQuestionAnswer(questionId, answer);
|
|
561
|
+
}
|
|
562
|
+
|
|
483
563
|
// Offline fallback: park the answer in the durable outbox so Background Sync
|
|
484
564
|
// (or the next page load) delivers it. A replay against an already-answered
|
|
485
565
|
// or expired question gets a 4xx and is dropped, so this cannot double-answer.
|
|
@@ -499,7 +579,13 @@ async function enqueueRelayAnswerForSync(questionId, body) {
|
|
|
499
579
|
|
|
500
580
|
export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
|
|
501
581
|
const input = document.getElementById(`relay-question-input-${questionId}`);
|
|
502
|
-
|
|
582
|
+
// Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
|
|
583
|
+
// choices along with the typed text, exactly like its Reply button.
|
|
584
|
+
const pendingQuestion = relayQuestions.get(questionId) || null;
|
|
585
|
+
const composed = presetAnswer == null && isMultiSelectQuestion(pendingQuestion, multiModeFor(pendingQuestion))
|
|
586
|
+
? composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '')
|
|
587
|
+
: null;
|
|
588
|
+
const answer = String(presetAnswer != null ? presetAnswer : (composed ?? (input?.value || ''))).trim();
|
|
503
589
|
if (!answer) return;
|
|
504
590
|
|
|
505
591
|
const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
|
|
@@ -522,9 +608,12 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
|
|
|
522
608
|
}
|
|
523
609
|
relayQuestionDrafts.delete(questionId);
|
|
524
610
|
relayQuestionStructuredDrafts.delete(questionId);
|
|
611
|
+
relayQuestionMultiDrafts.delete(questionId);
|
|
612
|
+
relayQuestionMultiMode.delete(questionId);
|
|
525
613
|
relayQuestions.set(questionId, r.question);
|
|
526
614
|
updatePendingQuestionBanner();
|
|
527
615
|
window.renderConvList?.();
|
|
616
|
+
window.syncComposerButtonState?.();
|
|
528
617
|
renderRelayQuestions();
|
|
529
618
|
window.showTransientRelayNotice?.(`✅ Answer received: ${answer} · Agent continuing…`, 7000);
|
|
530
619
|
} catch (e) {
|
|
@@ -221,7 +221,7 @@ export function renderAttachmentPreview() {
|
|
|
221
221
|
if (!selectedAttachments.length) {
|
|
222
222
|
el.innerHTML = '';
|
|
223
223
|
el.classList.remove('visible');
|
|
224
|
-
window.
|
|
224
|
+
window.syncComposerButtonState?.();
|
|
225
225
|
syncComposerAttachmentWarning();
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
@@ -251,7 +251,7 @@ export function renderAttachmentPreview() {
|
|
|
251
251
|
`;
|
|
252
252
|
}).join('');
|
|
253
253
|
el.classList.add('visible');
|
|
254
|
-
window.
|
|
254
|
+
window.syncComposerButtonState?.();
|
|
255
255
|
syncComposerAttachmentWarning();
|
|
256
256
|
}
|
|
257
257
|
|
|
@@ -293,6 +293,41 @@ export function clearAttachments() {
|
|
|
293
293
|
renderAttachmentPreview();
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
function composerAttachmentSha(attachment) {
|
|
297
|
+
return String(attachment?.sha256 || attachment?.uploaded?.sha256 || '').trim().toLowerCase();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Removes the composer entries a send took with it and keeps anything added
|
|
302
|
+
* after that snapshot, uploading or not. Matches by identity first, then by
|
|
303
|
+
* content hash — switching away and back mid-send rebuilds the composer from
|
|
304
|
+
* the draft cache as new objects — and removes one entry per sent attachment,
|
|
305
|
+
* so a genuine second copy survives. Persisting what remains is the caller's
|
|
306
|
+
* job.
|
|
307
|
+
*/
|
|
308
|
+
export function removeComposerAttachments(sent = []) {
|
|
309
|
+
const pending = (Array.isArray(sent) ? sent : []).filter(Boolean);
|
|
310
|
+
if (!pending.length) return;
|
|
311
|
+
const removed = new Set();
|
|
312
|
+
const unmatched = [];
|
|
313
|
+
for (const att of pending) {
|
|
314
|
+
if (selectedAttachments.includes(att) && !removed.has(att)) removed.add(att);
|
|
315
|
+
else unmatched.push(att);
|
|
316
|
+
}
|
|
317
|
+
for (const att of unmatched) {
|
|
318
|
+
const sha = composerAttachmentSha(att);
|
|
319
|
+
if (!sha) continue;
|
|
320
|
+
const match = selectedAttachments.find((candidate) => !removed.has(candidate) && composerAttachmentSha(candidate) === sha);
|
|
321
|
+
if (match) removed.add(match);
|
|
322
|
+
}
|
|
323
|
+
if (!removed.size) return;
|
|
324
|
+
const kept = selectedAttachments.filter((att) => !removed.has(att));
|
|
325
|
+
for (const att of removed) releaseAttachmentPreviewUrl(att);
|
|
326
|
+
selectedAttachments.length = 0;
|
|
327
|
+
selectedAttachments.push(...kept);
|
|
328
|
+
renderAttachmentPreview();
|
|
329
|
+
}
|
|
330
|
+
|
|
296
331
|
/**
|
|
297
332
|
* Replaces composer attachments wholesale, used when hydrating a conversation's
|
|
298
333
|
* cached draft attachments. Object URLs from the outgoing set are released.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Mirrors shared/background-task-timeout.mjs, which the browser cannot import
|
|
2
|
+
// (only server/public is served). shared/background-task-timeout.test.mjs
|
|
3
|
+
// asserts the two stay identical.
|
|
4
|
+
//
|
|
5
|
+
// The settings slider shows this until the relay's own value loads, and keeps
|
|
6
|
+
// showing it if that request fails, so it must be the default the relay
|
|
7
|
+
// enforces: 4 hours. 0 means no limit only when chosen explicitly.
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;
|