@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
|
@@ -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
|
/**
|
|
@@ -461,6 +467,13 @@ export function createCopilotSdkSessionRunner({
|
|
|
461
467
|
// heartbeat snapshot, and a 10 s wait would often outlast the window in
|
|
462
468
|
// which the runtime still holds the message (live check 2026-09-25).
|
|
463
469
|
onCancellableChange = () => {},
|
|
470
|
+
// The relay turned this worker's newest task set away (`stale`): the
|
|
471
|
+
// worker asks for a heartbeat, on which the relay learns which process
|
|
472
|
+
// holds the session and asks for the set again.
|
|
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,
|
|
464
477
|
dbg = () => {},
|
|
465
478
|
} = {}) {
|
|
466
479
|
let client = null;
|
|
@@ -615,6 +628,24 @@ export function createCopilotSdkSessionRunner({
|
|
|
615
628
|
// cards; the registry alone decides liveness (the lifecycle pin).
|
|
616
629
|
let backgroundTasks = new Map();
|
|
617
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?.();
|
|
618
649
|
let taskRefreshTimer = null;
|
|
619
650
|
let taskRefreshChain = Promise.resolve();
|
|
620
651
|
// Reads of the registry that failed in a row. While there are some, "no
|
|
@@ -628,7 +659,15 @@ export function createCopilotSdkSessionRunner({
|
|
|
628
659
|
let taskRefreshQueued = 0;
|
|
629
660
|
let lastTaskRefreshAt = 0;
|
|
630
661
|
let taskPublishTimer = null;
|
|
662
|
+
// The last set the relay took, as JSON, and the one on its way to it.
|
|
631
663
|
let lastPublishedTasksSignature = '';
|
|
664
|
+
let inFlightTasksSignature = null;
|
|
665
|
+
let taskPublishSeq = 0;
|
|
666
|
+
// Every post of the set carries its place in this worker's order
|
|
667
|
+
// (`epoch` tells this process from one before it): posts can overtake
|
|
668
|
+
// each other on the way, and the relay applies the newer one only.
|
|
669
|
+
const taskSetEpoch = Date.now();
|
|
670
|
+
let taskSetStaleNotedAt = 0;
|
|
632
671
|
// Cap expiry is bounded: a task whose cancel the runtime refuses (or that
|
|
633
672
|
// keeps failing) this many times is abandoned — dropped from the live set so
|
|
634
673
|
// it stops pinning the runtime — rather than held forever.
|
|
@@ -666,6 +705,16 @@ export function createCopilotSdkSessionRunner({
|
|
|
666
705
|
// The detached shells this session has running. The lifecycle's pin, and the
|
|
667
706
|
// reason a settled one is worth waiting for.
|
|
668
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();
|
|
669
718
|
// When a shell settled and the continuation it should trigger has not opened
|
|
670
719
|
// yet. Holds the runtime for `continuationGraceMs`; cleared by the
|
|
671
720
|
// continuation opening, or by the grace expiring on a runtime that decided it
|
|
@@ -1541,6 +1590,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1541
1590
|
startedAt,
|
|
1542
1591
|
stoppable: true,
|
|
1543
1592
|
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1593
|
+
...shellOutputFields(task),
|
|
1544
1594
|
});
|
|
1545
1595
|
continue;
|
|
1546
1596
|
}
|
|
@@ -1560,37 +1610,98 @@ export function createCopilotSdkSessionRunner({
|
|
|
1560
1610
|
...(detail.lastToolCall ? { lastToolCall: detail.lastToolCall } : {}),
|
|
1561
1611
|
...(Number.isFinite(detail.totalTokens) && detail.totalTokens > 0 ? { totalTokens: detail.totalTokens } : {}),
|
|
1562
1612
|
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1613
|
+
...(Number.isInteger(detail.steps) ? { steps: detail.steps } : {}),
|
|
1563
1614
|
});
|
|
1564
1615
|
}
|
|
1565
1616
|
return cards;
|
|
1566
1617
|
}
|
|
1567
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
|
+
|
|
1568
1629
|
/**
|
|
1569
1630
|
* Mirror the live background work into the relay's task panel (REPLACE
|
|
1570
1631
|
* semantics, the same route the Claude worker publishes on). Set changes
|
|
1571
1632
|
* publish at once; enrichment-only changes (a tool call, tokens) ride a
|
|
1572
1633
|
* trailing throttle. Advisory: a failed post never disturbs events.
|
|
1634
|
+
*
|
|
1635
|
+
* A set equal to the last one the relay TOOK (or to the one on its way)
|
|
1636
|
+
* is not sent again: the signature is recorded when the post succeeds, so
|
|
1637
|
+
* a failed post is retried by the next publish instead of being
|
|
1638
|
+
* remembered as sent.
|
|
1639
|
+
* `resync`: the relay asked for the set again on a heartbeat (it
|
|
1640
|
+
* restarted, or a post was lost); that goes out whatever the signature
|
|
1641
|
+
* says, flagged. Returns the post.
|
|
1573
1642
|
*/
|
|
1574
|
-
function publishBackgroundTasks({ immediate = false } = {}) {
|
|
1643
|
+
function publishBackgroundTasks({ immediate = false, resync = false } = {}) {
|
|
1575
1644
|
const run = () => {
|
|
1576
1645
|
if (taskPublishTimer) {
|
|
1577
1646
|
clearTimeout(taskPublishTimer);
|
|
1578
1647
|
taskPublishTimer = null;
|
|
1579
1648
|
}
|
|
1580
|
-
const tasks =
|
|
1649
|
+
const tasks = currentTaskCards();
|
|
1581
1650
|
const signature = JSON.stringify(tasks);
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1651
|
+
const unchanged = inFlightTasksSignature === null
|
|
1652
|
+
? signature === lastPublishedTasksSignature
|
|
1653
|
+
: signature === inFlightTasksSignature;
|
|
1654
|
+
if (!resync && unchanged) return Promise.resolve();
|
|
1655
|
+
const seq = ++taskPublishSeq;
|
|
1656
|
+
inFlightTasksSignature = signature;
|
|
1657
|
+
return api('POST', '/api/background-tasks', {
|
|
1658
|
+
conversationId: sdkSessionId,
|
|
1659
|
+
tasks,
|
|
1660
|
+
order: { epoch: taskSetEpoch, seq },
|
|
1661
|
+
...(resync ? { resync: true } : {}),
|
|
1662
|
+
})
|
|
1663
|
+
.then((answer) => {
|
|
1664
|
+
// Only the newest post says what the relay holds now.
|
|
1665
|
+
if (seq !== taskPublishSeq) return;
|
|
1666
|
+
inFlightTasksSignature = null;
|
|
1667
|
+
if (answer?.stale === true) {
|
|
1668
|
+
// Turned away, not taken: nothing is recorded as sent, and a
|
|
1669
|
+
// heartbeat is asked for (at most one per ten seconds; the
|
|
1670
|
+
// periodic beat repeats the relay's request anyway).
|
|
1671
|
+
dbg('the relay dropped the newest background task set', answer.reason || '');
|
|
1672
|
+
if (Date.now() - taskSetStaleNotedAt < 10_000) return;
|
|
1673
|
+
taskSetStaleNotedAt = Date.now();
|
|
1674
|
+
try { onBackgroundTaskSetStale(); } catch {}
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
lastPublishedTasksSignature = signature;
|
|
1678
|
+
})
|
|
1679
|
+
.catch((error) => {
|
|
1680
|
+
// Not taken: the signature stays at what the relay last took, so
|
|
1681
|
+
// the next publish of this set is sent, not skipped.
|
|
1682
|
+
if (seq === taskPublishSeq) inFlightTasksSignature = null;
|
|
1683
|
+
dbg('background task publish failed', error?.message || String(error));
|
|
1684
|
+
});
|
|
1586
1685
|
};
|
|
1587
|
-
if (immediate || !(taskPublishThrottleMs > 0))
|
|
1588
|
-
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
if (taskPublishTimer) return;
|
|
1686
|
+
if (resync || immediate || !(taskPublishThrottleMs > 0)) return run();
|
|
1687
|
+
if (taskPublishTimer) return Promise.resolve();
|
|
1592
1688
|
taskPublishTimer = setTimeout(run, taskPublishThrottleMs);
|
|
1593
1689
|
taskPublishTimer.unref?.();
|
|
1690
|
+
return Promise.resolve();
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function currentTaskCards() {
|
|
1694
|
+
return sessionRpc.tasks ? registryCards() : shellTrackerCards();
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/** The ids of the live background tasks, for the heartbeat's digest. */
|
|
1698
|
+
function backgroundTaskIds() {
|
|
1699
|
+
return currentTaskCards().map((task) => task.taskId);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/** The relay asked for the task set again (heartbeat answer). */
|
|
1703
|
+
function resyncBackgroundTasks() {
|
|
1704
|
+
return publishBackgroundTasks({ resync: true });
|
|
1594
1705
|
}
|
|
1595
1706
|
|
|
1596
1707
|
// ------------------------------------------------------- background tasks --
|
|
@@ -1620,6 +1731,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
1620
1731
|
model: String(data.model || '').trim(),
|
|
1621
1732
|
lastToolCall: '',
|
|
1622
1733
|
totalTokens: 0,
|
|
1734
|
+
// Tool calls so far: the panel row's step count.
|
|
1735
|
+
steps: 0,
|
|
1623
1736
|
startedAt: Date.now(),
|
|
1624
1737
|
});
|
|
1625
1738
|
scheduleTaskRefresh();
|
|
@@ -1644,6 +1757,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1644
1757
|
const detail = agentDetails.get(agentId);
|
|
1645
1758
|
if (type === 'tool.execution_start') {
|
|
1646
1759
|
detail.lastToolCall = formatToolActivityText(data.toolName, data.arguments);
|
|
1760
|
+
detail.steps += 1;
|
|
1647
1761
|
publishBackgroundTasks();
|
|
1648
1762
|
} else if (type === 'assistant.usage') {
|
|
1649
1763
|
const used = (Number(data.inputTokens) || 0) + (Number(data.outputTokens) || 0);
|
|
@@ -1654,6 +1768,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
1654
1768
|
} else if (type === 'subagent.completed' || type === 'subagent.failed') {
|
|
1655
1769
|
const total = Number(data.totalTokens);
|
|
1656
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);
|
|
1657
1774
|
scheduleTaskRefresh();
|
|
1658
1775
|
}
|
|
1659
1776
|
}
|
|
@@ -1696,6 +1813,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
1696
1813
|
}
|
|
1697
1814
|
if (session !== target) return;
|
|
1698
1815
|
taskRefreshFailures = 0;
|
|
1816
|
+
const listedById = new Map((Array.isArray(listed?.tasks) ? listed.tasks : [])
|
|
1817
|
+
.map((task) => [String(task?.id || '').trim(), task]));
|
|
1699
1818
|
const next = new Map();
|
|
1700
1819
|
for (const task of Array.isArray(listed?.tasks) ? listed.tasks : []) {
|
|
1701
1820
|
const id = String(task?.id || '').trim();
|
|
@@ -1711,6 +1830,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
1711
1830
|
next.set(id, { ...task, id, status, progressSummary: backgroundTasks.get(id)?.progressSummary || '' });
|
|
1712
1831
|
}
|
|
1713
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) : {}]));
|
|
1714
1837
|
// Progress lines for the live set: the agent's latest intent / last
|
|
1715
1838
|
// activity, a shell's last output line. One RPC per live task per refresh
|
|
1716
1839
|
// (refreshes are debounced), best effort.
|
|
@@ -1719,26 +1842,68 @@ export function createCopilotSdkSessionRunner({
|
|
|
1719
1842
|
try {
|
|
1720
1843
|
const progress = (await askRuntime('tasks.getProgress', () => target.rpc.tasks.getProgress({ id: task.id })))?.progress || null;
|
|
1721
1844
|
task.progressSummary = summarizeTaskProgress(progress);
|
|
1845
|
+
mirrorShellProgress(task, progress);
|
|
1722
1846
|
} catch { /* a card without a progress line */ }
|
|
1723
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
|
+
}
|
|
1724
1858
|
}
|
|
1725
1859
|
if (session !== target) return;
|
|
1726
1860
|
const setChanged = next.size !== backgroundTasks.size
|
|
1727
1861
|
|| [...next.keys()].some((id) => !backgroundTasks.has(id));
|
|
1728
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
|
+
}
|
|
1729
1878
|
publishBackgroundTasks({ immediate: setChanged });
|
|
1730
1879
|
// The lifecycle poll (every `lifecyclePollMs`) picks the new set up; a
|
|
1731
1880
|
// re-evaluation here would re-schedule a refresh and spin.
|
|
1732
1881
|
}
|
|
1733
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
|
+
|
|
1734
1894
|
function summarizeTaskProgress(progress) {
|
|
1735
1895
|
if (!progress || typeof progress !== 'object') return '';
|
|
1736
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.
|
|
1737
1899
|
const intent = String(progress.latestIntent || '').trim();
|
|
1738
|
-
if (intent) return intent.slice(0, 300);
|
|
1900
|
+
if (intent && !isTransientProgressText(intent)) return intent.slice(0, 300);
|
|
1739
1901
|
const lines = Array.isArray(progress.recentActivity) ? progress.recentActivity : [];
|
|
1740
|
-
|
|
1741
|
-
|
|
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 '';
|
|
1742
1907
|
}
|
|
1743
1908
|
if (progress.type === 'shell') {
|
|
1744
1909
|
const output = String(progress.recentOutput || '').trim();
|
|
@@ -1910,7 +2075,46 @@ export function createCopilotSdkSessionRunner({
|
|
|
1910
2075
|
dbg(`copilot session rpc surfaces: queue=${sessionRpc.queue} interruptMainTurn=${sessionRpc.interruptMainTurn} tasks=${sessionRpc.tasks}`);
|
|
1911
2076
|
}
|
|
1912
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
|
+
|
|
1913
2116
|
function observeBackgroundShells(event) {
|
|
2117
|
+
noteShellExit(event);
|
|
1914
2118
|
let changed;
|
|
1915
2119
|
try {
|
|
1916
2120
|
changed = backgroundShells.observe(event);
|
|
@@ -1924,6 +2128,17 @@ export function createCopilotSdkSessionRunner({
|
|
|
1924
2128
|
}
|
|
1925
2129
|
for (const shell of changed.settled) {
|
|
1926
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
|
+
}
|
|
1927
2142
|
const note = describeSettledShell(shell);
|
|
1928
2143
|
// Only carried when there is no turn to publish into: inside a turn the
|
|
1929
2144
|
// normalizer already narrates the `read_bash` that reads the output. A
|
|
@@ -4679,6 +4894,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
4679
4894
|
async function dispose() {
|
|
4680
4895
|
disposed = true;
|
|
4681
4896
|
stopLifecycleTimer();
|
|
4897
|
+
clearInterval(spoolSweepTimer);
|
|
4682
4898
|
if (queueLaneTimer) {
|
|
4683
4899
|
clearTimeout(queueLaneTimer);
|
|
4684
4900
|
queueLaneTimer = null;
|
|
@@ -4700,6 +4916,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
4700
4916
|
steeringState,
|
|
4701
4917
|
getSettleFailed,
|
|
4702
4918
|
acknowledgeSettleFailed,
|
|
4919
|
+
backgroundTaskIds,
|
|
4920
|
+
resyncBackgroundTasks,
|
|
4921
|
+
// The relay answered a heartbeat: settle notices kept for it go out.
|
|
4922
|
+
flushKeptTaskSettles: () => keptTaskSettles.flush(),
|
|
4703
4923
|
cancelPushedMessage,
|
|
4704
4924
|
// The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
|
|
4705
4925
|
// subagent lane's Stop tries — the runtime tracks sync agents in the same
|
|
@@ -4726,6 +4946,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
4726
4946
|
? new Promise((resolve) => { setTimeout(resolve, taskRefreshMs + 5); }).then(() => taskRefreshChain)
|
|
4727
4947
|
: taskRefreshChain),
|
|
4728
4948
|
// Test seams / observability.
|
|
4949
|
+
_publishBackgroundTasks: publishBackgroundTasks,
|
|
4729
4950
|
_getState: () => ({
|
|
4730
4951
|
hasClient: !!client,
|
|
4731
4952
|
hasSession: !!session,
|
|
@@ -121,6 +121,7 @@ async function main() {
|
|
|
121
121
|
// A message became (or stopped being) cancellable: report it now rather
|
|
122
122
|
// than on the next 10 s heartbeat (coalesced by the controller).
|
|
123
123
|
onCancellableChange: () => heartbeat?.requestPulse(),
|
|
124
|
+
onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
|
|
124
125
|
canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
|
|
125
126
|
dbg,
|
|
126
127
|
});
|
|
@@ -144,6 +145,13 @@ async function main() {
|
|
|
144
145
|
getSteeringState: () => turnRunner.steeringState(),
|
|
145
146
|
getSettleFailed: () => turnRunner.getSettleFailed(),
|
|
146
147
|
onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
|
|
148
|
+
// The relay holds the task set in memory only: the digest of the live
|
|
149
|
+
// task ids rides every beat, and a relay that is out of step (it
|
|
150
|
+
// restarted, a post was lost) asks for the set again.
|
|
151
|
+
getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
|
|
152
|
+
onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
|
|
153
|
+
// The relay answered: settle notices it missed go out.
|
|
154
|
+
onAnswered: () => turnRunner.flushKeptTaskSettles?.(),
|
|
147
155
|
});
|
|
148
156
|
|
|
149
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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// How a Copilot background task ended, for the relay's settle post
|
|
2
|
+
// (`/api/background-task-settled`), with no I/O so it can be tested against
|
|
3
|
+
// the runtime's payloads.
|
|
4
|
+
//
|
|
5
|
+
// What the runtime gives (SDK 1.0.17, generated/rpc.d.ts and
|
|
6
|
+
// generated/session-events.d.ts):
|
|
7
|
+
//
|
|
8
|
+
// - `tasks.list()` keeps a finished task with `status` completed, failed or
|
|
9
|
+
// cancelled (`TaskStatus`); an agent adds `result` (its result text) or
|
|
10
|
+
// `error`. A shell entry (`TaskShellInfo`) carries no exit code.
|
|
11
|
+
// - `system.notification` of kind `shell_completed` (an attached shell) has
|
|
12
|
+
// an optional `exitCode`; `shell_detached_completed` has none, and its
|
|
13
|
+
// text only says to read the shell with `read_bash`.
|
|
14
|
+
// - a `read_bash` (or `bash`) result ends with
|
|
15
|
+
// `<detached command with shellId: 1 completed with exit code 0>` once
|
|
16
|
+
// the shell is done: the detached shell's exit code, when the model reads
|
|
17
|
+
// it.
|
|
18
|
+
//
|
|
19
|
+
// A progress line (`tasks.getProgress`: the agent's latest intent, its last
|
|
20
|
+
// activity line) is how the task is doing, not how it ended: a spinner or
|
|
21
|
+
// status line such as "⟳ Thinking…" is never a summary.
|
|
22
|
+
|
|
23
|
+
// Spinner and progress glyphs a status line starts with.
|
|
24
|
+
const SPINNER_PREFIX_RE = /^[\s⟳↻↺⏳⌛◌◐◓◑◒○●◴◷◶◵⠁-⣿·•…]+/u;
|
|
25
|
+
const STATUS_WORD_RE = /^(?:thinking|working|waiting|running|loading|processing|starting|reasoning|planning)(?:\s*(?:\.{2,}|…))?$/i;
|
|
26
|
+
// `<detached command with shellId: 1 completed with exit code 0>`,
|
|
27
|
+
// `<shellId: 0 completed with exit code 0>`.
|
|
28
|
+
const SHELL_EXIT_TEXT_RE = /shellId:\s*([^\s>,]+)[^<>]*?completed with exit code\s+(-?\d+)/i;
|
|
29
|
+
|
|
30
|
+
const SUMMARY_MAX = 500;
|
|
31
|
+
|
|
32
|
+
function text(value) {
|
|
33
|
+
return String(value ?? '').trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a progress line is a transient status ("⟳ Thinking…") rather than content. */
|
|
37
|
+
export function isTransientProgressText(value) {
|
|
38
|
+
const line = text(value);
|
|
39
|
+
if (!line) return true;
|
|
40
|
+
const glyphs = SPINNER_PREFIX_RE.exec(line)?.[0] || '';
|
|
41
|
+
const rest = line.slice(glyphs.length).trim();
|
|
42
|
+
if (!rest) return true;
|
|
43
|
+
// A status word alone, with or without a spinner in front.
|
|
44
|
+
return STATUS_WORD_RE.test(rest);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The exit code an event reports for a shell: `{ shellId, exitCode }` or
|
|
49
|
+
* null. From a `shell_completed` notification that has one, or from a shell
|
|
50
|
+
* tool's result text.
|
|
51
|
+
*/
|
|
52
|
+
export function shellExitFromEvent(event) {
|
|
53
|
+
const type = text(event?.type);
|
|
54
|
+
const data = event?.data && typeof event.data === 'object' ? event.data : {};
|
|
55
|
+
if (type === 'system.notification') {
|
|
56
|
+
const kind = data.kind && typeof data.kind === 'object' ? data.kind : {};
|
|
57
|
+
const shellId = text(kind.shellId);
|
|
58
|
+
if (kind.type === 'shell_completed' && shellId && Number.isInteger(kind.exitCode)) {
|
|
59
|
+
return { shellId, exitCode: kind.exitCode };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (type === 'tool.execution_complete') {
|
|
64
|
+
const result = data.result && typeof data.result === 'object' ? data.result : {};
|
|
65
|
+
const match = SHELL_EXIT_TEXT_RE.exec(`${text(result.content)}\n${text(result.detailedContent)}`);
|
|
66
|
+
if (!match) return null;
|
|
67
|
+
const exitCode = Number(match[2]);
|
|
68
|
+
return Number.isInteger(exitCode) ? { shellId: match[1], exitCode } : null;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How a task that left the live set ended: `{ status, exitCode, summary }`.
|
|
75
|
+
* `task` is the runtime's entry as `tasks.list()` gives it now (finished),
|
|
76
|
+
* or the last running one when the list no longer has it; `exitCode` what
|
|
77
|
+
* the events said for a shell (null when nothing did).
|
|
78
|
+
*/
|
|
79
|
+
export function settleForTask(task, { exitCode = null } = {}) {
|
|
80
|
+
const status = text(task?.status).toLowerCase();
|
|
81
|
+
const code = Number.isInteger(exitCode) ? exitCode : null;
|
|
82
|
+
let settled;
|
|
83
|
+
if (status === 'cancelled') settled = 'stopped';
|
|
84
|
+
else if (status === 'failed') settled = 'failed';
|
|
85
|
+
else if (code !== null) settled = code === 0 ? 'completed' : 'failed';
|
|
86
|
+
else if (status === 'completed') settled = 'completed';
|
|
87
|
+
else settled = 'unknown';
|
|
88
|
+
let summary = null;
|
|
89
|
+
if (task?.type === 'agent') {
|
|
90
|
+
const result = text(settled === 'failed' ? (task.error || task.result) : (task.result || task.latestResponse));
|
|
91
|
+
if (result && !isTransientProgressText(result)) summary = result.slice(0, SUMMARY_MAX);
|
|
92
|
+
} else if (code !== null) {
|
|
93
|
+
summary = `exit code ${code}`;
|
|
94
|
+
}
|
|
95
|
+
return { status: settled, exitCode: code, summary };
|
|
96
|
+
}
|