@oar-sh/oar 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -228,7 +228,11 @@ function createUsageAccumulator() {
228
228
  * `{ channel, payload }` actions; the session process dispatches them to the
229
229
  * relay and treats the first `result` action as the turn's terminator.
230
230
  */
231
- export function createCopilotEventNormalizer() {
231
+ export function createCopilotEventNormalizer({
232
+ // The runner's answer to "is this agent id a background agent?" — shared
233
+ // across turns, unlike this normalizer's own `backgroundRuns`.
234
+ isBackgroundAgent = () => false,
235
+ } = {}) {
232
236
  let sessionModel = '';
233
237
  let sessionId = '';
234
238
  let lastEmittedStreamText = '';
@@ -306,6 +310,13 @@ export function createCopilotEventNormalizer() {
306
310
  // runtime tags only one of them.
307
311
  const subagentRuns = new Map(); // runId -> { displayName, status, opened }
308
312
  const subagentAliases = new Map(); // agentId|toolCallId -> runId
313
+ // Runs spawned with `executionMode: "background"`: task-panel cards, not
314
+ // lanes. This normalizer lives for ONE turn while a background agent
315
+ // outlives it, so the runner's knowledge (`isBackgroundAgent`) is consulted
316
+ // too — a later turn must not open a lane for an agent spawned earlier.
317
+ const backgroundRuns = new Set();
318
+ const isBackgroundRun = (runId, agentId) => backgroundRuns.has(runId)
319
+ || (!!agentId && isBackgroundAgent(agentId) === true);
309
320
 
310
321
  /**
311
322
  * `useToolCallId` is true only for `subagent.*` lifecycle events, where
@@ -432,6 +443,17 @@ export function createCopilotEventNormalizer() {
432
443
  return { channel: 'activity', payload: { text, subagentRunId } };
433
444
  }
434
445
 
446
+ /**
447
+ * A line that only tracks background work: the main agent polling its
448
+ * background agent (`read_agent`), or a permission granted for a tool call
449
+ * (the call's own line, when it is the main agent's, is the real work). It
450
+ * still shows on the row; it just does not make a runtime-opened row worth
451
+ * keeping on its own (the task card covers the background agent).
452
+ */
453
+ function bookkeepingActivityAction(text) {
454
+ return { ...activityAction(text), bookkeeping: true };
455
+ }
456
+
435
457
  /**
436
458
  * Build the single terminal `result` action for this turn. Guarded so a
437
459
  * `session.error` arriving after a `session.idle` (or a second error) cannot
@@ -498,23 +520,43 @@ export function createCopilotEventNormalizer() {
498
520
  case 'subagent.started': {
499
521
  const runId = resolveSubagentRun(event, { useToolCallId: true });
500
522
  if (!runId) return [];
523
+ // A BACKGROUND agent (`executionMode: "background"`) is not a lane in
524
+ // this reply: it outlives the turn and shows in the task panel as a
525
+ // stoppable card (the runner reads `rpc.tasks`), and its completion
526
+ // arrives as a continuation. Opening a lane for it would leave a
527
+ // bubble spinning after the reply settled — or, if closed at the
528
+ // terminal, would mark a running agent failed. A one-line note keeps
529
+ // the transcript honest; its tagged events are dropped below.
530
+ if (String(data.executionMode || '').trim().toLowerCase() === 'background') {
531
+ backgroundRuns.add(runId);
532
+ const name = subagentDisplayName(data);
533
+ const agentType = String(data.agentType || '').trim();
534
+ return [activityAction(truncate(
535
+ `Started background agent: ${name}${agentType && agentType !== name ? ` (${agentType})` : ''} — see the task panel`,
536
+ ))];
537
+ }
501
538
  return ensureSubagentRun(runId, subagentDisplayName(data));
502
539
  }
503
540
  case 'subagent.configured':
504
541
  case 'subagent.selected': {
505
- // `subagent.configured` is UNDOCUMENTED — it appears in no published
506
- // schema and a live probe found it between started and completed,
507
- // carrying `agentId` but neither `toolCallId` nor a display name. It is
508
- // handled rather than ignored so the lane still opens if it ever
509
- // arrives first, and it must never clobber the roster's display name
510
- // with its own absent one (hence the `||` in ensureSubagentRun).
542
+ // `subagent.configured` (since documented: `{model, multiTurn, …}`)
543
+ // arrives between started and completed, carrying `agentId` but
544
+ // neither `toolCallId` nor a display name. It is handled rather than
545
+ // ignored so the lane still opens if it ever arrives first, and it
546
+ // must never clobber the roster's display name with its own absent
547
+ // one (hence the `||` in ensureSubagentRun). A background agent's
548
+ // arrives too, and must not open the lane `subagent.started` withheld.
511
549
  const runId = resolveSubagentRun(event, { useToolCallId: true });
512
550
  if (!runId) return [];
551
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) return [];
513
552
  return ensureSubagentRun(runId, subagentDisplayName(data));
514
553
  }
515
554
  case 'subagent.completed': {
516
555
  const runId = resolveSubagentRun(event, { useToolCallId: true });
517
556
  if (!runId) return [];
557
+ // A background agent finishing inside a later turn is the task panel's
558
+ // business (the runner watches the same event); no lane to close.
559
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) return [];
518
560
  const actions = closeSubagentRun(runId, 'completed', subagentDisplayName(data));
519
561
  const stats = formatSubagentStats(data);
520
562
  if (stats) actions.push(activityAction(truncate(`Finished · ${stats}`), runId));
@@ -525,6 +567,11 @@ export function createCopilotEventNormalizer() {
525
567
  if (!runId) return [];
526
568
  const name = subagentDisplayName(data);
527
569
  const reason = String(data.error || '').trim() || 'unknown error';
570
+ // A background agent's failure still deserves a main-thread line (the
571
+ // parent agent recovers silently), but there is no lane to close.
572
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) {
573
+ return [activityAction(truncate(`Background agent failed (${name}): ${reason}`))];
574
+ }
528
575
  const actions = closeSubagentRun(runId, 'failed', name);
529
576
  actions.push(activityAction(truncate(`Subagent failed: ${reason}`), runId));
530
577
  // ALSO on the main thread. The parent agent recovers from a failed
@@ -551,6 +598,14 @@ export function createCopilotEventNormalizer() {
551
598
  if (isSubagentEvent(event)) {
552
599
  const runId = resolveSubagentRun(event);
553
600
  if (!runId) return [];
601
+ // A background agent's traffic belongs to its task card (the runner
602
+ // tracks its live tool call and tokens from the same events), not to a
603
+ // lane in this reply — but its model calls still cost real money, so
604
+ // they count toward the spend of whichever turn is live, as before.
605
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) {
606
+ if (type === 'assistant.usage') usage.add(data, { allowModel: false });
607
+ return [];
608
+ }
554
609
  if (type === 'assistant.message') {
555
610
  const content = String(data.content || '');
556
611
  if (!content.trim()) return [];
@@ -718,7 +773,8 @@ export function createCopilotEventNormalizer() {
718
773
  lastEmittedStreamText = text;
719
774
  return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
720
775
  }
721
- return [activityAction(formatToolActivityText(toolName, data.arguments))];
776
+ const toolLine = formatToolActivityText(toolName, data.arguments);
777
+ return [toolName === 'read_agent' ? bookkeepingActivityAction(toolLine) : activityAction(toolLine)];
722
778
  }
723
779
  case 'tool.execution_complete': {
724
780
  // There is no `tool.*_failed` event type — failure is `success: false`
@@ -743,9 +799,9 @@ export function createCopilotEventNormalizer() {
743
799
  const summary = permissionRequests.get(requestId) || '';
744
800
  permissionRequests.delete(requestId);
745
801
  const kind = String(data.result?.kind || 'unknown').trim();
746
- return [activityAction(truncate(summary
747
- ? `Permission ${kind}: ${summary}`
748
- : `Permission ${kind}`))];
802
+ const line = truncate(summary ? `Permission ${kind}: ${summary}` : `Permission ${kind}`);
803
+ // A denial is news; a grant is bookkeeping for the call it unblocked.
804
+ return [kind.startsWith('approved') ? bookkeepingActivityAction(line) : activityAction(line)];
749
805
  }
750
806
  case 'user_input.requested': {
751
807
  // The question is forwarded to the relay as a question card; this row
@@ -791,7 +847,40 @@ export function createCopilotEventNormalizer() {
791
847
  lastEmittedStreamText = text;
792
848
  return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
793
849
  }
794
- // ---- the one true terminator -----------------------------------------
850
+ // ---- compaction ------------------------------------------------------
851
+ //
852
+ // Surfaced as activity lines so a long pause reads as what it is. The
853
+ // runner ALSO watches these two events directly for its steering hold
854
+ // (a message must not be pushed into a compaction's replay).
855
+ case 'session.compaction_start': {
856
+ return [activityAction('Compacting the conversation context…')];
857
+ }
858
+ case 'session.compaction_complete': {
859
+ if (data.success === false) {
860
+ const reason = String(data.error || '').trim();
861
+ return [activityAction(truncate(reason ? `Context compaction failed: ${reason}` : 'Context compaction failed'))];
862
+ }
863
+ const removed = Number(data.messagesRemoved);
864
+ const tokens = Number(data.tokensRemoved);
865
+ const parts = [];
866
+ if (Number.isFinite(removed) && removed > 0) parts.push(`${removed} messages`);
867
+ if (Number.isFinite(tokens) && tokens > 0) parts.push(`${Math.round(tokens / 1000)}k tokens`);
868
+ return [activityAction(parts.length ? `Context compacted (removed ${parts.join(', ')})` : 'Context compacted')];
869
+ }
870
+ // ---- the terminators ---------------------------------------------------
871
+ //
872
+ // `assistant.idle` is "the main agent's processing loop went idle" and
873
+ // fires even while background agents / attached shells keep the
874
+ // session-level idle deferred (runtime 1.0.88, live-probed 2026-09-25).
875
+ // It is the terminator that lets a row settle while a background agent
876
+ // the turn spawned keeps running under the task panel — exactly the
877
+ // Claude worker's lingering model. `session.idle` stays the hard
878
+ // terminator for runtimes that do not emit the assistant-level one and
879
+ // for the drain's end; `terminalEmitted` makes the second a no-op.
880
+ case 'assistant.idle': {
881
+ if (data.aborted === true) aborted = true;
882
+ return terminalAction({ subtype: aborted ? 'aborted' : 'completed' });
883
+ }
795
884
  case 'session.idle': {
796
885
  if (data.aborted === true) aborted = true;
797
886
  return terminalAction({ subtype: aborted ? 'aborted' : 'completed' });