@oar-sh/oar 0.9.11 → 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 +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- 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 +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -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 +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- 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/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -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-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -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 +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/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -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 +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
|
@@ -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,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
|
+
}
|
|
@@ -120,6 +120,10 @@ import {
|
|
|
120
120
|
probeRuntime,
|
|
121
121
|
resolveTurnStallWindows,
|
|
122
122
|
} from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
123
|
+
import { createTaskOutputSpool } from '../../shared/worker-runtime/task-output-spool.mjs';
|
|
124
|
+
import { createShellOutputMirror } from './copilot-shell-output-mirror.mjs';
|
|
125
|
+
import { isTransientProgressText, settleForTask, shellExitFromEvent } from './copilot-task-settle.mjs';
|
|
126
|
+
import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
|
|
123
127
|
|
|
124
128
|
// How long the runtime may sit with no session activity before the worker
|
|
125
129
|
// closes it. The worker process itself stays up and reconnects lazily on the
|
|
@@ -235,6 +239,8 @@ const STOPPED_ROW_PUBLISH_WAIT_MS = 5_000;
|
|
|
235
239
|
* (which carries no payload and fires ~20 times per shell call).
|
|
236
240
|
*/
|
|
237
241
|
const DEFAULT_TASK_REFRESH_MS = 250;
|
|
242
|
+
/** How often the task output spool drops files of tasks that ended over an hour ago. */
|
|
243
|
+
const SPOOL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
238
244
|
/** Trailing throttle for enrichment-only card updates (a live tool call, tokens). */
|
|
239
245
|
const DEFAULT_TASK_PUBLISH_THROTTLE_MS = 2_000;
|
|
240
246
|
/**
|
|
@@ -465,6 +471,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
465
471
|
// worker asks for a heartbeat, on which the relay learns which process
|
|
466
472
|
// holds the session and asks for the set again.
|
|
467
473
|
onBackgroundTaskSetStale = () => {},
|
|
474
|
+
// Where attached shells' output is mirrored for the task console
|
|
475
|
+
// (shared/worker-runtime/task-output-spool.mjs). Tests pass their own.
|
|
476
|
+
taskOutputSpool = null,
|
|
468
477
|
dbg = () => {},
|
|
469
478
|
} = {}) {
|
|
470
479
|
let client = null;
|
|
@@ -619,6 +628,24 @@ export function createCopilotSdkSessionRunner({
|
|
|
619
628
|
// cards; the registry alone decides liveness (the lifecycle pin).
|
|
620
629
|
let backgroundTasks = new Map();
|
|
621
630
|
const agentDetails = new Map();
|
|
631
|
+
// A detached shell names its own log (`TaskShellInfo.logPath`); an attached
|
|
632
|
+
// one has only the runtime's recent-output window, mirrored into a spool
|
|
633
|
+
// file here so the task console can read it like any other log.
|
|
634
|
+
const spool = taskOutputSpool || (() => {
|
|
635
|
+
try { return createTaskOutputSpool({ sessionId: sdkSessionId }); } catch (error) {
|
|
636
|
+
dbg('task output spool unavailable', error?.message || String(error));
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
})();
|
|
640
|
+
const shellOutputMirror = createShellOutputMirror({ spool, dbg });
|
|
641
|
+
// Ended tasks' files are kept about an hour for the relay; swept at start
|
|
642
|
+
// and hourly.
|
|
643
|
+
const sweepSpool = () => {
|
|
644
|
+
try { spool?.sweep(); } catch (error) { dbg('task output spool sweep failed', error?.message || String(error)); }
|
|
645
|
+
};
|
|
646
|
+
sweepSpool();
|
|
647
|
+
const spoolSweepTimer = setInterval(sweepSpool, SPOOL_SWEEP_INTERVAL_MS);
|
|
648
|
+
spoolSweepTimer.unref?.();
|
|
622
649
|
let taskRefreshTimer = null;
|
|
623
650
|
let taskRefreshChain = Promise.resolve();
|
|
624
651
|
// Reads of the registry that failed in a row. While there are some, "no
|
|
@@ -678,6 +705,16 @@ export function createCopilotSdkSessionRunner({
|
|
|
678
705
|
// The detached shells this session has running. The lifecycle's pin, and the
|
|
679
706
|
// reason a settled one is worth waiting for.
|
|
680
707
|
const backgroundShells = createBackgroundShellTracker();
|
|
708
|
+
// How a background task ended, told to the relay (its live set only says
|
|
709
|
+
// the task is gone). A failed post is kept and sent again after the next
|
|
710
|
+
// heartbeat answer.
|
|
711
|
+
const keptTaskSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
|
|
712
|
+
// shellId → the exit code the events reported (a `shell_completed`
|
|
713
|
+
// notification, a `read_bash` result), and the ends already noted
|
|
714
|
+
// (taskId → { body, exitCode, registryStatus }): a detached shell's code
|
|
715
|
+
// usually comes with the `read_bash` after its end, and is posted then.
|
|
716
|
+
const shellExitCodes = new Map();
|
|
717
|
+
const postedSettles = new Map();
|
|
681
718
|
// When a shell settled and the continuation it should trigger has not opened
|
|
682
719
|
// yet. Holds the runtime for `continuationGraceMs`; cleared by the
|
|
683
720
|
// continuation opening, or by the grace expiring on a runtime that decided it
|
|
@@ -1553,6 +1590,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1553
1590
|
startedAt,
|
|
1554
1591
|
stoppable: true,
|
|
1555
1592
|
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1593
|
+
...shellOutputFields(task),
|
|
1556
1594
|
});
|
|
1557
1595
|
continue;
|
|
1558
1596
|
}
|
|
@@ -1572,11 +1610,22 @@ export function createCopilotSdkSessionRunner({
|
|
|
1572
1610
|
...(detail.lastToolCall ? { lastToolCall: detail.lastToolCall } : {}),
|
|
1573
1611
|
...(Number.isFinite(detail.totalTokens) && detail.totalTokens > 0 ? { totalTokens: detail.totalTokens } : {}),
|
|
1574
1612
|
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1613
|
+
...(Number.isInteger(detail.steps) ? { steps: detail.steps } : {}),
|
|
1575
1614
|
});
|
|
1576
1615
|
}
|
|
1577
1616
|
return cards;
|
|
1578
1617
|
}
|
|
1579
1618
|
|
|
1619
|
+
/** Where the task console reads a shell's output: its own log, or the spool mirror. */
|
|
1620
|
+
function shellOutputFields(task) {
|
|
1621
|
+
const logPath = String(task.logPath || '').trim();
|
|
1622
|
+
if (logPath) return { outputPath: logPath, outputKind: 'file' };
|
|
1623
|
+
if (spool && shellOutputMirror.has(task.id)) {
|
|
1624
|
+
try { return { outputPath: spool.pathFor(task.id), outputKind: 'spool' }; } catch {}
|
|
1625
|
+
}
|
|
1626
|
+
return {};
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1580
1629
|
/**
|
|
1581
1630
|
* Mirror the live background work into the relay's task panel (REPLACE
|
|
1582
1631
|
* semantics, the same route the Claude worker publishes on). Set changes
|
|
@@ -1682,6 +1731,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
1682
1731
|
model: String(data.model || '').trim(),
|
|
1683
1732
|
lastToolCall: '',
|
|
1684
1733
|
totalTokens: 0,
|
|
1734
|
+
// Tool calls so far: the panel row's step count.
|
|
1735
|
+
steps: 0,
|
|
1685
1736
|
startedAt: Date.now(),
|
|
1686
1737
|
});
|
|
1687
1738
|
scheduleTaskRefresh();
|
|
@@ -1706,6 +1757,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1706
1757
|
const detail = agentDetails.get(agentId);
|
|
1707
1758
|
if (type === 'tool.execution_start') {
|
|
1708
1759
|
detail.lastToolCall = formatToolActivityText(data.toolName, data.arguments);
|
|
1760
|
+
detail.steps += 1;
|
|
1709
1761
|
publishBackgroundTasks();
|
|
1710
1762
|
} else if (type === 'assistant.usage') {
|
|
1711
1763
|
const used = (Number(data.inputTokens) || 0) + (Number(data.outputTokens) || 0);
|
|
@@ -1716,6 +1768,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
1716
1768
|
} else if (type === 'subagent.completed' || type === 'subagent.failed') {
|
|
1717
1769
|
const total = Number(data.totalTokens);
|
|
1718
1770
|
if (Number.isFinite(total) && total > 0) detail.totalTokens = total;
|
|
1771
|
+
// The runtime's own count wins over the events this worker saw.
|
|
1772
|
+
const toolCalls = Number(data.totalToolCalls);
|
|
1773
|
+
if (Number.isFinite(toolCalls) && toolCalls >= 0) detail.steps = Math.floor(toolCalls);
|
|
1719
1774
|
scheduleTaskRefresh();
|
|
1720
1775
|
}
|
|
1721
1776
|
}
|
|
@@ -1758,6 +1813,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
1758
1813
|
}
|
|
1759
1814
|
if (session !== target) return;
|
|
1760
1815
|
taskRefreshFailures = 0;
|
|
1816
|
+
const listedById = new Map((Array.isArray(listed?.tasks) ? listed.tasks : [])
|
|
1817
|
+
.map((task) => [String(task?.id || '').trim(), task]));
|
|
1761
1818
|
const next = new Map();
|
|
1762
1819
|
for (const task of Array.isArray(listed?.tasks) ? listed.tasks : []) {
|
|
1763
1820
|
const id = String(task?.id || '').trim();
|
|
@@ -1773,6 +1830,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
1773
1830
|
next.set(id, { ...task, id, status, progressSummary: backgroundTasks.get(id)?.progressSummary || '' });
|
|
1774
1831
|
}
|
|
1775
1832
|
}
|
|
1833
|
+
// Tasks that left the live set, with where a shell's output is (before
|
|
1834
|
+
// its mirror ends below).
|
|
1835
|
+
const departed = [...backgroundTasks.values()].filter((task) => !next.has(task.id));
|
|
1836
|
+
const departedOutput = new Map(departed.map((task) => [task.id, task.type === 'shell' ? shellOutputFields(task) : {}]));
|
|
1776
1837
|
// Progress lines for the live set: the agent's latest intent / last
|
|
1777
1838
|
// activity, a shell's last output line. One RPC per live task per refresh
|
|
1778
1839
|
// (refreshes are debounced), best effort.
|
|
@@ -1781,26 +1842,68 @@ export function createCopilotSdkSessionRunner({
|
|
|
1781
1842
|
try {
|
|
1782
1843
|
const progress = (await askRuntime('tasks.getProgress', () => target.rpc.tasks.getProgress({ id: task.id })))?.progress || null;
|
|
1783
1844
|
task.progressSummary = summarizeTaskProgress(progress);
|
|
1845
|
+
mirrorShellProgress(task, progress);
|
|
1784
1846
|
} catch { /* a card without a progress line */ }
|
|
1785
1847
|
}
|
|
1848
|
+
// A mirrored shell that just left the live set: its last window, once
|
|
1849
|
+
// (the runtime still tracks a finished task), then the mirror ends.
|
|
1850
|
+
for (const id of shellOutputMirror.ids()) {
|
|
1851
|
+
if (next.has(id)) continue;
|
|
1852
|
+
try {
|
|
1853
|
+
const progress = (await askRuntime('tasks.getProgress', () => target.rpc.tasks.getProgress({ id })))?.progress || null;
|
|
1854
|
+
if (progress?.type === 'shell') shellOutputMirror.mirror(id, progress.recentOutput);
|
|
1855
|
+
} catch { /* the file keeps what it had */ }
|
|
1856
|
+
shellOutputMirror.forget(id);
|
|
1857
|
+
}
|
|
1786
1858
|
}
|
|
1787
1859
|
if (session !== target) return;
|
|
1788
1860
|
const setChanged = next.size !== backgroundTasks.size
|
|
1789
1861
|
|| [...next.keys()].some((id) => !backgroundTasks.has(id));
|
|
1790
1862
|
backgroundTasks = next;
|
|
1863
|
+
// How they ended: the registry's finished entry (its status, an agent's
|
|
1864
|
+
// result) and a shell's exit code.
|
|
1865
|
+
for (const task of departed) {
|
|
1866
|
+
if (task.type !== 'shell' && task.type !== 'agent') continue;
|
|
1867
|
+
const finished = listedById.get(task.id) || task;
|
|
1868
|
+
const shell = task.type === 'shell';
|
|
1869
|
+
postTaskSettle(task.id, {
|
|
1870
|
+
...settleForTask(finished, { exitCode: shell ? (shellExitCodes.get(task.id) ?? null) : null }),
|
|
1871
|
+
description: task.description || task.displayName || task.command || (shell ? 'Background shell' : 'Background agent'),
|
|
1872
|
+
taskType: shell ? 'local_bash' : 'local_agent',
|
|
1873
|
+
startedAt: Date.parse(String(task.startedAt || '')) || null,
|
|
1874
|
+
...(task.toolCallId ? { toolUseId: String(task.toolCallId) } : {}),
|
|
1875
|
+
...(departedOutput.get(task.id) || {}),
|
|
1876
|
+
}, { registryStatus: String(finished.status || '') });
|
|
1877
|
+
}
|
|
1791
1878
|
publishBackgroundTasks({ immediate: setChanged });
|
|
1792
1879
|
// The lifecycle poll (every `lifecyclePollMs`) picks the new set up; a
|
|
1793
1880
|
// re-evaluation here would re-schedule a refresh and spin.
|
|
1794
1881
|
}
|
|
1795
1882
|
|
|
1883
|
+
/**
|
|
1884
|
+
* An attached shell's recent-output window into the spool (a detached one
|
|
1885
|
+
* writes its own log). The refresh cadence is the registry's, unchanged:
|
|
1886
|
+
* the window only stops being thrown away.
|
|
1887
|
+
*/
|
|
1888
|
+
function mirrorShellProgress(task, progress) {
|
|
1889
|
+
if (task.type !== 'shell' || progress?.type !== 'shell') return;
|
|
1890
|
+
if (String(task.logPath || '').trim()) return;
|
|
1891
|
+
shellOutputMirror.mirror(task.id, progress.recentOutput);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1796
1894
|
function summarizeTaskProgress(progress) {
|
|
1797
1895
|
if (!progress || typeof progress !== 'object') return '';
|
|
1798
1896
|
if (progress.type === 'agent') {
|
|
1897
|
+
// A spinner or status line ("⟳ Thinking…") says nothing the row does
|
|
1898
|
+
// not; the newest line with content does, else none.
|
|
1799
1899
|
const intent = String(progress.latestIntent || '').trim();
|
|
1800
|
-
if (intent) return intent.slice(0, 300);
|
|
1900
|
+
if (intent && !isTransientProgressText(intent)) return intent.slice(0, 300);
|
|
1801
1901
|
const lines = Array.isArray(progress.recentActivity) ? progress.recentActivity : [];
|
|
1802
|
-
|
|
1803
|
-
|
|
1902
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1903
|
+
const line = String(lines[index]?.message || '').trim();
|
|
1904
|
+
if (line && !isTransientProgressText(line)) return line.slice(0, 300);
|
|
1905
|
+
}
|
|
1906
|
+
return '';
|
|
1804
1907
|
}
|
|
1805
1908
|
if (progress.type === 'shell') {
|
|
1806
1909
|
const output = String(progress.recentOutput || '').trim();
|
|
@@ -1972,7 +2075,46 @@ export function createCopilotSdkSessionRunner({
|
|
|
1972
2075
|
dbg(`copilot session rpc surfaces: queue=${sessionRpc.queue} interruptMainTurn=${sessionRpc.interruptMainTurn} tasks=${sessionRpc.tasks}`);
|
|
1973
2076
|
}
|
|
1974
2077
|
|
|
2078
|
+
/** A shell's exit code from an event; a settle already posted without it goes again. */
|
|
2079
|
+
function noteShellExit(event) {
|
|
2080
|
+
let exit = null;
|
|
2081
|
+
try { exit = shellExitFromEvent(event); } catch {}
|
|
2082
|
+
if (!exit) return;
|
|
2083
|
+
shellExitCodes.set(exit.shellId, exit.exitCode);
|
|
2084
|
+
while (shellExitCodes.size > 200) shellExitCodes.delete(shellExitCodes.keys().next().value);
|
|
2085
|
+
const posted = postedSettles.get(exit.shellId);
|
|
2086
|
+
if (!posted || posted.exitCode !== null || posted.body.taskType !== 'local_bash') return;
|
|
2087
|
+
postTaskSettle(exit.shellId, {
|
|
2088
|
+
...posted.body,
|
|
2089
|
+
...settleForTask({ type: 'shell', status: posted.registryStatus || '' }, { exitCode: exit.exitCode }),
|
|
2090
|
+
}, { registryStatus: posted.registryStatus });
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/**
|
|
2094
|
+
* Tell the relay how a background task ended (advisory, kept on failure).
|
|
2095
|
+
* An end nothing has told yet (`unknown`) is only noted: the relay already
|
|
2096
|
+
* calls a task that left its set without a word unknown, and an exit code
|
|
2097
|
+
* that comes later is posted then.
|
|
2098
|
+
*/
|
|
2099
|
+
function postTaskSettle(taskId, { status, exitCode, summary, ...rest }, { registryStatus } = {}) {
|
|
2100
|
+
const id = String(taskId || '').trim();
|
|
2101
|
+
if (!id) return;
|
|
2102
|
+
const body = {
|
|
2103
|
+
conversationId: sdkSessionId,
|
|
2104
|
+
taskId: id,
|
|
2105
|
+
status,
|
|
2106
|
+
summary: summary || null,
|
|
2107
|
+
...rest,
|
|
2108
|
+
...(Number.isInteger(exitCode) ? { exitCode } : {}),
|
|
2109
|
+
};
|
|
2110
|
+
postedSettles.set(id, { body, exitCode: Number.isInteger(exitCode) ? exitCode : null, registryStatus: registryStatus || '' });
|
|
2111
|
+
while (postedSettles.size > 200) postedSettles.delete(postedSettles.keys().next().value);
|
|
2112
|
+
if (status === 'unknown') return;
|
|
2113
|
+
void keptTaskSettles.post('/api/background-task-settled', body);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
1975
2116
|
function observeBackgroundShells(event) {
|
|
2117
|
+
noteShellExit(event);
|
|
1976
2118
|
let changed;
|
|
1977
2119
|
try {
|
|
1978
2120
|
changed = backgroundShells.observe(event);
|
|
@@ -1986,6 +2128,17 @@ export function createCopilotSdkSessionRunner({
|
|
|
1986
2128
|
}
|
|
1987
2129
|
for (const shell of changed.settled) {
|
|
1988
2130
|
dbg('detached shell settled', shell.shellId);
|
|
2131
|
+
// Without the task registry the tracker's set is the panel's: the end
|
|
2132
|
+
// of a shell it listed is told here (an id it never saw open, as a
|
|
2133
|
+
// foreground shell's result names one, was never a task).
|
|
2134
|
+
if (!sessionRpc.tasks && shell.startedAt) {
|
|
2135
|
+
postTaskSettle(shell.shellId, {
|
|
2136
|
+
...settleForTask({ type: 'shell', status: '' }, { exitCode: shellExitCodes.get(shell.shellId) ?? null }),
|
|
2137
|
+
description: shell.description || 'Detached shell',
|
|
2138
|
+
taskType: 'local_bash',
|
|
2139
|
+
startedAt: shell.startedAt || null,
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
1989
2142
|
const note = describeSettledShell(shell);
|
|
1990
2143
|
// Only carried when there is no turn to publish into: inside a turn the
|
|
1991
2144
|
// normalizer already narrates the `read_bash` that reads the output. A
|
|
@@ -4741,6 +4894,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
4741
4894
|
async function dispose() {
|
|
4742
4895
|
disposed = true;
|
|
4743
4896
|
stopLifecycleTimer();
|
|
4897
|
+
clearInterval(spoolSweepTimer);
|
|
4744
4898
|
if (queueLaneTimer) {
|
|
4745
4899
|
clearTimeout(queueLaneTimer);
|
|
4746
4900
|
queueLaneTimer = null;
|
|
@@ -4764,6 +4918,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
4764
4918
|
acknowledgeSettleFailed,
|
|
4765
4919
|
backgroundTaskIds,
|
|
4766
4920
|
resyncBackgroundTasks,
|
|
4921
|
+
// The relay answered a heartbeat: settle notices kept for it go out.
|
|
4922
|
+
flushKeptTaskSettles: () => keptTaskSettles.flush(),
|
|
4767
4923
|
cancelPushedMessage,
|
|
4768
4924
|
// The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
|
|
4769
4925
|
// subagent lane's Stop tries — the runtime tracks sync agents in the same
|
|
@@ -150,6 +150,8 @@ async function main() {
|
|
|
150
150
|
// restarted, a post was lost) asks for the set again.
|
|
151
151
|
getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
|
|
152
152
|
onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
|
|
153
|
+
// The relay answered: settle notices it missed go out.
|
|
154
|
+
onAnswered: () => turnRunner.flushKeptTaskSettles?.(),
|
|
153
155
|
});
|
|
154
156
|
|
|
155
157
|
const wsLink = createWorkerWebSocketLink({
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// An attached Copilot shell keeps its output in the runtime only: the worker
|
|
2
|
+
// sees it as `tasks.getProgress().recentOutput`, a sliding window of the last
|
|
3
|
+
// lines, on every registry refresh. The task console needs a file, so each
|
|
4
|
+
// window's new part is appended to the session's spool
|
|
5
|
+
// (shared/worker-runtime/task-output-spool.mjs).
|
|
6
|
+
//
|
|
7
|
+
// Consecutive windows overlap: the new part is what follows the longest
|
|
8
|
+
// suffix of the previous window that is also a prefix of the new one. With no
|
|
9
|
+
// overlap at all (more output between two refreshes than the window holds)
|
|
10
|
+
// the lines in between are lost; the new window is appended whole after a
|
|
11
|
+
// line break, and the modal says the output is mirrored from a window.
|
|
12
|
+
|
|
13
|
+
/** The runtime's placeholder for a shell that has printed nothing yet. */
|
|
14
|
+
const NO_OUTPUT_PLACEHOLDER = '(no output yet)';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Length of the longest suffix of `previous` that is a prefix of `next`
|
|
18
|
+
* (prefix function over `next + sep + previous`, linear time: windows are
|
|
19
|
+
* kilobytes and are compared on every refresh).
|
|
20
|
+
*/
|
|
21
|
+
export function overlapLength(previous, next) {
|
|
22
|
+
const a = String(next || '');
|
|
23
|
+
const b = String(previous || '');
|
|
24
|
+
if (!a || !b) return 0;
|
|
25
|
+
const n = a.length;
|
|
26
|
+
const fail = new Int32Array(n);
|
|
27
|
+
for (let i = 1, k = 0; i < n; i += 1) {
|
|
28
|
+
while (k > 0 && a[i] !== a[k]) k = fail[k - 1];
|
|
29
|
+
if (a[i] === a[k]) k += 1;
|
|
30
|
+
fail[i] = k;
|
|
31
|
+
}
|
|
32
|
+
let k = 0;
|
|
33
|
+
for (let i = 0; i < b.length; i += 1) {
|
|
34
|
+
while (k > 0 && (k === n || b[i] !== a[k])) k = fail[k - 1];
|
|
35
|
+
if (b[i] === a[k]) k += 1;
|
|
36
|
+
}
|
|
37
|
+
return k;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What to append when the window moves from `previous` to `next`:
|
|
42
|
+
* `{ text, gap }`, `gap` true when the windows share nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function newOutputSuffix(previous, next) {
|
|
45
|
+
const before = String(previous || '');
|
|
46
|
+
const after = String(next || '');
|
|
47
|
+
if (!after || after === before) return { text: '', gap: false };
|
|
48
|
+
if (!before) return { text: after, gap: false };
|
|
49
|
+
if (after.startsWith(before)) return { text: after.slice(before.length), gap: false };
|
|
50
|
+
const overlap = overlapLength(before, after);
|
|
51
|
+
if (overlap > 0) return { text: after.slice(overlap), gap: false };
|
|
52
|
+
return { text: after, gap: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createShellOutputMirror({ spool, dbg = () => {} } = {}) {
|
|
56
|
+
// taskId → { window, endsWithNewline }
|
|
57
|
+
const mirrors = new Map();
|
|
58
|
+
|
|
59
|
+
/** Feed one `recentOutput` window; returns the text appended. */
|
|
60
|
+
function mirror(taskId, recentOutput) {
|
|
61
|
+
const id = String(taskId || '').trim();
|
|
62
|
+
if (!id || !spool) return '';
|
|
63
|
+
const state = mirrors.get(id) || { window: '', endsWithNewline: true };
|
|
64
|
+
mirrors.set(id, state);
|
|
65
|
+
const window = typeof recentOutput === 'string' ? recentOutput : '';
|
|
66
|
+
if (!window || window.trim() === NO_OUTPUT_PLACEHOLDER) return '';
|
|
67
|
+
const { text, gap } = newOutputSuffix(state.window, window);
|
|
68
|
+
state.window = window;
|
|
69
|
+
if (!text) return '';
|
|
70
|
+
const written = gap && !state.endsWithNewline ? `\n${text}` : text;
|
|
71
|
+
const result = spool.append(id, written);
|
|
72
|
+
if (result && result.ok === false) dbg('task output spool append failed', id);
|
|
73
|
+
state.endsWithNewline = written.endsWith('\n');
|
|
74
|
+
return written;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether a window of this task was ever fed (its spool file is named). */
|
|
78
|
+
function has(taskId) {
|
|
79
|
+
return mirrors.has(String(taskId || '').trim());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The task ended: no more windows; the file stays for the relay. */
|
|
83
|
+
function forget(taskId) {
|
|
84
|
+
const id = String(taskId || '').trim();
|
|
85
|
+
if (!mirrors.delete(id)) return;
|
|
86
|
+
try { spool?.end?.(id); } catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ids() {
|
|
90
|
+
return [...mirrors.keys()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { mirror, has, forget, ids };
|
|
94
|
+
}
|