@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
@@ -210,6 +210,13 @@
210
210
  document.documentElement.style.setProperty('--font-scale', String(value));
211
211
  document.documentElement.style.setProperty('--font-scale-percent', `${Math.round(value * 100)}%`);
212
212
  })();
213
+ // Mirrored by syncScreenShortSide in app/store.js.
214
+ (function applyScreenShortSideEarly() {
215
+ const shortSide = Math.min(Number(screen.width) || 0, Number(screen.height) || 0);
216
+ if (!shortSide) return;
217
+ document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
218
+ document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
219
+ })();
213
220
  </script>
214
221
  <style>
215
222
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -318,6 +325,20 @@
318
325
 
319
326
  html {
320
327
  font-size: var(--font-scale-percent, 100%);
328
+ /* Mobile browsers otherwise inflate text on rotation. */
329
+ -webkit-text-size-adjust: 100%;
330
+ text-size-adjust: 100%;
331
+ }
332
+ /* A rotated phone must keep its portrait text size. Portrait scales with
333
+ the width (3.4vw, phone rule below), which is the screen's short side;
334
+ landscape uses that short side directly. No viewport unit or height
335
+ works here: with interactive-widget=resizes-content the on-screen
336
+ keyboard shrinks the viewport, and would shrink the text with it (and
337
+ make a portrait phone match this landscape query mid-typing). :root
338
+ outranks the later width-based html rule, which a narrow phone still
339
+ matches in landscape. */
340
+ @media (pointer: coarse) and (orientation: landscape) and (max-height: 500px) {
341
+ :root[data-phone-screen] { font-size: calc(clamp(12px, calc(var(--screen-short-side) * 0.034), 14px) * var(--font-scale, 1)); }
321
342
  }
322
343
  html, body {
323
344
  height: 100%;
@@ -508,6 +529,65 @@
508
529
  box-shadow: 0 0 0 1px rgba(210, 153, 34, 0.42), 0 0 8px rgba(210, 153, 34, 0.35);
509
530
  }
510
531
 
532
+ #conv-filter-row {
533
+ position: relative;
534
+ padding: 10px 16px;
535
+ }
536
+ #conv-filter-input {
537
+ width: 100%;
538
+ box-sizing: border-box;
539
+ height: 36px;
540
+ border: 1px solid var(--border);
541
+ border-radius: 8px;
542
+ background: var(--bg2);
543
+ color: var(--text);
544
+ padding: 0 38px 0 10px;
545
+ font-size: var(--fs-meta-md);
546
+ }
547
+ /* iOS Safari zooms the page on focus for inputs under 16px, and the mobile
548
+ root font is 12-14px, so 1rem alone is not enough. */
549
+ @media (pointer: coarse), (max-width: 680px) {
550
+ #conv-filter-input { font-size: max(16px, 1rem); }
551
+ }
552
+ #conv-filter-input:focus {
553
+ outline: none;
554
+ border-color: var(--accent);
555
+ }
556
+ #conv-filter-clear {
557
+ position: absolute;
558
+ top: 50%;
559
+ right: 20px;
560
+ transform: translateY(-50%);
561
+ width: 28px;
562
+ height: 28px;
563
+ display: inline-flex;
564
+ align-items: center;
565
+ justify-content: center;
566
+ border: none;
567
+ border-radius: 6px;
568
+ background: transparent;
569
+ color: var(--muted);
570
+ cursor: pointer;
571
+ font-size: 1rem;
572
+ line-height: 1;
573
+ padding: 0;
574
+ }
575
+ #conv-filter-clear:hover { color: var(--text); background: var(--bg3); }
576
+ /* The display rule above outranks the UA [hidden] rule, same trap as the
577
+ other [hidden] overrides in this file. */
578
+ #conv-filter-clear[hidden] { display: none; }
579
+ #conv-filter-status {
580
+ position: absolute;
581
+ width: 1px;
582
+ height: 1px;
583
+ margin: -1px;
584
+ padding: 0;
585
+ overflow: hidden;
586
+ clip: rect(0 0 0 0);
587
+ white-space: nowrap;
588
+ border: 0;
589
+ }
590
+
511
591
  #new-conv-btn {
