@oar-sh/oar 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +168 -0
  4. package/README.md +20 -4
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-session-process.mjs +118 -1
  7. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  9. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  10. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  11. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  12. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  13. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  15. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  16. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  17. package/server/db-schema.mjs +7 -0
  18. package/server/grok-worker/acp-client.mjs +68 -12
  19. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  21. package/server/public/app/api-client.js +17 -2
  22. package/server/public/app/bootstrap.js +3 -0
  23. package/server/public/app/composer-control-state.mjs +4 -3
  24. package/server/public/app/conversation-view.js +95 -4
  25. package/server/public/app/journal-view.js +6 -0
  26. package/server/public/app/markdown-strikethrough.mjs +31 -0
  27. package/server/public/app/relay-board-inline.mjs +70 -0
  28. package/server/public/app/relay-board-view.js +157 -61
  29. package/server/public/app/relay-error-ctas.mjs +12 -3
  30. package/server/public/app/socket-handlers.js +9 -0
  31. package/server/public/app/store.js +4 -1
  32. package/server/public/app/subagent-fold.mjs +61 -0
  33. package/server/public/app/usage-limit-ui.js +225 -0
  34. package/server/public/index.html +90 -1
  35. package/server/repositories/message-repository.mjs +10 -1
  36. package/server/repositories/question-repository.mjs +1 -0
  37. package/server/repositories/remote-relay-repository.mjs +30 -0
  38. package/server/routes/messages-routes.mjs +85 -14
  39. package/server/routes/relay-board-routes.mjs +56 -2
  40. package/server/routes/sessions-routes.mjs +36 -0
  41. package/server/server-runtime.mjs +33 -2
  42. package/server/services/preview-proxy-server.mjs +64 -36
  43. package/server/services/process-list-service.mjs +122 -0
  44. package/server/services/relay-console-log.mjs +199 -0
  45. package/server/services/remote-relay-caller-context.mjs +37 -17
  46. package/server/services/remote-relay-dispatcher.mjs +72 -8
  47. package/server/services/remote-relay-inbound.mjs +35 -0
  48. package/server/services/session-worker-process-service.mjs +85 -32
  49. package/server/services/session-worker-registry-service.mjs +1 -1
  50. package/server/services/session-worker-supervisor-service.mjs +33 -1
  51. package/server/services/usage-limit-pause-service.mjs +441 -0
  52. package/shared/ask-user-bridge.mjs +14 -39
  53. package/shared/claude-usage-limit.mjs +257 -0
  54. package/shared/control-poller.mjs +69 -20
  55. package/shared/failure-note-text.mjs +38 -0
  56. package/shared/question-wait.mjs +108 -0
  57. package/shared/remote-relay-contract.mjs +16 -2
  58. package/shared/steer-settle-failure.mjs +16 -0
  59. package/shared/steer-settle-markers.mjs +8 -0
  60. package/shared/worker-runtime/process-tree.mjs +483 -0
  61. package/shared/worker-runtime/relay-errors.mjs +31 -0
  62. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  63. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -1,6 +1,14 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
 
