@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.
- package/CHANGELOG.md +206 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/README.md +186 -11
- package/server/claude-worker/claude-session-process.mjs +53 -2
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +57 -3
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +67 -0
- package/server/relay-tools.md +5 -0
- package/server/repositories/message-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +155 -27
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +75 -3
- package/server/server-runtime.mjs +192 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +187 -19
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-service.mjs +217 -18
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +15 -8
- package/shared/task-output-text.mjs +196 -83
- 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
|
|
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
|
});
|
package/server/db-schema.mjs
CHANGED
|
@@ -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' :
|
|
71
|
+
cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : systemToolPath('powershell');
|
|
69
72
|
} catch {
|
|
70
|
-
cachedWindowsShell =
|
|
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(
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
1440
|
-
|
|
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
|