@oar-sh/oar 0.9.5 → 0.9.7

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 (119) 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 +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. 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
  },
@@ -11,6 +11,8 @@ import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-works
11
11
  import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
12
12
  import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
13
13
  import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
14
+ import { migrateWorkspaceRootSettings } from './migrations/0006-workspace-root-settings.mjs';
15
+ import { migrateClaudeCloud } from './migrations/0007-claude-cloud.mjs';
14
16
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
15
17
 
16
18
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -665,6 +667,13 @@ if (!queueColumns.includes('continuation_op_id')) {
665
667
  // after a lost response must find the row it already created, not mint a twin.
666
668
  db.exec(`ALTER TABLE queue ADD COLUMN continuation_op_id TEXT`);
667
669
  }
670
+ if (!queueColumns.includes('usage_limit_pause')) {
671
+ // Set on the follow-up the relay queues when a Claude turn is refused at the
672
+ // subscription's usage limit (JSON: which window, its reset, when to send).
673
+ // While such a row is pending with next_attempt_at ahead, the turn is
674
+ // paused: it is held back, keeps no worker warm and blocks no restart.
675
+ db.exec(`ALTER TABLE queue ADD COLUMN usage_limit_pause TEXT`);
676
+ }
668
677
 
669
678
  // recent_workspace_roots gained a case-normalized primary key (path_key). The
670
679
  // CREATE TABLE IF NOT EXISTS above only covers fresh databases, so upgrade an
@@ -710,6 +719,21 @@ try {
710
719
  } catch (error) {
711
720
  console.warn(`[remote-relays] migration 0005 failed; retrying on next boot: ${error?.message || error}`);
712
721
  }
722
+ // Per-folder settings (commit attribution override). Readers tolerate the
723
+ // table's absence, so a failure is logged and retried next boot like 0005.
724
+ try {
725
+ migrateWorkspaceRootSettings(db);
726
+ } catch (error) {
727
+ console.warn(`[workspace-root-settings] migration 0006 failed; retrying on next boot: ${error?.message || error}`);
728
+ }
729
+ // Claude Cloud conversations: the cloud session binding and what it clones.
730
+ // Additive columns whose readers tolerate their absence, so a failure is
731
+ // logged and retried next boot like 0006.
732
+ try {
733
+ migrateClaudeCloud(db);
734
+ } catch (error) {
735
+ console.warn(`[claude-cloud] migration 0007 failed; retrying on next boot: ${error?.message || error}`);
736
+ }
713
737
 