4
+ import {
5
+ createTurnLiveness,
6
+ createTurnStalledError,
7
+ describeTurnStall,
8
+ isTurnStalledError,
9
+ resolveTurnStallWindows,
10
+ } from '../../shared/worker-runtime/turn-liveness.mjs';
11
+
4
12
  /**
5
13
  * The only module in the repo that imports `@cursor/sdk` (and its
6
14
  * `@cursor/sdk/sqlite` entry). All SDK access is injectable and the real
@@ -169,10 +177,16 @@ export function startCursorRun({
169
177
  // LocalSendOptions passthrough — `{ force: true }` is the SDK's documented
170
178
  // recovery for a local agent left wedged by a crashed process.
171
179
  sendLocal = null,
172
- // Turn backstop: with no SDK traffic for this long (and no client-hosted
180
+ // Turn backstop: with no SDK traffic for too long (and no client-hosted
173
181
  // work pending, e.g. an open ask_user card) the run is failed as stalled
174
- // instead of parking the merged iterator forever. 0 disables.
175
- stallTimeoutMs = 120_000,
182
+ // instead of parking the merged iterator forever. How long depends on what
183
+ // the run is doing (shared/worker-runtime/turn-liveness.mjs):
184
+ // `stallTimeoutMs` until the run exists, the model window while it works,
185
+ // the tool window while a tool call is open. 0 disables.
186
+ stallTimeoutMs = undefined,
187
+ stallModelTimeoutMs = undefined,
188
+ stallToolTimeoutMs = undefined,
189
+ env = process.env,
176
190
  hasPendingClientWork = () => false,
177
191
  dbg = () => {},
178
192
  } = {}) {
@@ -181,8 +195,11 @@ export function startCursorRun({
181
195
  let failure = null;
182
196
  let notify = null;
183
197
  let run = null;
184
- let lastEventAt = Date.now();
185
198
  let stallTimer = null;
199
+ const stallWindows = resolveTurnStallWindows({
200
+ env, idleMs: stallTimeoutMs, modelMs: stallModelTimeoutMs, toolMs: stallToolTimeoutMs,
201
+ });
202
+ const liveness = createTurnLiveness({ windows: stallWindows });
186
203
 
187
204
  const wake = () => {
188
205
  if (!notify) return;
@@ -190,9 +207,25 @@ export function startCursorRun({
190
207
  notify = null;
191
208
  resolve();
192
209
  };
210
+ // A tool call is reported on both surfaces (the delta hook and the message
211
+ // stream) under the same id; either one opens and closes it.
212
+ const observe = (event) => {
213
+ if (event.source === 'delta') {
214
+ const update = event.update || {};
215
+ liveness.traffic(update.type);
216
+ if (update.type === 'tool-call-started') liveness.begin('tool', update.callId, update.toolCall?.type);
217
+ else if (update.type === 'tool-call-completed') liveness.end('tool', update.callId);
218
+ return;
219
+ }
220
+ const message = event.message || {};
221
+ liveness.traffic(message.type);
222
+ if (message.type !== 'tool_call') return;
223
+ if (message.status === 'running') liveness.begin('tool', message.call_id, message.name);
224
+ else if (message.status) liveness.end('tool', message.call_id);
225
+ };
193
226
  const push = (event) => {
194
227
  if (ended) return;
195
- lastEventAt = Date.now();
228
+ observe(event);
196
229
  queue.push(event);
197
230
  wake();
198
231
  };
@@ -216,21 +249,31 @@ export function startCursorRun({
216
249
  abortSignal.addEventListener('abort', onAbort, { once: true });
217
250
  }
218
251
 
219
- if (!ended && Number(stallTimeoutMs) > 0) {
252
+ if (!ended && stallWindows.idle > 0) {
220
253
  stallTimer = setInterval(() => {
221
254
  if (ended) return;
222
255
  if (hasPendingClientWork()) {
223
256
  // A human is thinking about a question card; the clock restarts when
224
257
  // the tool settles and traffic resumes.
225
- lastEventAt = Date.now();
258
+ liveness.traffic('client-work');
226
259
  return;
227
260
  }
228
- if (Date.now() - lastEventAt >= Number(stallTimeoutMs)) {
229
- failure = failure || new Error(`cursor turn stalled: no SDK traffic for ${Math.round(Number(stallTimeoutMs) / 1000)}s`);
230
- dbg('cursor run stalled, ending merged stream');
231
- end();
232
- }
233
- }, Math.min(15_000, Math.max(1_000, Math.floor(Number(stallTimeoutMs) / 4))));
261
+ const verdict = liveness.check();
262
+ if (!verdict.stalled) return;
263
+ failure = failure || createTurnStalledError({
264
+ agentLabel: 'Cursor',
265
+ quietMs: verdict.quietMs,
266
+ phase: verdict.phase,
267
+ tools: liveness.toolsInFlight(),
268
+ });
269
+ dbg('cursor run stalled, ending merged stream', liveness.describe());
270
+ // The run is still the agent's active one; left alone it made the next
271
+ // message wait for the busy retry.
272
+ Promise.resolve().then(() => run?.cancel?.()).catch((error) => {
273
+ dbg('cursor run cancel after a stall failed', error?.message || String(error));
274
+ });
275
+ end();
276
+ }, Math.min(15_000, Math.max(5, Math.floor(stallWindows.idle / 4))));
234
277
  stallTimer.unref?.();
235
278
  }
236
279
 
@@ -247,6 +290,17 @@ export function startCursorRun({
247
290
  ...(sendLocal && typeof sendLocal === 'object' ? { local: sendLocal } : {}),
248
291
  onDelta: ({ update }) => push({ source: 'delta', update }),
249
292
  });
293
+ if (ended) {
294
+ // Stalled (or aborted) before the run existed: nothing cancelled it
295
+ // then, and left active it makes the next message wait for it.
296
+ Promise.resolve().then(() => run?.cancel?.()).catch((error) => {
297
+ dbg('cursor run cancel after a late start failed', error?.message || String(error));
298
+ });
299
+ return;
300
+ }
301
+ // From here on the run is either waiting for the model or for a tool.
302
+ liveness.traffic('run-started');
303
+ liveness.begin('model', 'run');
250
304
  for await (const sdkMessage of run.stream()) {
251
305
  if (ended) break;
252
306
  push({ source: 'stream', message: sdkMessage });
@@ -314,6 +368,19 @@ export function classifyCursorError(error) {
314
368
  const message = String(error?.message || error);
315
369
  const isRetryable = error?.isRetryable === true;
316
370
  const name = String(error?.name || '');
371
+ if (isTurnStalledError(error)) {
372
+ return {
373
+ isAuth: false,
374
+ isBusy: false,
375
+ isStalled: true,
376
+ isRetryable,
377
+ code: 'turn-stalled',
378
+ stableCode: 'cursor.turn-stalled',
379
+ message,
380
+ // What the user reads; `message` is the log's wording.
381
+ text: describeTurnStall(error),
382
+ };
383
+ }
317
384
  if (name === 'AuthenticationError' || isCursorAuthErrorMessage(message)) {
318
385
  return {
319
386
  isAuth: true,
@@ -778,13 +778,16 @@ export function createCursorTurnRunner({
778
778
  await publishResponse(message, {
779
779
  text: classified.isAuth
780
780
  ? `System note: the Cursor runtime could not authenticate (${classified.message}). Set or renew the Cursor API key in provider settings, then retry.`
781
- : `System note: the Cursor turn failed (${classified.message}).`,
781
+ : classified.text || `System note: the Cursor turn failed (${classified.message}).`,
782
782
  model: null,
783
783
  terminalError: {
784
784
  kind: 'cursor-turn-failed',
785
785
  code: classified.code,
786
786
  stableCode: classified.stableCode,
787
- message: classified.message,
787
+ // A stall is told in the user's terms, and with its own advice: the
788
+ // run was cancelled, so sending the message again is all it takes.
789
+ message: classified.text || classified.message,
790
+ ...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
788
791
  failedAt: new Date().toISOString(),
789
792
  queueMessageId: String(message.id || '') || null,
790
793
  },
@@ -665,6 +665,13 @@ if (!queueColumns.includes('continuation_op_id')) {
665
665
  // after a lost response must find the row it already created, not mint a twin.
666
666
  db.exec(`ALTER TABLE queue ADD COLUMN continuation_op_id TEXT`);
667
667
  }
668
+ if (!queueColumns.includes('usage_limit_pause')) {
669
+ // Set on the follow-up the relay queues when a Claude turn is refused at the
670
+ // subscription's usage limit (JSON: which window, its reset, when to send).
671
+ // While such a row is pending with next_attempt_at ahead, the turn is
672
+ // paused: it is held back, keeps no worker warm and blocks no restart.
673
+ db.exec(`ALTER TABLE queue ADD COLUMN usage_limit_pause TEXT`);
674
+ }
668
675
 
669
676
  // recent_workspace_roots gained a case-normalized primary key (path_key). The
670
677
  // CREATE TABLE IF NOT EXISTS above only covers fresh databases, so upgrade an
@@ -7,14 +7,24 @@ import { EventEmitter } from 'node:events';
7
7
  import { spawn } from 'node:child_process';
8
8
  import readline from 'node:readline';
9
9
 
10
+ import {
11
+ createTurnLiveness,
12
+ createTurnStalledError,
13
+ resolveTurnStallWindows,
14
+ } from '../../shared/worker-runtime/turn-liveness.mjs';
15
+
10
16
  function whichCommand(command = 'grok') {
11
17
  const value = String(command || 'grok').trim() || 'grok';
12
18
  return value;
13
19
  }
14
20
 
15
- // Prompt watchdog defaults: a turn that produces no ACP traffic for the
16
- // inactivity window (while no client-side work is pending) is stalled; the
17
- // ceiling bounds a single prompt absolutely. Both are overridable per call.
21
+ // Prompt watchdog defaults: a turn that produces no ACP traffic for too long
22
+ // (while no client-side work is pending) is stalled; the ceiling bounds a
23
+ // single prompt absolutely. Both are overridable per call. How long "too
24
+ // long" is depends on what the agent is doing
25
+ // (shared/worker-runtime/turn-liveness.mjs): a prompt in flight is a model
26
+ // request unless one of the agent's own tool calls is open, and the
27
+ // inactivity window below is the base the two are derived from.
18
28
  export const ACP_PROMPT_INACTIVITY_MS = 120_000;
19
29
  export const ACP_PROMPT_MAX_TURN_MS = 1_800_000;
20
30
  // How long the watchdog waits for its `holdStall` question before treating
@@ -352,12 +362,31 @@ export class AcpClient extends EventEmitter {
352
362
  * `holdCheckTimeoutMs` counts as `false`.
353
363
  *
354
364
  * @param {object} [extra] additional session/prompt params (e.g. `_meta`)
355
- * @param {object} [watchdog] { inactivityMs, maxTurnMs, hasPendingWork, holdStall, holdCheckTimeoutMs }
365
+ * @param {object} [watchdog] { inactivityMs, modelMs, toolMs, env, maxTurnMs, hasPendingWork, holdStall, holdCheckTimeoutMs }
356
366
  */
