@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
|
@@ -43,6 +43,7 @@ import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
|
43
43
|
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
44
44
|
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
45
45
|
import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
46
|
+
import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
|
|
46
47
|
import {
|
|
47
48
|
ANSWERED_ELSEWHERE_KIND,
|
|
48
49
|
DELIVERY_ANSWERED_ELSEWHERE_TEXT,
|
|
@@ -57,6 +58,13 @@ import {
|
|
|
57
58
|
withSlashCommandGuardContent,
|
|
58
59
|
} from '../../shared/slash-command-guard.mjs';
|
|
59
60
|
import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
|
|
61
|
+
import {
|
|
62
|
+
discoverTaskOutputPaths,
|
|
63
|
+
exitCodeFromSummary,
|
|
64
|
+
isShellTaskType,
|
|
65
|
+
outputDirOf,
|
|
66
|
+
usableOutputPath,
|
|
67
|
+
} from './task-output-discovery.mjs';
|
|
60
68
|
|
|
61
69
|
/**
|
|
62
70
|
* Which system-prompt append a relay mode gets (claude-sdk-adapter's
|
|
@@ -76,6 +84,8 @@ export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
|
|
|
76
84
|
/** Task types that run a model of their own (vs. bash/monitor/workflow). */
|
|
77
85
|
const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
|
|
78
86
|
const MAX_TASK_TOOL_CALL_CHARS = 300;
|
|
87
|
+
/** Output paths remembered per process (one per backgrounded shell). */
|
|
88
|
+
const MAX_TASK_OUTPUT_PATHS = 200;
|
|
79
89
|
|
|
80
90
|
// ---------------------------------------------------------------------------
|
|
81
91
|
// Workflow progress sources (CLI 2.1.226 on-disk formats)
|
|
@@ -459,6 +469,10 @@ export function createClaudeSessionRunner({
|
|
|
459
469
|
// leaves the CLI's own attribution. Piggybacked on deliveries like the
|
|
460
470
|
// window above.
|
|
461
471
|
getAttribution = () => null,
|
|
472
|
+
// How long a backgrounded Bash command may run without its own timeout
|
|
473
|
+
// (ms; the relay's Claude setting). Read at spawn only: the CLI takes it
|
|
474
|
+
// from its environment, so a change applies to the next process.
|
|
475
|
+
getBackgroundCommandLimitMs = () => null,
|
|
462
476
|
// The per-conversation thinking state ({enabled, display}); read per turn
|
|
463
477
|
// like the window, so a settings change reaches a running process on its
|
|
464
478
|
// next delivery.
|
|
@@ -546,6 +560,10 @@ export function createClaudeSessionRunner({
|
|
|
546
560
|
// the socket link: a hold withdraws the relay's readiness, and its end
|
|
547
561
|
// re-arms it at once so a held message steers into the resumed turn.
|
|
548
562
|
onDeliveryReadinessChange = () => {},
|
|
563
|
+
// The relay turned this worker's newest task set away (`stale`): the
|
|
564
|
+
// worker asks for a heartbeat, on which the relay learns which process
|
|
565
|
+
// holds the session and asks for the set again.
|
|
566
|
+
onBackgroundTaskSetStale = () => {},
|
|
549
567
|
// Whether CLI processes carry the relay's `remote_relay` tool
|
|
550
568
|
// (shared/remote-relay-tool-core.mjs). Default: ask the relay.
|
|
551
569
|
remoteRelayToolGate = null,
|
|
@@ -604,6 +622,34 @@ export function createClaudeSessionRunner({
|
|
|
604
622
|
// id → terminalError for settling rows the worker gave up posting; reported
|
|
605
623
|
// in the heartbeat's `settleFailed` until the relay confirms it failed them.
|
|
606
624
|
const settleFailedTerminals = new Map();
|
|
625
|
+
// Settle notices of background tasks whose post failed (the relay was
|
|
626
|
+
// away): sent again after the next heartbeat it answers.
|
|
627
|
+
// Every post of the task set carries its place in this worker's order:
|
|
628
|
+
// two posts can overtake each other on the way, and the relay applies the
|
|
629
|
+
// newer one only. `epoch` tells this process from one before it.
|
|
630
|
+
const taskSetEpoch = Date.now();
|
|
631
|
+
let taskSetSeq = 0;
|
|
632
|
+
const nextTaskSetOrder = () => ({ epoch: taskSetEpoch, seq: ++taskSetSeq });
|
|
633
|
+
let taskSetStaleNotedAt = 0;
|
|
634
|
+
/**
|
|
635
|
+
* The relay's answer to a set post. `stale` for the newest one means the
|
|
636
|
+
* relay holds no current set of this worker; an older one was simply
|
|
637
|
+
* overtaken. At most one extra heartbeat per ten seconds: the periodic
|
|
638
|
+
* beat repeats the request anyway.
|
|
639
|
+
*/
|
|
640
|
+
function noteTaskSetAnswer(answer, order) {
|
|
641
|
+
if (answer?.stale !== true || order.seq !== taskSetSeq) return;
|
|
642
|
+
dbg('the relay dropped the newest background task set', answer.reason || '');
|
|
643
|
+
if (Date.now() - taskSetStaleNotedAt < 10_000) return;
|
|
644
|
+
taskSetStaleNotedAt = Date.now();
|
|
645
|
+
try { onBackgroundTaskSetStale(); } catch {}
|
|
646
|
+
}
|
|
647
|
+
const postTaskSet = (body) => {
|
|
648
|
+
const order = nextTaskSetOrder();
|
|
649
|
+
return api('POST', '/api/background-tasks', { conversationId: sdkSessionId, ...body, order })
|
|
650
|
+
.then((answer) => noteTaskSetAnswer(answer, order));
|
|
651
|
+
};
|
|
652
|
+
const keptTaskSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
|
|
607
653
|
// Row id → the latest in-flight /api/queue-consumed POST touching it.
|
|
608
654
|
const consumedMarkChains = new Map();
|
|
609
655
|
|
|
@@ -1694,10 +1740,19 @@ export function createClaudeSessionRunner({
|
|
|
1694
1740
|
* which renders it as the composer's background-tasks panel. Membership
|
|
1695
1741
|
* changes post immediately; chatty progress updates are trailing-edge
|
|
1696
1742
|
* throttled. Advisory: failures must never disturb the stream consumer.
|
|
1743
|
+
*
|
|
1744
|
+
* `resync`: the relay asked for the set again on a heartbeat, because its
|
|
1745
|
+
* own set for this worker differs (it restarted, or a post was lost). The
|
|
1746
|
+
* same set, flagged, and "no tasks" is an answer too. Returns the post.
|
|
1697
1747
|
*/
|
|
1698
|
-
function publishBackgroundTasks({ throttled = false } = {}) {
|
|
1748
|
+
function publishBackgroundTasks({ throttled = false, resync = false } = {}) {
|
|
1699
1749
|
const processRef = proc;
|
|
1700
|
-
if (!processRef)
|
|
1750
|
+
if (!processRef) {
|
|
1751
|
+
if (!resync) return Promise.resolve();
|
|
1752
|
+
return postTaskSet({ tasks: [], resync: true }).catch((error) => {
|
|
1753
|
+
dbg('background task resync failed', error?.message || String(error));
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1701
1756
|
const send = () => {
|
|
1702
1757
|
processRef.taskPublishTimer = null;
|
|
1703
1758
|
const tasks = [...processRef.liveTasks.entries()].map(([taskId, task]) => {
|
|
@@ -1727,9 +1782,12 @@ export function createClaudeSessionRunner({
|
|
|
1727
1782
|
// The workflow digest (poller below) rides the same row; the relay
|
|
1728
1783
|
// sanitizer whitelists and re-clamps it before storing.
|
|
1729
1784
|
...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
|
|
1785
|
+
...(agentLike && Number.isInteger(task.steps) ? { steps: task.steps } : {}),
|
|
1786
|
+
// Where the relay reads the task's console from.
|
|
1787
|
+
...(isShellTaskType(task.taskType) ? taskOutputFields(processRef, taskId) : {}),
|
|
1730
1788
|
};
|
|
1731
1789
|
});
|
|
1732
|
-
|
|
1790
|
+
return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
|
|
1733
1791
|
dbg('background task publish failed', error?.message || String(error));
|
|
1734
1792
|
});
|
|
1735
1793
|
};
|
|
@@ -1738,12 +1796,95 @@ export function createClaudeSessionRunner({
|
|
|
1738
1796
|
clearTimeout(processRef.taskPublishTimer);
|
|
1739
1797
|
processRef.taskPublishTimer = null;
|
|
1740
1798
|
}
|
|
1741
|
-
send();
|
|
1742
|
-
return;
|
|
1799
|
+
return send();
|
|
1743
1800
|
}
|
|
1744
|
-
if (processRef.taskPublishTimer) return;
|
|
1801
|
+
if (processRef.taskPublishTimer) return Promise.resolve();
|
|
1745
1802
|
processRef.taskPublishTimer = setTimeout(send, 2_000);
|
|
1746
1803
|
processRef.taskPublishTimer.unref?.();
|
|
1804
|
+
return Promise.resolve();
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// ---------------------------------------------------------------------------
|
|
1808
|
+
// Task output files
|
|
1809
|
+
//
|
|
1810
|
+
// The relay reads a shell task's console straight from the file the CLI
|
|
1811
|
+
// writes (`<tasks dir>/<taskId>.output`); the worker only names it. Bash
|
|
1812
|
+
// results announce the path (task-output-discovery.mjs); a Monitor result
|
|
1813
|
+
// does not, but its file follows the same scheme next to any known one, and
|
|
1814
|
+
// is named only once it exists. Without a path the relay falls back to the
|
|
1815
|
+
// CLI's `getTaskOutput` snapshot (task_output_snapshot control).
|
|
1816
|
+
|
|
1817
|
+
function rememberTaskOutputPath(processRef, taskId, outputPath) {
|
|
1818
|
+
const id = String(taskId || '').trim();
|
|
1819
|
+
const usable = usableOutputPath(outputPath);
|
|
1820
|
+
if (!id || !usable) return false;
|
|
1821
|
+
const dir = outputDirOf(usable);
|
|
1822
|
+
if (dir) processRef.taskOutputDir = dir;
|
|
1823
|
+
// The first path a task names wins, here as on the relay.
|
|
1824
|
+
if (processRef.taskOutputPaths.has(id)) return false;
|
|
1825
|
+
processRef.taskOutputPaths.set(id, usable);
|
|
1826
|
+
while (processRef.taskOutputPaths.size > MAX_TASK_OUTPUT_PATHS) {
|
|
1827
|
+
processRef.taskOutputPaths.delete(processRef.taskOutputPaths.keys().next().value);
|
|
1828
|
+
}
|
|
1829
|
+
return true;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function taskOutputFields(processRef, taskId) {
|
|
1833
|
+
const known = processRef.taskOutputPaths.get(taskId);
|
|
1834
|
+
if (known) return { outputPath: known, outputKind: 'file' };
|
|
1835
|
+
if (processRef.taskOutputDir) {
|
|
1836
|
+
const candidate = nodePath.join(processRef.taskOutputDir, `${taskId}.output`);
|
|
1837
|
+
let exists = false;
|
|
1838
|
+
try { exists = nodeFs.existsSync(candidate); } catch {}
|
|
1839
|
+
if (exists && rememberTaskOutputPath(processRef, taskId, candidate)) {
|
|
1840
|
+
return { outputPath: candidate, outputKind: 'file' };
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return { outputKind: 'snapshot' };
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/** A backgrounded Bash/Monitor result: note its file, and show it on the live row. */
|
|
1847
|
+
function recordTaskOutputPaths(sdkMessage) {
|
|
1848
|
+
let liveChanged = false;
|
|
1849
|
+
for (const { taskId, outputPath } of discoverTaskOutputPaths(sdkMessage)) {
|
|
1850
|
+
if (rememberTaskOutputPath(proc, taskId, outputPath) && proc.liveTasks.has(taskId)) liveChanged = true;
|
|
1851
|
+
}
|
|
1852
|
+
if (liveChanged) publishBackgroundTasks();
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* The relay's fallback for a shell task with no file: the CLI's last 8 KiB
|
|
1857
|
+
* (`Query.getTaskOutput`, implemented in sdk.mjs though sdk.d.ts does not
|
|
1858
|
+
* declare it), posted for the relay to serve. Asked once per viewer open.
|
|
1859
|
+
*/
|
|
1860
|
+
async function snapshotTaskOutput(taskId) {
|
|
1861
|
+
const id = String(taskId || '').trim();
|
|
1862
|
+
const processRef = proc;
|
|
1863
|
+
if (!id || typeof processRef?.turn?.getTaskOutput !== 'function') {
|
|
1864
|
+
dbg('task output snapshot unavailable', id || '(no task)');
|
|
1865
|
+
return false;
|
|
1866
|
+
}
|
|
1867
|
+
let answer;
|
|
1868
|
+
try {
|
|
1869
|
+
answer = await processRef.turn.getTaskOutput(id);
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
dbg('getTaskOutput failed', id, error?.message || String(error));
|
|
1872
|
+
return false;
|
|
1873
|
+
}
|
|
1874
|
+
const output = typeof answer?.output === 'string' ? answer.output : '';
|
|
1875
|
+
const totalBytes = Number(answer?.total_bytes);
|
|
1876
|
+
try {
|
|
1877
|
+
await api('POST', `/api/background-tasks/${encodeURIComponent(id)}/output-snapshot`, {
|
|
1878
|
+
conversationId: sdkSessionId,
|
|
1879
|
+
output,
|
|
1880
|
+
totalBytes: Number.isFinite(totalBytes) && totalBytes >= 0 ? totalBytes : Buffer.byteLength(output),
|
|
1881
|
+
truncated: answer?.truncated === true,
|
|
1882
|
+
});
|
|
1883
|
+
return true;
|
|
1884
|
+
} catch (error) {
|
|
1885
|
+
dbg('task output snapshot post failed', id, error?.message || String(error));
|
|
1886
|
+
return false;
|
|
1887
|
+
}
|
|
1747
1888
|
}
|
|
1748
1889
|
|
|
1749
1890
|
// ---------------------------------------------------------------------------
|
|
@@ -2184,6 +2325,11 @@ export function createClaudeSessionRunner({
|
|
|
2184
2325
|
recordSubagentActivity(sdkMessage);
|
|
2185
2326
|
return;
|
|
2186
2327
|
}
|
|
2328
|
+
if (type === 'user') {
|
|
2329
|
+
// Any agent's backgrounded shell, the main thread's or a subagent's.
|
|
2330
|
+
recordTaskOutputPaths(sdkMessage);
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2187
2333
|
if (type !== 'system') return;
|
|
2188
2334
|
const subtype = String(sdkMessage.subtype || '');
|
|
2189
2335
|
if (subtype === 'init') {
|
|
@@ -2441,7 +2587,12 @@ export function createClaudeSessionRunner({
|
|
|
2441
2587
|
// capture a settling workflow's final digest for the transcript card
|
|
2442
2588
|
// (the notification then refines the status when it lands).
|
|
2443
2589
|
for (const [taskId, task] of previous) {
|
|
2444
|
-
if (
|
|
2590
|
+
if (proc.liveTasks.has(taskId)) continue;
|
|
2591
|
+
bufferSettledWorkflowRun(taskId, task);
|
|
2592
|
+
// What the task was, for its settle notice: the row is gone by the
|
|
2593
|
+
// time the notification says how it ended.
|
|
2594
|
+
proc.departedTasks.set(taskId, task);
|
|
2595
|
+
if (proc.departedTasks.size > 50) proc.departedTasks.delete(proc.departedTasks.keys().next().value);
|
|
2445
2596
|
}
|
|
2446
2597
|
syncWorkflowPoller(proc);
|
|
2447
2598
|
publishBackgroundTasks();
|
|
@@ -2477,6 +2628,9 @@ export function createClaudeSessionRunner({
|
|
|
2477
2628
|
known.toolUseId = String(sdkMessage.tool_use_id || '').trim() || known.toolUseId || '';
|
|
2478
2629
|
const totalTokens = Number(sdkMessage.usage?.total_tokens);
|
|
2479
2630
|
if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
|
|
2631
|
+
// The sub-agent's tool calls so far: the panel row's step count.
|
|
2632
|
+
const toolUses = Number(sdkMessage.usage?.tool_uses);
|
|
2633
|
+
if (Number.isFinite(toolUses) && toolUses >= 0) known.steps = Math.floor(toolUses);
|
|
2480
2634
|
publishBackgroundTasks({ throttled: true });
|
|
2481
2635
|
}
|
|
2482
2636
|
return;
|
|
@@ -2491,14 +2645,37 @@ export function createClaudeSessionRunner({
|
|
|
2491
2645
|
// this session (the live set only says it is gone). Ambient tasks are
|
|
2492
2646
|
// not activity. Advisory: a failed post never disturbs the stream.
|
|
2493
2647
|
if (taskId && sdkMessage.ambient !== true) {
|
|
2494
|
-
|
|
2648
|
+
// A task that was in the published set is described too, so that a
|
|
2649
|
+
// relay which no longer knows it (it restarted meanwhile) can still
|
|
2650
|
+
// tell its watchers the end. A failed post is kept and sent again.
|
|
2651
|
+
const published = proc.liveTasks.get(taskId) || proc.departedTasks.get(taskId) || null;
|
|
2652
|
+
proc.departedTasks.delete(taskId);
|
|
2653
|
+
// A shell's file and exit code make the relay's ended row: its
|
|
2654
|
+
// console stays readable after the task is gone.
|
|
2655
|
+
rememberTaskOutputPath(proc, taskId, sdkMessage.output_file);
|
|
2656
|
+
const shellLike = isShellTaskType(published?.taskType);
|
|
2657
|
+
const outputPath = shellLike ? (proc.taskOutputPaths.get(taskId) || '') : '';
|
|
2658
|
+
const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
|
|
2659
|
+
const toolUses = Number(sdkMessage.usage?.tool_uses);
|
|
2660
|
+
const steps = AGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
|
|
2661
|
+
? Math.floor(toolUses)
|
|
2662
|
+
: null;
|
|
2663
|
+
void keptTaskSettles.post('/api/background-task-settled', {
|
|
2495
2664
|
conversationId: sdkSessionId,
|
|
2496
2665
|
taskId,
|
|
2497
|
-
toolUseId: String(sdkMessage.tool_use_id || '').trim() || null,
|
|
2666
|
+
toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() || null,
|
|
2498
2667
|
status: String(sdkMessage.status || '').trim() || 'completed',
|
|
2499
2668
|
summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
|
|
2500
2669
|
...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
|
|
2501
|
-
|
|
2670
|
+
...(published?.description ? {
|
|
2671
|
+
description: String(published.description).slice(0, 500),
|
|
2672
|
+
taskType: String(published.taskType || ''),
|
|
2673
|
+
startedAt: published.startedAt || null,
|
|
2674
|
+
} : {}),
|
|
2675
|
+
...(outputPath ? { outputPath } : {}),
|
|
2676
|
+
...(exitCode !== null ? { exitCode } : {}),
|
|
2677
|
+
...(steps !== null ? { steps } : {}),
|
|
2678
|
+
});
|
|
2502
2679
|
}
|
|
2503
2680
|
// A settled session-level task means the CLI is about to dequeue a
|
|
2504
2681
|
// continuation turn — the process must not idle out under it. The SDK
|
|
@@ -3249,7 +3426,7 @@ export function createClaudeSessionRunner({
|
|
|
3249
3426
|
if (processRef.liveTasks.size) {
|
|
3250
3427
|
processRef.liveTasks = new Map();
|
|
3251
3428
|
if (wasCurrent) {
|
|
3252
|
-
|
|
3429
|
+
postTaskSet({ tasks: [] }).catch(() => {});
|
|
3253
3430
|
}
|
|
3254
3431
|
}
|
|
3255
3432
|
// Steers pushed into a turn the user stopped: if the Stop fell back to
|
|
@@ -3308,6 +3485,7 @@ export function createClaudeSessionRunner({
|
|
|
3308
3485
|
const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
|
|
3309
3486
|
const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
|
|
3310
3487
|
const attribution = getAttribution() || null;
|
|
3488
|
+
const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
|
|
3311
3489
|
const abortController = new AbortController();
|
|
3312
3490
|
const processRef = {
|
|
3313
3491
|
turn: null,
|
|
@@ -3331,6 +3509,14 @@ export function createClaudeSessionRunner({
|
|
|
3331
3509
|
permissionMode: permissionModeForRelayMode(relayMode),
|
|
3332
3510
|
liveTasks: new Map(),
|
|
3333
3511
|
knownTasks: new Set(),
|
|
3512
|
+
// taskId → the row of a task that left the live set, until its
|
|
3513
|
+
// notification arrives.
|
|
3514
|
+
departedTasks: new Map(),
|
|
3515
|
+
// taskId → the file a shell task's output goes to (first named wins),
|
|
3516
|
+
// and the directory of the last one seen: a Monitor task's file sits
|
|
3517
|
+
// next to it.
|
|
3518
|
+
taskOutputPaths: new Map(),
|
|
3519
|
+
taskOutputDir: '',
|
|
3334
3520
|
// taskId → { runId, labels, digestJson } for live local_workflow tasks;
|
|
3335
3521
|
// pruned by syncWorkflowPoller when a task leaves the live set.
|
|
3336
3522
|
workflowStates: new Map(),
|
|
@@ -3478,6 +3664,7 @@ export function createClaudeSessionRunner({
|
|
|
3478
3664
|
thinkingEnabled,
|
|
3479
3665
|
thinkingDisplay,
|
|
3480
3666
|
attribution,
|
|
3667
|
+
backgroundCommandLimitMs,
|
|
3481
3668
|
abortController,
|
|
3482
3669
|
canUseTool,
|
|
3483
3670
|
pathToClaudeCodeExecutable,
|
|
@@ -4034,6 +4221,21 @@ export function createClaudeSessionRunner({
|
|
|
4034
4221
|
return false;
|
|
4035
4222
|
}
|
|
4036
4223
|
|
|
4224
|
+
/** The ids of the live background tasks, for the heartbeat's digest. */
|
|
4225
|
+
function backgroundTaskIds() {
|
|
4226
|
+
return proc ? [...proc.liveTasks.keys()] : [];
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
/** The relay asked for the task set again (heartbeat answer). */
|
|
4230
|
+
function resyncBackgroundTasks() {
|
|
4231
|
+
return publishBackgroundTasks({ resync: true });
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
/** The relay answered a heartbeat: settle notices kept for it go out. */
|
|
4235
|
+
function flushKeptTaskSettles() {
|
|
4236
|
+
return keptTaskSettles.flush();
|
|
4237
|
+
}
|
|
4238
|
+
|
|
4037
4239
|
return {
|
|
4038
4240
|
handlePendingPayload,
|
|
4039
4241
|
canAcceptSteering,
|
|
@@ -4046,6 +4248,10 @@ export function createClaudeSessionRunner({
|
|
|
4046
4248
|
isTurnActive,
|
|
4047
4249
|
stopBackgroundTask,
|
|
4048
4250
|
stopBackgroundTaskByToolUseId,
|
|
4251
|
+
snapshotTaskOutput,
|
|
4252
|
+
backgroundTaskIds,
|
|
4253
|
+
resyncBackgroundTasks,
|
|
4254
|
+
flushKeptTaskSettles,
|
|
4049
4255
|
shutdown,
|
|
4050
4256
|
// Test seams / observability.
|
|
4051
4257
|
_getProcess: () => proc,
|
|
@@ -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,9 +123,11 @@ 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.
|
|
130
|
+
onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
|
|
122
131
|
onDeliveryReadinessChange: (ready) => {
|
|
123
132
|
linkBridge.onDeliveryReadinessChange(ready);
|
|
124
133
|
heartbeat?.requestPulse();
|
|
@@ -147,6 +156,12 @@ async function main() {
|
|
|
147
156
|
// Consumed rows the runner could not fail itself; the relay fails them.
|
|
148
157
|
getSettleFailed: () => turnRunner.getSettleFailed(),
|
|
149
158
|
onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
|
|
159
|
+
// The relay holds the task set in memory only: the digest of the live
|
|
160
|
+
// task ids rides every beat, and a relay that is out of step (it
|
|
161
|
+
// restarted, a post was lost) asks for the set again.
|
|
162
|
+
getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
|
|
163
|
+
onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
|
|
164
|
+
onAnswered: () => turnRunner.flushKeptTaskSettles(),
|
|
150
165
|
});
|
|
151
166
|
|
|
152
167
|
const wsLink = createWorkerWebSocketLink({
|
|
@@ -164,7 +179,8 @@ async function main() {
|
|
|
164
179
|
// even between deliveries — a delivery drawn into it is only handed back.
|
|
165
180
|
getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
|
|
166
181
|
// A relay that just (re)started knows nothing of this worker until it
|
|
167
|
-
// heartbeats; the composer's steering snapshot rides that beat
|
|
182
|
+
// heartbeats; the composer's steering snapshot rides that beat, and so
|
|
183
|
+
// does the digest that makes the relay ask for the background tasks.
|
|
168
184
|
onServerHello: () => heartbeat.requestPulse(),
|
|
169
185
|
getSessionId: () => sdkSessionId,
|
|
170
186
|
getPid: () => process.pid,
|
|
@@ -182,6 +198,7 @@ async function main() {
|
|
|
182
198
|
// user choosing Host default.
|
|
183
199
|
thinking = resolveDeliveredThinking(thinking, pending?.settings);
|
|
184
200
|
attribution = resolveDeliveredAttribution(attribution, pending?.settings);
|
|
201
|
+
backgroundCommandLimitMs = resolveDeliveredClaudeBackgroundCommandLimitMs(backgroundCommandLimitMs, pending?.settings);
|
|
185
202
|
try {
|
|
186
203
|
return await turnRunner.handlePendingPayload(pending);
|
|
187
204
|
} catch (error) {
|
|
@@ -197,6 +214,14 @@ async function main() {
|
|
|
197
214
|
await turnRunner.stopBackgroundTask(taskId);
|
|
198
215
|
return;
|
|
199
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
|
+
}
|
|
200
225
|
dbg('unknown worker control ignored', type || '(none)');
|
|
201
226
|
},
|
|
202
227
|
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
|
|
17
|
+
const OUTPUT_MARKER = 'Output is being written to';
|
|
18
|
+
// The CLI's own file name: a path with spaces (a Windows profile dir) still
|
|
19
|
+
// reads whole, up to its `.output` and the sentence's full stop.
|
|
20
|
+
const OUTPUT_FILE_PATTERN = /Output is being written to:?\s+([^\n;]+?\.output)(?=[.;,]?(?:\s|$))/;
|
|
21
|
+
// Any other shape: one token, without the trailing punctuation.
|
|
22
|
+
const OUTPUT_TOKEN_PATTERN = /Output is being written to:?\s+(\S+?)[.;,]?(?:\s|$)/;
|
|
23
|
+
const TASK_ID_PATTERN = /\bID:\s*([A-Za-z0-9_-]+)/;
|
|
24
|
+
// What the CLI's own background notes start with (Bash, SDK 0.3.292). A
|
|
25
|
+
// foreground command that merely prints "Output is being written to: ..."
|
|
26
|
+
// must never bind a path to a task, so only these, the MCP form below, or a
|
|
27
|
+
// result whose structured output names the task are read.
|
|
28
|
+
const CLI_BACKGROUND_PREFIXES = [
|
|
29
|
+
'Command running in background',
|
|
30
|
+
'Command was manually backgrounded',
|
|
31
|
+
'Command was moved to the background',
|
|
32
|
+
'Command did not complete within',
|
|
33
|
+
];
|
|
34
|
+
// The backgrounded MCP task's note: no colon, then "; use <tool> on that path".
|
|
35
|
+
const MCP_BACKGROUND_PATTERN = /Output is being written to (\S[^\n;]*?\.output); use \S/;
|
|
36
|
+
|
|
37
|
+
export function isShellTaskType(taskType) {
|
|
38
|
+
return SHELL_TASK_TYPES.has(String(taskType || '').trim());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An absolute path of sane length, or ''. Windows shapes count on every host. */
|
|
42
|
+
export function usableOutputPath(value) {
|
|
43
|
+
const text = String(value || '').trim();
|
|
44
|
+
if (!text || text.length > MAX_OUTPUT_PATH_CHARS) return '';
|
|
45
|
+
return nodePath.posix.isAbsolute(text) || nodePath.win32.isAbsolute(text) ? text : '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `.../tasks/b8k2m4x7q.output` → `b8k2m4x7q`. */
|
|
49
|
+
function taskIdFromOutputFile(filePath) {
|
|
50
|
+
const base = String(filePath).split(/[\\/]/).pop() || '';
|
|
51
|
+
return base.endsWith('.output') ? base.slice(0, -'.output'.length) : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The directory part of a path, for either separator. */
|
|
55
|
+
export function outputDirOf(filePath) {
|
|
56
|
+
const text = String(filePath || '');
|
|
57
|
+
const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'));
|
|
58
|
+
return cut > 0 ? text.slice(0, cut) : '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toolResultText(block) {
|
|
62
|
+
const content = block?.content;
|
|
63
|
+
if (typeof content === 'string') return content;
|
|
64
|
+
if (!Array.isArray(content)) return '';
|
|
65
|
+
return content
|
|
66
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
67
|
+
.map((part) => part.text)
|
|
68
|
+
.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `{ taskId, outputPath }` from one tool result's text, or null. */
|
|
72
|
+
export function parseOutputAnnouncement(text, knownTaskId = '') {
|
|
73
|
+
const source = String(text || '');
|
|
74
|
+
if (!source.includes(OUTPUT_MARKER)) return null;
|
|
75
|
+
const match = source.match(OUTPUT_FILE_PATTERN) || source.match(OUTPUT_TOKEN_PATTERN);
|
|
76
|
+
const outputPath = usableOutputPath(match?.[1]);
|
|
77
|
+
if (!outputPath) return null;
|
|
78
|
+
const taskId = String(knownTaskId || '').trim()
|
|
79
|
+
|| taskIdFromOutputFile(outputPath)
|
|
80
|
+
|| (source.match(TASK_ID_PATTERN)?.[1] || '');
|
|
81
|
+
return taskId ? { taskId, outputPath } : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The CLI's own note about a backgrounded call, not a command's printed output. */
|
|
85
|
+
function isBackgroundNote(text) {
|
|
86
|
+
const source = String(text || '').trimStart();
|
|
87
|
+
if (CLI_BACKGROUND_PREFIXES.some((prefix) => source.startsWith(prefix))) return true;
|
|
88
|
+
const mcp = source.match(MCP_BACKGROUND_PATTERN);
|
|
89
|
+
return Boolean(mcp && taskIdFromOutputFile(mcp[1]));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every task output file a `user` SDK message announces: the tool_result
|
|
94
|
+
* blocks of backgrounded Bash (and Monitor) calls. The structured result's
|
|
95
|
+
* `backgroundTaskId` names the task when the message answers a single call.
|
|
96
|
+
*/
|
|
97
|
+
export function discoverTaskOutputPaths(sdkMessage) {
|
|
98
|
+
if (String(sdkMessage?.type || '') !== 'user') return [];
|
|
99
|
+
const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
100
|
+
const results = blocks.filter((block) => block?.type === 'tool_result');
|
|
101
|
+
if (!results.length) return [];
|
|
102
|
+
const structured = sdkMessage.tool_use_result && typeof sdkMessage.tool_use_result === 'object'
|
|
103
|
+
? sdkMessage.tool_use_result
|
|
104
|
+
: null;
|
|
105
|
+
const structuredTaskId = results.length === 1 ? String(structured?.backgroundTaskId || '').trim() : '';
|
|
106
|
+
const found = [];
|
|
107
|
+
for (const block of results) {
|
|
108
|
+
const text = toolResultText(block);
|
|
109
|
+
if (!structuredTaskId && !isBackgroundNote(text)) continue;
|
|
110
|
+
const announced = parseOutputAnnouncement(text, structuredTaskId);
|
|
111
|
+
if (announced) found.push(announced);
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The exit code a shell task's settle summary names: `Background command
|
|
118
|
+
* "npm test" failed with exit code 2` / `... completed (exit code 0)`.
|
|
119
|
+
* The last mention wins (the command's own description may say "exit code").
|
|
120
|
+
*/
|
|
121
|
+
export function exitCodeFromSummary(summary) {
|
|
122
|
+
const matches = [...String(summary || '').matchAll(/exit code (-?\d+)/gi)];
|
|
123
|
+
if (!matches.length) return null;
|
|
124
|
+
const code = Number(matches[matches.length - 1][1]);
|
|
125
|
+
return Number.isInteger(code) ? code : null;
|
|
126
|
+
}
|