@oar-sh/oar 0.9.20 → 0.9.22
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 +325 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +309 -49
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -178,6 +178,18 @@ export function createClaudeCloudSessionRunner({
|
|
|
178
178
|
// Background work is over and nothing has moved for this long: ask the
|
|
179
179
|
// session whether the agent is still going to say something.
|
|
180
180
|
backgroundQuietMs = 45_000,
|
|
181
|
+
// Between turns, while the cloud may still be working on something no
|
|
182
|
+
// relay turn owns (its last known status was not idle, or a turn ended here
|
|
183
|
+
// without its `result`), the session is read again this often: the relay
|
|
184
|
+
// waits on that answer before it replaces this worker (releaseHold).
|
|
185
|
+
statusRecheckMs = 20_000,
|
|
186
|
+
// A session that stays not idle this long is no longer read (one warning
|
|
187
|
+
// line); the next delivery starts the reads again. The hold stays: the
|
|
188
|
+
// relay does not replace a worker whose session it last saw working.
|
|
189
|
+
statusRecheckGiveUpMs = 2 * 60 * 60_000,
|
|
190
|
+
// How often the stored binding is asked for again at start when the relay
|
|
191
|
+
// does not answer (attachOnStart); three tries in all.
|
|
192
|
+
bindingRetryMs = 5_000,
|
|
181
193
|
} = {}) {
|
|
182
194
|
const publisher = createClaudeTurnPublisher({ api, dbg });
|
|
183
195
|
// The account's usage limit as the sandbox's Claude Code reports it: the
|
|
@@ -211,10 +223,23 @@ export function createClaudeCloudSessionRunner({
|
|
|
211
223
|
// - `attributionRequestId`: the request whose answer is still to come.
|
|
212
224
|
// - `model`: the model this process last gave the sandbox (the create's,
|
|
213
225
|
// or a later set_model); undefined while it does not know.
|
|
226
|
+
// - `workerStatus`: the session's `worker_status` at the last read of it
|
|
227
|
+
// ('idle' when the cloud has nothing running), null while unknown.
|
|
214
228
|
let session = null;
|
|
215
229
|
let turn = null;
|
|
216
230
|
let stream = null;
|
|
217
231
|
let idleTimer = null;
|
|
232
|
+
let statusTimer = null;
|
|
233
|
+
// A read of the session between turns that may attach it (syncSession):
|
|
234
|
+
// a delivery waits for it, so the two never read the log at once.
|
|
235
|
+
let pendingSync = null;
|
|
236
|
+
// When the current run of status rechecks began (null: none runs), and
|
|
237
|
+
// whether it was given up (statusRecheckGiveUpMs).
|
|
238
|
+
let recheckSince = null;
|
|
239
|
+
let recheckGivenUp = false;
|
|
240
|
+
// A turn the session started by itself is being registered with the relay.
|
|
241
|
+
let openingStrayTurn = false;
|
|
242
|
+
let disposed = false;
|
|
218
243
|
// Events are handled one at a time and in order; posting a user message
|
|
219
244
|
// takes a place in the same line, so no event of the turn it starts is
|
|
220
245
|
// looked at before the turn's start is known.
|
|
@@ -249,6 +274,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
249
274
|
attribution: undefined,
|
|
250
275
|
attributionRequestId: null,
|
|
251
276
|
model: undefined,
|
|
277
|
+
workerStatus: null,
|
|
252
278
|
};
|
|
253
279
|
}
|
|
254
280
|
|
|
@@ -275,6 +301,33 @@ export function createClaudeCloudSessionRunner({
|
|
|
275
301
|
return Boolean(turn);
|
|
276
302
|
}
|
|
277
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Why the relay may not stop this worker and launch a fresh one now, or ''
|
|
306
|
+
* when it may (the hold names of the relay's STEERING_RELEASE_HOLDS):
|
|
307
|
+
* - 'turn': a turn runs here, delivered or one the session started itself
|
|
308
|
+
* (from the moment the relay is asked for its row).
|
|
309
|
+
* - 'background': no turn runs here, but the cloud may still be working on
|
|
310
|
+
* one: a turn that ended here without its `result` (a Stop the cloud did
|
|
311
|
+
* not confirm, the background-hold ceiling, a turn the relay would not
|
|
312
|
+
* register), or a session whose last known status is not idle. A worker
|
|
313
|
+
* replaced then would lose what that work still writes: the count lives
|
|
314
|
+
* only in this process, and a fresh one reads on from the stored cursor
|
|
315
|
+
* as if nothing were running.
|
|
316
|
+
* A session this process has not read yet holds nothing.
|
|
317
|
+
*/
|
|
318
|
+
function releaseHold() {
|
|
319
|
+
if (turn || openingStrayTurn) return 'turn';
|
|
320
|
+
if (!session) return '';
|
|
321
|
+
if (session.unfinishedTurns > 0) return 'background';
|
|
322
|
+
if (session.workerStatus && session.workerStatus !== 'idle') return 'background';
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Remember what a read of the session said about the cloud's work. */
|
|
327
|
+
function noteSessionRead(workerStatus) {
|
|
328
|
+
if (session) session.workerStatus = workerStatus || null;
|
|
329
|
+
}
|
|
330
|
+
|
|
278
331
|
// ---------------------------------------------------------------------------
|
|
279
332
|
// Relay-facing publishing
|
|
280
333
|
|
|
@@ -456,6 +509,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
456
509
|
let workerStatus = '';
|
|
457
510
|
try {
|
|
458
511
|
({ workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs)));
|
|
512
|
+
noteSessionRead(workerStatus);
|
|
459
513
|
} catch (error) {
|
|
460
514
|
dbg('cloud session read failed', error?.message || String(error));
|
|
461
515
|
}
|
|
@@ -532,6 +586,9 @@ export function createClaudeCloudSessionRunner({
|
|
|
532
586
|
if (!response?.messageId) {
|
|
533
587
|
dbg('stray cloud turn not registered; skipping it until its result');
|
|
534
588
|
session.unfinishedTurns += 1;
|
|
589
|
+
// No turn here will end the wait on its `result`; the session's
|
|
590
|
+
// status will.
|
|
591
|
+
scheduleStatusRecheck();
|
|
535
592
|
return null;
|
|
536
593
|
}
|
|
537
594
|
const message = {
|
|
@@ -569,6 +626,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
569
626
|
endTurn(t);
|
|
570
627
|
if (turn === t) turn = null;
|
|
571
628
|
scheduleIdleClose();
|
|
629
|
+
scheduleStatusRecheck();
|
|
572
630
|
}
|
|
573
631
|
})();
|
|
574
632
|
return t;
|
|
@@ -592,7 +650,14 @@ export function createClaudeCloudSessionRunner({
|
|
|
592
650
|
// continuation of its own, so the chat shows it.
|
|
593
651
|
if (!t && session.attached && sequence !== null && session.unfinishedTurns === 0
|
|
594
652
|
&& sequence > session.cursorSeq && startsStrayTurn(event, payload, type)) {
|
|
595
|
-
|
|
653
|
+
// The relay is asked for a row first: the turn runs already, and the
|
|
654
|
+
// release holds meanwhile as it will once the row is there.
|
|
655
|
+
openingStrayTurn = true;
|
|
656
|
+
try {
|
|
657
|
+
t = await openStrayTurn(sequence, payload, type);
|
|
658
|
+
} finally {
|
|
659
|
+
openingStrayTurn = false;
|
|
660
|
+
}
|
|
596
661
|
if (!t) return;
|
|
597
662
|
}
|
|
598
663
|
|
|
@@ -963,12 +1028,159 @@ export function createClaudeCloudSessionRunner({
|
|
|
963
1028
|
async function confirmUnfinishedTurns() {
|
|
964
1029
|
try {
|
|
965
1030
|
const { workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs));
|
|
1031
|
+
noteSessionRead(workerStatus);
|
|
966
1032
|
if (workerStatus === 'idle') session.unfinishedTurns = 0;
|
|
967
1033
|
} catch (error) {
|
|
968
1034
|
dbg('cloud session read failed', error?.message || String(error));
|
|
969
1035
|
}
|
|
970
1036
|
}
|
|
971
1037
|
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
// Between turns: the session's status, and the attach at start
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The session's status, read once (bounded); null when the read failed, in
|
|
1043
|
+
* which case what was known before stays.
|
|
1044
|
+
*/
|
|
1045
|
+
async function readWorkerStatus() {
|
|
1046
|
+
try {
|
|
1047
|
+
const { workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs));
|
|
1048
|
+
noteSessionRead(workerStatus);
|
|
1049
|
+
return workerStatus || null;
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
dbg('cloud session read failed', error?.message || String(error));
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Read the session again later while it holds the release with no turn
|
|
1058
|
+
* here to end the wait (a turn's end schedules this itself), or while the
|
|
1059
|
+
* session bound at start is not attached yet.
|
|
1060
|
+
*/
|
|
1061
|
+
function scheduleStatusRecheck() {
|
|
1062
|
+
if (statusTimer || disposed || !session || turn) return;
|
|
1063
|
+
if (session.attached && !releaseHold()) {
|
|
1064
|
+
recheckSince = null;
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (recheckGivenUp) return;
|
|
1068
|
+
if (recheckSince === null) {
|
|
1069
|
+
recheckSince = now();
|
|
1070
|
+
} else if (now() - recheckSince >= statusRecheckGiveUpMs) {
|
|
1071
|
+
// Not forever: a session stuck in a working state would be read every
|
|
1072
|
+
// statusRecheckMs for the life of the worker.
|
|
1073
|
+
recheckGivenUp = true;
|
|
1074
|
+
dbg(
|
|
1075
|
+
`warning: the cloud session has not been idle for ${Math.round(statusRecheckGiveUpMs / 60_000)} min;`,
|
|
1076
|
+
'no more status reads until the next delivery, and the relay does not replace this worker meanwhile',
|
|
1077
|
+
);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
statusTimer = setTimeoutImpl(() => {
|
|
1081
|
+
statusTimer = null;
|
|
1082
|
+
void syncSession();
|
|
1083
|
+
}, statusRecheckMs);
|
|
1084
|
+
statusTimer?.unref?.();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function clearStatusRecheck() {
|
|
1088
|
+
if (statusTimer) clearTimeoutImpl(statusTimer);
|
|
1089
|
+
statusTimer = null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Between turns: read the session, and act on an idle one. An idle cloud
|
|
1094
|
+
* has no `result` left to send, so the turns that ended here without one
|
|
1095
|
+
* are done with (as confirmUnfinishedTurns does before a send). An idle
|
|
1096
|
+
* session bound at start but not attached yet is read on from the stored
|
|
1097
|
+
* cursor up to now and its stream opened, so a turn the session starts by
|
|
1098
|
+
* itself from here on is followed as a continuation, as the worker before
|
|
1099
|
+
* this one did. A session that is working is not attached: the turn it
|
|
1100
|
+
* runs may be a delivered one a dead worker left behind, which the relay
|
|
1101
|
+
* delivers again and the delivery follows from the log (catchUp); taken
|
|
1102
|
+
* for a turn of the session's own, it would be published twice.
|
|
1103
|
+
* A delivery waits for this (pendingSync).
|
|
1104
|
+
*/
|
|
1105
|
+
function syncSession() {
|
|
1106
|
+
if (pendingSync) return pendingSync;
|
|
1107
|
+
if (disposed || !session || turn || openingStrayTurn) return Promise.resolve(false);
|
|
1108
|
+
const run = (async () => {
|
|
1109
|
+
let attachedNow = false;
|
|
1110
|
+
const status = await readWorkerStatus();
|
|
1111
|
+
if (!disposed && session && !turn && status === 'idle') {
|
|
1112
|
+
session.unfinishedTurns = 0;
|
|
1113
|
+
if (!session.attached) {
|
|
1114
|
+
await catchUp();
|
|
1115
|
+
if (!disposed && session && !session.attached) {
|
|
1116
|
+
session.attached = true;
|
|
1117
|
+
ensureStream();
|
|
1118
|
+
attachedNow = true;
|
|
1119
|
+
dbg('cloud event stream opened between turns', `after=${session.lastSeq}`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return attachedNow;
|
|
1124
|
+
})();
|
|
1125
|
+
pendingSync = run
|
|
1126
|
+
.catch((error) => {
|
|
1127
|
+
dbg('cloud session sync failed', error?.message || String(error));
|
|
1128
|
+
return false;
|
|
1129
|
+
})
|
|
1130
|
+
.finally(() => {
|
|
1131
|
+
pendingSync = null;
|
|
1132
|
+
scheduleStatusRecheck();
|
|
1133
|
+
});
|
|
1134
|
+
return pendingSync;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** The binding the relay stores for this conversation, or null. */
|
|
1138
|
+
async function readStoredBinding() {
|
|
1139
|
+
const routePath = `/api/claude-cloud-session?conversationId=${encodeURIComponent(sdkSessionId)}`;
|
|
1140
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1141
|
+
try {
|
|
1142
|
+
return await api('GET', routePath);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
// A relay without the route answers 404 for good: the first delivery
|
|
1145
|
+
// binds the session, as before.
|
|
1146
|
+
if (Number(error?.status) === 404 || attempt >= 3 || disposed) {
|
|
1147
|
+
dbg('stored cloud session not read', error?.message || String(error));
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
await sleep(bindingRetryMs);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* At worker start: bind the cloud session the relay stores for this
|
|
1157
|
+
* conversation and open its event stream from the stored cursor, before
|
|
1158
|
+
* any delivery. The worker this one replaces kept its stream open for its
|
|
1159
|
+
* whole life (idleCloseMs = 0) and so mirrored the turns the session
|
|
1160
|
+
* starts by itself (a message sent on claude.ai, the agent's closing turn
|
|
1161
|
+
* after background work); without this a fresh worker would see none of
|
|
1162
|
+
* them until its first delivery. A conversation with no cloud session yet
|
|
1163
|
+
* has nothing to attach: its first delivery creates one. A session that is
|
|
1164
|
+
* working at start is read again every statusRecheckMs and attached once
|
|
1165
|
+
* idle (syncSession says why not before).
|
|
1166
|
+
*
|
|
1167
|
+
* Resolves true when the stream was opened.
|
|
1168
|
+
*/
|
|
1169
|
+
async function attachOnStart() {
|
|
1170
|
+
if (session || turn || disposed || pendingSync) return false;
|
|
1171
|
+
const run = (async () => {
|
|
1172
|
+
const binding = await readStoredBinding();
|
|
1173
|
+
const id = String(binding?.sessionId || '').trim();
|
|
1174
|
+
if (!id || session || disposed) return false;
|
|
1175
|
+
session = newSession(id, { cursor: toCloudSequence(binding.lastSequence) ?? 0 });
|
|
1176
|
+
return true;
|
|
1177
|
+
})();
|
|
1178
|
+
// A delivery that arrives meanwhile waits until the binding is known.
|
|
1179
|
+
pendingSync = run.catch(() => false).finally(() => { pendingSync = null; });
|
|
1180
|
+
if (!await pendingSync) return false;
|
|
1181
|
+
return syncSession();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
972
1184
|
/**
|
|
973
1185
|
* Before a message goes into a session that exists: hand the sandbox the
|
|
974
1186
|
* commit attribution the relay delivered, unless this process already did.
|
|
@@ -1173,6 +1385,7 @@ export function createClaudeCloudSessionRunner({
|
|
|
1173
1385
|
dbg('cloud session read failed', error?.message || String(error));
|
|
1174
1386
|
}
|
|
1175
1387
|
if (usage.sessionUrl) session.url = usage.sessionUrl;
|
|
1388
|
+
if (usage.workerStatus !== undefined) noteSessionRead(usage.workerStatus);
|
|
1176
1389
|
const costUsd = usage.costUsd ?? state.result?.totalCostUsd ?? null;
|
|
1177
1390
|
await reportSession(message, {
|
|
1178
1391
|
...(costUsd !== null ? { costUsd } : {}),
|
|
@@ -1318,7 +1531,19 @@ export function createClaudeCloudSessionRunner({
|
|
|
1318
1531
|
dbg('turn already active; rejecting concurrent deliver', message.id);
|
|
1319
1532
|
return false;
|
|
1320
1533
|
}
|
|
1534
|
+
// The session is being read between turns (the attach at start, or a
|
|
1535
|
+
// status check): this delivery binds and reads it after that, never
|
|
1536
|
+
// beside it. A turn the session started meanwhile takes precedence.
|
|
1537
|
+
while (pendingSync) await pendingSync;
|
|
1538
|
+
if (turn) {
|
|
1539
|
+
dbg('turn already active; rejecting concurrent deliver', message.id);
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1321
1542
|
clearIdleClose();
|
|
1543
|
+
clearStatusRecheck();
|
|
1544
|
+
// A delivery starts the status reads afresh, also after they were given up.
|
|
1545
|
+
recheckSince = null;
|
|
1546
|
+
recheckGivenUp = false;
|
|
1322
1547
|
const t = openTurn(message);
|
|
1323
1548
|
const relayHoldMs = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
1324
1549
|
if (Number.isFinite(relayHoldMs) && relayHoldMs > 0) t.holdMs = relayHoldMs;
|
|
@@ -1337,11 +1562,14 @@ export function createClaudeCloudSessionRunner({
|
|
|
1337
1562
|
endTurn(t);
|
|
1338
1563
|
turn = null;
|
|
1339
1564
|
scheduleIdleClose();
|
|
1565
|
+
scheduleStatusRecheck();
|
|
1340
1566
|
}
|
|
1341
1567
|
}
|
|
1342
1568
|
|
|
1343
1569
|
async function dispose() {
|
|
1570
|
+
disposed = true;
|
|
1344
1571
|
clearIdleClose();
|
|
1572
|
+
clearStatusRecheck();
|
|
1345
1573
|
if (turn) endTurn(turn);
|
|
1346
1574
|
const entry = stream;
|
|
1347
1575
|
stream = null;
|
|
@@ -1353,6 +1581,8 @@ export function createClaudeCloudSessionRunner({
|
|
|
1353
1581
|
getActiveQueueMessageId,
|
|
1354
1582
|
getActiveQueueAttempt,
|
|
1355
1583
|
isTurnActive,
|
|
1584
|
+
releaseHold,
|
|
1585
|
+
attachOnStart,
|
|
1356
1586
|
dispose,
|
|
1357
1587
|
};
|
|
1358
1588
|
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
15
15
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
16
16
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
17
|
+
import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
|
|
18
|
+
import { resolveBuildId } from '../../shared/build-id.mjs';
|
|
17
19
|
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
18
20
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
19
21
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
@@ -21,6 +23,7 @@ import { createClaudeCloudCredentials } from '../../shared/claude-cloud/credenti
|
|
|
21
23
|
import { createClaudeCloudClient } from '../../shared/claude-cloud/api-client.mjs';
|
|
22
24
|
import { resolveClaudeCloudBaseUrl } from '../../shared/claude-cloud/base-url.mjs';
|
|
23
25
|
import { createClaudeCloudSessionRunner } from './claude-cloud-session-process.mjs';
|
|
26
|
+
import { buildClaudeCloudSteeringState } from './claude-cloud-worker-steering.mjs';
|
|
24
27
|
|
|
25
28
|
// Windows console launch: the launcher names the worker log, this copies
|
|
26
29
|
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
@@ -51,6 +54,10 @@ async function main() {
|
|
|
51
54
|
|
|
52
55
|
const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
|
|
53
56
|
|| path.resolve(__dirname, '..', 'config.json');
|
|
57
|
+
// The code this process started on, read once from the repo root and kept
|
|
58
|
+
// for its whole life: the relay recycles a worker whose build differs from
|
|
59
|
+
// its own, and a tree that moves on under us must not change our answer.
|
|
60
|
+
const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
|
|
54
61
|
const serverUrl = resolveRelayServerUrl({ configPath });
|
|
55
62
|
const token = loadTokenFromConfig(configPath);
|
|
56
63
|
const defaultModel = String(process.env.CLAUDE_CLOUD_RELAY_MODEL || '').trim();
|
|
@@ -101,17 +108,33 @@ async function main() {
|
|
|
101
108
|
let heartbeatTimer = null;
|
|
102
109
|
const heartbeat = createHeartbeatController({
|
|
103
110
|
api,
|
|
111
|
+
buildId,
|
|
104
112
|
pollMs: HEARTBEAT_MS,
|
|
105
113
|
getSessionReady: () => true,
|
|
106
114
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
107
115
|
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
108
116
|
getActiveQueueMessageId: () => runner.getActiveQueueMessageId(),
|
|
117
|
+
// No steering here; the snapshot tells the relay when it may replace
|
|
118
|
+
// this worker: between turns, with no turn that ended here without its
|
|
119
|
+
// result, and with the cloud session idle at its last read. Anything else
|
|
120
|
+
// is cloud work whose output only this process would still publish. The
|
|
121
|
+
// cloud session lives on in the cloud; the fresh worker binds it at start
|
|
122
|
+
// and re-opens its event stream from the cursor the relay stores
|
|
123
|
+
// (runner.attachOnStart below), so the turns the session starts by
|
|
124
|
+
// itself are still followed.
|
|
125
|
+
getSteeringState: () => buildClaudeCloudSteeringState(runner),
|
|
109
126
|
});
|
|
110
127
|
|
|
128
|
+
// One timer for the heartbeat and the socket's readiness refresh: 10 s
|
|
129
|
+
// while working, 30 s while idle on a relay that allows it.
|
|
130
|
+
let keepalive = null;
|
|
111
131
|
const wsLink = createWorkerWebSocketLink({
|
|
112
132
|
serverUrl,
|
|
133
|
+
readyRefreshMs: 0,
|
|
134
|
+
onRelayFrame: () => keepalive?.nudge(),
|
|
113
135
|
token,
|
|
114
136
|
dbg,
|
|
137
|
+
buildId,
|
|
115
138
|
getSessionReady: () => true,
|
|
116
139
|
getSessionId: () => sdkSessionId,
|
|
117
140
|
getPid: () => process.pid,
|
|
@@ -125,10 +148,12 @@ async function main() {
|
|
|
125
148
|
}
|
|
126
149
|
},
|
|
127
150
|
});
|
|
151
|
+
keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
|
|
128
152
|
|
|
129
153
|
const shutdown = async (signal) => {
|
|
130
154
|
dbg(`shutting down (${signal})`);
|
|
131
155
|
try { wsLink.stop(); } catch {}
|
|
156
|
+
try { keepalive.stop(); } catch {}
|
|
132
157
|
try { heartbeat.stopHeartbeat(); } catch {}
|
|
133
158
|
try { controlPoller.stop(); } catch {}
|
|
134
159
|
// The cloud session is left as it is: a turn in flight keeps running
|
|
@@ -144,9 +169,13 @@ async function main() {
|
|
|
144
169
|
getActiveQueueMessageIds: () => [runner.getActiveQueueAttempt()],
|
|
145
170
|
});
|
|
146
171
|
|
|
147
|
-
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'}`);
|
|
148
|
-
|
|
172
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
|
|
173
|
+
keepalive.start();
|
|
149
174
|
wsLink.start();
|
|
175
|
+
// Not awaited: deliveries wait for it inside the runner, and a relay that
|
|
176
|
+
// cannot say which cloud session this is leaves the binding to the first
|
|
177
|
+
// delivery, as before.
|
|
178
|
+
runner.attachOnStart().catch((error) => dbg('cloud session attach at start failed', error?.message || String(error)));
|
|
150
179
|
}
|
|
151
180
|
|
|
152
181
|
main().catch((error) => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The heartbeat steering snapshot of the Claude Cloud worker. It steers
|
|
3
|
+
* nothing (`supported: false`, so the composer offers no mid-turn steering and
|
|
4
|
+
* reads neither the hold nor the cancellable ids); it is sent for the release
|
|
5
|
+
* fields, which tell the relay whether it may stop this worker and launch a
|
|
6
|
+
* fresh one now (its recycle after a deploy).
|
|
7
|
+
*
|
|
8
|
+
* The rule is the runner's (`releaseHold()` in claude-cloud-session-process.mjs):
|
|
9
|
+
* not while a turn runs here, not while a turn that ended here without its
|
|
10
|
+
* `result` may still run in the cloud, and not while the cloud session's last
|
|
11
|
+
* known status is not idle. The generic snapshot of the other small mains
|
|
12
|
+
* (shared/worker-runtime/simple-steering-state.mjs) knows only the turn; the
|
|
13
|
+
* cloud session can be busy with no relay turn at all.
|
|
14
|
+
*
|
|
15
|
+
* `runner` is the object createClaudeCloudSessionRunner returns.
|
|
16
|
+
*/
|
|
17
|
+
export function buildClaudeCloudSteeringState(runner) {
|
|
18
|
+
const attempt = runner?.getActiveQueueAttempt?.() || null;
|
|
19
|
+
const messageId = String(attempt?.id || '').trim() || null;
|
|
20
|
+
const turnActive = runner?.isTurnActive?.() === true || Boolean(messageId);
|
|
21
|
+
// A runner without the hold function still never says yes mid-turn.
|
|
22
|
+
const hold = String(runner?.releaseHold?.() || '').trim() || (turnActive ? 'turn' : '');
|
|
23
|
+
return {
|
|
24
|
+
turnActive,
|
|
25
|
+
canSteer: false,
|
|
26
|
+
holdReason: null,
|
|
27
|
+
messageId,
|
|
28
|
+
supported: false,
|
|
29
|
+
cancellableIds: [],
|
|
30
|
+
releasable: !hold,
|
|
31
|
+
releaseHold: hold || null,
|
|
32
|
+
backgroundTaskCount: 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -2,6 +2,7 @@ import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
2
2
|
|
|
3
3
|
import { formatClaudeBackgroundCommandLimitLabel } from '../../shared/claude-background-command-limit.mjs';
|
|
4
4
|
import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
|
|
5
|
+
import { PERSON_ONLY_QUESTION_SOURCE } from '../../shared/relay-asks.mjs';
|
|
5
6
|
import { MEDIA_EMBED_INSTRUCTION_TEXT } from '../../shared/media-embed-instructions.mjs';
|
|
6
7
|
import { REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS } from '../../shared/remote-relay-tool-core.mjs';
|
|
7
8
|
import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
|
|
@@ -631,11 +632,151 @@ export async function readPlanUsage(turn, dbg = () => {}, { timeoutMs = 10000 }
|
|
|
631
632
|
}
|
|
632
633
|
}
|
|
633
634
|
|
|
635
|
+
// Asks Claude Code marks as person-only: a removal of a workspace or system
|
|
636
|
+
// directory, a target it cannot resolve. No rule, mode or automatic approval
|
|
637
|
+
// may answer them, so allow-all parity stops here. The SDK does not forward
|
|
638
|
+
// the wire's `classifier_approvable`, so the safety-check kinds count only
|
|
639
|
+
// together with `suppressAlwaysAllowRule`; a sensitive-file safety check
|
|
640
|
+
// (an edit under .git/ or .claude/) carries no such flag and stays allowed.
|
|
641
|
+
// A user's `permissions.ask` rule (`matchedAskRule`) changes nothing: the
|
|
642
|
+
// flags decide, and an ask that rule forced without them stays allowed.
|
|
643
|
+
const PERSON_ONLY_REASON_TYPES = new Set(['safetyCheck', 'subcommandResults']);
|
|
644
|
+
export const PERSON_ONLY_ALLOW_CHOICE = 'Allow once';
|
|
645
|
+
export const PERSON_ONLY_DENY_CHOICE = 'Deny';
|
|
646
|
+
export { PERSON_ONLY_QUESTION_SOURCE };
|
|
647
|
+
// The card's first line is fixed text: the relay's console log prints the
|
|
648
|
+
// first 60 characters of a question, and the command must not reach it.
|
|
649
|
+
const PERSON_ONLY_QUESTION_LEAD = 'Claude Code marks this as person-only: no rule or automatic approval may allow it. Allow it once?';
|
|
650
|
+
const PERSON_ONLY_RATIONALE = 'Claude Code needs a person to approve this before it runs. Nothing runs until you choose.';
|
|
651
|
+
// The card shows the whole command or nothing: a cut could hide the removal.
|
|
652
|
+
export const PERSON_ONLY_COMMAND_MAX = 8000;
|
|
653
|
+
export const PERSON_ONLY_TOO_LONG_MESSAGE = 'Not run: too long to show for approval; issue the removal as a short command of its own.';
|
|
654
|
+
const PERSON_ONLY_REASON_MAX = 400;
|
|
655
|
+
const PERSON_ONLY_TYPED_ANSWER_MAX = 500;
|
|
656
|
+
|
|
657
|
+
export function isPersonOnlyAsk(options) {
|
|
658
|
+
if (!options || typeof options !== 'object') return false;
|
|
659
|
+
if (options.defaultToNo === true) return true;
|
|
660
|
+
return PERSON_ONLY_REASON_TYPES.has(options.decisionReasonType)
|
|
661
|
+
&& options.suppressAlwaysAllowRule === true;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Characters that hide or reorder text: C0 and C1 controls, bidi controls,
|
|
665
|
+
// zero-width characters and the Unicode line separators. On the card each
|
|
666
|
+
// one shows as a visible \u{XXXX} escape (the body is pre-wrap plain text).
|
|
667
|
+
const INVISIBLE_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
|
|
668
|
+
|
|
669
|
+
function escapeInvisible(text) {
|
|
670
|
+
return String(text ?? '').replace(INVISIBLE_CHARACTERS, (char) => (
|
|
671
|
+
`\\u{${char.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}}`
|
|
672
|
+
));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// The CLI's reason may carry ANSI colour (sdk.d.ts): drop well-formed
|
|
676
|
+
// sequences, then escape what is left, newlines included (one line).
|
|
677
|
+
function reasonLine(value) {
|
|
678
|
+
const text = String(value ?? '')
|
|
679
|
+
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
|
|
680
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
681
|
+
.trim();
|
|
682
|
+
return capText(escapeInvisible(text), PERSON_ONLY_REASON_MAX);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function capText(text, max) {
|
|
686
|
+
if (text.length <= max) return text;
|
|
687
|
+
return `${text.slice(0, max)}… (${text.length - max} more characters)`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function personOnlySubject(input) {
|
|
691
|
+
if (typeof input?.command === 'string') return { label: 'command', text: input.command };
|
|
692
|
+
for (const key of ['file_path', 'notebook_path', 'path']) {
|
|
693
|
+
if (typeof input?.[key] === 'string') return { label: 'path', text: input[key] };
|
|
694
|
+
}
|
|
695
|
+
let json = '';
|
|
696
|
+
try { json = JSON.stringify(input ?? {}); } catch { json = ''; }
|
|
697
|
+
return { label: 'input', text: json };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Each line escaped and behind "│ ", each line break marked "⏎", so no line
|
|
701
|
+
// of the command can pass for the card's own text.
|
|
702
|
+
function subjectLines(text) {
|
|
703
|
+
return String(text ?? '').split('\n').map(escapeInvisible);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function subjectTooLong(input) {
|
|
707
|
+
return subjectLines(personOnlySubject(input).text).join('\n').length > PERSON_ONLY_COMMAND_MAX;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function personOnlyQuestionText(toolName, input, decisionReason) {
|
|
711
|
+
const subject = personOnlySubject(input);
|
|
712
|
+
const lines = subjectLines(subject.text);
|
|
713
|
+
const parts = [PERSON_ONLY_QUESTION_LEAD];
|
|
714
|
+
const reason = reasonLine(decisionReason);
|
|
715
|
+
if (reason) parts.push(`Reason: ${reason}`);
|
|
716
|
+
const count = lines.length > 1 ? `, ${lines.length} lines` : '';
|
|
717
|
+
parts.push(`${toolName || 'Tool'} ${subject.label}${count}:\n│ ${lines.join(' ⏎\n│ ')}`);
|
|
718
|
+
return parts.join('\n\n');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function personOnlyDenyMessage(decisionReason, why) {
|
|
722
|
+
const reason = reasonLine(decisionReason);
|
|
723
|
+
return `Not run: Claude Code marks this as person-only${reason ? ` (${reason})` : ''}, and ${why}. `
|
|
724
|
+
+ 'Do not work around it by splitting, scripting or re-issuing it through another tool.';
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Allow/Deny card over the question bridge, no typed replies. Only the exact
|
|
728
|
+
// "Allow once" allows, and never with `updatedPermissions`: the next such ask
|
|
729
|
+
// asks again.
|
|
730
|
+
async function askPersonOnly({ askUserBridge, toolName, input, options, signal, dbg }) {
|
|
731
|
+
const reason = options?.decisionReason;
|
|
732
|
+
const kind = options?.decisionReasonType || 'defaultToNo';
|
|
733
|
+
const deny = (why, outcome) => {
|
|
734
|
+
dbg('person-only ask', toolName, kind, outcome);
|
|
735
|
+
return { behavior: 'deny', message: personOnlyDenyMessage(reason, why) };
|
|
736
|
+
};
|
|
737
|
+
if (signal?.aborted) return deny('the turn was stopped', 'aborted');
|
|
738
|
+
if (subjectTooLong(input)) {
|
|
739
|
+
dbg('person-only ask', toolName, kind, 'too-long');
|
|
740
|
+
return { behavior: 'deny', message: PERSON_ONLY_TOO_LONG_MESSAGE };
|
|
741
|
+
}
|
|
742
|
+
if (!askUserBridge) return deny('the relay could not show the question', 'no-bridge');
|
|
743
|
+
const question = personOnlyQuestionText(toolName, input, reason);
|
|
744
|
+
let result;
|
|
745
|
+
try {
|
|
746
|
+
result = await askUserBridge.handleAskUserQuestion({
|
|
747
|
+
questions: [{
|
|
748
|
+
question,
|
|
749
|
+
header: 'Safety check',
|
|
750
|
+
options: [{ label: PERSON_ONLY_ALLOW_CHOICE }, { label: PERSON_ONLY_DENY_CHOICE }],
|
|
751
|
+
}],
|
|
752
|
+
}, {
|
|
753
|
+
signal,
|
|
754
|
+
source: PERSON_ONLY_QUESTION_SOURCE,
|
|
755
|
+
rationale: PERSON_ONLY_RATIONALE,
|
|
756
|
+
allowFreeform: false,
|
|
757
|
+
});
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (signal?.aborted) return deny('the turn was stopped', 'aborted');
|
|
760
|
+
dbg('person-only ask bridge failed', error?.message || String(error));
|
|
761
|
+
return deny('the relay could not show the question', 'bridge-failed');
|
|
762
|
+
}
|
|
763
|
+
if (signal?.aborted) return deny('the turn was stopped', 'aborted');
|
|
764
|
+
if (result?.timedOut) return deny('no answer came before the timeout', 'timed-out');
|
|
765
|
+
const answer = String(result?.answers?.[question] ?? '').trim();
|
|
766
|
+
if (answer === PERSON_ONLY_ALLOW_CHOICE) {
|
|
767
|
+
dbg('person-only ask', toolName, kind, 'allowed');
|
|
768
|
+
return { behavior: 'allow', updatedInput: input };
|
|
769
|
+
}
|
|
770
|
+
if (!answer || answer === PERSON_ONLY_DENY_CHOICE) return deny('the user denied it', 'denied');
|
|
771
|
+
return deny(`the user denied it: ${capText(escapeInvisible(answer), PERSON_ONLY_TYPED_ANSWER_MAX)}`, 'denied-other');
|
|
772
|
+
}
|
|
773
|
+
|
|
634
774
|
/**
|
|
635
775
|
* Build the `canUseTool` callback for one turn. Everything is auto-allowed
|
|
636
|
-
* (parity with the Copilot workers' `--allow-all` posture)
|
|
637
|
-
*
|
|
638
|
-
*
|
|
776
|
+
* (parity with the Copilot workers' `--allow-all` posture) except the asks
|
|
777
|
+
* Claude Code marks as person-only (an Allow/Deny relay card, see
|
|
778
|
+
* isPersonOnlyAsk); the other interceptions are AskUserQuestion (bridged to
|
|
779
|
+
* relay question cards) and ExitPlanMode (surfaces the plan_ready board).
|
|
639
780
|
*
|
|
640
781
|
* ExitPlanMode is DENIED after the board is posted: allowing it tells the CLI
|
|
641
782
|
* the user approved the plan, and the same turn rolls straight into
|
|
@@ -658,8 +799,12 @@ export function createCanUseTool({
|
|
|
658
799
|
onExitPlanMode,
|
|
659
800
|
dbg = () => {},
|
|
660
801
|
} = {}) {
|
|
661
|
-
return async function canUseTool(toolName, input,
|
|
802
|
+
return async function canUseTool(toolName, input, options = {}) {
|
|
803
|
+
const signal = options?.signal;
|
|
662
804
|
const normalizedToolName = String(toolName || '').trim();
|
|
805
|
+
if (isPersonOnlyAsk(options)) {
|
|
806
|
+
return askPersonOnly({ askUserBridge, toolName: normalizedToolName, input, options, signal, dbg });
|
|
807
|
+
}
|
|
663
808
|
try {
|
|
664
809
|
if (normalizedToolName === 'AskUserQuestion' && askUserBridge) {
|
|
665
810
|
const { answers } = await askUserBridge.handleAskUserQuestion(input, { signal });
|