@oar-sh/oar 0.9.11 → 0.9.12

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/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -174,6 +174,10 @@ export function createQuestionRepository(db) {
174
174
  WHERE response_message_id = ?
175
175
  ORDER BY created_at ASC, id ASC
176
176
  `),
177
+ // Every card of a conversation, for the shared view's link redaction:
178
+ // a paged transcript hydrates only its page's cards, yet a reply on
179
+ // the page may quote the link of a card outside it.
180
+ listPreviewCardJsonByConversation: db.prepare(`SELECT preview_json FROM preview_cards WHERE conversation_id = ?`),
177
181
  deleteConvPreviewCards: db.prepare(`DELETE FROM preview_cards WHERE conversation_id = ?`),
178
182
 
179
183
  // relay boards
@@ -42,6 +42,14 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
42
42
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
43
43
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
44
44
  import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
45
+ import {
46
+ DEFAULT_TASK_STALL_MINUTES,
47
+ TASK_OUTPUT_LIMITS,
48
+ normalizeTaskOutputKind,
49
+ normalizeTaskOutputPath,
50
+ } from '../../shared/task-output-text.mjs';
51
+ import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../services/task-output-service.mjs';
52
+ import { taskKindOf } from '../services/session-activity-tracker.mjs';
45
53
  import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
46
54
  import { isLiveResend } from '../services/steer-resend.mjs';
47
55
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
@@ -1779,6 +1787,12 @@ export function registerMessagesRoutes(app, deps) {
1779
1787
  buildRelayReadyBannerData,
1780
1788
  backgroundTaskStore,
1781
1789
  backgroundWorkView = null,
1790
+ // The 8 KiB a worker posts for a task without an output file
1791
+ // (services/task-output-service.mjs createTaskOutputSnapshots).
1792
+ taskOutputSnapshots = null,
1793
+ // Has the health poller read soon (a task with a console ended).
1794
+ pokeTaskHealth = null,
1795
+ getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
1782
1796
  sendWorkerControl,
1783
1797
  ensureSessionId,
1784
1798
  touchCli,
@@ -5468,6 +5482,7 @@ export function registerMessagesRoutes(app, deps) {
5468
5482
  io.emit('background_tasks', {
5469
5483
  conversationId: requesterSessionId,
5470
5484
  tasks: backgroundWorkView.panelTasks(requesterSessionId, requesterConversationId),
5485
+ endedTasks: backgroundTaskStore?.ended?.(requesterSessionId) || [],
5471
5486
  });
5472
5487
  }
5473
5488
  }
@@ -7629,6 +7644,34 @@ export function registerMessagesRoutes(app, deps) {
7629
7644
  res.json({ ok: true, messageId, conversationId, attemptId });
7630
7645
  });
7631
7646
 
7647
+ // An integer from a worker's post, or undefined (dropped from the row).
7648
+ function wholeNumber(value, { min = Number.MIN_SAFE_INTEGER } = {}) {
7649
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') return undefined;
7650
+ const number = Number(value);
7651
+ return Number.isSafeInteger(number) && number >= min ? number : undefined;
7652
+ }
7653
+
7654
+ // A task row's console fields as the publish route keeps them.
7655
+ function taskOutputFields(task) {
7656
+ const outputPath = normalizeTaskOutputPath(task?.outputPath);
7657
+ const requestedKind = String(task?.outputKind || '').trim();
7658
+ if (!outputPath && !requestedKind) return {};
7659
+ const outputKind = normalizeTaskOutputKind(requestedKind, outputPath);
7660
+ // A file kind without a readable path has nothing to show.
7661
+ if ((outputKind === 'file' || outputKind === 'spool') && !outputPath) return { outputKind: 'none' };
7662
+ return { ...(outputPath ? { outputPath } : {}), outputKind };
7663
+ }
7664
+
7665
+ // `background_tasks` with the health readings on the live rows and the
7666
+ // ended rows beside them, for the key the worker posts under.
7667
+ function emitBackgroundTasks(key, tasks = backgroundTaskStore?.get?.(key) || []) {
7668
+ io.emit('background_tasks', {
7669
+ conversationId: key,
7670
+ tasks: backgroundTaskStore?.withHealth ? backgroundTaskStore.withHealth(tasks, key) : tasks,
7671
+ endedTasks: backgroundTaskStore?.ended?.(key) || [],
7672
+ });
7673
+ }
7674
+
7632
7675
  function resolveTaskConversationId(id) {
7633
7676
  const key = String(id || '').trim();
7634
7677
  if (!key) return '';
@@ -7648,14 +7691,34 @@ export function registerMessagesRoutes(app, deps) {
7648
7691
  const conversationId = resolveTaskConversationId(req.body?.conversationId);
7649
7692
  const taskId = String(req.body?.taskId || '').trim();
7650
7693
  if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
7651
- const exitCode = Number(req.body?.exitCode);
7694
+ // Only a number the worker sent: a missing or null exit code stays null
7695
+ // (Number(null) would read as a clean exit).
7696
+ const exitCode = wholeNumber(req.body?.exitCode);
7652
7697
  const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
7698
+ // The store is keyed as the worker posts. A task with a console stays
7699
+ // viewable as an ended row; one that ended while this runtime was away
7700
+ // becomes one when the post names its output.
7701
+ const settledOutputPath = normalizeTaskOutputPath(req.body?.outputPath);
7702
+ const endedRow = backgroundTaskStore?.settle?.(String(req.body?.conversationId || '').trim(), taskId, {
7703
+ status: req.body?.status,
7704
+ exitCode: exitCode ?? null,
7705
+ steps: wholeNumber(req.body?.steps, { min: 0 }),
7706
+ outputPath: settledOutputPath || undefined,
7707
+ outputKind: settledOutputPath ? normalizeTaskOutputKind(req.body?.outputKind, settledOutputPath) : undefined,
7708
+ description: settledDescription,
7709
+ taskType: String(req.body?.taskType || '').trim().slice(0, 80),
7710
+ startedAt: Number(req.body?.startedAt) || null,
7711
+ }) || null;
7712
+ if (endedRow) {
7713
+ emitBackgroundTasks(String(req.body?.conversationId || '').trim());
7714
+ pokeTaskHealth?.();
7715
+ }
7653
7716
  sessionActivityTracker?.noteTaskSettled?.(conversationId, {
7654
7717
  taskId,
7655
7718
  toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
7656
7719
  status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
7657
7720
  summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
7658
- exitCode: Number.isInteger(exitCode) ? exitCode : null,
7721
+ exitCode: exitCode ?? null,
7659
7722
  // What the task was, from a worker that sends it: the end of a task
7660
7723
  // this runtime never knew (it ended while the relay was away) is
7661
7724
  // still an event then.
@@ -7702,8 +7765,23 @@ export function registerMessagesRoutes(app, deps) {
7702
7765
  // The spawning tool_use id: the sub-agent run this task is. The panel
7703
7766
  // opens the run's viewer from it.
7704
7767
  toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
7768
+ // The console (shell and Monitor tasks): the file holding the
7769
+ // output, how it is obtained ('none': the provider has none to give).
7770
+ ...taskOutputFields(task),
7771
+ // Tool calls a sub-agent made so far.
7772
+ steps: wholeNumber(task?.steps, { min: 0 }),
7773
+ exitCode: wholeNumber(task?.exitCode),
7705
7774
  }))
7706
7775
  .filter((task) => task.taskId);
7776
+ // The first path a task names wins: a later post cannot point a live
7777
+ // task's console at another file.
7778
+ for (const task of tasks) {
7779
+ const held = (backgroundTaskStore?.get?.(conversationId) || []).find((entry) => entry?.taskId === task.taskId);
7780
+ if (held?.outputPath && task.outputPath !== held.outputPath) {
7781
+ task.outputPath = held.outputPath;
7782
+ task.outputKind = held.outputKind;
7783
+ }
7784
+ }
7707
7785
  const resync = req.body?.resync === true;
7708
7786
  // Posts can overtake each other on the way (an ordinary publish and the
7709
7787
  // answer to a resync, two publishes): a worker numbers its sets
@@ -7720,9 +7798,15 @@ export function registerMessagesRoutes(app, deps) {
7720
7798
  console.log(`[${typeof ts === 'function' ? ts() : new Date().toISOString()}] BG-TASKS set dropped (${setOrder.reason}) session=${conversationId.slice(0, 8)} epoch=${Number(req.body?.order?.epoch) || 0} seq=${Number(req.body?.order?.seq) || 0} tasks=${tasks.length}`);
7721
7799
  return res.json({ ok: true, count: tasks.length, stale: true, reason: setOrder.reason });
7722
7800
  }
7801
+ // A task with a console that leaves the set ended: its file is read once
7802
+ // more soon (its last output often lands after the poller's last look).
7803
+ const nextIds = new Set(tasks.map((task) => task.taskId));
7804
+ const endedWithConsole = (backgroundTaskStore?.get?.(conversationId) || [])
7805
+ .some((task) => task?.outputPath && !nextIds.has(task.taskId));
7723
7806
  backgroundTaskStore?.replace?.(conversationId, tasks);
7724
7807
  backgroundTaskStore?.announce?.(conversationId, false);
7725
- io.emit('background_tasks', { conversationId, tasks });
7808
+ if (endedWithConsole) pokeTaskHealth?.();
7809
+ emitBackgroundTasks(conversationId, tasks);
7726
7810
  // The store is keyed as the worker posts (its SDK session id); the
7727
7811
  // tracker by the conversation.
7728
7812
  try {
@@ -7769,6 +7853,168 @@ export function registerMessagesRoutes(app, deps) {
7769
7853
  res.json({ ok: true });
7770
7854
  });
7771
7855
 
7856
+ // ─── Task console ──────────────────────────────────────────────────────────
7857
+
7858
+ // The task a conversation's console asks for: in the live or the ended set
7859
+ // of the conversation, under its own id or its worker's session id (the
7860
+ // key the worker posts under), as the stop route resolves it.
7861
+ function findConversationTask(conversationId, taskId) {
7862
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
7863
+ if (!conversation) return { conversation: null, found: null, sessionId: '' };
7864
+ const sessionId = String(conversation.sdk_session_id || conversationId);
7865
+ const found = backgroundTaskStore?.findTask?.([conversationId, sessionId], taskId) || null;
7866
+ return { conversation, found, sessionId };
7867
+ }
7868
+
7869
+ // The providers whose workers name a shell task's output (a file, a spool
7870
+ // or a snapshot). A task of theirs without one comes from a worker that
7871
+ // started before the console existed.
7872
+ const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
7873
+ const SUBAGENT_OUTPUT_MESSAGE = 'Sub-agents have no shell output; open the sub-agent view.';
7874
+
7875
+ // Why a task has no output to show, for the console's `none` answer.
7876
+ function noOutputMessage(conversationId, task) {
7877
+ if (taskKindOf(task?.taskType) === 'subagent') return SUBAGENT_OUTPUT_MESSAGE;
7878
+ let provider = '';
7879
+ try {
7880
+ provider = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type || '').trim().toLowerCase();
7881
+ } catch {}
7882
+ if (!provider || TASK_OUTPUT_PROVIDERS.has(provider)) {
7883
+ return 'This session did not report an output file for this task. Sessions started before the task console need a restart to show output.';
7884
+ }
7885
+ return 'Output is not available for this provider.';
7886
+ }
7887
+
7888
+ // A task the session's task list shows that the task store does not hold:
7889
+ // a sub-agent (live or ended, its run is its record) or a task that ended
7890
+ // without a console. The activity tracker knows it by its task or run id.
7891
+ function trackedTask(conversationId, taskId) {
7892
+ let view = null;
7893
+ try { view = sessionActivityTracker?.describe?.(conversationId) || null; } catch {}
7894
+ const tasks = Array.isArray(view?.tasks) ? view.tasks : [];
7895
+ const found = tasks.find((task) => !task?.placeholder && (String(task?.id || '') === taskId || String(task?.runId || '') === taskId));
7896
+ if (found) return found;
7897
+ const run = stmts.getSubagentRun?.get?.(taskId) || null;
7898
+ if (run && String(run.conversation_id || '') === conversationId) {
7899
+ return { id: taskId, kind: 'subagent', type: run.subagent_type || null, description: run.display_name || '', status: run.status || null };
7900
+ }
7901
+ return null;
7902
+ }
7903
+
7904
+ function taskStallMinutes() {
7905
+ try {
7906
+ return getTaskStallMinutes();
7907
+ } catch {
7908
+ return DEFAULT_TASK_STALL_MINUTES;
7909
+ }
7910
+ }
7911
+
7912
+ // GET /api/conversation/:conversationId/background-task/:taskId/output —
7913
+ // the console's read of a shell or Monitor task's output. Without
7914
+ // `before`: the last `lines` lines (default 2,000, at most 5,000) within
7915
+ // 256 KB; with `before` (a startOffset of an earlier answer): the lines
7916
+ // ending there. Everything is cleaned (controls out, colours kept,
7917
+ // credentials scrubbed) for every reader. Another relay's agent reads it
7918
+ // too (remote_relay read_task_output, read permission): no inbound switch,
7919
+ // nothing it reads changes anything.
7920
+ app.get('/api/conversation/:conversationId/background-task/:taskId/output', auth, async (req, res) => {
7921
+ const conversationId = String(req.params.conversationId || '').trim();
7922
+ const taskId = String(req.params.taskId || '').trim();
7923
+ if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
7924
+ const { conversation, found, sessionId } = findConversationTask(conversationId, taskId);
7925
+ if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
7926
+ if (!found) {
7927
+ const tracked = trackedTask(conversationId, taskId);
7928
+ if (!tracked) return res.status(404).json({ error: 'Task not found', code: 'task-not-found' });
7929
+ const running = String(tracked.status || 'running') === 'running';
7930
+ const subagent = tracked.kind !== 'shell';
7931
+ return res.json({
7932
+ ok: true,
7933
+ taskId,
7934
+ kind: 'none',
7935
+ ended: !running,
7936
+ status: running ? 'running' : String(tracked.status || 'unknown'),
7937
+ exitCode: running ? null : (Number.isInteger(tracked.exitCode) ? tracked.exitCode : null),
7938
+ health: null,
7939
+ taskStallMinutes: taskStallMinutes(),
7940
+ task: {
7941
+ taskId,
7942
+ taskType: tracked.type || (subagent ? 'local_agent' : 'local_bash'),
7943
+ description: String(tracked.description || ''),
7944
+ startedAt: Date.parse(tracked.startedAt) || null,
7945
+ endedAt: Date.parse(tracked.endedAt) || null,
7946
+ stoppable: false,
7947
+ },
7948
+ message: subagent ? SUBAGENT_OUTPUT_MESSAGE : noOutputMessage(conversationId, { taskType: 'local_bash' }),
7949
+ });
7950
+ }
7951
+ const { task, ended, key } = found;
7952
+ const { kind, path } = taskOutputSource(task);
7953
+ const head = {
7954
+ ok: true,
7955
+ taskId,
7956
+ kind,
7957
+ ended,
7958
+ status: ended ? task.status : 'running',
7959
+ exitCode: ended ? (task.exitCode ?? null) : null,
7960
+ health: task.health || null,
7961
+ taskStallMinutes: taskStallMinutes(),
7962
+ task: {
7963
+ taskId,
7964
+ taskType: task.taskType || null,
7965
+ description: task.description || '',
7966
+ startedAt: task.startedAt || null,
7967
+ endedAt: task.endedAt || null,
7968
+ stoppable: !ended && task.stoppable !== false,
7969
+ },
7970
+ };
7971
+ if (kind === 'none') {
7972
+ return res.json({ ...head, message: noOutputMessage(conversationId, task) });
7973
+ }
7974
+ if (kind === 'snapshot') {
7975
+ // The worker answers a request with a post of the task's last 8 KiB;
7976
+ // the console asks again every few seconds and gets the newest copy.
7977
+ const cached = taskOutputSnapshots?.get?.([key, sessionId, conversationId], taskId) || null;
7978
+ if (!ended) {
7979
+ const sent = sendWorkerControl?.(sessionId, { type: 'task_output_snapshot', taskId });
7980
+ if (!sent && !cached) return res.status(409).json({ error: 'Session worker is not connected', code: 'worker-not-connected' });
7981
+ }
7982
+ if (!cached) return res.json({ ...head, pending: true, text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false });
7983
+ return res.json({ ...head, ...shapeSnapshot(cached) });
7984
+ }
7985
+ if (!path) return res.json({ ...head, kind: 'none', message: 'Output is not available for this task.' });
7986
+ const lines = req.query?.lines;
7987
+ const before = req.query?.before;
7988
+ const read = before !== undefined && before !== ''
7989
+ ? await readBefore(path, { before, lines })
7990
+ : await readTail(path, { lines });
7991
+ if (!read.ok) {
7992
+ // No file yet (a spool is named before its first byte) reads as no
7993
+ // output yet; one the relay does not read (a symlink, a folder) is
7994
+ // said so instead of failing.
7995
+ const empty = { text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false };
7996
+ return res.json({ ...head, ...empty, ...(read.code === 'missing' ? {} : { unavailable: read.code }) });
7997
+ }
7998
+ return res.json({ ...head, ...read });
7999
+ });
8000
+
8001
+ // POST /api/background-tasks/:taskId/output-snapshot — a worker's answer
8002
+ // to `task_output_snapshot`: the task's latest output where it has no
8003
+ // file (Claude's getTaskOutput, its last 8 KiB).
8004
+ app.post('/api/background-tasks/:taskId/output-snapshot', auth, (req, res) => {
8005
+ touchCli();
8006
+ const key = String(req.body?.conversationId || '').trim();
8007
+ const taskId = String(req.params.taskId || '').trim();
8008
+ if (!key || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
8009
+ if (typeof req.body?.output !== 'string') return res.status(400).json({ error: 'output must be a string' });
8010
+ taskOutputSnapshots?.set?.(key, taskId, {
8011
+ output: req.body.output.slice(0, TASK_OUTPUT_LIMITS.chunkBytes),
8012
+ totalBytes: req.body?.totalBytes,
8013
+ truncated: req.body?.truncated === true,
8014
+ });
8015
+ res.json({ ok: true });
8016
+ });
8017
+
7772
8018
  // POST /api/activity — relay sends in-flight activity updates (tool/search sections)
7773
8019
  // Optional attempt fence for the live lanes (stream/thought/activity/
7774
8020
  // subagent): a writer that names its processing attempt is rejected once