@oar-sh/oar 0.9.12 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
package/server/public/index.html
CHANGED
|
@@ -2984,6 +2984,15 @@
|
|
|
2984
2984
|
}
|
|
2985
2985
|
.pending-action-text { overflow-wrap: anywhere; }
|
|
2986
2986
|
.pending-action-cancel { margin-left: 0; }
|
|
2987
|
+
/* The queued relay restart's row (relay-restart-ui.js): the headline, the
|
|
2988
|
+
sessions it waits for (links), held messages, background tasks, buttons. */
|
|
2989
|
+
.relay-restart-row { flex-direction: column; gap: 3px; }
|
|
2990
|
+
.relay-restart-head { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
|
|
2991
|
+
.relay-restart-waited { opacity: 0.85; }
|
|
2992
|
+
.relay-restart-detail { color: var(--text); overflow-wrap: anywhere; max-height: 4.5em; overflow-y: auto; }
|
|
2993
|
+
.relay-restart-session-link { color: inherit; text-decoration: underline; }
|
|
2994
|
+
.relay-restart-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
|
|
2995
|
+
.relay-restart-row[data-asking="1"] .relay-restart-head { font-weight: 600; }
|
|
2987
2996
|
.suspend-host-blockers {
|
|
2988
2997
|
margin: 6px 0 10px;
|
|
2989
2998
|
padding-left: 20px;
|
|
@@ -3438,6 +3447,26 @@
|
|
|
3438
3447
|
font-size: var(--fs-meta-sm); text-decoration: underline; text-underline-offset: 3px;
|
|
3439
3448
|
}
|
|
3440
3449
|
#task-console-stop:hover:not(:disabled) { border-color: #f85149; color: #f85149; }
|
|
3450
|
+
/* "Load earlier" within reach while following the end; the one at the top
|
|
3451
|
+
of the output stays. */
|
|
3452
|
+
#task-console-earlier-top {
|
|
3453
|
+
background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer;
|
|
3454
|
+
font-size: var(--fs-meta-sm); text-decoration: underline; text-underline-offset: 3px;
|
|
3455
|
+
}
|
|
3456
|
+
#task-console-earlier-top:disabled { color: var(--muted); text-decoration: none; cursor: default; }
|
|
3457
|
+
#task-console-earlier-top[hidden] { display: none; }
|
|
3458
|
+
/* Where the output of a command piped into tail stops until it ends. */
|
|
3459
|
+
#task-console-hold {
|
|
3460
|
+
margin: 8px 0 0; padding: 6px 10px; border-left: 3px solid var(--warn); border-radius: 4px;
|
|
3461
|
+
background: var(--bg2); color: var(--text); font-size: var(--fs-meta-sm);
|
|
3462
|
+
}
|
|
3463
|
+
#task-console-hold[hidden] { display: none; }
|
|
3464
|
+
.tc-hold-text { font-weight: 600; }
|
|
3465
|
+
.tc-hold-hint { color: var(--muted); margin-top: 2px; }
|
|
3466
|
+
/* Wider screens: the console ends above the composer's open task row, so
|
|
3467
|
+
the row's chip, clock and Stop stay in view (task-console.mjs sets the
|
|
3468
|
+
bottom). Phones keep the full-screen console. */
|
|
3469
|
+
#task-console-modal.tc-above-panel .task-console-dialog { height: 100%; }
|
|
3441
3470
|
#task-console-scroll {
|
|
3442
3471
|
flex: 1; overflow: auto; overscroll-behavior: contain; padding: 8px 14px 28px;
|
|
3443
3472
|
background: var(--bg);
|
|
@@ -3496,6 +3525,9 @@
|
|
|
3496
3525
|
.tc-bg-13 { background: var(--tc-ansi-13); }
|
|
3497
3526
|
.tc-bg-14 { background: var(--tc-ansi-14); }
|
|
3498
3527
|
.tc-bg-15 { background: var(--tc-ansi-15); }
|
|
3528
|
+
/* Inverse text (SGR 7) with a side left unset: the console's own colours, swapped. */
|
|
3529
|
+
.tc-fg-inverse { color: var(--bg); }
|
|
3530
|
+
.tc-bg-inverse { background: var(--text); }
|
|
3499
3531
|
#task-console-status { color: var(--muted); padding: 10px 0; font-size: var(--fs-meta-sm); }
|
|
3500
3532
|
#task-console-status[hidden] { display: none; }
|
|
3501
3533
|
#task-console-jump {
|
|
@@ -3712,6 +3744,8 @@
|
|
|
3712
3744
|
.relay-event-time { flex: 0 0 auto; }
|
|
3713
3745
|
.relay-event-lines { margin: 2px 0 4px; padding-left: 18px; }
|
|
3714
3746
|
.relay-event-line { margin: 2px 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
3747
|
+
/* The relay's note to carry on after a restart (relay-restart-note-view.mjs). */
|
|
3748
|
+
.relay-restart-note-text { padding-left: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
3715
3749
|
.relay-event-line.relay-event-question-asked,
|
|
3716
3750
|
.relay-event-line.relay-event-error,
|
|
3717
3751
|
.relay-event-line.relay-event-task-failed,
|
|
@@ -5871,6 +5905,10 @@
|
|
|
5871
5905
|
color: var(--muted);
|
|
5872
5906
|
font-size: var(--fs-meta-xs);
|
|
5873
5907
|
}
|
|
5908
|
+
.relay-question-header {
|
|
5909
|
+
font-weight: 600;
|
|
5910
|
+
color: var(--text);
|
|
5911
|
+
}
|
|
5874
5912
|
.relay-question-body {
|
|
5875
5913
|
line-height: 1.5;
|
|
5876
5914
|
color: var(--text);
|
|
@@ -7156,7 +7194,7 @@
|
|
|
7156
7194
|
checked
|
|
7157
7195
|
>
|
|
7158
7196
|
</div>
|
|
7159
|
-
<div class="settings-help" style="margin-top: 6px;">
|
|
7197
|
+
<div class="settings-help" style="margin-top: 6px;">Shows or hides the menu action in this browser only. Suspend works only when the relay runs on Windows; a queued suspend still shows its banner here either way.</div>
|
|
7160
7198
|
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7161
7199
|
<label for="scroll-on-send-toggle">Jump to the end when I send a message</label>
|
|
7162
7200
|
<input
|
|
@@ -7198,7 +7236,7 @@
|
|
|
7198
7236
|
onchange="updateTurnCeilingSetting(this.value)"
|
|
7199
7237
|
>
|
|
7200
7238
|
</div>
|
|
7201
|
-
<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
|
|
7239
|
+
<div class="settings-help" style="margin-top: 6px;">A hard cap on how long one turn may run before the relay requeues it, counted from the start of the turn or from your last answer to one of its questions. Turns are already kept alive by worker heartbeats, and the relay never cuts off a turn waiting on your answer to a question — so this only catches a worker that has hung. Grok also enforces the limit itself, counted from the start of the prompt and without pausing for questions (remote relay approvals). Set to 0 for no limit.</div>
|
|
7202
7240
|
<div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
7203
7241
|
<label for="background-task-timeout-slider">Background task timeout <span id="background-task-timeout-value">4 h</span></label>
|
|
7204
7242
|
<input
|
|
@@ -7229,6 +7267,30 @@
|
|
|
7229
7267
|
>
|
|
7230
7268
|
</div>
|
|
7231
7269
|
<div class="settings-help" style="margin-top: 6px;">A shell or Monitor task that has written no output for this long gets an amber "no output for N min" note in the composer panel and its console.</div>
|
|
7270
|
+
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7271
|
+
<label for="restart-resume-toggle">After a relay restart, tell interrupted sessions to carry on</label>
|
|
7272
|
+
<input
|
|
7273
|
+
id="restart-resume-toggle"
|
|
7274
|
+
type="checkbox"
|
|
7275
|
+
onchange="updateRestartResumeSettingFromToggle(this.checked)"
|
|
7276
|
+
aria-label="After a relay restart, tell interrupted sessions to carry on"
|
|
7277
|
+
>
|
|
7278
|
+
</div>
|
|
7279
|
+
<div class="settings-help" style="margin-top: 6px;">Most turns a restart interrupts run again by themselves. A turn that cannot (the agent had already taken the message, or had started on its own after a background task) just stops. With this on, such a session gets one relay note once the relay is back, saying when it restarted and asking the agent to check what it was doing and carry on. Your message is never sent again; idle, archived and usage-limit-paused sessions get nothing. Off by default; a setting of this relay.</div>
|
|
7280
|
+
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7281
|
+
<label for="relay-restart-max-wait-select">Ask after a queued relay restart has waited</label>
|
|
7282
|
+
<select id="relay-restart-max-wait-select" onchange="updateRelayRestartMaxWaitSetting(this.value)" aria-label="Ask after a queued relay restart has waited">
|
|
7283
|
+
<option value="5">5 min</option>
|
|
7284
|
+
<option value="10">10 min</option>
|
|
7285
|
+
<option value="15">15 min</option>
|
|
7286
|
+
<option value="30" selected>30 min</option>
|
|
7287
|
+
<option value="60">1 h</option>
|
|
7288
|
+
<option value="120">2 h</option>
|
|
7289
|
+
<option value="240">4 h</option>
|
|
7290
|
+
<option value="0">No limit</option>
|
|
7291
|
+
</select>
|
|
7292
|
+
</div>
|
|
7293
|
+
<div class="settings-help" style="margin-top: 6px;">A queued relay restart waits for the running turns, and new messages wait for the restart. After this long the banner asks whether to restart now, keep waiting or cancel; it never restarts while the turns run, and still restarts as soon as they end. No limit waits until the turns end.</div>
|
|
7232
7294
|
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7233
7295
|
<label for="subagent-record-retention-select">Keep sub-agent tool output</label>
|
|
7234
7296
|
<select id="subagent-record-retention-select" onchange="updateSubagentRecordRetentionSetting(this.value)" aria-label="Keep sub-agent tool output">
|
|
@@ -7431,7 +7493,7 @@
|
|
|
7431
7493
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
|
7432
7494
|
<button id="copilot-save-btn" type="button" class="summary-btn" onclick="saveCopilotSettings()">Save Copilot settings</button>
|
|
7433
7495
|
</div>
|
|
7434
|
-
<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
|
|
7496
|
+
<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 where no engine was saved and that cannot run the SDK engine uses Extension. Applies whenever a conversation's worker starts; sessions already running keep their engine until their worker restarts.</div>
|
|
7435
7497
|
</div>
|
|
7436
7498
|
</div>
|
|
7437
7499
|
<div
|
|
@@ -7825,7 +7887,7 @@
|
|
|
7825
7887
|
<button id="cursor-dashboard-token-save-btn" type="button" class="summary-btn" onclick="saveCursorDashboardToken()">Save token</button>
|
|
7826
7888
|
<button id="cursor-dashboard-token-remove-btn" type="button" class="summary-btn" onclick="removeCursorDashboardToken()">Remove</button>
|
|
7827
7889
|
</div>
|
|
7828
|
-
<div class="settings-help">Unlocks the live "
|
|
7890
|
+
<div class="settings-help">Unlocks the live percentage bars on Check Usage: "Plan usage (total)", "Auto + Composer" and "API". Detected automatically from this machine's Cursor IDE login when one exists; paste the <code>WorkosCursorSessionToken</code> cookie from a logged-in cursor.com tab (DevTools → Application → Cookies) only for headless relay hosts or to use a different account. Headless hosts can also set <code>CURSOR_SESSION_TOKEN</code> in the relay environment. Tokens stay on the relay.</div>
|
|
7829
7891
|
</div>
|
|
7830
7892
|
</div>
|
|
7831
7893
|
</div>
|
|
@@ -7871,7 +7933,7 @@
|
|
|
7871
7933
|
checked
|
|
7872
7934
|
>
|
|
7873
7935
|
</div>
|
|
7874
|
-
<div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still read sessions here, but cannot send prompts, start sessions, answer questions or
|
|
7936
|
+
<div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still list, read and wait on sessions here, but cannot send prompts, start sessions, answer questions, stop turns, cancel queued turns, archive or unarchive sessions, or watch sessions here for events. The relay token stays the real boundary: anyone holding it has full access.</div>
|
|
7875
7937
|
<!-- Agent sessions (agent-sessions-settings-ui.js). Hidden until the relay
|
|
7876
7938
|
answers with settings; a relay without the route never shows it. -->
|
|
7877
7939
|
<div id="agent-sessions-section" hidden>
|
|
@@ -8073,6 +8135,10 @@
|
|
|
8073
8135
|
<label for="push-event-host-suspend-toggle">Host suspend fired or was dropped</label>
|
|
8074
8136
|
<input id="push-event-host-suspend-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
|
|
8075
8137
|
</div>
|
|
8138
|
+
<div class="settings-row" style="width: 100%; margin-top: 6px; margin-bottom: 0;">
|
|
8139
|
+
<label for="push-event-relay-restart-toggle">Queued relay restart waited its maximum time</label>
|
|
8140
|
+
<input id="push-event-relay-restart-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
|
|
8141
|
+
</div>
|
|
8076
8142
|
<label style="margin-top: 10px;">Devices</label>
|
|
8077
8143
|
<div id="push-device-list" style="width: 100%;"></div>
|
|
8078
8144
|
<div class="settings-help">Notifications are only sent while no device has the app in the foreground. Content defaults to generic text; message previews are opt-in per device.</div>
|
|
@@ -8251,11 +8317,16 @@
|
|
|
8251
8317
|
<span id="task-console-stall" class="tc-stall" hidden></span>
|
|
8252
8318
|
<button id="task-console-chip" class="bg-task-chip" type="button" hidden></button>
|
|
8253
8319
|
<button id="task-console-failure" type="button" hidden>first failure</button>
|
|
8320
|
+
<button id="task-console-earlier-top" type="button" hidden>Load earlier</button>
|
|
8254
8321
|
</div>
|
|
8255
8322
|
</div>
|
|
8256
8323
|
<div id="task-console-scroll">
|
|
8257
8324
|
<button id="task-console-earlier" type="button" hidden>Load earlier</button>
|
|
8258
8325
|
<pre id="task-console-body"></pre>
|
|
8326
|
+
<div id="task-console-hold" role="note" hidden>
|
|
8327
|
+
<div class="tc-hold-text"></div>
|
|
8328
|
+
<div class="tc-hold-hint"></div>
|
|
8329
|
+
</div>
|
|
8259
8330
|
<div id="task-console-status" hidden></div>
|
|
8260
8331
|
</div>
|
|
8261
8332
|
<button id="task-console-jump" type="button" hidden>Jump to end ↓</button>
|
|
@@ -2,3 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// Intentional relay restart requested via authenticated localhost API.
|
|
4
4
|
export const RELAY_RESTART_EXIT_CODE = 75;
|
|
5
|
+
// The runtime stopped on purpose and starting it again at once cannot help:
|
|
6
|
+
// its database needs a decision (adoptEarlierDatabase refused), or its data
|
|
7
|
+
// folder cannot be made (ensureDataDirs). The supervisors do not retry it, so
|
|
8
|
+
// its one line is said once; the code goes on to whatever started the relay.
|
|
9
|
+
// 78 is sysexits' EX_CONFIG.
|
|
10
|
+
export const RELAY_STOP_EXIT_CODE = 78;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { RELAY_RESTART_EXIT_CODE } from './relay-exit-codes.mjs';
|
|
3
|
+
import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
|
|
4
4
|
|
|
5
5
|
// Role selection travels on argv, never in the environment. The runtime's env is
|
|
6
6
|
// inherited by tmux worker sessions and by the Copilot CLI it launches, and the
|
|
@@ -151,6 +151,11 @@ export async function runDirectRelaySupervisor({
|
|
|
151
151
|
return await exitImpl(0);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// Stopped on purpose, with its reason already said: not a crash.
|
|
155
|
+
if (exitCode === RELAY_STOP_EXIT_CODE) {
|
|
156
|
+
return await exitImpl(RELAY_STOP_EXIT_CODE);
|
|
157
|
+
}
|
|
158
|
+
|
|
154
159
|
const attempt = crashCount + 1;
|
|
155
160
|
logger?.error?.(`[relay] runtime crashed with exit code ${exitCode ?? 'null'} (crash ${attempt}/${maxCrashRestarts}).`);
|
|
156
161
|
if (attempt >= maxCrashRestarts) {
|
package/server/relay-tools.md
CHANGED
|
@@ -2,46 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.
|
|
4
4
|
|
|
5
|
-
When using ask_user, ALWAYS include a `choices` array with 2-
|
|
5
|
+
When using ask_user, ALWAYS include a `choices` array with 2-8 answer options so the web relay can render clickable buttons. The relay keeps the first 8 choices and drops the rest. Example:
|
|
6
6
|
```json
|
|
7
7
|
{
|
|
8
|
-
"question": "
|
|
9
|
-
"choices": ["
|
|
8
|
+
"question": "The login test fails because the session cookie expires too early. How should I fix it?",
|
|
9
|
+
"choices": ["Fix the cookie lifetime in the code", "Update the test to match the code", "Leave it and only report it"]
|
|
10
10
|
}
|
|
11
11
|
```
|
|
12
|
-
At runtime, inject the question and choices from a random item in your quiz pool.
|
|
13
12
|
Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
|
|
14
13
|
|
|
15
14
|
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
16
|
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.
|
|
18
17
|
|
|
19
|
-
For relay
|
|
18
|
+
For a relay restart or shutdown, 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.
|
|
20
19
|
|
|
21
|
-
Note:
|
|
20
|
+
Note: the request is queued. The relay only shuts down or restarts once no turn is running on it, your own included (after a maximum wait it asks the user), and it holds new messages until then, so end your turn instead of waiting for the restart. The answer names the sessions it waits for (`waitingFor`), the messages it holds (`held`) and the background tasks (`backgroundTasks`, each with whether it `survives`): tell the user.
|
|
22
21
|
|
|
23
22
|
Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.
|
|
24
23
|
|
|
24
|
+
Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.
|
|
25
|
+
|
|
26
|
+
To go on with your work once the relay is back, subscribe first with `remote_relay` `{"action":"subscribe","events":["relay_restarted"],"once":true}` (no sessions needed), then request the restart: your own timers and scheduled jobs may not survive it. A restart requested while a turn that `relay_restarted` woke is still running does not wake that conversation (a `once` subscription ends with it).
|
|
27
|
+
|
|
25
28
|
## Preview servers
|
|
26
29
|
|
|
27
|
-
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close
|
|
30
|
+
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close this conversation's previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
|
|
28
31
|
|
|
29
|
-
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews
|
|
32
|
+
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews?conversationId=<conv>` lists this conversation's previews, `DELETE /api/previews/:token?conversationId=<conv>` closes one of them without touching the dev server behind it. Always pass the conversation: you may list and close only this conversation's previews, another conversation's is refused (403), and the relay-wide list is the user's (Settings → Previews). A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
|
|
30
33
|
|
|
31
34
|
## Remote relays
|
|
32
35
|
|
|
33
36
|
If you have a `remote_relay` tool, it works with sessions on OAR relays: the other relays this relay is paired with and, when its owner allows it, this relay itself. It lists and reads sessions, prompts a session, starts a new one and waits for its reply. Start with `{"action":"list_relays"}`.
|
|
34
37
|
|
|
35
|
-
A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it.
|
|
38
|
+
A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name) or an agent on that relay writes to this conversation; a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it, or to answer an agent on it that wrote to this conversation.
|
|
36
39
|
|
|
37
40
|
This relay itself is listed with "self": true when its owner switched agent sessions on, and is addressed by its name or as "this"; it needs no mention. Use it to hand parts of your task to other sessions:
|
|
38
41
|
|
|
39
42
|
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
40
43
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
|
|
41
44
|
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
|
|
45
|
+
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
|
|
42
46
|
- A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
|
|
43
47
|
- Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
|
|
44
48
|
- Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
|
|
45
49
|
- `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
|
|
46
50
|
|
|
51
|
+
On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command. "relay_restarted" (this relay only, never in the default) wakes you when the relay runs again after a restart, with who requested it and why; named alone it needs no sessions.
|
|
52
|
+
|
|
53
|
+
The first `subscribe` of a conversation on a relay, and any to "all", shows the user an approval card. `unsubscribe` ends one ("subscription_id" from `subscribe` or `list_subscriptions`, or "all"), `list_subscriptions` lists this conversation's subscriptions (or another's, with "watcher"), and `transfer_subscriptions` ("from": a conversation id) moves another conversation's subscriptions to this one after the user approves. `usage` shows what each provider plan of a relay has left and whether a Claude usage limit has paused turns there: check it before you start work that costs. `stop` stops a session's running turn and `archive` archives a session. The `remote_relay` tool's own description is the full reference for every action and field.
|
|
54
|
+
|
|
47
55
|
Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
|
|
@@ -18,6 +18,25 @@ export function createMessageRepository(db) {
|
|
|
18
18
|
const notHeldForUsageLimit = (alias = '') => (queueHasUsageLimitPause
|
|
19
19
|
? `AND NOT (${alias}usage_limit_pause IS NOT NULL AND ${alias}status = 'pending' AND ${alias}next_attempt_at IS NOT NULL AND ${alias}next_attempt_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`
|
|
20
20
|
: '');
|
|
21
|
+
// When a processing row is due for the stale sweep; one rule for the
|
|
22
|
+
// listings and the requeue, so the UPDATE can never take a row the
|
|
23
|
+
// listing did not. Inactivity is measured from the last heartbeat, the
|
|
24
|
+
// ceiling (@ceilingBefore, NULL = no limit) from the later of the turn's
|
|
25
|
+
// start and its last answered question, and a turn waiting on an
|
|
26
|
+
// unanswered question is never due. The comments at the statements say why.
|
|
27
|
+
const staleProcessingSql = `(
|
|
28
|
+
(
|
|
29
|
+
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
30
|
+
OR (
|
|
31
|
+
@ceilingBefore IS NOT NULL
|
|
32
|
+
AND MAX(
|
|
33
|
+
COALESCE(processing_at, timestamp),
|
|
34
|
+
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
35
|
+
) < @ceilingBefore
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
39
|
+
)`;
|
|
21
40
|
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
22
41
|
const messagesHaveSourceId = messageColumns.has('source_message_id');
|
|
23
42
|
const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
|
|
@@ -37,6 +56,9 @@ export function createMessageRepository(db) {
|
|
|
37
56
|
listPinnedMessages: messagePins.listPinnedMessages,
|
|
38
57
|
countPinnedMessages: messagePins.countPinnedMessages,
|
|
39
58
|
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
59
|
+
// The model of the conversation's latest user turn: one of the
|
|
60
|
+
// fallbacks for a send that names no model.
|
|
61
|
+
getLatestUserMessageModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND role = 'user' AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
40
62
|
getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
41
63
|
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
42
64
|
// Provenance is derived from the authenticated responder identity at
|
|
@@ -366,18 +388,13 @@ export function createMessageRepository(db) {
|
|
|
366
388
|
FROM queue
|
|
367
389
|
WHERE status = 'processing'
|
|
368
390
|
AND COALESCE(kind, '') != 'continuation'
|
|
369
|
-
AND
|
|
370
|
-
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
371
|
-
OR (
|
|
372
|
-
@ceilingBefore IS NOT NULL
|
|
373
|
-
AND MAX(
|
|
374
|
-
COALESCE(processing_at, timestamp),
|
|
375
|
-
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
376
|
-
) < @ceilingBefore
|
|
377
|
-
)
|
|
378
|
-
)
|
|
379
|
-
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
391
|
+
AND ${staleProcessingSql}
|
|
380
392
|
`),
|
|
393
|
+
// Requeues one row listRecoverableProcessing returned, so the caller
|
|
394
|
+
// knows exactly which rows it requeued and announces only those. The
|
|
395
|
+
// sweep runs synchronously, so nothing changes between listing and
|
|
396
|
+
// requeue; re-checking the same rule here is a safeguard that keeps
|
|
397
|
+
// this statement from ever taking a row the listing would not.
|
|
381
398
|
recoverProcessingBefore: db.prepare(`
|
|
382
399
|
UPDATE queue
|
|
383
400
|
SET status = 'pending',
|
|
@@ -387,14 +404,11 @@ export function createMessageRepository(db) {
|
|
|
387
404
|
owner_lease_expires_at = NULL,
|
|
388
405
|
owner_last_claimed_at = NULL,
|
|
389
406
|
retry_count = retry_count + 1
|
|
390
|
-
WHERE
|
|
407
|
+
WHERE id = @id
|
|
408
|
+
AND status = 'processing'
|
|
391
409
|
AND COALESCE(kind, '') != 'continuation'
|
|
392
410
|
AND consumed_at IS NULL
|
|
393
|
-
AND
|
|
394
|
-
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
395
|
-
OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
|
|
396
|
-
)
|
|
397
|
-
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
411
|
+
AND ${staleProcessingSql}
|
|
398
412
|
`),
|
|
399
413
|
// Dead-worker recovery: the owner's process is provably gone, so its
|
|
400
414
|
// in-flight rows go back to pending — owner intact (the primer
|
|
@@ -464,17 +478,7 @@ export function createMessageRepository(db) {
|
|
|
464
478
|
FROM queue
|
|
465
479
|
WHERE status = 'processing'
|
|
466
480
|
AND kind = 'continuation'
|
|
467
|
-
AND
|
|
468
|
-
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
469
|
-
OR (
|
|
470
|
-
@ceilingBefore IS NOT NULL
|
|
471
|
-
AND MAX(
|
|
472
|
-
COALESCE(processing_at, timestamp),
|
|
473
|
-
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
474
|
-
) < @ceilingBefore
|
|
475
|
-
)
|
|
476
|
-
)
|
|
477
|
-
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
481
|
+
AND ${staleProcessingSql}
|
|
478
482
|
`),
|
|
479
483
|
listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
|
|
480
484
|
// Authoritative "is a turn in flight" signal for the conversation list.
|
|
@@ -27,7 +27,9 @@ function parseOrigin(json) {
|
|
|
27
27
|
|
|
28
28
|
export function createRemoteRelayRepository(db) {
|
|
29
29
|
const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
|
|
30
|
-
|
|
30
|
+
// Two unlocks can share a millisecond (Windows' clock is coarse); the rowid
|
|
31
|
+
// keeps them in the order they were recorded rather than by relay id.
|
|
32
|
+
const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC`);
|
|
31
33
|
const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
|
|
32
34
|
// The relay's unlocks and the approvals stored under its id (`<id>:watch`).
|
|
33
35
|
const deleteUnlocksForRelay = db.prepare(`
|
|
@@ -64,7 +66,7 @@ export function createRemoteRelayRepository(db) {
|
|
|
64
66
|
hasUnlock(conversationId, remoteRelayId) {
|
|
65
67
|
return !!hasUnlock.get(String(conversationId || ''), String(remoteRelayId || ''));
|
|
66
68
|
},
|
|
67
|
-
/** `[{ remoteRelayId, messageId, createdAt }]`, oldest first. */
|
|
69
|
+
/** `[{ remoteRelayId, messageId, createdAt }]`, oldest first, ties in the order recorded. */
|
|
68
70
|
listUnlocks(conversationId) {
|
|
69
71
|
return listUnlocks.all(String(conversationId || '')).map((row) => ({
|
|
70
72
|
remoteRelayId: row.remote_relay_id,
|
|
@@ -47,6 +47,8 @@ export function createSubagentTimelineStatements(db) {
|
|
|
47
47
|
// The column list every run read uses, so the header travels with the
|
|
48
48
|
// run wherever runs are listed (conversation payload, in-flight snapshot).
|
|
49
49
|
subagentRunSelectColumns,
|
|
50
|
+
// A run that runs again (a resumed agent): no end time while it runs.
|
|
51
|
+
reopenSubagentRun: db.prepare(`UPDATE subagent_runs SET status = 'running', updated_at = ?, completed_at = NULL WHERE id = ?`),
|
|
50
52
|
updateSubagentRunHeader: null,
|
|
51
53
|
setSubagentRunTruncated: null,
|
|
52
54
|
listChildSubagentRuns: null,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
|
|
4
4
|
import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
5
5
|
import { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
|
|
6
|
+
import { closedQuestionAnswer, questionAcceptsFreeText } from '../../shared/question-answer-rule.mjs';
|
|
6
7
|
import {
|
|
7
8
|
extractRequestedSchema,
|
|
8
9
|
normalizeSchema,
|
|
@@ -35,19 +36,39 @@ function firstKnownValue(source, keyPaths = []) {
|
|
|
35
36
|
return null;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function parseRowJson(value) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(String(value || ''));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
/**
|
|
39
48
|
* A stored question row that is this relay's Allow / Deny card for one of its
|
|
40
49
|
* own agent's calls to another relay (remote-relay-caller-context.mjs). The
|
|
41
50
|
* context lives in the row's request envelope.
|
|
42
51
|
*/
|
|
43
52
|
function isRemoteRelayApprovalRow(row) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
const envelope = parseRowJson(row?.request);
|
|
54
|
+
return envelope ? isRemoteRelayApprovalQuestion(envelope) : false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The choices of a stored card that takes no typed text (approval and
|
|
59
|
+
* permission cards, and a card with choices that stored no flag, as the
|
|
60
|
+
* runtime's formatQuestionRow reads it for the card view), or null for a
|
|
61
|
+
* card that takes any reply.
|
|
62
|
+
*/
|
|
63
|
+
function closedQuestionOfRow(row) {
|
|
64
|
+
const envelope = parseRowJson(row?.request);
|
|
65
|
+
const parsedChoices = parseRowJson(row?.choices);
|
|
66
|
+
const choices = Array.isArray(parsedChoices) ? parsedChoices.map((choice) => String(choice ?? '')) : [];
|
|
67
|
+
const allowFreeform = envelope && typeof envelope === 'object' && typeof envelope.allowFreeform === 'boolean'
|
|
68
|
+
? envelope.allowFreeform
|
|
69
|
+
: null;
|
|
70
|
+
if (questionAcceptsFreeText({ allowFreeform, choices })) return null;
|
|
71
|
+
return { choices, multiSelect: envelope?.context?.multiSelect === true };
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
function extractContinuationOwnership(rawRequest) {
|
|
@@ -297,6 +318,19 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
297
318
|
flatText = summarizeStructuredAnswer(schema, structured) || flatText;
|
|
298
319
|
} else if (!flatText) {
|
|
299
320
|
return res.status(400).json({ error: 'Empty answer' });
|
|
321
|
+
} else {
|
|
322
|
+
// A card that takes no typed text is answered by its choices only.
|
|
323
|
+
// Anything else is refused and the card stays open: it must not pass
|
|
324
|
+
// for an answer (an approval reader would take it for a denial).
|
|
325
|
+
const closed = closedQuestionOfRow(row);
|
|
326
|
+
if (closed) {
|
|
327
|
+
const accepted = closedQuestionAnswer(closed, flatText);
|
|
328
|
+
if (!accepted) {
|
|
329
|
+
console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=not-a-choice`);
|
|
330
|
+
return res.status(400).json({ error: 'This question takes one of its choices, not typed text', choices: closed.choices });
|
|
331
|
+
}
|
|
332
|
+
flatText = accepted;
|
|
333
|
+
}
|
|
300
334
|
}
|
|
301
335
|
|
|
302
336
|
const result = askUserRoutingService.routeAnswer({
|