@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.
- package/CHANGELOG.md +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- 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 +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -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 +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -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/0017-conversation-history-indexes.mjs +89 -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 +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- 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 +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- 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 +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -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 +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- 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 +18 -4
- 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 +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -58,6 +58,14 @@ import {
|
|
|
58
58
|
withSlashCommandGuardContent,
|
|
59
59
|
} from '../../shared/slash-command-guard.mjs';
|
|
60
60
|
import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
|
|
61
|
+
import {
|
|
62
|
+
discoverTaskOutputPaths,
|
|
63
|
+
shellCommandsOf,
|
|
64
|
+
exitCodeFromSummary,
|
|
65
|
+
isShellTaskType,
|
|
66
|
+
outputDirOf,
|
|
67
|
+
usableOutputPath,
|
|
68
|
+
} from './task-output-discovery.mjs';
|
|
61
69
|
|
|
62
70
|
/**
|
|
63
71
|
* Which system-prompt append a relay mode gets (claude-sdk-adapter's
|
|
@@ -77,6 +85,8 @@ export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
|
|
|
77
85
|
/** Task types that run a model of their own (vs. bash/monitor/workflow). */
|
|
78
86
|
const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
|
|
79
87
|
const MAX_TASK_TOOL_CALL_CHARS = 300;
|
|
88
|
+
/** Output paths remembered per process (one per backgrounded shell). */
|
|
89
|
+
const MAX_TASK_OUTPUT_PATHS = 200;
|
|
80
90
|
|
|
81
91
|
// ---------------------------------------------------------------------------
|
|
82
92
|
// Workflow progress sources (CLI 2.1.226 on-disk formats)
|
|
@@ -460,6 +470,10 @@ export function createClaudeSessionRunner({
|
|
|
460
470
|
// leaves the CLI's own attribution. Piggybacked on deliveries like the
|
|
461
471
|
// window above.
|
|
462
472
|
getAttribution = () => null,
|
|
473
|
+
// How long a backgrounded Bash command may run without its own timeout
|
|
474
|
+
// (ms; the relay's Claude setting). Read at spawn only: the CLI takes it
|
|
475
|
+
// from its environment, so a change applies to the next process.
|
|
476
|
+
getBackgroundCommandLimitMs = () => null,
|
|
463
477
|
// The per-conversation thinking state ({enabled, display}); read per turn
|
|
464
478
|
// like the window, so a settings change reaches a running process on its
|
|
465
479
|
// next delivery.
|
|
@@ -1769,6 +1783,11 @@ export function createClaudeSessionRunner({
|
|
|
1769
1783
|
// The workflow digest (poller below) rides the same row; the relay
|
|
1770
1784
|
// sanitizer whitelists and re-clamps it before storing.
|
|
1771
1785
|
...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
|
|
1786
|
+
...(agentLike && Number.isInteger(task.steps) ? { steps: task.steps } : {}),
|
|
1787
|
+
// Where the relay reads the task's console from, and the command
|
|
1788
|
+
// line the console reads a `| tail` off.
|
|
1789
|
+
...(isShellTaskType(task.taskType) ? taskOutputFields(processRef, taskId) : {}),
|
|
1790
|
+
...(isShellTaskType(task.taskType) && taskCommand(processRef, taskId, task) ? { command: taskCommand(processRef, taskId, task) } : {}),
|
|
1772
1791
|
};
|
|
1773
1792
|
});
|
|
1774
1793
|
return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
|
|
@@ -1788,6 +1807,126 @@ export function createClaudeSessionRunner({
|
|
|
1788
1807
|
return Promise.resolve();
|
|
1789
1808
|
}
|
|
1790
1809
|
|
|
1810
|
+
// ---------------------------------------------------------------------------
|
|
1811
|
+
// Task output files
|
|
1812
|
+
//
|
|
1813
|
+
// The relay reads a shell task's console straight from the file the CLI
|
|
1814
|
+
// writes (`<tasks dir>/<taskId>.output`); the worker only names it. Bash
|
|
1815
|
+
// results announce the path (task-output-discovery.mjs); a Monitor result
|
|
1816
|
+
// does not, but its file follows the same scheme next to any known one, and
|
|
1817
|
+
// is named only once it exists. Without a path the relay falls back to the
|
|
1818
|
+
// CLI's `getTaskOutput` snapshot (task_output_snapshot control).
|
|
1819
|
+
|
|
1820
|
+
function rememberTaskOutputPath(processRef, taskId, outputPath) {
|
|
1821
|
+
const id = String(taskId || '').trim();
|
|
1822
|
+
const usable = usableOutputPath(outputPath);
|
|
1823
|
+
if (!id || !usable) return false;
|
|
1824
|
+
const dir = outputDirOf(usable);
|
|
1825
|
+
if (dir) processRef.taskOutputDir = dir;
|
|
1826
|
+
// The first path a task names wins, here as on the relay.
|
|
1827
|
+
if (processRef.taskOutputPaths.has(id)) return false;
|
|
1828
|
+
processRef.taskOutputPaths.set(id, usable);
|
|
1829
|
+
while (processRef.taskOutputPaths.size > MAX_TASK_OUTPUT_PATHS) {
|
|
1830
|
+
processRef.taskOutputPaths.delete(processRef.taskOutputPaths.keys().next().value);
|
|
1831
|
+
}
|
|
1832
|
+
return true;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function taskOutputFields(processRef, taskId) {
|
|
1836
|
+
const known = processRef.taskOutputPaths.get(taskId);
|
|
1837
|
+
if (known) return { outputPath: known, outputKind: 'file' };
|
|
1838
|
+
if (processRef.taskOutputDir) {
|
|
1839
|
+
const candidate = nodePath.join(processRef.taskOutputDir, `${taskId}.output`);
|
|
1840
|
+
let exists = false;
|
|
1841
|
+
try { exists = nodeFs.existsSync(candidate); } catch {}
|
|
1842
|
+
if (exists && rememberTaskOutputPath(processRef, taskId, candidate)) {
|
|
1843
|
+
return { outputPath: candidate, outputKind: 'file' };
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return { outputKind: 'snapshot' };
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** A backgrounded Bash/Monitor result: note its file, and show it on the live row. */
|
|
1850
|
+
function recordTaskOutputPaths(sdkMessage) {
|
|
1851
|
+
let liveChanged = false;
|
|
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
|
+
}
|
|
1858
|
+
if (rememberTaskOutputPath(proc, taskId, outputPath) && proc.liveTasks.has(taskId)) liveChanged = true;
|
|
1859
|
+
}
|
|
1860
|
+
if (liveChanged) publishBackgroundTasks();
|
|
1861
|
+
}
|
|
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
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* The relay's fallback for a shell task with no file: the CLI's last 8 KiB
|
|
1897
|
+
* (`Query.getTaskOutput`, implemented in sdk.mjs though sdk.d.ts does not
|
|
1898
|
+
* declare it), posted for the relay to serve. Asked once per viewer open.
|
|
1899
|
+
*/
|
|
1900
|
+
async function snapshotTaskOutput(taskId) {
|
|
1901
|
+
const id = String(taskId || '').trim();
|
|
1902
|
+
const processRef = proc;
|
|
1903
|
+
if (!id || typeof processRef?.turn?.getTaskOutput !== 'function') {
|
|
1904
|
+
dbg('task output snapshot unavailable', id || '(no task)');
|
|
1905
|
+
return false;
|
|
1906
|
+
}
|
|
1907
|
+
let answer;
|
|
1908
|
+
try {
|
|
1909
|
+
answer = await processRef.turn.getTaskOutput(id);
|
|
1910
|
+
} catch (error) {
|
|
1911
|
+
dbg('getTaskOutput failed', id, error?.message || String(error));
|
|
1912
|
+
return false;
|
|
1913
|
+
}
|
|
1914
|
+
const output = typeof answer?.output === 'string' ? answer.output : '';
|
|
1915
|
+
const totalBytes = Number(answer?.total_bytes);
|
|
1916
|
+
try {
|
|
1917
|
+
await api('POST', `/api/background-tasks/${encodeURIComponent(id)}/output-snapshot`, {
|
|
1918
|
+
conversationId: sdkSessionId,
|
|
1919
|
+
output,
|
|
1920
|
+
totalBytes: Number.isFinite(totalBytes) && totalBytes >= 0 ? totalBytes : Buffer.byteLength(output),
|
|
1921
|
+
truncated: answer?.truncated === true,
|
|
1922
|
+
});
|
|
1923
|
+
return true;
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
dbg('task output snapshot post failed', id, error?.message || String(error));
|
|
1926
|
+
return false;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1791
1930
|
// ---------------------------------------------------------------------------
|
|
1792
1931
|
// Workflow progress poller
|
|
1793
1932
|
//
|
|
@@ -2222,10 +2361,16 @@ export function createClaudeSessionRunner({
|
|
|
2222
2361
|
function observeProcessLevel(sdkMessage) {
|
|
2223
2362
|
const type = String(sdkMessage?.type || '');
|
|
2224
2363
|
if (type === 'assistant') {
|
|
2364
|
+
recordShellCommands(sdkMessage);
|
|
2225
2365
|
recordSubagentSpawns(sdkMessage);
|
|
2226
2366
|
recordSubagentActivity(sdkMessage);
|
|
2227
2367
|
return;
|
|
2228
2368
|
}
|
|
2369
|
+
if (type === 'user') {
|
|
2370
|
+
// Any agent's backgrounded shell, the main thread's or a subagent's.
|
|
2371
|
+
recordTaskOutputPaths(sdkMessage);
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2229
2374
|
if (type !== 'system') return;
|
|
2230
2375
|
const subtype = String(sdkMessage.subtype || '');
|
|
2231
2376
|
if (subtype === 'init') {
|
|
@@ -2492,6 +2637,7 @@ export function createClaudeSessionRunner({
|
|
|
2492
2637
|
}
|
|
2493
2638
|
syncWorkflowPoller(proc);
|
|
2494
2639
|
publishBackgroundTasks();
|
|
2640
|
+
prunePinnedCommands(proc);
|
|
2495
2641
|
evaluateLifecycle();
|
|
2496
2642
|
return;
|
|
2497
2643
|
}
|
|
@@ -2524,6 +2670,9 @@ export function createClaudeSessionRunner({
|
|
|
2524
2670
|
known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
|
|
2525
2671
|
const totalTokens = Number(sdkMessage.usage?.total_tokens);
|
|
2526
2672
|
if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
|
|
2673
|
+
// The sub-agent's tool calls so far: the panel row's step count.
|
|
2674
|
+
const toolUses = Number(sdkMessage.usage?.tool_uses);
|
|
2675
|
+
if (Number.isFinite(toolUses) && toolUses >= 0) known.steps = Math.floor(toolUses);
|
|
2527
2676
|
publishBackgroundTasks({ throttled: true });
|
|
2528
2677
|
}
|
|
2529
2678
|
return;
|
|
@@ -2543,6 +2692,18 @@ export function createClaudeSessionRunner({
|
|
|
2543
2692
|
// tell its watchers the end. A failed post is kept and sent again.
|
|
2544
2693
|
const published = proc.liveTasks.get(taskId) || proc.departedTasks.get(taskId) || null;
|
|
2545
2694
|
proc.departedTasks.delete(taskId);
|
|
2695
|
+
// A shell's file and exit code make the relay's ended row: its
|
|
2696
|
+
// console stays readable after the task is gone.
|
|
2697
|
+
rememberTaskOutputPath(proc, taskId, sdkMessage.output_file);
|
|
2698
|
+
const shellLike = isShellTaskType(published?.taskType);
|
|
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);
|
|
2702
|
+
const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
|
|
2703
|
+
const toolUses = Number(sdkMessage.usage?.tool_uses);
|
|
2704
|
+
const steps = AGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
|
|
2705
|
+
? Math.floor(toolUses)
|
|
2706
|
+
: null;
|
|
2546
2707
|
void keptTaskSettles.post('/api/background-task-settled', {
|
|
2547
2708
|
conversationId: sdkSessionId,
|
|
2548
2709
|
taskId,
|
|
@@ -2555,6 +2716,10 @@ export function createClaudeSessionRunner({
|
|
|
2555
2716
|
taskType: String(published.taskType || ''),
|
|
2556
2717
|
startedAt: published.startedAt || null,
|
|
2557
2718
|
} : {}),
|
|
2719
|
+
...(outputPath ? { outputPath } : {}),
|
|
2720
|
+
...(command ? { command } : {}),
|
|
2721
|
+
...(exitCode !== null ? { exitCode } : {}),
|
|
2722
|
+
...(steps !== null ? { steps } : {}),
|
|
2558
2723
|
});
|
|
2559
2724
|
}
|
|
2560
2725
|
// A settled session-level task means the CLI is about to dequeue a
|
|
@@ -3365,6 +3530,7 @@ export function createClaudeSessionRunner({
|
|
|
3365
3530
|
const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
|
|
3366
3531
|
const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
|
|
3367
3532
|
const attribution = getAttribution() || null;
|
|
3533
|
+
const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
|
|
3368
3534
|
const abortController = new AbortController();
|
|
3369
3535
|
const processRef = {
|
|
3370
3536
|
turn: null,
|
|
@@ -3391,6 +3557,17 @@ export function createClaudeSessionRunner({
|
|
|
3391
3557
|
// taskId → the row of a task that left the live set, until its
|
|
3392
3558
|
// notification arrives.
|
|
3393
3559
|
departedTasks: new Map(),
|
|
3560
|
+
// taskId → the file a shell task's output goes to (first named wins),
|
|
3561
|
+
// and the directory of the last one seen: a Monitor task's file sits
|
|
3562
|
+
// next to it.
|
|
3563
|
+
taskOutputPaths: new Map(),
|
|
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(),
|
|
3394
3571
|
// taskId → { runId, labels, digestJson } for live local_workflow tasks;
|
|
3395
3572
|
// pruned by syncWorkflowPoller when a task leaves the live set.
|
|
3396
3573
|
workflowStates: new Map(),
|
|
@@ -3538,6 +3715,7 @@ export function createClaudeSessionRunner({
|
|
|
3538
3715
|
thinkingEnabled,
|
|
3539
3716
|
thinkingDisplay,
|
|
3540
3717
|
attribution,
|
|
3718
|
+
backgroundCommandLimitMs,
|
|
3541
3719
|
abortController,
|
|
3542
3720
|
canUseTool,
|
|
3543
3721
|
pathToClaudeCodeExecutable,
|
|
@@ -4121,6 +4299,7 @@ export function createClaudeSessionRunner({
|
|
|
4121
4299
|
isTurnActive,
|
|
4122
4300
|
stopBackgroundTask,
|
|
4123
4301
|
stopBackgroundTaskByToolUseId,
|
|
4302
|
+
snapshotTaskOutput,
|
|
4124
4303
|
backgroundTaskIds,
|
|
4125
4304
|
resyncBackgroundTasks,
|
|
4126
4305
|
flushKeptTaskSettles,
|
|
@@ -18,6 +18,10 @@ import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
|
18
18
|
import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
19
19
|
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
20
20
|
import { resolveDeliveredAttribution } from '../../shared/claude-attribution.mjs';
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS,
|
|
23
|
+
resolveDeliveredClaudeBackgroundCommandLimitMs,
|
|
24
|
+
} from '../../shared/claude-background-command-limit.mjs';
|
|
21
25
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
22
26
|
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
23
27
|
import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
|
|
@@ -70,6 +74,9 @@ async function main() {
|
|
|
70
74
|
// What this session's commits and PRs say; same piggyback. null = the
|
|
71
75
|
// CLI's own attribution (vanilla).
|
|
72
76
|
let attribution = null;
|
|
77
|
+
// How long a backgrounded Bash command may run without its own timeout;
|
|
78
|
+
// same piggyback, applied when the CLI process is next spawned.
|
|
79
|
+
let backgroundCommandLimitMs = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
|
|
73
80
|
|
|
74
81
|
const api = createApiClient({
|
|
75
82
|
serverUrl,
|
|
@@ -116,6 +123,7 @@ async function main() {
|
|
|
116
123
|
getAutoCompactWindow: () => autoCompactWindow,
|
|
117
124
|
getThinking: () => thinking,
|
|
118
125
|
getAttribution: () => attribution,
|
|
126
|
+
getBackgroundCommandLimitMs: () => backgroundCommandLimitMs,
|
|
119
127
|
// A readiness flip is a hold starting or ending (question card,
|
|
120
128
|
// compaction, a turn-opening delivery): the steering snapshot changed
|
|
121
129
|
// with it, so it goes out now rather than on the next 10 s heartbeat.
|
|
@@ -190,6 +198,7 @@ async function main() {
|
|
|
190
198
|
// user choosing Host default.
|
|
191
199
|
thinking = resolveDeliveredThinking(thinking, pending?.settings);
|
|
192
200
|
attribution = resolveDeliveredAttribution(attribution, pending?.settings);
|
|
201
|
+
backgroundCommandLimitMs = resolveDeliveredClaudeBackgroundCommandLimitMs(backgroundCommandLimitMs, pending?.settings);
|
|
193
202
|
try {
|
|
194
203
|
return await turnRunner.handlePendingPayload(pending);
|
|
195
204
|
} catch (error) {
|
|
@@ -205,6 +214,14 @@ async function main() {
|
|
|
205
214
|
await turnRunner.stopBackgroundTask(taskId);
|
|
206
215
|
return;
|
|
207
216
|
}
|
|
217
|
+
if (type === 'task_output_snapshot') {
|
|
218
|
+
// A console opened on a shell task with no output file: the CLI's
|
|
219
|
+
// last 8 KiB goes to the relay, which serves it as a static tail.
|
|
220
|
+
const taskId = String(control?.taskId || '').trim();
|
|
221
|
+
dbg('task output snapshot control', taskId);
|
|
222
|
+
await turnRunner.snapshotTaskOutput(taskId);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
208
225
|
dbg('unknown worker control ignored', type || '(none)');
|
|
209
226
|
},
|
|
210
227
|
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Where a Claude background shell writes its output, read off the SDK stream.
|
|
2
|
+
//
|
|
3
|
+
// The CLI writes every Bash task (and every Monitor tool task, which is a
|
|
4
|
+
// `local_bash` task of kind "monitor") to `<tasks dir>/<taskId>.output`, and
|
|
5
|
+
// tells the model the path in the Bash tool's result text: "Command running in
|
|
6
|
+
// background with ID: <id>. Output is being written to: <path>." (wording
|
|
7
|
+
// read from the bundled CLI, SDK 0.3.292). The structured result
|
|
8
|
+
// (`SDKUserMessage.tool_use_result`, a BashOutput) names the task id as
|
|
9
|
+
// `backgroundTaskId` but carries no path of its own. `task_notification`
|
|
10
|
+
// repeats the path as `output_file` when the task ends.
|
|
11
|
+
import nodePath from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** Task types whose output is a shell's stdout/stderr (getTaskOutput serves these too). */
|
|
14
|
+
export const SHELL_TASK_TYPES = new Set(['local_bash', 'monitor', 'monitor_mcp', 'monitor_ws']);
|
|
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']);
|
|
23
|
+
|
|
24
|
+
const OUTPUT_MARKER = 'Output is being written to';
|
|
25
|
+
// The CLI's own file name: a path with spaces (a Windows profile dir) still
|
|
26
|
+
// reads whole, up to its `.output` and the sentence's full stop.
|
|
27
|
+
const OUTPUT_FILE_PATTERN = /Output is being written to:?\s+([^\n;]+?\.output)(?=[.;,]?(?:\s|$))/;
|
|
28
|
+
// Any other shape: one token, without the trailing punctuation.
|
|
29
|
+
const OUTPUT_TOKEN_PATTERN = /Output is being written to:?\s+(\S+?)[.;,]?(?:\s|$)/;
|
|
30
|
+
const TASK_ID_PATTERN = /\bID:\s*([A-Za-z0-9_-]+)/;
|
|
31
|
+
// What the CLI's own background notes start with (Bash, SDK 0.3.292). A
|
|
32
|
+
// foreground command that merely prints "Output is being written to: ..."
|
|
33
|
+
// must never bind a path to a task, so only these, the MCP form below, or a
|
|
34
|
+
// result whose structured output names the task are read.
|
|
35
|
+
const CLI_BACKGROUND_PREFIXES = [
|
|
36
|
+
'Command running in background',
|
|
37
|
+
'Command was manually backgrounded',
|
|
38
|
+
'Command was moved to the background',
|
|
39
|
+
'Command did not complete within',
|
|
40
|
+
];
|
|
41
|
+
// The backgrounded MCP task's note: no colon, then "; use <tool> on that path".
|
|
42
|
+
const MCP_BACKGROUND_PATTERN = /Output is being written to (\S[^\n;]*?\.output); use \S/;
|
|
43
|
+
|
|
44
|
+
export function isShellTaskType(taskType) {
|
|
45
|
+
return SHELL_TASK_TYPES.has(String(taskType || '').trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An absolute path of sane length, or ''. Windows shapes count on every host. */
|
|
49
|
+
export function usableOutputPath(value) {
|
|
50
|
+
const text = String(value || '').trim();
|
|
51
|
+
if (!text || text.length > MAX_OUTPUT_PATH_CHARS) return '';
|
|
52
|
+
return nodePath.posix.isAbsolute(text) || nodePath.win32.isAbsolute(text) ? text : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `.../tasks/b8k2m4x7q.output` → `b8k2m4x7q`. */
|
|
56
|
+
function taskIdFromOutputFile(filePath) {
|
|
57
|
+
const base = String(filePath).split(/[\\/]/).pop() || '';
|
|
58
|
+
return base.endsWith('.output') ? base.slice(0, -'.output'.length) : '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The directory part of a path, for either separator. */
|
|
62
|
+
export function outputDirOf(filePath) {
|
|
63
|
+
const text = String(filePath || '');
|
|
64
|
+
const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'));
|
|
65
|
+
return cut > 0 ? text.slice(0, cut) : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toolResultText(block) {
|
|
69
|
+
const content = block?.content;
|
|
70
|
+
if (typeof content === 'string') return content;
|
|
71
|
+
if (!Array.isArray(content)) return '';
|
|
72
|
+
return content
|
|
73
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
74
|
+
.map((part) => part.text)
|
|
75
|
+
.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `{ taskId, outputPath }` from one tool result's text, or null. */
|
|
79
|
+
export function parseOutputAnnouncement(text, knownTaskId = '') {
|
|
80
|
+
const source = String(text || '');
|
|
81
|
+
if (!source.includes(OUTPUT_MARKER)) return null;
|
|
82
|
+
const match = source.match(OUTPUT_FILE_PATTERN) || source.match(OUTPUT_TOKEN_PATTERN);
|
|
83
|
+
const outputPath = usableOutputPath(match?.[1]);
|
|
84
|
+
if (!outputPath) return null;
|
|
85
|
+
const taskId = String(knownTaskId || '').trim()
|
|
86
|
+
|| taskIdFromOutputFile(outputPath)
|
|
87
|
+
|| (source.match(TASK_ID_PATTERN)?.[1] || '');
|
|
88
|
+
return taskId ? { taskId, outputPath } : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The CLI's own note about a backgrounded call, not a command's printed output. */
|
|
92
|
+
function isBackgroundNote(text) {
|
|
93
|
+
const source = String(text || '').trimStart();
|
|
94
|
+
if (CLI_BACKGROUND_PREFIXES.some((prefix) => source.startsWith(prefix))) return true;
|
|
95
|
+
const mcp = source.match(MCP_BACKGROUND_PATTERN);
|
|
96
|
+
return Boolean(mcp && taskIdFromOutputFile(mcp[1]));
|
|
97
|
+
}
|
|
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
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every task output file a `user` SDK message announces: the tool_result
|
|
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
|
|
121
|
+
* `backgroundTaskId` names the task when the message answers a single call.
|
|
122
|
+
*/
|
|
123
|
+
export function discoverTaskOutputPaths(sdkMessage) {
|
|
124
|
+
if (String(sdkMessage?.type || '') !== 'user') return [];
|
|
125
|
+
const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
126
|
+
const results = blocks.filter((block) => block?.type === 'tool_result');
|
|
127
|
+
if (!results.length) return [];
|
|
128
|
+
const structured = sdkMessage.tool_use_result && typeof sdkMessage.tool_use_result === 'object'
|
|
129
|
+
? sdkMessage.tool_use_result
|
|
130
|
+
: null;
|
|
131
|
+
const structuredTaskId = results.length === 1 ? String(structured?.backgroundTaskId || '').trim() : '';
|
|
132
|
+
const found = [];
|
|
133
|
+
for (const block of results) {
|
|
134
|
+
const text = toolResultText(block);
|
|
135
|
+
if (!structuredTaskId && !isBackgroundNote(text)) continue;
|
|
136
|
+
const announced = parseOutputAnnouncement(text, structuredTaskId);
|
|
137
|
+
if (announced) found.push({ ...announced, toolUseId: String(block.tool_use_id || '').trim() });
|
|
138
|
+
}
|
|
139
|
+
return found;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The exit code a shell task's settle summary names: `Background command
|
|
144
|
+
* "npm test" failed with exit code 2` / `... completed (exit code 0)`.
|
|
145
|
+
* The last mention wins (the command's own description may say "exit code").
|
|
146
|
+
*/
|
|
147
|
+
export function exitCodeFromSummary(summary) {
|
|
148
|
+
const matches = [...String(summary || '').matchAll(/exit code (-?\d+)/gi)];
|
|
149
|
+
if (!matches.length) return null;
|
|
150
|
+
const code = Number(matches[matches.length - 1][1]);
|
|
151
|
+
return Number.isInteger(code) ? code : null;
|
|
152
|
+
}
|
|
@@ -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
|
}
|