@oar-sh/oar 0.9.11 → 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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  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 +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -11,6 +11,7 @@
11
11
  * If a capability is dropped here, drop it from the initialize payload too.
12
12
  */
13
13
  import { spawn, spawnSync } from 'node:child_process';
14
+ import { randomBytes } from 'node:crypto';
14
15
  import fs from 'node:fs/promises';
15
16
  import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
16
17
 
@@ -36,11 +37,22 @@ export function createAcpHostServices({
36
37
  platform = process.platform,
37
38
  spawnImpl = spawn,
38
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,
39
43
  fsImpl = fs,
44
+ // Told about every terminal's life (grok-background-tasks.mjs publishes
45
+ // them as background tasks): `started`, `output`, `killed`, `released`,
46
+ // `exited`. Advisory: an observer that throws never disturbs a terminal.
47
+ taskObserver = null,
40
48
  dbg = () => {},
41
49
  } = {}) {
42
50
  const terminals = new Map();
43
51
  let nextTerminalId = 1;
52
+ // Terminal ids become task ids and spool file names on the relay side: a
53
+ // fresh set of host services (the handle is rebuilt after a failed turn)
54
+ // must not reuse the ids of the one before it.
55
+ const terminalIdPrefix = `term-${randomBytes(3).toString('hex')}`;
44
56
  let inflightRequests = 0;
45
57
  let cachedWindowsShell = '';
46
58
  let disposed = false;
@@ -56,9 +68,9 @@ export function createAcpHostServices({
56
68
  timeout: 10_000,
57
69
  stdio: 'ignore',
58
70
  });
59
- cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : windowsSystemToolPath('powershell');
71
+ cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : systemToolPath('powershell');
60
72
  } catch {
61
- cachedWindowsShell = windowsSystemToolPath('powershell');
73
+ cachedWindowsShell = systemToolPath('powershell');
62
74
  }
63
75
  return cachedWindowsShell;
64
76
  }
