@oar-sh/oar 0.9.12 → 0.9.13

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 (62) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/README.md +186 -11
  5. package/server/claude-worker/claude-session-process.mjs +53 -2
  6. package/server/claude-worker/task-output-discovery.mjs +28 -2
  7. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
  9. package/server/db-schema.mjs +10 -0
  10. package/server/grok-worker/acp-host-services.mjs +6 -3
  11. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  12. package/server/migrations/0018-background-task-rows.mjs +101 -0
  13. package/server/public/app/action-confirmations.js +12 -15
  14. package/server/public/app/api-client.js +57 -3
  15. package/server/public/app/background-tasks-view.mjs +56 -7
  16. package/server/public/app/bootstrap.js +7 -0
  17. package/server/public/app/conversation-preferences.mjs +10 -4
  18. package/server/public/app/conversation-view.js +33 -9
  19. package/server/public/app/host-suspend-ui.js +45 -44
  20. package/server/public/app/journal-view.js +9 -3
  21. package/server/public/app/push-settings.js +1 -0
  22. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  23. package/server/public/app/relay-restart-note-view.mjs +36 -0
  24. package/server/public/app/relay-restart-ui.js +568 -0
  25. package/server/public/app/settings-modal.js +52 -0
  26. package/server/public/app/socket-handlers.js +1 -1
  27. package/server/public/app/task-console-render.mjs +171 -7
  28. package/server/public/app/task-console.mjs +111 -11
  29. package/server/public/app/task-output-text.mjs +15 -6
  30. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  31. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  32. package/server/public/index.html +67 -0
  33. package/server/relay-tools.md +5 -0
  34. package/server/repositories/message-repository.mjs +3 -0
  35. package/server/repositories/remote-relay-repository.mjs +4 -2
  36. package/server/routes/messages-routes.mjs +155 -27
  37. package/server/routes/relay-board-routes.mjs +19 -2
  38. package/server/routes/relay-restart-routes.mjs +73 -0
  39. package/server/routes/sessions-routes.mjs +75 -3
  40. package/server/server-runtime.mjs +192 -137
  41. package/server/services/about-service.mjs +13 -12
  42. package/server/services/background-task-persistence.mjs +133 -0
  43. package/server/services/background-task-store.mjs +240 -13
  44. package/server/services/conversation-model-fallback.mjs +57 -0
  45. package/server/services/host-suspend-command.mjs +2 -1
  46. package/server/services/preview-proxy-server.mjs +14 -1
  47. package/server/services/process-list-service.mjs +2 -1
  48. package/server/services/provider-model-selection.mjs +195 -0
  49. package/server/services/push-dispatch-service.mjs +9 -2
  50. package/server/services/relay-event-service.mjs +27 -9
  51. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  52. package/server/services/relay-restart-picture.mjs +209 -0
  53. package/server/services/relay-restart-queue.mjs +380 -0
  54. package/server/services/relay-shutdown-gate.mjs +4 -1
  55. package/server/services/remote-relay-dispatcher.mjs +187 -19
  56. package/server/services/restart-resume-service.mjs +303 -0
  57. package/server/services/session-worker-process-service.mjs +6 -4
  58. package/server/services/task-output-service.mjs +217 -18
  59. package/shared/relay-events.mjs +55 -2
  60. package/shared/remote-relay-contract.mjs +15 -8
  61. package/shared/task-output-text.mjs +196 -83
  62. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -60,6 +60,7 @@ import {
60
60
  import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
61
61
  import {
62
62
  discoverTaskOutputPaths,
63
+ shellCommandsOf,
63
64
  exitCodeFromSummary,
64
65
  isShellTaskType,
65
66
  outputDirOf,
@@ -1783,8 +1784,10 @@ export function createClaudeSessionRunner({
1783
1784
  // sanitizer whitelists and re-clamps it before storing.
1784
1785
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1785
1786
  ...(agentLike && Number.isInteger(task.steps) ? { steps: task.steps } : {}),
1786
- // Where the relay reads the task's console from.
1787
+ // Where the relay reads the task's console from, and the command
1788
+ // line the console reads a `| tail` off.
1787
1789
  ...(isShellTaskType(task.taskType) ? taskOutputFields(processRef, taskId) : {}),
1790
+ ...(isShellTaskType(task.taskType) && taskCommand(processRef, taskId, task) ? { command: taskCommand(processRef, taskId, task) } : {}),
1788
1791
  };
1789
1792
  });
1790
1793
  return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
@@ -1846,12 +1849,49 @@ export function createClaudeSessionRunner({
1846
1849
  /** A backgrounded Bash/Monitor result: note its file, and show it on the live row. */
1847
1850
  function recordTaskOutputPaths(sdkMessage) {
1848
1851
  let liveChanged = false;
1849
- for (const { taskId, outputPath } of discoverTaskOutputPaths(sdkMessage)) {
1852
+ for (const { taskId, outputPath, toolUseId } of discoverTaskOutputPaths(sdkMessage)) {
1853
+ if (toolUseId && !proc.taskToolUses.has(taskId)) {
1854
+ proc.taskToolUses.set(taskId, toolUseId);
1855
+ capMap(proc.taskToolUses, MAX_TASK_OUTPUT_PATHS);
1856
+ if (proc.liveTasks.has(taskId) && proc.shellCommands.has(toolUseId)) liveChanged = true;
1857
+ }
1850
1858
  if (rememberTaskOutputPath(proc, taskId, outputPath) && proc.liveTasks.has(taskId)) liveChanged = true;
1851
1859
  }
1852
1860
  if (liveChanged) publishBackgroundTasks();
1853
1861
  }
1854
1862
 
1863
+ function capMap(map, max) {
1864
+ while (map.size > max) map.delete(map.keys().next().value);
1865
+ }
1866
+
1867
+ // The command line of each Bash or Monitor call, by its tool_use id: a
1868
+ // shell task's row names it (task_started's tool_use_id, or the call whose
1869
+ // result announced the task's file).
1870
+ function recordShellCommands(sdkMessage) {
1871
+ for (const { toolUseId, command } of shellCommandsOf(sdkMessage)) {
1872
+ proc.shellCommands.set(toolUseId, command);
1873
+ capMap(proc.shellCommands, MAX_TASK_OUTPUT_PATHS);
1874
+ }
1875
+ }
1876
+
1877
+ // A task's command, pinned to its id once found: the 200 newest calls
1878
+ // the map by tool_use id keeps may move past a long-running task's call.
1879
+ function taskCommand(processRef, taskId, task) {
1880
+ const pinned = processRef.taskCommands.get(taskId);
1881
+ if (pinned) return pinned;
1882
+ const toolUseId = task?.toolUseId || processRef.taskToolUses.get(taskId) || '';
1883
+ const command = (toolUseId && processRef.shellCommands.get(toolUseId)) || '';
1884
+ if (command && (processRef.liveTasks.has(taskId) || processRef.departedTasks.has(taskId))) processRef.taskCommands.set(taskId, command);
1885
+ return command;
1886
+ }
1887
+
1888
+ // Pinned commands go with their task: live or departed until its settle.
1889
+ function prunePinnedCommands(processRef) {
1890
+ for (const taskId of [...processRef.taskCommands.keys()]) {
1891
+ if (!processRef.liveTasks.has(taskId) && !processRef.departedTasks.has(taskId)) processRef.taskCommands.delete(taskId);
1892
+ }
1893
+ }
1894
+
1855
1895
  /**
1856
1896
  * The relay's fallback for a shell task with no file: the CLI's last 8 KiB
1857
1897
  * (`Query.getTaskOutput`, implemented in sdk.mjs though sdk.d.ts does not
@@ -2321,6 +2361,7 @@ export function createClaudeSessionRunner({
2321
2361
  function observeProcessLevel(sdkMessage) {
2322
2362
  const type = String(sdkMessage?.type || '');
2323
2363
  if (type === 'assistant') {
2364
+ recordShellCommands(sdkMessage);
2324
2365
  recordSubagentSpawns(sdkMessage);
2325
2366
  recordSubagentActivity(sdkMessage);
2326
2367
  return;
@@ -2596,6 +2637,7 @@ export function createClaudeSessionRunner({
2596
2637
  }
2597
2638
  syncWorkflowPoller(proc);
2598
2639
  publishBackgroundTasks();
2640
+ prunePinnedCommands(proc);
2599
2641
  evaluateLifecycle();
2600
2642
  return;
2601
2643
  }
@@ -2655,6 +2697,8 @@ export function createClaudeSessionRunner({
2655
2697
  rememberTaskOutputPath(proc, taskId, sdkMessage.output_file);
2656
2698
  const shellLike = isShellTaskType(published?.taskType);
2657
2699
  const outputPath = shellLike ? (proc.taskOutputPaths.get(taskId) || '') : '';
2700
+ const command = shellLike ? taskCommand(proc, taskId, { toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() }) : '';
2701
+ proc.taskCommands.delete(taskId);
2658
2702
  const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
2659
2703
  const toolUses = Number(sdkMessage.usage?.tool_uses);
2660
2704
  const steps = AGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
@@ -2673,6 +2717,7 @@ export function createClaudeSessionRunner({
2673
2717
  startedAt: published.startedAt || null,
2674
2718
  } : {}),
2675
2719
  ...(outputPath ? { outputPath } : {}),
2720
+ ...(command ? { command } : {}),
2676
2721
  ...(exitCode !== null ? { exitCode } : {}),
2677
2722
  ...(steps !== null ? { steps } : {}),
2678
2723
  });
@@ -3517,6 +3562,12 @@ export function createClaudeSessionRunner({
3517
3562
  // next to it.
3518
3563
  taskOutputPaths: new Map(),
3519
3564
  taskOutputDir: '',
3565
+ // tool_use id → the command line of a Bash or Monitor call, and taskId
3566
+ // → the call whose result announced the task: a shell row's command.
3567
+ shellCommands: new Map(),
3568
+ taskToolUses: new Map(),
3569
+ // taskId → its command, pinned while the task is live or departed.
3570
+ taskCommands: new Map(),
3520
3571
  // taskId → { runId, labels, digestJson } for live local_workflow tasks;
3521
3572
  // pruned by syncWorkflowPoller when a task leaves the live set.
3522
3573
  workflowStates: new Map(),
@@ -13,6 +13,13 @@ import nodePath from 'node:path';
13
13
  /** Task types whose output is a shell's stdout/stderr (getTaskOutput serves these too). */
14
14
  export const SHELL_TASK_TYPES = new Set(['local_bash', 'monitor', 'monitor_mcp', 'monitor_ws']);
15
15
  export const MAX_OUTPUT_PATH_CHARS = 1024;
16
+ // The command line a shell row carries. The relay scrubs it, then cuts it
17
+ // to 2,000 characters: a cut here first could split a credential it would
18
+ // no longer recognise, so this one is only a bound on the post.
19
+ export const MAX_TASK_COMMAND_CHARS = 8000;
20
+ // The tools whose call starts a shell task: their input's `command` is the
21
+ // task's command line (the console marks a `| tail` from it).
22
+ const SHELL_TOOL_NAMES = new Set(['Bash', 'Monitor']);
16
23
 
17
24
  const OUTPUT_MARKER = 'Output is being written to';
18
25
  // The CLI's own file name: a path with spaces (a Windows profile dir) still
@@ -89,9 +96,28 @@ function isBackgroundNote(text) {
89
96
  return Boolean(mcp && taskIdFromOutputFile(mcp[1]));
90
97
  }
91
98
 
99
+ /**
100
+ * `{ toolUseId, command }` of each Bash or Monitor call in an `assistant` SDK
101
+ * message: the command line of the task such a call starts in the
102
+ * background, cut at 8,000 characters (the relay cuts after scrubbing).
103
+ */
104
+ export function shellCommandsOf(sdkMessage) {
105
+ if (String(sdkMessage?.type || '') !== 'assistant') return [];
106
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
107
+ const found = [];
108
+ for (const block of blocks) {
109
+ if (block?.type !== 'tool_use' || !SHELL_TOOL_NAMES.has(String(block.name || ''))) continue;
110
+ const toolUseId = String(block.id || '').trim();
111
+ const command = typeof block.input?.command === 'string' ? block.input.command.trim().slice(0, MAX_TASK_COMMAND_CHARS) : '';
112
+ if (toolUseId && command) found.push({ toolUseId, command });
113
+ }
114
+ return found;
115
+ }
116
+
92
117
  /**
93
118
  * Every task output file a `user` SDK message announces: the tool_result
94
- * blocks of backgrounded Bash (and Monitor) calls. The structured result's
119
+ * blocks of backgrounded Bash (and Monitor) calls, with the id of the call
120
+ * (`toolUseId`, '' when the block names none). The structured result's
95
121
  * `backgroundTaskId` names the task when the message answers a single call.
96
122
  */
97
123
  export function discoverTaskOutputPaths(sdkMessage) {
@@ -108,7 +134,7 @@ export function discoverTaskOutputPaths(sdkMessage) {
108
134
  const text = toolResultText(block);
109
135
  if (!structuredTaskId && !isBackgroundNote(text)) continue;
110
136
  const announced = parseOutputAnnouncement(text, structuredTaskId);
111
- if (announced) found.push(announced);
137
+ if (announced) found.push({ ...announced, toolUseId: String(block.tool_use_id || '').trim() });
112
138
  }
113
139
  return found;
114
140
  }
@@ -249,9 +249,13 @@ export function createReplayGate() {
249
249
  * `observe(event)` returns what the event changed, so the caller can pin the
250
250
  * runtime's lifecycle and note that a continuation is due without re-parsing.
251
251
  */
252
+ // A shell row's command line: a bound on the post. The relay scrubs it, then
253
+ // cuts it to 2,000 characters.
254
+ const SHELL_COMMAND_CHARS_MAX = 8000;
255
+
252
256
  export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
253
- const shells = new Map(); // shellId -> { shellId, description, startedAt }
254
- const toolCalls = new Map(); // toolCallId -> { toolName, shellId, description }
257
+ const shells = new Map(); // shellId -> { shellId, description, command, startedAt }
258
+ const toolCalls = new Map(); // toolCallId -> { toolName, shellId, description, command }
255
259
 
256
260
  function rememberToolCall(toolCallId, entry) {
257
261
  if (!toolCallId) return;
@@ -264,10 +268,10 @@ export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
264
268
  }
265
269
  }
266
270
 
267
- function open(shellId, description) {
271
+ function open(shellId, description, command = '') {
268
272
  const id = normalizeShellId(shellId);
269
273
  if (!id || shells.has(id)) return null;
270
- const shell = { shellId: id, description: String(description || '').trim(), startedAt: now() };
274
+ const shell = { shellId: id, description: String(description || '').trim(), command: String(command || '').trim(), startedAt: now() };
271
275
  shells.set(id, shell);
272
276
  return shell;
273
277
  }
@@ -308,6 +312,8 @@ export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
308
312
  toolName,
309
313
  shellId: normalizeShellId(args.shellId),
310
314
  description: String(args.description || '').trim(),
315
+ // The shell tool's command line, for the row.
316
+ command: typeof args.command === 'string' ? args.command.trim().slice(0, SHELL_COMMAND_CHARS_MAX) : '',
311
317
  });
312
318
  return { opened, settled, heralded };
313
319
  }
@@ -337,7 +343,7 @@ export function createBackgroundShellTracker({ now = () => Date.now() } = {}) {
337
343
 
338
344
  const started = SHELL_OPENED_RE.exec(text);
339
345
  if (started && data.success !== false) {
340
- const shell = open(started[1], call?.description);
346
+ const shell = open(started[1], call?.description, call?.command);
341
347
  if (shell) opened.push(shell);
342
348
  return { opened, settled, heralded };
343
349
  }
@@ -1567,6 +1567,7 @@ export function createCopilotSdkSessionRunner({
1567
1567
  taskId: shell.shellId,
1568
1568
  taskType: 'local_bash',
1569
1569
  description: shell.description || 'Detached shell',
1570
+ ...(shell.command ? { command: shell.command } : {}),
1570
1571
  startedAt: shell.startedAt || null,
1571
1572
  stoppable: false,
1572
1573
  }));
@@ -1587,6 +1588,7 @@ export function createCopilotSdkSessionRunner({
1587
1588
  taskId: task.id,
1588
1589
  taskType: 'local_bash',
1589
1590
  description: task.description || task.command || 'Background shell',
1591
+ ...shellCommandField(task),
1590
1592
  startedAt,
1591
1593
  stoppable: true,
1592
1594
  ...(task.progressSummary ? { summary: task.progressSummary } : {}),
@@ -1616,6 +1618,12 @@ export function createCopilotSdkSessionRunner({
1616
1618
  return cards;
1617
1619
  }
1618
1620
 
1621
+ /** A registry shell's command line, bounded (the relay scrubs, then cuts it to 2,000). */
1622
+ function shellCommandField(task) {
1623
+ const command = typeof task?.command === 'string' ? task.command.trim().slice(0, 8000) : '';
1624
+ return command ? { command } : {};
1625
+ }
1626
+
1619
1627
  /** Where the task console reads a shell's output: its own log, or the spool mirror. */
1620
1628
  function shellOutputFields(task) {
1621
1629
  const logPath = String(task.logPath || '').trim();
@@ -1869,6 +1877,7 @@ export function createCopilotSdkSessionRunner({
1869
1877
  postTaskSettle(task.id, {
1870
1878
  ...settleForTask(finished, { exitCode: shell ? (shellExitCodes.get(task.id) ?? null) : null }),
1871
1879
  description: task.description || task.displayName || task.command || (shell ? 'Background shell' : 'Background agent'),
1880
+ ...(shell ? shellCommandField(task) : {}),
1872
1881
  taskType: shell ? 'local_bash' : 'local_agent',
1873
1882
  startedAt: Date.parse(String(task.startedAt || '')) || null,
1874
1883
  ...(task.toolCallId ? { toolUseId: String(task.toolCallId) } : {}),
@@ -2135,6 +2144,7 @@ export function createCopilotSdkSessionRunner({
2135
2144
  postTaskSettle(shell.shellId, {
2136
2145
  ...settleForTask({ type: 'shell', status: '' }, { exitCode: shellExitCodes.get(shell.shellId) ?? null }),
2137
2146
  description: shell.description || 'Detached shell',
2147
+ ...(shell.command ? { command: shell.command } : {}),
2138
2148
  taskType: 'local_bash',
2139
2149
  startedAt: shell.startedAt || null,
2140
2150
  });
@@ -23,6 +23,7 @@ import { migrateConversationGroups } from './migrations/0014-conversation-groups
23
23
  import { migrateConversationGroupPosition } from './migrations/0015-conversation-group-position.mjs';
24
24
  import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
25
25
  import { migrateConversationHistoryIndexes } from './migrations/0017-conversation-history-indexes.mjs';
26
+ import { migrateBackgroundTaskRows } from './migrations/0018-background-task-rows.mjs';
26
27
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
27
28
 
28
29
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -845,6 +846,15 @@ try {
845
846
  console.warn(`[history] migration 0017 failed; retrying on next boot: ${error?.message || error}`);
846
847
  }
847
848
 
849
+ // The task console's rows across a restart: one new table. The task store
850
+ // checks for it and keeps its rows in memory only without it, so a failure is
851
+ // logged and retried next boot like 0017.
852
+ try {
853
+ migrateBackgroundTaskRows(db);
854
+ } catch (error) {
855
+ console.warn(`[background-tasks] migration 0018 failed; retrying on next boot: ${error?.message || error}`);
856
+ }
857
+
848
858
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
849
859
  if (runtimeSessionColumns.length) {
850
860
  if (!runtimeSessionColumns.includes('strategy')) {
@@ -37,6 +37,9 @@ export function createAcpHostServices({
37
37
  platform = process.platform,
38
38
  spawnImpl = spawn,
39
39
  spawnSyncImpl = spawnSync,
40
+ // Full path of a program Windows ships (taskkill, Windows PowerShell), or
41
+ // its bare name when it is not where Windows keeps it.
42
+ systemToolPath = windowsSystemToolPath,
40
43
  fsImpl = fs,
41
44
  // Told about every terminal's life (grok-background-tasks.mjs publishes
42
45
  // them as background tasks): `started`, `output`, `killed`, `released`,
@@ -65,9 +68,9 @@ export function createAcpHostServices({
65
68
  timeout: 10_000,
66
69
  stdio: 'ignore',
67
70
  });
68
- cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : windowsSystemToolPath('powershell');
71
+ cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : systemToolPath('powershell');
69
72
  } catch {
70
- cachedWindowsShell = windowsSystemToolPath('powershell');
73
+ cachedWindowsShell = systemToolPath('powershell');
71
74
  }
72
75
  return cachedWindowsShell;
73
76
  }
@@ -210,7 +213,7 @@ export function createAcpHostServices({
210
213
  if (platform === 'win32' && entry.proc.pid) {
211
214
  // proc.kill() only reaches the shell; shells leave grandchildren behind.
212
215
  try {
213
- spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
216
+ spawnImpl(systemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
214
217
  stdio: 'ignore',
215
218
  windowsHide: true,
216
219
  });
@@ -15,6 +15,8 @@ import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
15
15
  import { createTaskOutputSpool } from '../../shared/worker-runtime/task-output-spool.mjs';
16
16
 
17
17
  const MAX_DESCRIPTION_CHARS = 500;
18
+ // A bound on the post: the relay scrubs the command, then cuts it to 2,000.
19
+ const MAX_COMMAND_CHARS = 8000;
18
20
  const SPOOL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
19
21
  // Ended terminals remembered for a late exit after their release.
20
22
  const MAX_TRACKED_TERMINALS = 200;
@@ -68,6 +70,7 @@ export function createGrokTaskPublisher({
68
70
  taskId: entry.taskId,
69
71
  taskType: 'local_bash',
70
72
  description: entry.description,
73
+ ...(entry.command ? { command: entry.command } : {}),
71
74
  startedAt: entry.startedAt,
72
75
  stoppable: true,
73
76
  ...(outputPath ? { outputPath, outputKind: 'spool' } : {}),
@@ -117,6 +120,9 @@ export function createGrokTaskPublisher({
117
120
  const entry = {
118
121
  taskId,
119
122
  description: String(command || '').trim().slice(0, MAX_DESCRIPTION_CHARS) || 'Terminal',
123
+ // The whole command line (the description is cut shorter), for the
124
+ // console's `| tail` marker.
125
+ command: String(command || '').trim().slice(0, MAX_COMMAND_CHARS),
120
126
  startedAt,
121
127
  kill,
122
128
  live: false,
@@ -196,6 +202,7 @@ export function createGrokTaskPublisher({
196
202
  status,
197
203
  summary: exitCode !== null ? `exit code ${exitCode}` : (signal ? `killed by ${signal}` : null),
198
204
  description: entry.description,
205
+ ...(entry.command ? { command: entry.command } : {}),
199
206
  taskType: 'local_bash',
200
207
  startedAt: entry.startedAt,
201
208
  // Absent rather than null: a killed process has no exit code.
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the task console's rows outlive a relay restart.
5
+ *
6
+ * `background_task_rows` holds what the relay keeps of a background task
7
+ * that has a console (a shell or Monitor task naming its output file), per
8
+ * store key (the id the worker posts under, its SDK session id) and task:
9
+ *
10
+ * - `state` 'live': a task of the worker's last set. After a restart it is
11
+ * held back until the worker reports again: the worker's set decides which
12
+ * are still running, and those of a worker that does not report are marked
13
+ * ended, never shown as running again.
14
+ * - `state` 'ended': the dimmed row the panel keeps for an hour after the end,
15
+ * with its status and exit code. `ended_at` (epoch ms) is what the hour
16
+ * counts from.
17
+ * - `row_json` the task row as the store holds it, without the health
18
+ * reading (the poller reads the file again).
19
+ *
20
+ * Rows go when the hour is up, when the conversation is deleted, and live
21
+ * rows when the worker's set no longer names them.
22
+ *
23
+ * Additive and idempotent. Safe to run on every boot. An older build never
24
+ * reads the table; its consoles start empty after a restart as before.
25
+ *
26
+ * Usage: node server/migrations/0018-background-task-rows.mjs [path/to/copilot.db]
27
+ */
28
+
29
+ import { fileURLToPath } from 'url';
30
+ import path from 'path';
31
+ import process from 'process';
32
+ import Database from 'better-sqlite3';
33
+
34
+ export const BACKGROUND_TASK_ROWS_COLUMNS = Object.freeze(['store_key', 'task_id', 'state', 'row_json', 'ended_at', 'updated_at']);
35
+
36
+ function tableExists(db, name) {
37
+ return !!db
38
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
39
+ .get(name);
40
+ }
41
+
42
+ /** Why the table cannot be used (a table of that name with other columns), or ''. */
43
+ export function backgroundTaskRowsTableProblem(db) {
44
+ if (!tableExists(db, 'background_task_rows')) return 'no background_task_rows table';
45
+ const present = new Set(db.prepare(`PRAGMA table_info(background_task_rows)`).all().map((column) => column.name));
46
+ const missing = BACKGROUND_TASK_ROWS_COLUMNS.filter((name) => !present.has(name));
47
+ return missing.length ? `background_task_rows lacks ${missing.join(', ')}` : '';
48
+ }
49
+
50
+ export function migrateBackgroundTaskRows(db) {
51
+ let tablesAdded = 0;
52
+ if (!tableExists(db, 'background_task_rows')) {
53
+ db.exec(`
54
+ CREATE TABLE background_task_rows (
55
+ store_key TEXT NOT NULL,
56
+ task_id TEXT NOT NULL,
57
+ state TEXT NOT NULL CHECK (state IN ('live', 'ended')),
58
+ row_json TEXT NOT NULL,
59
+ ended_at INTEGER,
60
+ updated_at INTEGER NOT NULL,
61
+ PRIMARY KEY (store_key, task_id)
62
+ )
63
+ `);
64
+ tablesAdded += 1;
65
+ }
66
+ db.exec(`
67
+ CREATE INDEX IF NOT EXISTS idx_background_task_rows_ended
68
+ ON background_task_rows(state, ended_at)
69
+ `);
70
+ return { applied: tablesAdded > 0, tablesAdded };
71
+ }
72
+
73
+ function defaultDbPath() {
74
+ const here = path.dirname(fileURLToPath(import.meta.url));
75
+ return path.resolve(here, '..', 'data', 'copilot.db');
76
+ }
77
+
78
+ export function migrate(dbPath, options = {}) {
79
+ const db = new Database(dbPath, options);
80
+ try {
81
+ return migrateBackgroundTaskRows(db);
82
+ } finally {
83
+ db.close();
84
+ }
85
+ }
86
+
87
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
88
+ if (isMain) {
89
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
90
+ try {
91
+ const result = migrate(dbPath);
92
+ console.log(`Migration 0018 on ${dbPath}`);
93
+ console.log(result.applied
94
+ ? ` background task rows: tables added ${result.tablesAdded}`
95
+ : ' skipped: nothing to change');
96
+ process.exit(0);
97
+ } catch (err) {
98
+ console.error(`Migration 0018 failed: ${err?.message || err}`);
99
+ process.exit(1);
100
+ }
101
+ }
@@ -20,6 +20,7 @@ import {
20
20
  } from './api-client.js';
21
21
  import { isSuspendHostActionVisible } from './settings-modal.js';
22
22
  import { describeRelayRestartError } from './relay-restart-error.mjs';
23
+ import { initRelayRestartUi, openRestartRelayConfirmation } from './relay-restart-ui.js';
23
24
  import {
24
25
  applyHostSuspendState,
25
26
  describeHostSuspendBlocker,
@@ -106,21 +107,10 @@ export async function confirmKillCurrentSession() {
106
107
  }
107
108
  }
108
109
 
109
- export function openRestartRelayConfirmation() {
110
- openSummaryModal({
111
- title: 'Restart web relay',
112
- subtitle: 'Queues restart via /api/relay/shutdown',
113
- kind: 'restart-relay',
114
- bodyHtml: `
115
- <p>Queue a manual relay restart now?</p>
116
- <p>The restart waits until the current turn is idle, so it does not interrupt an in-flight turn immediately.</p>
117
- <div class="summary-modal-actions">
118
- <button class="chat-title-action-btn" type="button" onclick="confirmRestartWebRelay()">🌄 Restart web relay</button>
119
- <button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
120
- </div>
121
- `,
122
- });
123
- }
110
+ // ⋯ → Restart web relay first shows what the restart would wait for and
111
+ // leave running (relay-restart-ui.js); its buttons land in
112
+ // confirmRestartWebRelay below or in confirmRestartRelayNow.
113
+ export { openRestartRelayConfirmation };
124
114
 
125
115
  export function openEmptyQueueConfirmation() {
126
116
  menuDeps.lockChatActionsMenuShield(350);
@@ -375,6 +365,10 @@ export async function confirmRestartWebRelay() {
375
365
  showTransientRelayNotice('Relay is already shutting down/restarting.', 7000);
376
366
  return;
377
367
  }
368
+ if (result.status === 'shutting_down') {
369
+ showTransientRelayNotice('Nothing was running: the relay is restarting now.', 7000);
370
+ return;
371
+ }
378
372
  const queue = result.queue || {};
379
373
  showTransientRelayNotice(
380
374
  `Relay restart queued (pending=${Number(queue.pendingCount || 0)}, processing=${Number(queue.processingCount || 0)}).`,
@@ -399,6 +393,9 @@ export function initActionConfirmations({
399
393
  syncQueueStatusMenuEntry: typeof syncQueueStatusMenuEntry === 'function' ? syncQueueStatusMenuEntry : menuDeps.syncQueueStatusMenuEntry,
400
394
  refreshSessionWorkerStatus: typeof refreshSessionWorkerStatus === 'function' ? refreshSessionWorkerStatus : menuDeps.refreshSessionWorkerStatus,
401
395
  };
396
+ // The restart confirmations' and banner's inline handlers live on window
397
+ // either way (bootstrap exposes this module's own).
398
+ initRelayRestartUi();
402
399
  if (!exposeOnWindow) return;
403
400
  window.openKillSessionConfirmation = openKillSessionConfirmation;
404
401
  window.openRestartRelayConfirmation = openRestartRelayConfirmation;
@@ -45,7 +45,7 @@ export function areNetworkRequestsEnabled() {
45
45
  return networkRequestsEnabled;
46
46
  }
47
47
 
48
- export async function apiFetch(url, opts = {}) {
48
+ export async function apiFetch(url, { onErrorResponse, ...opts } = {}) {
49
49
  // A keepalive request is a page's last word; it goes out even when the
50
50
  // page's regular network work is already suspended.
51
51
  if (!networkRequestsEnabled && !opts.keepalive) return null;
@@ -61,6 +61,9 @@ export async function apiFetch(url, opts = {}) {
61
61
  });
62
62
  if (!response.ok) {
63
63
  console.error('API error', response.status, url);
64
+ // A caller that shows why a request failed reads the answer here; the
65
+ // result is still null.
66
+ if (typeof onErrorResponse === 'function') await onErrorResponse(response);
64
67
  return null;
65
68
  }
66
69
  noteFetchSuccess();
@@ -585,6 +588,18 @@ export async function updateTaskStallMinutesSetting(minutes) {
585
588
  });
586
589
  }
587
590
 
591
+ // "After a relay restart, tell interrupted sessions to carry on" (off by default).
592
+ export async function loadRestartResumeSetting() {
593
+ return apiFetch('/api/settings/restart-resume');
594
+ }
595
+
596
+ export async function updateRestartResumeSetting(enabled) {
597
+ return apiFetch('/api/settings/restart-resume', {
598
+ method: 'POST',
599
+ body: JSON.stringify({ enabled: enabled === true }),
600
+ });
601
+ }
602
+
588
603
  export async function loadPwaAppNameSetting() {
589
604
  return apiFetch('/api/settings/pwa-app-name');
590
605
  }
@@ -1425,6 +1440,31 @@ export async function cancelRelayRestart(body = {}) {
1425
1440
  });
1426
1441
  }
1427
1442
 
1443
+ // The queued restart's state, and the picture for a restart asked now: the
1444
+ // sessions it would wait for, the messages it would hold, the background
1445
+ // tasks and whether they survive (routes/relay-restart-routes.mjs).
1446
+ export async function loadRelayRestartPreview(action = 'restart') {
1447
+ return apiFetchOrThrow(`/api/relay/shutdown?action=${encodeURIComponent(action)}`);
1448
+ }
1449
+
1450
+ export async function keepWaitingForRelayRestart(body = {}) {
1451
+ return apiFetchOrThrow('/api/relay/shutdown/keep-waiting', {
1452
+ method: 'POST',
1453
+ body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
1454
+ });
1455
+ }
1456
+
1457
+ export async function loadRelayRestartMaxWaitSetting() {
1458
+ return apiFetch('/api/settings/relay-restart-max-wait');
1459
+ }
1460
+
1461
+ export async function updateRelayRestartMaxWaitSetting(maxWaitMinutes) {
1462
+ return apiFetchOrThrow('/api/settings/relay-restart-max-wait', {
1463
+ method: 'POST',
1464
+ body: JSON.stringify({ maxWaitMinutes: Number(maxWaitMinutes) }),
1465
+ });
1466
+ }
1467
+
1428
1468
  export async function requestQueueEmpty(body = {}) {
1429
1469
  return apiFetchOrThrow('/api/queue/empty', {
1430
1470
  method: 'POST',
@@ -1436,8 +1476,22 @@ export async function requestQueueEmpty(body = {}) {
1436
1476
  });
1437
1477
  }
1438
1478
 
1439
- export async function sendMessage(body) {
1440
- return apiFetch('/api/message', { method: 'POST', body: JSON.stringify(body) });
1479
+ // Null on any failure, like apiFetch. A send the relay refused for a reason
1480
+ // the user can act on (a 409 such as "this conversation's model is no longer
1481
+ // available") hands that reason to onRefused first, so the composer can say
1482
+ // it instead of "could not be sent". A replayed send that already landed
1483
+ // (DUPLICATE_MESSAGE_ID) is not a refusal.
1484
+ export async function sendMessage(body, { onRefused } = {}) {
1485
+ return apiFetch('/api/message', {
1486
+ method: 'POST',
1487
+ body: JSON.stringify(body),
1488
+ onErrorResponse: async (response) => {
1489
+ if (response.status !== 409 || typeof onRefused !== 'function') return;
1490
+ const payload = await response.json().catch(() => null);
1491
+ const reason = readableServerError(payload?.error);
1492
+ if (reason && payload?.code !== 'DUPLICATE_MESSAGE_ID') onRefused(reason, payload);
1493
+ },
1494
+ });
1441
1495
  }
1442
1496
 
1443
1497
  // Raw fetch instead of apiFetch: a rejected bootstrap (bad CWD, unavailable