@oar-sh/oar 0.9.10 → 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.
- package/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
package/server/db-schema.mjs
CHANGED
|
@@ -18,6 +18,11 @@ import { migrateSubagentTimeline } from './migrations/0009-subagent-timeline.mjs
|
|
|
18
18
|
import { migrateSubagentTimelinePruning } from './migrations/0010-subagent-timeline-pruning.mjs';
|
|
19
19
|
import { migrateRelayEventSubscriptions } from './migrations/0011-relay-event-subscriptions.mjs';
|
|
20
20
|
import { migrateCrossRelayEventSubscriptions } from './migrations/0012-cross-relay-event-subscriptions.mjs';
|
|
21
|
+
import { migratePreviews } from './migrations/0013-previews.mjs';
|
|
22
|
+
import { migrateConversationGroups } from './migrations/0014-conversation-groups.mjs';
|
|
23
|
+
import { migrateConversationGroupPosition } from './migrations/0015-conversation-group-position.mjs';
|
|
24
|
+
import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
|
|
25
|
+
import { migrateConversationHistoryIndexes } from './migrations/0017-conversation-history-indexes.mjs';
|
|
21
26
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
22
27
|
|
|
23
28
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -426,8 +431,8 @@ export const SCHEMA_SQL = `
|
|
|
426
431
|
ON workflow_runs(conversation_id);
|
|
427
432
|
|
|
428
433
|
-- Previews published mid-turn, pinned to the transcript. Snapshot JSON, not a
|
|
429
|
-
-- registry reference: the
|
|
430
|
-
--
|
|
434
|
+
-- registry reference: the card must still render after the preview closes
|
|
435
|
+
-- (or after a restart dropped it as no longer valid). Written with the
|
|
431
436
|
-- queue id (the response id does not exist yet mid-turn) and linked to the
|
|
432
437
|
-- response at finalize, exactly like relay_activity.
|
|
433
438
|
CREATE TABLE IF NOT EXISTS preview_cards (
|
|
@@ -445,6 +450,24 @@ export const SCHEMA_SQL = `
|
|
|
445
450
|
CREATE INDEX IF NOT EXISTS idx_preview_cards_queue
|
|
446
451
|
ON preview_cards(queue_message_id);
|
|
447
452
|
|
|
453
|
+
-- Published previews, restored at boot so their public links survive a
|
|
454
|
+
-- restart. Existing databases get the table from migration 0013 (same
|
|
455
|
+
-- columns; a test keeps the two declarations equal). Times are epoch ms.
|
|
456
|
+
-- Its index comes from the migration only, which skips a previews table of
|
|
457
|
+
-- other columns instead of failing the boot over it.
|
|
458
|
+
CREATE TABLE IF NOT EXISTS previews (
|
|
459
|
+
token TEXT PRIMARY KEY,
|
|
460
|
+
mode TEXT NOT NULL,
|
|
461
|
+
conversation_id TEXT,
|
|
462
|
+
label TEXT NOT NULL,
|
|
463
|
+
target_host TEXT,
|
|
464
|
+
target_port INTEGER,
|
|
465
|
+
root_dir TEXT,
|
|
466
|
+
created_at INTEGER NOT NULL,
|
|
467
|
+
last_hit_at INTEGER,
|
|
468
|
+
host_boot_id TEXT
|
|
469
|
+
);
|
|
470
|
+
|
|
448
471
|
CREATE TABLE IF NOT EXISTS uploaded_files (
|
|
449
472
|
sha256 TEXT PRIMARY KEY,
|
|
450
473
|
original_name TEXT,
|
|
@@ -778,6 +801,49 @@ try {
|
|
|
778
801
|
} catch (error) {
|
|
779
802
|
console.warn(`[relay-events] migration 0012 failed; retrying on next boot: ${error?.message || error}`);
|
|
780
803
|
}
|
|
804
|
+
// Persisted previews: one new table. The preview store checks for it and keeps
|
|
805
|
+
// previews in memory only without it, so a failure is logged and retried next
|
|
806
|
+
// boot like 0012.
|
|
807
|
+
try {
|
|
808
|
+
migratePreviews(db);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
console.warn(`[previews] migration 0013 failed; retrying on next boot: ${error?.message || error}`);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Session groups: the group table and the conversations' group column. The
|
|
814
|
+
// group repository checks for them and sees no groups without them, so a
|
|
815
|
+
// failure is logged and retried next boot like 0012.
|
|
816
|
+
try {
|
|
817
|
+
migrateConversationGroups(db);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
console.warn(`[session-groups] migration 0014 failed; retrying on next boot: ${error?.message || error}`);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// A hand order inside a session group: one column. The group repository
|
|
823
|
+
// checks for it and keeps the activity order without it, so a failure is
|
|
824
|
+
// logged and retried next boot like 0014.
|
|
825
|
+
try {
|
|
826
|
+
migrateConversationGroupPosition(db);
|
|
827
|
+
} catch (error) {
|
|
828
|
+
console.warn(`[session-groups] migration 0015 failed; retrying on next boot: ${error?.message || error}`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Share lineages and the share access log: one column, one table. The share
|
|
832
|
+
// repository checks for them; without them a renew starts a fresh lineage and
|
|
833
|
+
// no open is logged, so a failure is logged and retried next boot like 0015.
|
|
834
|
+
try {
|
|
835
|
+
migrateShareAccessLog(db);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
console.warn(`[shares] migration 0016 failed; retrying on next boot: ${error?.message || error}`);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Two indexes for the history of long conversations. Only speed depends on
|
|
841
|
+
// them, so a failure is logged and retried next boot like 0016.
|
|
842
|
+
try {
|
|
843
|
+
migrateConversationHistoryIndexes(db);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
console.warn(`[history] migration 0017 failed; retrying on next boot: ${error?.message || error}`);
|
|
846
|
+
}
|
|
781
847
|
|
|
782
848
|
const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
|
|
783
849
|
if (runtimeSessionColumns.length) {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
createTurnStalledError,
|
|
13
13
|
resolveTurnStallWindows,
|
|
14
14
|
} from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
15
|
+
import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
|
|
15
16
|
|
|
16
17
|
function whichCommand(command = 'grok') {
|
|
17
18
|
const value = String(command || 'grok').trim() || 'grok';
|
|
@@ -522,7 +523,7 @@ export class AcpClient extends EventEmitter {
|
|
|
522
523
|
this.proc.kill();
|
|
523
524
|
if (process.platform === 'win32' && this.proc.pid) {
|
|
524
525
|
try {
|
|
525
|
-
this.spawnImpl('taskkill', ['/pid', String(this.proc.pid), '/t', '/f'], {
|
|
526
|
+
this.spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(this.proc.pid), '/t', '/f'], {
|
|
526
527
|
stdio: 'ignore',
|
|
527
528
|
windowsHide: true,
|
|
528
529
|
});
|
|
@@ -11,7 +11,9 @@
|
|
|
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';
|
|
16
|
+
import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
|
|
15
17
|
|
|
16
18
|
const DEFAULT_OUTPUT_BYTE_LIMIT = 1024 * 1024;
|
|
17
19
|
const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024;
|
|
@@ -36,10 +38,18 @@ export function createAcpHostServices({
|
|
|
36
38
|
spawnImpl = spawn,
|
|
37
39
|
spawnSyncImpl = spawnSync,
|
|
38
40
|
fsImpl = fs,
|
|
41
|
+
// Told about every terminal's life (grok-background-tasks.mjs publishes
|
|
42
|
+
// them as background tasks): `started`, `output`, `killed`, `released`,
|
|
43
|
+
// `exited`. Advisory: an observer that throws never disturbs a terminal.
|
|
44
|
+
taskObserver = null,
|
|
39
45
|
dbg = () => {},
|
|
40
46
|
} = {}) {
|
|
41
47
|
const terminals = new Map();
|
|
42
48
|
let nextTerminalId = 1;
|
|
49
|
+
// Terminal ids become task ids and spool file names on the relay side: a
|
|
50
|
+
// fresh set of host services (the handle is rebuilt after a failed turn)
|
|
51
|
+
// must not reuse the ids of the one before it.
|
|
52
|
+
const terminalIdPrefix = `term-${randomBytes(3).toString('hex')}`;
|
|
43
53
|
let inflightRequests = 0;
|
|
44
54
|
let cachedWindowsShell = '';
|
|
45
55
|
let disposed = false;
|
|
@@ -55,9 +65,9 @@ export function createAcpHostServices({
|
|
|
55
65
|
timeout: 10_000,
|
|
56
66
|
stdio: 'ignore',
|
|
57
67
|
});
|
|
58
|
-
cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : 'powershell';
|
|
68
|
+
cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : windowsSystemToolPath('powershell');
|
|
59
69
|
} catch {
|
|
60
|
-
cachedWindowsShell = 'powershell';
|
|
70
|
+
cachedWindowsShell = windowsSystemToolPath('powershell');
|
|
61
71
|
}
|
|
62
72
|
return cachedWindowsShell;
|
|
63
73
|
}
|
|
@@ -76,9 +86,21 @@ export function createAcpHostServices({
|
|
|
76
86
|
return { file: shell, args: ['-c', command] };
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
function tell(hook, ...args) {
|
|
90
|
+
const fn = taskObserver?.[hook];
|
|
91
|
+
if (typeof fn !== 'function') return;
|
|
92
|
+
try {
|
|
93
|
+
const result = fn(...args);
|
|
94
|
+
if (result && typeof result.catch === 'function') result.catch(() => {});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
dbg('terminal observer failed', hook, error?.message || String(error));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
function appendOutput(entry, chunk) {
|
|
80
101
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
81
102
|
entry.lastActivityAt = Date.now();
|
|
103
|
+
tell('output', entry.terminalId, buf);
|
|
82
104
|
entry.chunks.push(buf);
|
|
83
105
|
entry.byteLength += buf.length;
|
|
84
106
|
// ACP truncates from the start of the output: the most recent bytes win.
|
|
@@ -100,6 +122,7 @@ export function createAcpHostServices({
|
|
|
100
122
|
if (entry.exitStatus) return;
|
|
101
123
|
entry.exitStatus = exitStatus;
|
|
102
124
|
for (const resolve of entry.exitWaiters.splice(0)) resolve(exitStatus);
|
|
125
|
+
tell('exited', entry.terminalId, exitStatus);
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
function getTerminal(terminalId) {
|
|
@@ -124,9 +147,12 @@ export function createAcpHostServices({
|
|
|
124
147
|
windowsHide: true,
|
|
125
148
|
shell: false,
|
|
126
149
|
});
|
|
127
|
-
const terminalId =
|
|
150
|
+
const terminalId = `${terminalIdPrefix}-${nextTerminalId++}`;
|
|
128
151
|
const entry = {
|
|
129
152
|
terminalId,
|
|
153
|
+
// What the task panel shows for it.
|
|
154
|
+
command: [command, ...(Array.isArray(params.args) ? params.args.map(String) : [])].join(' '),
|
|
155
|
+
startedAt: Date.now(),
|
|
130
156
|
proc,
|
|
131
157
|
chunks: [],
|
|
132
158
|
byteLength: 0,
|
|
@@ -151,6 +177,12 @@ export function createAcpHostServices({
|
|
|
151
177
|
});
|
|
152
178
|
});
|
|
153
179
|
terminals.set(terminalId, entry);
|
|
180
|
+
tell('started', {
|
|
181
|
+
terminalId,
|
|
182
|
+
command: entry.command,
|
|
183
|
+
startedAt: entry.startedAt,
|
|
184
|
+
kill: () => killEntryProcess(entry),
|
|
185
|
+
});
|
|
154
186
|
dbg('terminal/create', terminalId, command.slice(0, 160));
|
|
155
187
|
return { terminalId };
|
|
156
188
|
}
|
|
@@ -178,7 +210,7 @@ export function createAcpHostServices({
|
|
|
178
210
|
if (platform === 'win32' && entry.proc.pid) {
|
|
179
211
|
// proc.kill() only reaches the shell; shells leave grandchildren behind.
|
|
180
212
|
try {
|
|
181
|
-
spawnImpl('taskkill', ['/pid', String(entry.proc.pid), '/t', '/f'], {
|
|
213
|
+
spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
|
|
182
214
|
stdio: 'ignore',
|
|
183
215
|
windowsHide: true,
|
|
184
216
|
});
|
|
@@ -187,15 +219,19 @@ export function createAcpHostServices({
|
|
|
187
219
|
}
|
|
188
220
|
|
|
189
221
|
function terminalKill(params = {}) {
|
|
190
|
-
|
|
222
|
+
const entry = getTerminal(params.terminalId);
|
|
223
|
+
if (!entry.exitStatus) tell('killed', entry.terminalId);
|
|
224
|
+
killEntryProcess(entry);
|
|
191
225
|
return null;
|
|
192
226
|
}
|
|
193
227
|
|
|
194
228
|
function terminalRelease(params = {}) {
|
|
195
229
|
const entry = terminals.get(String(params?.terminalId || ''));
|
|
196
230
|
if (!entry) return null;
|
|
231
|
+
if (!entry.exitStatus) tell('killed', entry.terminalId);
|
|
197
232
|
killEntryProcess(entry);
|
|
198
233
|
terminals.delete(entry.terminalId);
|
|
234
|
+
tell('released', entry.terminalId);
|
|
199
235
|
return null;
|
|
200
236
|
}
|
|
201
237
|
|
|
@@ -269,7 +305,11 @@ export function createAcpHostServices({
|
|
|
269
305
|
|
|
270
306
|
function disposeAll() {
|
|
271
307
|
disposed = true;
|
|
272
|
-
for (const entry of terminals.values())
|
|
308
|
+
for (const entry of terminals.values()) {
|
|
309
|
+
if (!entry.exitStatus) tell('killed', entry.terminalId);
|
|
310
|
+
killEntryProcess(entry);
|
|
311
|
+
tell('released', entry.terminalId);
|
|
312
|
+
}
|
|
273
313
|
terminals.clear();
|
|
274
314
|
}
|
|
275
315
|
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
const SPOOL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
19
|
+
// Ended terminals remembered for a late exit after their release.
|
|
20
|
+
const MAX_TRACKED_TERMINALS = 200;
|
|
21
|
+
// ACP runs every shell command through a terminal, most of them over in a
|
|
22
|
+
// moment; only one that lives this long becomes a row (and later a settle
|
|
23
|
+
// notice), so a `ls` never crowds the panel's ended rows.
|
|
24
|
+
export const GROK_TERMINAL_PUBLISH_AFTER_MS = 2_000;
|
|
25
|
+
|
|
26
|
+
export function createGrokTaskPublisher({
|
|
27
|
+
api,
|
|
28
|
+
sdkSessionId,
|
|
29
|
+
spool = null,
|
|
30
|
+
// The relay dropped the newest set: ask for a heartbeat (the worker wires
|
|
31
|
+
// it to requestPulse), at most once per ten seconds.
|
|
32
|
+
onBackgroundTaskSetStale = () => {},
|
|
33
|
+
publishAfterMs = GROK_TERMINAL_PUBLISH_AFTER_MS,
|
|
34
|
+
now = () => Date.now(),
|
|
35
|
+
dbg = () => {},
|
|
36
|
+
} = {}) {
|
|
37
|
+
const taskSpool = spool || (() => {
|
|
38
|
+
try { return createTaskOutputSpool({ sessionId: sdkSessionId }); } catch (error) {
|
|
39
|
+
dbg('task output spool unavailable', error?.message || String(error));
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
// terminalId → { taskId, description, startedAt, kill, live, published,
|
|
44
|
+
// publishTimer, exited, stopRequested, killRequested }. `live`: in the set.
|
|
45
|
+
const terminals = new Map();
|
|
46
|
+
const keptSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
|
|
47
|
+
const epoch = now();
|
|
48
|
+
let seq = 0;
|
|
49
|
+
let staleNotedAt = 0;
|
|
50
|
+
|
|
51
|
+
// Ended tasks' files are kept about an hour for the relay; swept at start
|
|
52
|
+
// and hourly.
|
|
53
|
+
const sweep = () => {
|
|
54
|
+
try { taskSpool?.sweep(); } catch (error) { dbg('task output spool sweep failed', error?.message || String(error)); }
|
|
55
|
+
};
|
|
56
|
+
sweep();
|
|
57
|
+
const sweepTimer = setInterval(sweep, SPOOL_SWEEP_INTERVAL_MS);
|
|
58
|
+
sweepTimer.unref?.();
|
|
59
|
+
|
|
60
|
+
function outputPathFor(taskId) {
|
|
61
|
+
if (!taskSpool) return '';
|
|
62
|
+
try { return taskSpool.pathFor(taskId); } catch { return ''; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function rowOf(entry) {
|
|
66
|
+
const outputPath = outputPathFor(entry.taskId);
|
|
67
|
+
return {
|
|
68
|
+
taskId: entry.taskId,
|
|
69
|
+
taskType: 'local_bash',
|
|
70
|
+
description: entry.description,
|
|
71
|
+
startedAt: entry.startedAt,
|
|
72
|
+
stoppable: true,
|
|
73
|
+
...(outputPath ? { outputPath, outputKind: 'spool' } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function liveEntries() {
|
|
78
|
+
return [...terminals.values()].filter((entry) => entry.live);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function publish({ resync = false } = {}) {
|
|
82
|
+
const order = { epoch, seq: ++seq };
|
|
83
|
+
return Promise.resolve()
|
|
84
|
+
.then(() => api('POST', '/api/background-tasks', {
|
|
85
|
+
conversationId: sdkSessionId,
|
|
86
|
+
tasks: liveEntries().map(rowOf),
|
|
87
|
+
order,
|
|
88
|
+
...(resync ? { resync: true } : {}),
|
|
89
|
+
}))
|
|
90
|
+
.then((answer) => {
|
|
91
|
+
// `stale` for the newest post: the relay holds no current set of
|
|
92
|
+
// this worker. An older one was only overtaken.
|
|
93
|
+
if (answer?.stale !== true || order.seq !== seq) return;
|
|
94
|
+
dbg('the relay dropped the newest background task set', answer.reason || '');
|
|
95
|
+
if (now() - staleNotedAt < 10_000) return;
|
|
96
|
+
staleNotedAt = now();
|
|
97
|
+
try { onBackgroundTaskSetStale(); } catch {}
|
|
98
|
+
})
|
|
99
|
+
.catch((error) => {
|
|
100
|
+
dbg('background task publish failed', error?.message || String(error));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function forgetOldest() {
|
|
105
|
+
while (terminals.size > MAX_TRACKED_TERMINALS) {
|
|
106
|
+
const oldest = [...terminals.entries()].find(([, entry]) => entry.exited || entry.released);
|
|
107
|
+
if (!oldest) break;
|
|
108
|
+
clearTimeout(oldest[1].publishTimer);
|
|
109
|
+
terminals.delete(oldest[0]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `terminal/create` spawned a process. */
|
|
114
|
+
function started({ terminalId, command = '', startedAt = now(), kill = () => {} } = {}) {
|
|
115
|
+
const taskId = String(terminalId || '').trim();
|
|
116
|
+
if (!taskId) return Promise.resolve();
|
|
117
|
+
const entry = {
|
|
118
|
+
taskId,
|
|
119
|
+
description: String(command || '').trim().slice(0, MAX_DESCRIPTION_CHARS) || 'Terminal',
|
|
120
|
+
startedAt,
|
|
121
|
+
kill,
|
|
122
|
+
live: false,
|
|
123
|
+
published: false,
|
|
124
|
+
publishTimer: null,
|
|
125
|
+
exited: false,
|
|
126
|
+
released: false,
|
|
127
|
+
stopRequested: false,
|
|
128
|
+
killRequested: false,
|
|
129
|
+
};
|
|
130
|
+
terminals.set(taskId, entry);
|
|
131
|
+
forgetOldest();
|
|
132
|
+
// Output is spooled from the first byte (output() below), so the console
|
|
133
|
+
// is complete when the row appears.
|
|
134
|
+
const show = () => {
|
|
135
|
+
entry.publishTimer = null;
|
|
136
|
+
if (entry.exited || entry.released) return Promise.resolve();
|
|
137
|
+
entry.live = true;
|
|
138
|
+
entry.published = true;
|
|
139
|
+
return publish();
|
|
140
|
+
};
|
|
141
|
+
if (!(publishAfterMs > 0)) return show();
|
|
142
|
+
entry.publishTimer = setTimeout(show, publishAfterMs);
|
|
143
|
+
entry.publishTimer.unref?.();
|
|
144
|
+
return Promise.resolve();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A chunk of the terminal's stdout/stderr, as it arrives. */
|
|
148
|
+
function output(terminalId, chunk) {
|
|
149
|
+
const taskId = String(terminalId || '').trim();
|
|
150
|
+
if (!taskId || !taskSpool || !terminals.has(taskId)) return;
|
|
151
|
+
const result = taskSpool.append(taskId, chunk);
|
|
152
|
+
if (result && result.ok === false) dbg('task output spool append failed', taskId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The agent killed its own terminal (terminal/kill or release): a stop, not a failure. */
|
|
156
|
+
function killed(terminalId) {
|
|
157
|
+
const entry = terminals.get(String(terminalId || '').trim());
|
|
158
|
+
if (entry && !entry.exited) entry.killRequested = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `terminal/release`: the agent is done with it; the row goes. */
|
|
162
|
+
function released(terminalId) {
|
|
163
|
+
const entry = terminals.get(String(terminalId || '').trim());
|
|
164
|
+
if (!entry || entry.released) return Promise.resolve();
|
|
165
|
+
entry.released = true;
|
|
166
|
+
clearTimeout(entry.publishTimer);
|
|
167
|
+
entry.publishTimer = null;
|
|
168
|
+
if (!entry.live) return Promise.resolve();
|
|
169
|
+
entry.live = false;
|
|
170
|
+
return publish();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The process exited: say how, then publish the set without it. */
|
|
174
|
+
async function exited(terminalId, exitStatus = {}) {
|
|
175
|
+
const taskId = String(terminalId || '').trim();
|
|
176
|
+
const entry = terminals.get(taskId);
|
|
177
|
+
if (!entry || entry.exited) return;
|
|
178
|
+
entry.exited = true;
|
|
179
|
+
clearTimeout(entry.publishTimer);
|
|
180
|
+
entry.publishTimer = null;
|
|
181
|
+
const wasLive = entry.live;
|
|
182
|
+
entry.live = false;
|
|
183
|
+
try { taskSpool?.end?.(taskId); } catch {}
|
|
184
|
+
// Never shown: nothing to settle (its spool file is swept with the rest).
|
|
185
|
+
if (!entry.published) return;
|
|
186
|
+
const exitCode = Number.isInteger(exitStatus?.exitCode) ? exitStatus.exitCode : null;
|
|
187
|
+
const signal = String(exitStatus?.signal || '').trim();
|
|
188
|
+
const status = entry.stopRequested || entry.killRequested
|
|
189
|
+
? 'stopped'
|
|
190
|
+
: (exitCode === 0 ? 'completed' : 'failed');
|
|
191
|
+
const outputPath = outputPathFor(taskId);
|
|
192
|
+
await keptSettles.post('/api/background-task-settled', {
|
|
193
|
+
conversationId: sdkSessionId,
|
|
194
|
+
taskId,
|
|
195
|
+
toolUseId: null,
|
|
196
|
+
status,
|
|
197
|
+
summary: exitCode !== null ? `exit code ${exitCode}` : (signal ? `killed by ${signal}` : null),
|
|
198
|
+
description: entry.description,
|
|
199
|
+
taskType: 'local_bash',
|
|
200
|
+
startedAt: entry.startedAt,
|
|
201
|
+
// Absent rather than null: a killed process has no exit code.
|
|
202
|
+
...(exitCode !== null ? { exitCode } : {}),
|
|
203
|
+
...(outputPath ? { outputPath, outputKind: 'spool' } : {}),
|
|
204
|
+
});
|
|
205
|
+
if (wasLive) await publish();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The panel's Stop (worker control `stop_background_task`). */
|
|
209
|
+
function stop(taskId) {
|
|
210
|
+
const entry = terminals.get(String(taskId || '').trim());
|
|
211
|
+
if (!entry || !entry.live || entry.exited) return false;
|
|
212
|
+
entry.stopRequested = true;
|
|
213
|
+
try {
|
|
214
|
+
entry.kill();
|
|
215
|
+
return true;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
dbg('terminal stop failed', entry.taskId, error?.message || String(error));
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
started,
|
|
224
|
+
output,
|
|
225
|
+
killed,
|
|
226
|
+
released,
|
|
227
|
+
exited,
|
|
228
|
+
stop,
|
|
229
|
+
/** The ids of the live terminals, for the heartbeat's digest. */
|
|
230
|
+
backgroundTaskIds: () => liveEntries().map((entry) => entry.taskId),
|
|
231
|
+
/** The relay asked for the set again (heartbeat answer). */
|
|
232
|
+
resync: () => publish({ resync: true }),
|
|
233
|
+
/** The relay answered a heartbeat: settle notices kept for it go out. */
|
|
234
|
+
flushKeptSettles: () => keptSettles.flush(),
|
|
235
|
+
sweep,
|
|
236
|
+
close() {
|
|
237
|
+
clearInterval(sweepTimer);
|
|
238
|
+
for (const entry of terminals.values()) clearTimeout(entry.publishTimer);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -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
|
-
|
|
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;
|