@@ -77,9 +89,21 @@ export function createAcpHostServices({
77
89
  return { file: shell, args: ['-c', command] };
78
90
  }
79
91
 
92
+ function tell(hook, ...args) {
93
+ const fn = taskObserver?.[hook];
94
+ if (typeof fn !== 'function') return;
95
+ try {
96
+ const result = fn(...args);
97
+ if (result && typeof result.catch === 'function') result.catch(() => {});
98
+ } catch (error) {
99
+ dbg('terminal observer failed', hook, error?.message || String(error));
100
+ }
101
+ }
102
+
80
103
  function appendOutput(entry, chunk) {
81
104
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
82
105
  entry.lastActivityAt = Date.now();
106
+ tell('output', entry.terminalId, buf);
83
107
  entry.chunks.push(buf);
84
108
  entry.byteLength += buf.length;
85
109
  // ACP truncates from the start of the output: the most recent bytes win.
@@ -101,6 +125,7 @@ export function createAcpHostServices({
101
125
  if (entry.exitStatus) return;
102
126
  entry.exitStatus = exitStatus;
103
127
  for (const resolve of entry.exitWaiters.splice(0)) resolve(exitStatus);
128
+ tell('exited', entry.terminalId, exitStatus);
104
129
  }
105
130
 
106
131
  function getTerminal(terminalId) {
@@ -125,9 +150,12 @@ export function createAcpHostServices({
125
150
  windowsHide: true,
126
151
  shell: false,
127
152
  });
128
- const terminalId = `term-${nextTerminalId++}`;
153
+ const terminalId = `${terminalIdPrefix}-${nextTerminalId++}`;
129
154
  const entry = {
130
155
  terminalId,
156
+ // What the task panel shows for it.
157
+ command: [command, ...(Array.isArray(params.args) ? params.args.map(String) : [])].join(' '),
158
+ startedAt: Date.now(),
131
159
  proc,
132
160
  chunks: [],
133
161
  byteLength: 0,
@@ -152,6 +180,12 @@ export function createAcpHostServices({
152
180
  });
153
181
  });
154
182
  terminals.set(terminalId, entry);
183
+ tell('started', {
184
+ terminalId,
185
+ command: entry.command,
186
+ startedAt: entry.startedAt,
187
+ kill: () => killEntryProcess(entry),
188
+ });
155
189
  dbg('terminal/create', terminalId, command.slice(0, 160));
156
190
  return { terminalId };
157
191
  }
@@ -179,7 +213,7 @@ export function createAcpHostServices({
179
213
  if (platform === 'win32' && entry.proc.pid) {
180
214
  // proc.kill() only reaches the shell; shells leave grandchildren behind.
181
215
  try {
182
- spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
216
+ spawnImpl(systemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
183
217
  stdio: 'ignore',
184
218
  windowsHide: true,
185
219
  });
@@ -188,15 +222,19 @@ export function createAcpHostServices({
188
222
  }
189
223
 
190
224
  function terminalKill(params = {}) {
191
- killEntryProcess(getTerminal(params.terminalId));
225
+ const entry = getTerminal(params.terminalId);
226
+ if (!entry.exitStatus) tell('killed', entry.terminalId);
227
+ killEntryProcess(entry);
192
228
  return null;
193
229
  }
194
230
 
195
231
  function terminalRelease(params = {}) {
196
232
  const entry = terminals.get(String(params?.terminalId || ''));
197
233
  if (!entry) return null;
234
+ if (!entry.exitStatus) tell('killed', entry.terminalId);
198
235
  killEntryProcess(entry);
199
236
  terminals.delete(entry.terminalId);
237
+ tell('released', entry.terminalId);
200
238
  return null;
201
239
  }
202
240
 
@@ -270,7 +308,11 @@ export function createAcpHostServices({
270
308
 
271
309
  function disposeAll() {
272
310
  disposed = true;
273
- for (const entry of terminals.values()) killEntryProcess(entry);
311
+ for (const entry of terminals.values()) {
312
+ if (!entry.exitStatus) tell('killed', entry.terminalId);
313
+ killEntryProcess(entry);
314
+ tell('released', entry.terminalId);
315
+ }
274
316
  terminals.clear();
275
317
  }
276
318
 
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The Grok worker's background-tasks publisher: every ACP terminal the agent
3
+ * opens (acp-host-services.mjs) is a shell row in the composer's
4
+ * background-tasks panel, with a console the relay reads from the session's
5
+ * spool file (shared/worker-runtime/task-output-spool.mjs), a Stop that kills
6
+ * the terminal, and a settle notice with the exit code when it ends.
7
+ *
8
+ * Same relay contract as the Claude and Copilot workers: the set is posted
9
+ * whole (REPLACE semantics) with its place in this worker's order
10
+ * (`{ epoch, seq }`); the heartbeat carries the live ids and the relay asks
11
+ * for the set again when its own differs; a settle notice the relay was not
12
+ * there to take is kept and sent again after the next answered heartbeat.
13
+ */
14
+ import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
15
+ import { createTaskOutputSpool } from '../../shared/worker-runtime/task-output-spool.mjs';
16
+
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;
20
+ const SPOOL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
21
+ // Ended terminals remembered for a late exit after their release.
22
+ const MAX_TRACKED_TERMINALS = 200;
23
+ // ACP runs every shell command through a terminal, most of them over in a
24
+ // moment; only one that lives this long becomes a row (and later a settle
25
+ // notice), so a `ls` never crowds the panel's ended rows.
26
+ export const GROK_TERMINAL_PUBLISH_AFTER_MS = 2_000;
27
+
28
+ export function createGrokTaskPublisher({
29
+ api,
30
+ sdkSessionId,
31
+ spool = null,
32
+ // The relay dropped the newest set: ask for a heartbeat (the worker wires
33
+ // it to requestPulse), at most once per ten seconds.
34
+ onBackgroundTaskSetStale = () => {},
35
+ publishAfterMs = GROK_TERMINAL_PUBLISH_AFTER_MS,
36
+ now = () => Date.now(),
37
+ dbg = () => {},
38
+ } = {}) {
39
+ const taskSpool = spool || (() => {
40
+ try { return createTaskOutputSpool({ sessionId: sdkSessionId }); } catch (error) {
41
+ dbg('task output spool unavailable', error?.message || String(error));
42
+ return null;
43
+ }
44
+ })();
45
+ // terminalId → { taskId, description, startedAt, kill, live, published,
46
+ // publishTimer, exited, stopRequested, killRequested }. `live`: in the set.
47
+ const terminals = new Map();
48
+ const keptSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
49
+ const epoch = now();
50
+ let seq = 0;
51
+ let staleNotedAt = 0;
52
+
53
+ // Ended tasks' files are kept about an hour for the relay; swept at start
54
+ // and hourly.
55
+ const sweep = () => {
56
+ try { taskSpool?.sweep(); } catch (error) { dbg('task output spool sweep failed', error?.message || String(error)); }
57
+ };
58
+ sweep();
59
+ const sweepTimer = setInterval(sweep, SPOOL_SWEEP_INTERVAL_MS);
60
+ sweepTimer.unref?.();
61
+
62
+ function outputPathFor(taskId) {
63
+ if (!taskSpool) return '';
64
+ try { return taskSpool.pathFor(taskId); } catch { return ''; }
65
+ }
66
+
67
+ function rowOf(entry) {
68
+ const outputPath = outputPathFor(entry.taskId);
69
+ return {
70
+ taskId: entry.taskId,
71
+ taskType: 'local_bash',
72
+ description: entry.description,
73
+ ...(entry.command ? { command: entry.command } : {}),
74
+ startedAt: entry.startedAt,
75
+ stoppable: true,
76
+ ...(outputPath ? { outputPath, outputKind: 'spool' } : {}),
77
+ };
78
+ }
79
+
80
+ function liveEntries() {
81
+ return [...terminals.values()].filter((entry) => entry.live);
82
+ }
83
+
84
+ function publish({ resync = false } = {}) {
85
+ const order = { epoch, seq: ++seq };
86
+ return Promise.resolve()
87
+ .then(() => api('POST', '/api/background-tasks', {
88
+ conversationId: sdkSessionId,
89
+ tasks: liveEntries().map(rowOf),
90
+ order,
91
+ ...(resync ? { resync: true } : {}),
92
+ }))
93
+ .then((answer) => {
94
+ // `stale` for the newest post: the relay holds no current set of
95
+ // this worker. An older one was only overtaken.
96
+ if (answer?.stale !== true || order.seq !== seq) return;
97
+ dbg('the relay dropped the newest background task set', answer.reason || '');
98
+ if (now() - staleNotedAt < 10_000) return;
99
+ staleNotedAt = now();
100
+ try { onBackgroundTaskSetStale(); } catch {}
101
+ })
102
+ .catch((error) => {
103
+ dbg('background task publish failed', error?.message || String(error));
104
+ });
105
+ }
106
+
107
+ function forgetOldest() {
108
+ while (terminals.size > MAX_TRACKED_TERMINALS) {
109
+ const oldest = [...terminals.entries()].find(([, entry]) => entry.exited || entry.released);
110
+ if (!oldest) break;
111
+ clearTimeout(oldest[1].publishTimer);
112
+ terminals.delete(oldest[0]);
113
+ }
114
+ }
115
+
116
+ /** `terminal/create` spawned a process. */
117
+ function started({ terminalId, command = '', startedAt = now(), kill = () => {} } = {}) {
118
+ const taskId = String(terminalId || '').trim();
119
+ if (!taskId) return Promise.resolve();
120
+ const entry = {
121
+ taskId,
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),
126
+ startedAt,
127
+ kill,
128
+ live: false,
129
+ published: false,
130
+ publishTimer: null,
131
+ exited: false,
132
+ released: false,
133
+ stopRequested: false,
134
+ killRequested: false,
135
+ };
136
+ terminals.set(taskId, entry);
137
+ forgetOldest();
138
+ // Output is spooled from the first byte (output() below), so the console
139
+ // is complete when the row appears.
140
+ const show = () => {
141
+ entry.publishTimer = null;
142
+ if (entry.exited || entry.released) return Promise.resolve();
143
+ entry.live = true;
144
+ entry.published = true;
145
+ return publish();
146
+ };
147
+ if (!(publishAfterMs > 0)) return show();
148
+ entry.publishTimer = setTimeout(show, publishAfterMs);
149
+ entry.publishTimer.unref?.();
150
+ return Promise.resolve();
151
+ }
152
+
153
+ /** A chunk of the terminal's stdout/stderr, as it arrives. */
154
+ function output(terminalId, chunk) {
155
+ const taskId = String(terminalId || '').trim();
156
+ if (!taskId || !taskSpool || !terminals.has(taskId)) return;
157
+ const result = taskSpool.append(taskId, chunk);
158
+ if (result && result.ok === false) dbg('task output spool append failed', taskId);
159
+ }
160
+
161
+ /** The agent killed its own terminal (terminal/kill or release): a stop, not a failure. */
162
+ function killed(terminalId) {
163
+ const entry = terminals.get(String(terminalId || '').trim());
164
+ if (entry && !entry.exited) entry.killRequested = true;
165
+ }
166
+
167
+ /** `terminal/release`: the agent is done with it; the row goes. */
168
+ function released(terminalId) {
169
+ const entry = terminals.get(String(terminalId || '').trim());
170
+ if (!entry || entry.released) return Promise.resolve();
171
+ entry.released = true;
172
+ clearTimeout(entry.publishTimer);
173
+ entry.publishTimer = null;
174
+ if (!entry.live) return Promise.resolve();
175
+ entry.live = false;
176
+ return publish();
177
+ }
178
+
179
+ /** The process exited: say how, then publish the set without it. */
180
+ async function exited(terminalId, exitStatus = {}) {
181
+ const taskId = String(terminalId || '').trim();
182
+ const entry = terminals.get(taskId);
183
+ if (!entry || entry.exited) return;
184
+ entry.exited = true;
185
+ clearTimeout(entry.publishTimer);
186
+ entry.publishTimer = null;
187
+ const wasLive = entry.live;
188
+ entry.live = false;
189
+ try { taskSpool?.end?.(taskId); } catch {}
190
+ // Never shown: nothing to settle (its spool file is swept with the rest).
191
+ if (!entry.published) return;
192
+ const exitCode = Number.isInteger(exitStatus?.exitCode) ? exitStatus.exitCode : null;
193
+ const signal = String(exitStatus?.signal || '').trim();
194
+ const status = entry.stopRequested || entry.killRequested
195
+ ? 'stopped'
196
+ : (exitCode === 0 ? 'completed' : 'failed');
197
+ const outputPath = outputPathFor(taskId);
198
+ await keptSettles.post('/api/background-task-settled', {
199
+ conversationId: sdkSessionId,
200
+ taskId,
201
+ toolUseId: null,
202
+ status,
203
+ summary: exitCode !== null ? `exit code ${exitCode}` : (signal ? `killed by ${signal}` : null),
204
+ description: entry.description,
205
+ ...(entry.command ? { command: entry.command } : {}),
206
+ taskType: 'local_bash',
207
+ startedAt: entry.startedAt,
208
+ // Absent rather than null: a killed process has no exit code.
209
+ ...(exitCode !== null ? { exitCode } : {}),
210
+ ...(outputPath ? { outputPath, outputKind: 'spool' } : {}),
211
+ });
212
+ if (wasLive) await publish();
213
+ }
214
+
215
+ /** The panel's Stop (worker control `stop_background_task`). */
216
+ function stop(taskId) {
217
+ const entry = terminals.get(String(taskId || '').trim());
218
+ if (!entry || !entry.live || entry.exited) return false;
219
+ entry.stopRequested = true;
220
+ try {
221
+ entry.kill();
222
+ return true;
223
+ } catch (error) {
224
+ dbg('terminal stop failed', entry.taskId, error?.message || String(error));
225
+ return false;
226
+ }
227
+ }
228
+
229
+ return {
230
+ started,
231
+ output,
232
+ killed,
233
+ released,
234
+ exited,
235
+ stop,
236
+ /** The ids of the live terminals, for the heartbeat's digest. */
237
+ backgroundTaskIds: () => liveEntries().map((entry) => entry.taskId),
238
+ /** The relay asked for the set again (heartbeat answer). */
239
+ resync: () => publish({ resync: true }),
240
+ /** The relay answered a heartbeat: settle notices kept for it go out. */
241
+ flushKeptSettles: () => keptSettles.flush(),
242
+ sweep,
243
+ close() {
244
+ clearInterval(sweepTimer);
245
+ for (const entry of terminals.values()) clearTimeout(entry.publishTimer);
246
+ },
247
+ };
248
+ }
@@ -85,6 +85,8 @@ export async function createGrokAgentHandle({
85
85
  model = '',
86
86
  AcpClientImpl = AcpClient,
87
87
  createHostServicesImpl = createAcpHostServices,
88
+ // Told about every terminal (the worker's background-tasks publisher).
89
+ taskObserver = null,
88
90
  dbg = () => {},
89
91
  } = {}) {
90
92
  const client = new AcpClientImpl({
@@ -109,7 +111,7 @@ export async function createGrokAgentHandle({
109
111
  // The terminal/fs handlers backing the capabilities initialize() advertises.
110
112
  // They must be attached before the first prompt, or the agent's first shell
111
113
  // command deadlocks the turn waiting on terminal/create.
112
- const hostServices = createHostServicesImpl({ cwd, env, dbg });
114
+ const hostServices = createHostServicesImpl({ cwd, env, dbg, ...(taskObserver ? { taskObserver } : {}) });
113
115
  hostServices.attach(client);
114
116
 
115
117
  await client.initialize();
@@ -16,6 +16,7 @@ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-fil
16
16
  import { createControlPoller } from '../../shared/control-poller.mjs';
17
17
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
18
  import { createGrokTurnRunner } from './grok-turn-runner.mjs';
19
+ import { createGrokTaskPublisher } from './grok-background-tasks.mjs';
19
20
 
20
21
  // Windows console launch: the launcher names the worker log, this copies
21
22
  // stdout/stderr into it. A no-op wherever the launcher redirects instead.
@@ -70,6 +71,16 @@ async function main() {
70
71
  abortAckNote: 'grok run cancelled',
71
72
  dbg,
72
73
  });
74
+ // Late-bound: the heartbeat controller is built after the runner.
75
+ let heartbeat = null;
76
+ // The agent's terminals, published as background tasks with a console
77
+ // (spooled output), a Stop and a settle notice.
78
+ const taskPublisher = createGrokTaskPublisher({
79
+ api,
80
+ sdkSessionId,
81
+ onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
82
+ dbg,
83
+ });
73
84
  const turnRunner = createGrokTurnRunner({
74
85
  api,
75
86
  sdkSessionId,
@@ -78,17 +89,23 @@ async function main() {
78
89
  command,
79
90
  alwaysApprove,
80
91
  controlPoller,
92
+ taskPublisher,
81
93
  dbg,
82
94
  });
83
95
 
84
96
  let heartbeatTimer = null;
85
- const heartbeat = createHeartbeatController({
97
+ heartbeat = createHeartbeatController({
86
98
  api,
87
99
  pollMs: HEARTBEAT_MS,
88
100
  getSessionReady: () => true,
89
101
  getHeartbeatTimer: () => heartbeatTimer,
90
102
  setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
91
103
  getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
104
+ // The relay holds the task set in memory only: the live terminal ids
105
+ // ride every beat, and a relay that is out of step asks for the set again.
106
+ getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
107
+ onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
108
+ onAnswered: () => turnRunner.flushKeptTaskSettles(),
92
109
  });
93
110
 
94
111
  const wsLink = createWorkerWebSocketLink({
@@ -96,6 +113,9 @@ async function main() {
96
113
  token,
97
114
  dbg,
98
115
  getSessionReady: () => true,
116
+ // A relay that just (re)started learns of the terminals from the digest
117
+ // on the next beat; ask for it now.
118
+ onServerHello: () => heartbeat.requestPulse(),
99
119
  getSessionId: () => sdkSessionId,
100
120
  getPid: () => process.pid,
101
121
  onDeliver: async (pending, reason) => {
@@ -111,6 +131,17 @@ async function main() {
111
131
  return false;
112
132
  }
113
133
  },
134
+ onControl: async (control) => {
135
+ const type = String(control?.type || '').trim();
136
+ if (type === 'stop_background_task') {
137
+ // The panel's Stop on a terminal row: kill its process.
138
+ const taskId = String(control?.taskId || '').trim();
139
+ dbg('stop background task control', taskId);
140
+ turnRunner.stopBackgroundTask(taskId);
141
+ return;
142
+ }
143
+ dbg('unknown worker control ignored', type || '(none)');
144
+ },
114
145
  });
115
146
 
116
147
  const shutdown = async (signal) => {
@@ -118,6 +149,7 @@ async function main() {
118
149
  try { wsLink.stop(); } catch {}
119
150
  try { heartbeat.stopHeartbeat(); } catch {}
120
151
  try { controlPoller.stop(); } catch {}
152
+ try { taskPublisher.close(); } catch {}
121
153
  // Bounded: a hung ACP agent teardown must never leave the process
122
154
  // ignoring the supervisor's SIGTERM.
123
155
  try {
@@ -122,8 +122,12 @@ export function createGrokTurnRunner({
122
122
  // gets through ACP `mcpServers`; undefined builds the default for this
123
123
  // worker's conversation, null leaves Grok without it.
124
124
  oarMcpServer = undefined,
125
+ // The terminals' background-task rows (grok-background-tasks.mjs), built
126
+ // by the worker entry; without one no terminal is published.
127
+ taskPublisher = null,
125
128
  dbg = () => {},
126
129
  } = {}) {
130
+ const terminalTasks = taskPublisher;
127
131
  // The OAR MCP server gives Grok the relay's real tools — `preview`, and
128
132
  // `remote_relay` when remotes are paired — bound to this conversation.
129
133
  // `OAR_MCP_SERVER=0` is the operator's way back to the preview instruction
@@ -203,6 +207,7 @@ export function createGrokTurnRunner({
203
207
  alwaysApprove,
204
208
  nativeSessionId: resumeId,
205
209
  model,
210
+ ...(terminalTasks ? { taskObserver: terminalTasks } : {}),
206
211
  // The adapter builds the ACP client and opens the session in one step,
207
212
  // so the client it builds must already carry the MCP servers — for
208
213
  // session/new and for session/load alike.
@@ -611,6 +616,12 @@ export function createGrokTurnRunner({
611
616
  getActiveQueueAttempt,
612
617
  isTurnActive,
613
618
  dispose,
619
+ // The terminals as background tasks: the heartbeat's digest and resync,
620
+ // the panel's Stop, kept settle notices.
621
+ backgroundTaskIds: () => terminalTasks?.backgroundTaskIds() || [],
622
+ resyncBackgroundTasks: () => terminalTasks?.resync(),
623
+ stopBackgroundTask: (taskId) => terminalTasks?.stop(taskId) || false,
624
+ flushKeptTaskSettles: () => terminalTasks?.flushKeptSettles(),
614
625
  setTurnCeilingMs(value) {
615
626
  const ms = Number(value);
616
627
  turnCeilingMs = Number.isFinite(ms) && ms >= 0 ? ms : null;
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: two indexes for loading the history of long conversations.
5
+ *
6
+ * - `idx_queue_response_message` on `queue(response_message_id)`: the
7
+ * sub-agent records of a reply are found through the queue row that
8
+ * produced it, one lookup per assistant message on every history page.
9
+ * Without the index each lookup scanned the whole queue.
10
+ * - `idx_messages_conv_order` on `messages(conversation_id, timestamp, id)`:
11
+ * a history page is selected in SQL in conversation order (timestamp, then
12
+ * id); the index serves that order and the check that a conversation's
13
+ * rows can be paged that way, without reading the message rows.
14
+ *
15
+ * Additive and idempotent. Safe to run on every boot. An older build ignores
16
+ * both indexes.
17
+ *
18
+ * Usage: node server/migrations/0017-conversation-history-indexes.mjs [path/to/copilot.db]
19
+ */
20
+
21
+ import { fileURLToPath } from 'url';
22
+ import path from 'path';
23
+ import process from 'process';
24
+ import Database from 'better-sqlite3';
25
+
26
+ function tableExists(db, name) {
27
+ return !!db
28
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
29
+ .get(name);
30
+ }
31
+
32
+ function indexExists(db, name) {
33
+ return !!db
34
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
35
+ .get(name);
36
+ }
37
+
38
+ function columnNames(db, table) {
39
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
40
+ }
41
+
42
+ export function migrateConversationHistoryIndexes(db) {
43
+ if (!tableExists(db, 'messages') || !tableExists(db, 'queue')) {
44
+ throw new Error('messages or queue is missing (the base schema has not run)');
45
+ }
46
+ if (!columnNames(db, 'queue').has('response_message_id')) {
47
+ throw new Error('queue.response_message_id is missing (the base schema has not run)');
48
+ }
49
+ let indexesAdded = 0;
50
+ if (!indexExists(db, 'idx_queue_response_message')) {
51
+ db.exec(`CREATE INDEX idx_queue_response_message ON queue(response_message_id)`);
52
+ indexesAdded += 1;
53
+ }
54
+ if (!indexExists(db, 'idx_messages_conv_order')) {
55
+ db.exec(`CREATE INDEX idx_messages_conv_order ON messages(conversation_id, timestamp, id)`);
56
+ indexesAdded += 1;
57
+ }
58
+ return { applied: indexesAdded > 0, indexesAdded };
59
+ }
60
+
61
+ function defaultDbPath() {
62
+ const here = path.dirname(fileURLToPath(import.meta.url));
63
+ return path.resolve(here, '..', 'data', 'copilot.db');
64
+ }
65
+
66
+ export function migrate(dbPath, options = {}) {
67
+ const db = new Database(dbPath, options);
68
+ try {
69
+ return migrateConversationHistoryIndexes(db);
70
+ } finally {
71
+ db.close();
72
+ }
73
+ }
74
+
75
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
76
+ if (isMain) {
77
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
78
+ try {
79
+ const result = migrate(dbPath);
80
+ console.log(`Migration 0017 on ${dbPath}`);
81
+ console.log(result.applied
82
+ ? ` conversation history indexes: added ${result.indexesAdded}`
83
+ : ' skipped: nothing to change');
84
+ process.exit(0);
85
+ } catch (err) {
86
+ console.error(`Migration 0017 failed: ${err?.message || err}`);
87
+ process.exit(1);
88
+ }
89
+ }