512
592
  margin: 0; padding: 10px 14px; background: var(--accent); color: #000;
513
593
  border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: var(--fs-meta-md);
@@ -1408,12 +1488,23 @@
1408
1488
  width: 100%; max-width: 100%; box-sizing: border-box;
1409
1489
  min-width: 0;
1410
1490
  }
1491
+ /* Sticky inside the scroller: Stop on the live bubble is the only way to
1492
+ stop a turn, and a long turn must stay stoppable without scrolling back
1493
+ up. It sticks within its own bubble only, so it never rides over the
1494
+ messages steered in below it. The bubble's background keeps streamed
1495
+ text from showing through; `top` cancels #messages' padding-top (sticky
1496
+ insets start at the padding edge), so nothing scrolls visibly above it. */
1411
1497
  .thinking-bubble-header {
1412
1498
  display: flex;
1413
1499
  align-items: center;
1414
1500
  justify-content: space-between;
1415
1501
  gap: 12px;
1416
1502
  margin-bottom: 4px;
1503
+ position: sticky;
1504
+ top: -20px;
1505
+ z-index: 2;
1506
+ background: var(--bot-bg);
1507
+ padding: 4px 0;
1417
1508
  }
1418
1509
  .thinking-bubble-header:empty { display: none; margin-bottom: 0; }
1419
1510
  .bubble-action-btn {
@@ -2239,6 +2330,19 @@
2239
2330
  pointer-events: none;
2240
2331
  }
2241
2332
  #relay-toast.visible { display: block; }
2333
+ #relay-toast.has-action { pointer-events: auto; }
2334
+ .relay-toast-action {
2335
+ margin-left: 10px;
2336
+ border: 1px solid var(--warn);
2337
+ background: transparent;
2338
+ color: var(--warn);
2339
+ border-radius: 999px;
2340
+ padding: 4px 12px;
2341
+ font-size: var(--fs-meta-sm);
2342
+ font-weight: 600;
2343
+ cursor: pointer;
2344
+ }
2345
+ .relay-toast-action:hover { background: rgba(210, 153, 34, 0.2); }
2242
2346
  #model-metadata-blocker {
2243
2347
  display: none;
2244
2348
  align-items: center;
@@ -2439,6 +2543,16 @@
2439
2543
  (badge above tokens/elapsed/Stop) so the badge cannot float mid-row when
2440
2544
  the wrapped description spans two lines. */
2441
2545
  .bg-task-side { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
2546
+ .bg-task-side-head { display: flex; align-items: center; gap: 8px; }
2547
+ .bg-task-model { color: var(--text); font-size: var(--fs-meta-sm); white-space: nowrap; }
2548
+ /* Many tasks or previews must not push the composer off-screen: both lists
2549
+ scroll together inside the panel while the summary line stays put. The dvh term keeps the whole
2550
+ composer on screen in phone landscape, where 45vh alone left the input
2551
+ row clipped below the fold (header + the rest of the composer take ~296px). */
2552
+ #background-panel-body {
2553
+ max-height: clamp(64px, calc(100dvh - 296px), min(45vh, 420px));
2554
+ overflow-y: auto; overscroll-behavior: contain;
2555
+ }
2442
2556
  .bg-task-side-status { display: flex; align-items: center; gap: 10px; }
2443
2557
  /* Mobile readability: bounded wrapping instead of single-line ellipsis.
2444
2558
  Long text clamps to two lines so the tail (the part that distinguishes
@@ -2454,6 +2568,19 @@
2454
2568
  background: var(--bg3); white-space: nowrap;
2455
2569
  }
2456
2570
  .bg-task-detail { color: var(--muted); font-size: var(--fs-meta-sm); }
2571
+ /* A live tool call is one line cropped to the row: the command's head is
2572
+ what identifies it, and wrapping would make rows jump on every call. */
2573
+ .bg-task-detail.bg-task-toolcall {
2574
+ display: block; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;
2575
+ }
2576
+ /* A phone row is too narrow for one line to say anything; two clamped lines
2577
+ fit beside the side column's height anyway. */
2578
+ @media (max-width: 680px) {
2579
+ .bg-task-detail.bg-task-toolcall {
2580
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
2581
+ white-space: normal; overflow-wrap: anywhere;
2582
+ }
2583
+ }
2457
2584
  .bg-task-tokens { color: var(--muted); flex-shrink: 0; font-variant-numeric: tabular-nums; }