714
738
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
715
739
  if (runtimeSessionColumns.length) {
@@ -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,
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: per-folder settings (`workspace_root_settings`).
5
+ *
6
+ * The first setting a repo folder can carry is the commit attribution mode
7
+ * of its Claude sessions (shared/claude-attribution.mjs): a repo that forbids
8
+ * co-author trailers, or wants its own, overrides the provider setting for
9
+ * every session that runs in it. Keyed like `recent_workspace_roots`
10
+ * (`normalizeWorkspaceRootKey`), but its own table: the MRU list is pruned.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot.
13
+ *
14
+ * Usage: node server/migrations/0006-workspace-root-settings.mjs [path/to/copilot.db]
15
+ */
16
+
17
+ import { fileURLToPath } from 'url';
18
+ import path from 'path';
19
+ import process from 'process';
20
+ import Database from 'better-sqlite3';
21
+
22
+ function tableExists(db, name) {
23
+ return !!db
24
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
25
+ .get(name);
26
+ }
27
+
28
+ export function migrateWorkspaceRootSettings(db) {
29
+ const existed = tableExists(db, 'workspace_root_settings');
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS workspace_root_settings (
32
+ path_key TEXT PRIMARY KEY,
33
+ path TEXT NOT NULL,
34
+ attribution_mode TEXT,
35
+ updated_at TEXT NOT NULL
36
+ )
37
+ `);
38
+ return { applied: !existed, tableCreated: !existed };
39
+ }
40
+
41
+ function defaultDbPath() {
42
+ const here = path.dirname(fileURLToPath(import.meta.url));
43
+ return path.resolve(here, '..', 'data', 'copilot.db');
44
+ }
45
+
46
+ export function migrate(dbPath, options = {}) {
47
+ const db = new Database(dbPath, options);
48
+ try {
49
+ return migrateWorkspaceRootSettings(db);
50
+ } finally {
51
+ db.close();
52
+ }
53
+ }
54
+
55
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
56
+ if (isMain) {
57
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
58
+ try {
59
+ const result = migrate(dbPath);
60
+ console.log(`Migration 0006 on ${dbPath}`);
61
+ console.log(result.applied ? ' workspace_root_settings table created' : ' skipped: nothing to change');
62
+ process.exit(0);
63
+ } catch (err) {
64
+ console.error(`Migration 0006 failed: ${err?.message || err}`);
65
+ process.exit(1);
66
+ }
67
+ }
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: storage for Claude Cloud conversations (provider `claude-cloud`).
5
+ *
6
+ * 1. `runtime_sessions.claude_cloud_session_id` — the cloud session a
7
+ * conversation runs in, reported by its worker after the first message
8
+ * (the per-provider native id, next to `claude_native_session_id`,
9
+ * `cursor_agent_id` and `grok_native_session_id`).
10
+ * 2. `runtime_sessions.claude_cloud_last_sequence` — the last event sequence
11
+ * the worker has handled: where it resumes the event stream after a restart.
12
+ * 3. `runtime_sessions.claude_cloud_cost_usd` — what the session has cost so
13
+ * far, as the cloud reports it.
14
+ * 4. `conversations.cloud_source_json` — what the session clones and where it
15
+ * is shown: `{ repoUrl, branch, environmentId, sessionUrl,
16
+ * pushedBranches: [{ branch, at }] }`.
17
+ *
18
+ * Additive and idempotent. Safe to run on every boot.
19
+ *
20
+ * Usage: node server/migrations/0007-claude-cloud.mjs [path/to/copilot.db]
21
+ */
22
+
23
+ import { fileURLToPath } from 'url';
24
+ import path from 'path';
25
+ import process from 'process';
26
+ import Database from 'better-sqlite3';
27
+
28
+ const COLUMNS = Object.freeze([
29
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_session_id', type: 'TEXT' }),
30
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_last_sequence', type: 'TEXT' }),
31
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_cost_usd', type: 'REAL' }),
32
+ Object.freeze({ table: 'conversations', name: 'cloud_source_json', type: 'TEXT' }),
33
+ ]);
34
+
35
+ function tableExists(db, name) {
36
+ return !!db
37
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
38
+ .get(name);
39
+ }
40
+
41
+ function columnNames(db, table) {
42
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
43
+ }
44
+
45
+ export function migrateClaudeCloud(db) {
46
+ let columnsAdded = 0;
47
+ for (const column of COLUMNS) {
48
+ if (!tableExists(db, column.table)) continue;
49
+ if (columnNames(db, column.table).has(column.name)) continue;
50
+ db.exec(`ALTER TABLE ${column.table} ADD COLUMN ${column.name} ${column.type}`);
51
+ columnsAdded += 1;
52
+ }
53
+ return { applied: columnsAdded > 0, columnsAdded };
54
+ }
55
+
56
+ function defaultDbPath() {
57
+ const here = path.dirname(fileURLToPath(import.meta.url));
58
+ return path.resolve(here, '..', 'data', 'copilot.db');
59
+ }
60
+
61
+ export function migrate(dbPath, options = {}) {
62
+ const db = new Database(dbPath, options);
63
+ try {
64
+ return migrateClaudeCloud(db);
65
+ } finally {
66
+ db.close();
67
+ }
68
+ }
69
+
70
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
71
+ if (isMain) {
72
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
73
+ try {
74
+ const result = migrate(dbPath);
75
+ console.log(`Migration 0007 on ${dbPath}`);
76
+ console.log(result.applied ? ` claude cloud columns added: ${result.columnsAdded}` : ' skipped: nothing to change');
77
+ process.exit(0);
78
+ } catch (err) {
79
+ console.error(`Migration 0007 failed: ${err?.message || err}`);
80
+ process.exit(1);
81
+ }
82
+ }