@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
package/server/public/index.html
CHANGED
|
@@ -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-
|
|
4997
|
-
|
|
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">
|
|
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="
|
|
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
|
|
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
|
|
5406
|
-
<option value="sdk">SDK —
|
|
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>
|
|
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
|
package/server/public/sw.js
CHANGED
|
@@ -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(/^\//, '')), {
|
package/server/relay-tools.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
156
|
-
|
|
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.
|
|
202
|
-
|
|
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.
|
|
224
|
-
|
|
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
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 = ?`),
|