2458
2585
  .bg-task-elapsed { color: var(--muted); flex-shrink: 0; font-variant-numeric: tabular-nums; }
2459
2586
  .bg-task-stop {
@@ -2535,6 +2662,30 @@
2535
2662
  background: rgba(63, 185, 80, 0.12); color: var(--relay-tone-online);
2536
2663
  border-radius: 6px; padding: 0 6px; font-size: var(--fs-meta-sm);
2537
2664
  }
2665
+ /* Mid-turn steering. The CLI usually FOLDS a steered message into the
2666
+ running turn and answers everything with one reply; the steered row then
2667
+ settles as a compact marker under its own message (kind='folded'). More
2668
+ rarely the turn is handed off: the interrupted reply settles as
2669
+ kind='absorbed' and the steered message carries the continuing reply. A
2670
+ steer cut off by Stop settles as kind='stopped' with a Resend button.
2671
+ Markers are notes, not answers, so they render muted and compact.
2672
+ steered-turn-merge.mjs adds the adjacency classes. */
2673
+ .msg.assistant.msg-absorbed .msg-bubble,
2674
+ .msg.assistant.msg-folded .msg-bubble,
2675
+ .msg.assistant.msg-steer-stopped .msg-bubble {
2676
+ font-size: var(--fs-meta-md); color: var(--muted); font-style: italic;
2677
+ padding: 6px 12px; opacity: 0.85;
2678
+ }
2679
+ .msg.assistant.msg-absorbed { margin-top: -4px; }
2680
+ /* Attached to the steered message above it (the transcript gap is 12px). */
2681
+ .msg.assistant.msg-folded,
2682
+ .msg.assistant.msg-steer-stopped { margin-top: -8px; }
2683
+ .msg.assistant.msg-folded .msg-label,
2684
+ .msg.assistant.msg-steer-stopped .msg-label { display: none; }
2685
+ .msg.assistant.msg-steer-stopped .msg-bubble-actions { font-style: normal; }
2686
+ .msg.user.msg-steered .msg-bubble { border-style: dashed; }
2687
+ .msg.user.msg-steered .msg-label::after { content: ' · steered mid-turn'; }
2688
+ .msg.assistant.msg-steered-continuation { margin-top: -2px; }
2538
2689
  body.shared-reader-mode #background-tasks-panel { display: none; }
2539
2690
  #model-pricing-details {
2540
2691
  display: none; margin-top: 6px; color: var(--muted); font-size: var(--fs-meta-sm);
@@ -4197,6 +4348,43 @@
4197
4348
  color: #000;
4198
4349
  font-weight: 700;
4199
4350
  }
4351
+ /* Multi-select cards: the same pills, with a checkmark instead of one-shot
4352
+ buttons, so several answers can be ticked before one Reply. */
4353
+ .relay-question-choice-check {
4354
+ display: inline-flex;
4355
+ align-items: center;
4356
+ gap: 8px;
4357
+ user-select: none;
4358
+ }
4359
+ .relay-question-choice-check input[type="checkbox"] {
4360
+ margin: 0;
4361
+ width: 16px;
4362
+ height: 16px;
4363
+ accent-color: var(--accent);
4364
+ cursor: pointer;
4365
+ }
4366
+ .relay-question-choice-check:has(input:checked) {
4367
+ border-color: var(--accent);
4368
+ background: rgba(88, 166, 255, 0.12);
4369
+ }
4370
+ /* The "Select several" switch on Copilot choice cards. */
4371
+ .relay-question-multi-toggle {
4372
+ display: inline-flex;
4373
+ align-items: center;
4374
+ gap: 6px;
4375
+ margin-top: 8px;
4376
+ font-size: var(--fs-meta-sm);
4377
+ color: var(--muted, var(--text));
4378
+ cursor: pointer;
4379
+ user-select: none;
4380
+ }
4381
+ .relay-question-multi-toggle input[type="checkbox"] {
4382
+ margin: 0;
4383
+ width: 16px;
4384
+ height: 16px;
4385
+ accent-color: var(--accent);
4386
+ cursor: pointer;
4387
+ }
4200
4388
  .relay-question-reply {
4201
4389
  display: flex;
4202
4390
  gap: 8px;
@@ -4397,10 +4585,12 @@
4397
4585
  font-size: var(--fs-meta-md);
4398
4586
  min-height: 36px;
4399
4587
  }