357
367
  async sessionPrompt(sessionId, prompt, onUpdate, extra = {}, watchdog = {}) {
358
- const inactivityMs = Number(watchdog?.inactivityMs) > 0
359
- ? Number(watchdog.inactivityMs)
360
- : ACP_PROMPT_INACTIVITY_MS;
368
+ const resolved = resolveTurnStallWindows({
369
+ env: watchdog?.env ?? process.env,
370
+ idleMs: Number(watchdog?.inactivityMs) > 0 ? Number(watchdog.inactivityMs) : undefined,
371
+ modelMs: watchdog?.modelMs,
372
+ toolMs: watchdog?.toolMs,
373
+ });
374
+ // An environment that turns the shared watchdog off does not turn this
375
+ // one off: it is the only thing that notices a dead transport. An idle
376
+ // window of 0 takes the two others to 0 with it, so all three are
377
+ // rebuilt from the default. A window the caller named stays the
378
+ // caller's.
379
+ const stallWindows = resolved.idle > 0
380
+ ? resolved
381
+ : resolveTurnStallWindows({
382
+ env: {},
383
+ idleMs: ACP_PROMPT_INACTIVITY_MS,
384
+ modelMs: watchdog?.modelMs,
385
+ toolMs: watchdog?.toolMs,
386
+ });
387
+ const inactivityMs = stallWindows.idle;
388
+ const liveness = createTurnLiveness({ windows: stallWindows });
389
+ liveness.begin('model', 'prompt');
361
390
  // An explicit 0 means NO absolute ceiling (the user's "No limit"
362
391
  // setting); only an absent/invalid value falls back to the default. The
363
392
  // inactivity watchdog still catches dead transports either way.
@@ -375,7 +404,22 @@ export class AcpClient extends EventEmitter {
375
404
  const handler = (msg) => {
376
405
  if (msg.method !== 'session/update') return;
377
406
  if (msg.params?.sessionId && msg.params.sessionId !== sessionId) return;
378
- onUpdate?.(msg.params?.update || msg.params);
407
+ const update = msg.params?.update || msg.params;
408
+ noteToolCall(update);
409
+ onUpdate?.(update);
410
+ };
411
+ // The agent's own tool calls (the ones it runs itself; client-hosted
412
+ // terminals are `hasPendingWork`). One that prints nothing sends nothing.
413
+ const noteToolCall = (update) => {
414
+ const kind = String(update?.sessionUpdate || update?.type || '').trim();
415
+ if (kind !== 'tool_call' && kind !== 'tool_call_update') return;
416
+ const id = String(update.toolCallId || update.tool_call_id || update.id || '').trim();
417
+ const status = String(update.status || (kind === 'tool_call' ? 'pending' : '')).trim().toLowerCase();
418
+ if (status === 'pending' || status === 'in_progress' || status === 'running') {
419
+ liveness.begin('tool', id, String(update.title || update.name || update.kind || '').trim());
420
+ } else if (status) {
421
+ liveness.end('tool', id);
422
+ }
379
423
  };
380
424
  this.on('notification', handler);
381
425
  let watchdogTimer = null;
@@ -386,10 +430,17 @@ export class AcpClient extends EventEmitter {
386
430
  const quietSince = () => Math.max(this.lastActivityAt, heldAt);
387
431
  const startedAt = Date.now();
388
432
  const stallPromise = new Promise((_, reject) => {
433
+ const quiet = () => liveness.check({ quietMs: Date.now() - quietSince() });
389
434
  const stall = () => {
390
435
  if (settled) return;
391
436
  try { this.sessionCancel(sessionId); } catch { /* ignore */ }
392
- reject(new Error(`grok turn stalled: no ACP activity for ${Math.round(inactivityMs / 1000)}s`));
437
+ const verdict = quiet();
438
+ reject(createTurnStalledError({
439
+ agentLabel: 'Grok',
440
+ quietMs: verdict.quietMs,
441
+ phase: verdict.phase,
442
+ tools: liveness.toolsInFlight(),
443
+ }));
393
444
  };
394
445
  const askForHold = () => {
395
446
  let timer = null;
@@ -410,10 +461,15 @@ export class AcpClient extends EventEmitter {
410
461
  return;
411
462
  }
412
463
  // Traffic that arrived while the question ran re-armed it anyway.
413
- if (Date.now() - quietSince() >= inactivityMs) stall();
464
+ if (quiet().stalled) stall();
414
465
  });
415
466
  };
416
- const pollMs = Math.max(250, Math.min(5000, Math.floor(inactivityMs / 4)));
467
+ // Often enough for the shortest window that is set, not only for the
468
+ // idle one.
469
+ const shortestWindowMs = Math.min(
470
+ ...[stallWindows.idle, stallWindows.model, stallWindows.tool].filter((ms) => Number(ms) > 0),
471
+ );
472
+ const pollMs = Math.max(250, Math.min(5000, Math.floor(shortestWindowMs / 4)));
417
473
  watchdogTimer = setInterval(() => {
418
474
  const now = Date.now();
419
475
  if (maxTurnMs > 0 && now - startedAt >= maxTurnMs) {
@@ -422,7 +478,7 @@ export class AcpClient extends EventEmitter {
422
478
  return;
423
479
  }
424
480
  if (hasPendingWork()) return;
425
- if (now - quietSince() < inactivityMs) return;
481
+ if (!quiet().stalled) return;
426
482
  if (!holdStall) {
427
483
  stall();
428
484
  return;
@@ -5,6 +5,7 @@
5
5
  import { AcpClient, extractGrokModelsFromInitialize, pickAllowOnceOptionId } from './acp-client.mjs';
6
6
  import { createAcpHostServices } from './acp-host-services.mjs';
7
7
  import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
8
+ import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
8
9
 
9
10
  export { extractGrokModelsFromInitialize };
10
11
 
@@ -44,6 +45,9 @@ export function classifyGrokError(error) {
44
45
  return {
45
46
  code: 'grok.turn-stalled',
46
47
  message: message || 'Grok turn stalled',
48
+ // The inactivity trip in the user's terms: how long, and what the agent
49
+ // was doing. The ceiling's own message already is.
50
+ ...(isTurnStalledError(error) ? { text: describeTurnStall(error) } : {}),
47
51
  isAuth: false,
48
52
  isBusy: false,
49
53
  isStalled: true,
@@ -63,7 +63,10 @@ function buildGrokTerminalError(classified, message) {
63
63
  kind: 'grok-turn-failed',
64
64
  code: classified.code,
65
65
  stableCode: classified.code,
66
- message: classified.message,
66
+ message: classified.text || classified.message,
67
+ // A stalled turn was cancelled and its agent is restarted for the next
68
+ // message, so sending the message again is all it takes.
69
+ ...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
67
70
  failedAt: new Date().toISOString(),
68
71
  queueMessageId: String(message?.id || '') || null,
69
72
  };
@@ -584,7 +587,8 @@ export function createGrokTurnRunner({
584
587
  const noteText = classified.isAuth
585
588
  ? `System note: the Grok agent could not authenticate (${classified.message})`
586
589
  : classified.isStalled
587
- ? `System note: the Grok turn stalled and was stopped (${classified.message}). Send a new message to retry.`
590
+ ? classified.text
591
+ || `System note: the Grok turn stalled and was stopped (${classified.message}). Send a new message to retry.`
588
592
  : `System note: the Grok turn failed (${classified.message}).`;
589
593
  await publishResponse(message, {
590
594
  text: noteText,
@@ -972,6 +972,20 @@ export async function cancelHostSuspend(body = {}) {
972
972
  });
973
973
  }
974
974
 
975
+ export async function resumeUsageLimitPause(conversationId) {
976
+ return apiFetch('/api/usage-limit/resume', {
977
+ method: 'POST',
978
+ body: JSON.stringify({ conversationId }),
979
+ });
980
+ }
981
+
982
+ export async function cancelUsageLimitPause(conversationId) {
983
+ return apiFetch('/api/usage-limit/cancel', {
984
+ method: 'POST',
985
+ body: JSON.stringify({ conversationId }),
986
+ });
987
+ }
988
+
975
989
  export async function cancelRelayRestart(body = {}) {
976
990
  return apiFetch('/api/relay/shutdown/cancel', {
977
991
  method: 'POST',
@@ -1091,8 +1105,9 @@ export async function answerRelayQuestionStructured(questionId, structuredAnswer
1091
1105
  }
1092
1106
  }
1093
1107
 
1094
- export async function loadRelayBoards(status = 'pending') {
1095
- return apiFetch(`/api/relay-boards?status=${encodeURIComponent(status)}`);
1108
+ export async function loadRelayBoards(status = 'pending', conversationId = '') {
1109
+ const conversation = String(conversationId || '').trim();
1110
+ return apiFetch(`/api/relay-boards?status=${encodeURIComponent(status)}${conversation ? `&conversationId=${encodeURIComponent(conversation)}` : ''}`);
1096
1111
  }
1097
1112
 
1098
1113
  export async function loadRelayBoard(boardId) {
@@ -312,6 +312,7 @@ import {
312
312
  cancelQueuedHostSuspend,
313
313
  } from './action-confirmations.js';
314
314
  import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
315
+ import { applyUsageLimitFromStatus, initUsageLimitUi } from './usage-limit-ui.js';
315
316
  import {
316
317
  startClaudeRelogin,
317
318
  submitClaudeLoginCodeFromInput,
@@ -3250,6 +3251,7 @@ async function refreshSessionWorkerStatus() {
3250
3251
  if (!status) return;
3251
3252
  syncQueueStatusMenuEntry(status);
3252
3253
  applyPendingActionsFromStatus(status);
3254
+ applyUsageLimitFromStatus(status);
3253
3255
  if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
3254
3256
  renderConvList();
3255
3257
  // A worker steering snapshot may have changed; the composer's Steer
@@ -3959,6 +3961,7 @@ async function initApp() {
3959
3961
  if (!sharedMode) {
3960
3962
  syncSuspendHostVisibility();
3961
3963
  initHostSuspendUi();
3964
+ initUsageLimitUi();
3962
3965
  }
3963
3966
  setupViewportTracking();
3964
3967
  bindChatSelectionGuard();
@@ -27,6 +27,7 @@ const STEERING_HOLD_TITLES = Object.freeze({
27
27
  compaction: 'Will steer in once the conversation finishes compacting',
28
28
  adoption: 'Will steer in once the compaction recovery finishes',
29
29
  delivery: 'Will steer in once the current delivery lands',
30
+ tool: 'Will steer in once the running tool call finishes',
30
31
  });
31
32
 
32
33
  export function deriveComposerControlState({
@@ -41,9 +42,9 @@ export function deriveComposerControlState({
41
42
  // not "Queue". Workers that do not advertise it keep the queue wording.
42
43
  steeringSupported = false,
43
44
  // The worker reports steering as momentarily held (open question card or
44
- // plan approval, compaction, post-compaction adoption). A send stays allowed
45
- // and reads "Queue": the relay holds the message and it steers in once the
46
- // hold clears.
45
+ // plan approval, compaction, post-compaction adoption, a running tool call
46
+ // that a steer would cut short). A send stays allowed and reads "Queue":
47
+ // the relay holds the message and it steers in once the hold clears.
47
48
  steeringHeld = false,
48
49
  steeringHoldReason = null,
49
50
  } = {}) {