@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. 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
- // Dormant until phase 3 registers a worker kind for it. Run it by hand against
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
- // How long live detached shells alone may hold the runtime open (0 = no
86
- // limit). Deliberately NOT the relay's `background_task_timeout_minutes`
87
- // slider that rides every delivery payload: that slider defaults to
88
- // 0/unlimited and governs Claude's background tasks, which the composer
89
- // lists and can stop. A Copilot detached shell has neither, so consuming it
90
- // would make "a forgotten shell pins a runtime forever" the default with
91
- // nothing in the UI to reveal it. The runner's own 30-minute cap applies
92
- // unless this says otherwise.
93
- ...(() => {
94
- const override = readOptionalMs('COPILOT_SDK_RELAY_BACKGROUND_TASK_TIMEOUT_MS');
95
- return override === undefined ? {} : { getBackgroundTaskTimeoutMs: () => override };
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
- const heartbeat = createHeartbeatController({
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
- // Background-task controls have no Copilot SDK equivalent yet: the
133
- // runtime's `session.background_tasks_changed` carries an empty payload
134
- // (23 of them fire during a single bash call), so there is no task id to
135
- // stop. Logged rather than silently dropped.
136
- dbg('worker control ignored (unsupported by the SDK worker)', String(control?.type || '(none)'));
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 {
@@ -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
- return apiFetch(`/api/conversation/${convId}`, { method: 'DELETE' });
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
- const choiceHtml = choices.length
253
- ? (question.status === 'pending'
254
- ? `<div class="relay-question-choices">${
255
- 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('')
256
- }</div>`
257
- : `<div class="relay-question-choices">${
258
- choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
259
- }</div>`)
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="Type a reply… (Ctrl/Cmd+Enter to send)" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
266
- <button class="relay-question-submit" onclick="submitRelayQuestionAnswer('${question.id}')">Reply</button>
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">Copilot question${modeTag} · ${fmtDate(question.createdAt)}</div>
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
- const answer = String(presetAnswer != null ? presetAnswer : (input?.value || '')).trim();
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.syncComposerControlState?.();
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.syncComposerControlState?.();
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;