4400
- .thinking-bubble-header { margin-bottom: 6px; }
4588
+ .thinking-bubble-header { margin-bottom: 6px; top: -12px; }
4401
4589
  .sidebar-header { padding: var(--sidebar-header-top-pad-mobile) 12px 12px; gap: 8px; }
4402
4590
  #new-conv-btn { padding: 9px 12px; border-radius: 8px; font-size: var(--fs-meta-md); }
4403
4591
  #status-btn { min-width: 72px; padding: 9px 10px; font-size: var(--fs-meta-md); }
4592
+ #conv-filter-row { padding: 8px 12px; }
4593
+ #conv-filter-clear { right: 16px; }
4404
4594
  #conv-list { padding: 0 5px 8px; }
4405
4595
  .conv-item { padding: 6px 11px; border-radius: 8px; }
4406
4596
  .conv-title {
@@ -4928,6 +5118,11 @@
4928
5118
  <button id="install-btn" class="header-icon-btn" onclick="promptInstallApp()" title="Install app to home screen" aria-label="Install app">⬇</button>
4929
5119
  </div>
4930
5120
  </div>
5121
+ <div id="conv-filter-row">
5122
+ <input id="conv-filter-input" type="text" placeholder="Filter conversations…" autocomplete="off" spellcheck="false" aria-label="Filter conversations by title">
5123
+ <button id="conv-filter-clear" type="button" title="Clear filter" aria-label="Clear filter" hidden>×</button>
5124
+ <div id="conv-filter-status" role="status" aria-live="polite"></div>
5125
+ </div>
4931
5126
  <div id="conv-list"></div>
4932
5127
  </div>
4933
5128
  <div id="sidebar-resizer" role="separator" aria-orientation="vertical" aria-label="Resize conversations sidebar"></div>
@@ -4993,8 +5188,10 @@
4993
5188
  <div id="slash-autocomplete-popup" aria-hidden="true" role="listbox"></div>
4994
5189
  <details id="background-tasks-panel" hidden>
4995
5190
  <summary id="background-tasks-summary"></summary>
4996
- <div id="background-previews-list"></div>
4997
- <div id="background-tasks-list"></div>
5191
+ <div id="background-panel-body">
5192
+ <div id="background-previews-list"></div>
5193
+ <div id="background-tasks-list"></div>
5194
+ </div>
4998
5195
  </details>
4999
5196
  <div class="input-row">
5000
5197
  <input id="image-input" type="file" multiple hidden onchange="handleAttachmentInput(this.files); this.value='';">
@@ -5214,20 +5411,20 @@
5214
5411
  </div>
5215
5412
  <div class="settings-help" style="margin-top: 6px;">A hard cap on how long one turn may run before the relay requeues it. Turns are already kept alive by worker heartbeats, and a turn waiting on your answer to a question is never cut off — so this only catches a worker that has hung. Set to 0 for no limit.</div>
5216
5413
  <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
5217
- <label for="background-task-timeout-slider">Background task timeout <span id="background-task-timeout-value">No limit</span></label>
5414
+ <label for="background-task-timeout-slider">Background task timeout <span id="background-task-timeout-value">4 h</span></label>
5218
5415
  <input
5219
5416
  id="background-task-timeout-slider"
5220
5417
  type="range"
5221
5418
  min="0"
5222
5419
  max="600"
5223
5420
  step="5"
5224
- value="0"
5421
+ value="240"
5225
5422
  aria-label="Background task timeout in minutes"
5226
5423
  oninput="previewBackgroundTaskTimeoutSetting(this.value)"
5227
5424
  onchange="updateBackgroundTaskTimeoutSetting(this.value)"
5228
5425
  >
5229
5426
  </div>
5230
- <div class="settings-help" style="margin-top: 6px;">How long background tasks (backgrounded commands, subagents, monitors) may keep a Claude session's CLI process alive after its turn ends. When the limit is reached the tasks are stopped and the agent reports what they left behind. Set to 0 for no limit — tasks then run until they finish or you stop them from the composer panel.</div>
5427
+ <div class="settings-help" style="margin-top: 6px;">How long background tasks may keep a session alive after its turn ends: backgrounded commands, subagents and monitors on Claude; background agents and detached shells on Copilot's SDK engine. When the limit is reached the tasks are stopped. Set to 0 for no limit — tasks then run until they finish or you stop them from the composer panel.</div>
5231
5428
  <div id="windows-autostart-setting" hidden>
5232
5429
  <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
5233
5430
  <label>Autostart (Windows)</label>
@@ -5402,14 +5599,14 @@
5402
5599
  <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
5403
5600
  <label for="copilot-engine-select">Copilot engine</label>
5404
5601
  <select id="copilot-engine-select" aria-label="Copilot engine">
5405
- <option value="extension">Extension — current</option>
5406
- <option value="sdk">SDK — experimental</option>
5602
+ <option value="extension">Extension</option>
5603
+ <option value="sdk">SDK — default</option>
5407
5604
  </select>
5408
5605
  <div id="copilot-settings-status" class="settings-help settings-provider-status">Loading Copilot settings…</div>
5409
5606
  <div style="display: flex; gap: 8px; margin-top: 8px;">
5410
5607
  <button id="copilot-save-btn" type="button" class="summary-btn" onclick="saveCopilotSettings()">Save Copilot settings</button>
5411
5608
  </div>
5412
- <div class="settings-help">Chooses how GitHub Copilot conversations run. <strong>Extension</strong> is the current default: the Copilot CLI in a terminal session, which you can attach to with the tmux inspector. <strong>SDK</strong> is experimental and headless — no CLI extension to install, but no tmux inspector for those sessions. Applies to newly started conversations; sessions already running keep their engine until their worker restarts.</div>
5609
+ <div class="settings-help">Chooses how GitHub Copilot conversations run. <strong>SDK</strong> is the default: a headless worker per conversation with mid-turn steering and background task cards — no CLI extension to install, but no tmux inspector for those sessions. <strong>Extension</strong> runs the Copilot CLI in a terminal session, which you can attach to with the tmux inspector. A relay that cannot run the SDK engine uses Extension. Applies to newly started conversations; sessions already running keep their engine until their worker restarts.</div>
5413
5610
  </div>
5414
5611
  </div>
5415
5612
  <div
@@ -205,6 +205,9 @@ const SYNC_DB_VERSION = 1;
205
205
  const SYNC_STORE_NAME = 'outbox';
206
206
  const SYNC_TAG = 'copilot-outbox';
207
207
  const SYNC_MAX_REPLAY_ATTEMPTS = 10;
208
+ // Mirrors DRAFT_FLUSH_MAX_AGE_MS in app/conversation-draft-sync.mjs: a draft
209
+ // flush replayed later than this would overwrite whatever the draft became.
210
+ const SYNC_DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
208
211
 
209
212
  function openSyncDb() {
210
213
  return new Promise((resolve, reject) => {
@@ -271,6 +274,10 @@ async function replaySyncOutbox() {
271
274
  try {
272
275
  const entries = await readSyncEntries(db);
273
276
  for (const { key, value } of entries) {
277
+ if (value?.kind === 'draft-flush' && !(Date.now() - Number(value.createdAt || 0) <= SYNC_DRAFT_FLUSH_MAX_AGE_MS)) {
278
+ await deleteSyncEntry(db, key);
279
+ continue;
280
+ }
274
281
  let response = null;
275
282
  try {
276
283
  response = await fetch(scopeUrl(String(value.path || '').replace(/^\//, '')), {
@@ -12,6 +12,8 @@ When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so
12
12
  At runtime, inject the question and choices from a random item in your quiz pool.
13
13
  Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
14
14
 
15
+ When the user may pick several of the choices (they are not mutually exclusive), set `"multi_select": true` if your ask_user tool offers that field; otherwise begin the question with "Select all that apply:". Either way, expect an answer that lists several choices separated by ", ".
16
+
15
17
  In autopilot, still call ask_user when user input is truly blocking, because the relay bridge can surface the question even when the direct SDK question hook is bypassed.
16
18
 
17
19
  For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.
@@ -7,6 +7,9 @@ export function createMessageRepository(db) {
7
7
  const shareVisibility = createShareVisibilityStatements(db);
8
8
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
9
  .some((column) => column.name === 'image_operation_id');
10
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
11
+ const messagesHaveSourceId = messageColumns.has('source_message_id');
12
+ const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
10
13
  const insertQueueSql = queueHasImageOperationId
11
14
  ? `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at, image_operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?, ?)`
12
15
  : `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?)`;
@@ -17,7 +20,7 @@ export function createMessageRepository(db) {
17
20
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
18
21
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
19
22
  getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
20
- getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
23
+ getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
21
24
  insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
22
25
  // Provenance is derived from the authenticated responder identity at
23
26
  // /api/response time, never from the response payload.
@@ -117,10 +120,28 @@ export function createMessageRepository(db) {
117
120
  // response resolves to the row its operation id already created.
118
121
  findQByContinuationOp: db.prepare(`SELECT * FROM queue WHERE continuation_op_id = ?`),
119
122
  setMessageKind: db.prepare(`UPDATE messages SET kind = ? WHERE id = ?`),
123
+ // The durable answer→prompt link (the queue's copy is pruned), so a
124
+ // reload still anchors the reply under the message it answers.
125
+ setMessageSourceId: messagesHaveSourceId
126
+ ? db.prepare(`UPDATE messages SET source_message_id = ? WHERE id = ?`)
127
+ : null,
128
+ setMessageResendOf: messagesHaveResendOf
129
+ ? db.prepare(`UPDATE messages SET resend_of_message_id = ? WHERE id = ?`)
130
+ : null,
120
131
  // Quiet teardown for a continuation whose worker died: there is no
121
132
  // user to answer, so it fails without the terminal-failure ceremony.
122
133
  dropStaleContinuation: db.prepare(`UPDATE queue SET status = 'failed', processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND kind = 'continuation'`),
123
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
134
+ // Claim ordering (all findPending* variants and the owner-transfer
135
+ // scan): strict send order, rowid breaking same-millisecond ties.
136
+ // Neither retry_count nor next_attempt_at may rank rows: dead-worker
137
+ // recovery and /api/requeue bump retry_count, so ranking by it ran a
138
+ // recovered OLDER message after newer ones in the same conversation,
139
+ // and ranking expired-backoff rows behind fresh arrivals starved them
140
+ // for as long as new messages kept coming (live 2026-09-19: a
141
+ // steer-recovered row waited 5 minutes while newer messages jumped
142
+ // it). Head-of-line protection is the WHERE clause's next_attempt_at
143
+ // backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
144
+ findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY timestamp ASC, rowid ASC LIMIT 1`),
124
145
  // Global fallback for requesters without a session identity (the legacy
125
146
  // Copilot relay CLI). Conversations bound to a session-worker provider
126
147
  // (claude/cursor/grok) are excluded outright: handing one of their turns
@@ -152,10 +173,8 @@ export function createMessageRepository(db) {
152
173
  'github'
153
174
  )) NOT IN (${sessionWorkerProviderSqlList()})
154
175
  ORDER BY
155
- q.retry_count ASC,
156
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
157
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
158
- q.timestamp ASC
176
+ q.timestamp ASC,
177
+ q.rowid ASC
159
178
  LIMIT 1
160
179
  `),
161
180
  findPendingForWorker: db.prepare(`
@@ -198,10 +217,8 @@ export function createMessageRepository(db) {
198
217
  ) = ? THEN 0
199
218
  ELSE 1
200
219
  END ASC,
201
- q.retry_count ASC,
202
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
203
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
204
- q.timestamp ASC
220
+ q.timestamp ASC,
221
+ q.rowid ASC
205
222
  LIMIT 1
206
223
  `),
207
224
  findPendingForSessionAffinity: db.prepare(`
@@ -220,10 +237,8 @@ export function createMessageRepository(db) {
220
237
  NULLIF(c.sdk_session_id, '')
221
238
  ) = ?
222
239
  ORDER BY
223
- q.retry_count ASC,
224
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
225
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
226
- q.timestamp ASC
240
+ q.timestamp ASC,
241
+ q.rowid ASC
227
242
  LIMIT 1
228
243
  `),
229
244
  countQueueWorkForSessionAffinity: db.prepare(`
@@ -301,7 +316,7 @@ export function createMessageRepository(db) {
301
316
  // Recovery clears only the lease, never the owner: a recovered row must
302
317
  // stay routed to its provider worker so the primer respawns that worker
303
318
  // instead of the row becoming claimable by the global relay poll.
304
- recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND processing_at < ?`),
319
+ recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND consumed_at IS NULL AND processing_at < ?`),
305
320
  // Staleness is inactivity, not elapsed turn time: owner_last_claimed_at is
306
321
  // refreshed by every worker heartbeat for the message it is working on, so a
307
322
  // long-but-alive turn keeps moving the cutoff. processing_at is only the
@@ -311,7 +326,7 @@ export function createMessageRepository(db) {
311
326
  // A turn blocked on an unanswered AskUserQuestion is never stale — it is
312
327
  // waiting on the human, and relay_questions carries its own expiry.
313
328
  listRecoverableProcessing: db.prepare(`
314
- SELECT id, conversation_id, kind, relay_mode, model, retry_count
329
+ SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
315
330
  FROM queue
316
331
  WHERE status = 'processing'
317
332
  AND COALESCE(kind, '') != 'continuation'
@@ -332,6 +347,7 @@ export function createMessageRepository(db) {
332
347
  retry_count = retry_count + 1
333
348
  WHERE status = 'processing'
334
349
  AND COALESCE(kind, '') != 'continuation'
350
+ AND consumed_at IS NULL
335
351
  AND (
336
352
  COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
337
353
  OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
@@ -347,7 +363,7 @@ export function createMessageRepository(db) {
347
363
  // worker can never collect its answer, so holding the row would leak
348
364
  // both the row and the question.
349
365
  listProcessingRowsForOwner: db.prepare(`
350
- SELECT id, conversation_id, kind, retry_count
366
+ SELECT id, conversation_id, kind, retry_count, consumed_at, relay_mode, model
351
367
  FROM queue
352
368
  WHERE status = 'processing'
353
369
  AND NULLIF(owner_sdk_session_id, '') = ?
@@ -361,7 +377,28 @@ export function createMessageRepository(db) {
361
377
  owner_lease_expires_at = NULL,
362
378
  owner_last_claimed_at = NULL,
363
379
  retry_count = retry_count + 1
364
- WHERE id = ? AND status = 'processing'
380
+ WHERE id = ? AND status = 'processing' AND consumed_at IS NULL
381
+ `),
382
+ // See the queue.consumed_at migration: at-most-once rows.
383
+ // Fenced like every other worker write: the row must be processing in
384
+ // this conversation, owned by the same worker session (NULL-safe, for
385
+ // routing-disabled installs), and — when the caller names one — still
386
+ // on the attempt it was pushed under.
387
+ markQueueConsumed: db.prepare(`
388
+ UPDATE queue
389
+ SET consumed_at = COALESCE(consumed_at, @at)
390
+ WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
391
+ AND NULLIF(owner_sdk_session_id, '') IS @owner
392
+ AND (@attemptId IS NULL OR attempt_id = @attemptId)
393
+ `),
394
+ // A consumed steer the CLI then replayed as a turn of its own is an
395
+ // ordinary in-flight row again.
396
+ clearQueueConsumed: db.prepare(`
397
+ UPDATE queue
398
+ SET consumed_at = NULL
399
+ WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
400
+ AND NULLIF(owner_sdk_session_id, '') IS @owner
401
+ AND (@attemptId IS NULL OR attempt_id = @attemptId)
365
402
  `),
366
403
  listProcessingOwnerSessionIds: db.prepare(`
367
404
  SELECT DISTINCT NULLIF(owner_sdk_session_id, '') AS sdk_session_id
@@ -390,6 +427,12 @@ export function createMessageRepository(db) {
390
427
  listConversationIdsWithActiveQueue: db.prepare(`SELECT DISTINCT conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
391
428
  deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
392
429
  getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
430
+ // All processing rows, oldest first. Mid-turn steering means a
431
+ // conversation can have more than one processing row at once (the live
432
+ // turn plus a steered message folded into it), so the single-row query
433
+ // above is ambiguous — callers that need "the live turn" resolve it
434
+ // from this list by which row is actually producing output.
435
+ listProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) ASC`),
393
436
  parkPendingQueueForRestart: db.prepare(`
394
437
  UPDATE queue
395
438
  SET
@@ -25,6 +25,21 @@ export function createQuestionRepository(db) {
25
25
  linkActivityToResponse: db.prepare(`UPDATE relay_activity SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
26
26
  listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
27
27
  listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
28
+ // When a queue row last produced output — its newest stream snapshot
29
+ // (rows are rewritten in place and re-stamped per update) or activity
30
+ // line. The live-turn picker compares rows by this, never by the
31
+ // per-row stream seq. `stream_done` is 1 once the row's main-thread
32
+ // snapshot is final: a row settling after a handoff re-stamps its
33
+ // snapshot with done=1, which must not read as live output.
34
+ getLastOutputAtByQueueMessage: db.prepare(`
35
+ SELECT
36
+ (SELECT MAX(at) FROM (
37
+ SELECT MAX(created_at) AS at FROM relay_stream_events WHERE queue_message_id = @id
38
+ UNION ALL
39
+ SELECT MAX(created_at) AS at FROM relay_activity WHERE queue_message_id = @id
40
+ )) AS last_output_at,
41
+ (SELECT MAX(done) FROM relay_stream_events WHERE queue_message_id = @id AND subagent_run_id IS NULL) AS stream_done
42
+ `),
28
43
  deleteConvActivity: db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`),
29
44
 
30
45
  // relay stream events — every update carries the full text-so-far
@@ -1,9 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
-
5
3
  export function createSessionRepository(db) {
6
- const shareVisibility = createShareVisibilityStatements(db);
7
4
  const runtimeSessionColumns = new Set(
8
5
  db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column?.name || '').trim()),
9
6
  );
@@ -36,36 +33,15 @@ export function createSessionRepository(db) {
36
33
  archiveConv: db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`),
37
34
  deleteConv: db.prepare(`DELETE FROM conversations WHERE id = ?`),
38
35
 
39
- // messages
40
- getMessages: db.prepare(`SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC`),
41
- getSharedMessages: shareVisibility.getSharedMessages,
42
- getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
43
- setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
36
+ // Queue and message statements live in message-repository.mjs — the
37
+ // single source of truth. Copies used to live here too; the runtime's
38
+ // {...sessionRepo, ...messageRepo} spread shadowed all of them, and the
39
+ // dead copies silently fossilized (this one's findPending predated the
40
+ // continuation-row exclusion — claiming through it would have replayed
41
+ // a background continuation as a user prompt). The two counts below are
42
+ // the only message/queue statements this repository actually owns.
44
43
  getConversationMessageCount: db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE conversation_id = ?`),
45
44
  getConversationActiveQueueCount: db.prepare(`SELECT COUNT(*) AS count FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')`),
46
- getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
47
- getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
48
- insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
49
-
50
- // queue
51
- insertQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL)`),
52
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
53
- countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
54
- countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
55
- setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
56
- setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
57
- setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
58
- setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
59
- setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ?`),
60
- deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
61
- findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
62
- pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
63
- recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
64
- listRecoverableProcessing: db.prepare(`SELECT id, conversation_id FROM queue WHERE status = 'processing' AND processing_at < ?`),
65
- recoverProcessingBefore: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
66
- listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
67
- deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
68
- getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
69
45
 
70
46
  // runtime sessions
71
47
  getRuntimeSessionByConversation: db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ?`),