@oar-sh/oar 0.9.8 → 0.9.10
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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -3,15 +3,20 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The threat: a semi-trusted producer (an AI worker writing a workspace file, or
|
|
5
5
|
// an uploaded attachment) can supply content whose MIME type the browser will
|
|
6
|
-
// execute as markup/script on THIS origin — `text/html`,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// execute as markup/script on THIS origin — `text/html`, XML with embedded
|
|
7
|
+
// script. Served inline, that is stored XSS against the app (and, via the
|
|
8
|
+
// unauthenticated /api/shared/* routes, against share viewers). We neutralize
|
|
9
|
+
// such types to `text/plain` and always send `nosniff`, and we attach a
|
|
10
|
+
// locked-down CSP + `sandbox` so anything that still reaches the browser as a
|
|
11
11
|
// document can neither run script nor navigate.
|
|
12
|
+
//
|
|
13
|
+
// SVG keeps its type: as an `<img>` it never runs script, and opened as a
|
|
14
|
+
// document the `sandbox` CSP denies it script and an origin, so the viewer
|
|
15
|
+
// and the file browser's thumbnails can show it.
|
|
12
16
|
|
|
13
17
|
// Types the browser renders as active documents on this origin.
|
|
14
|
-
const ACTIVE_MARKUP_PATTERN = /^text\/html\b|
|
|
18
|
+
const ACTIVE_MARKUP_PATTERN = /^text\/html\b|xhtml|(^|\/)xml\b|\+xml\b/i;
|
|
19
|
+
const SVG_TYPE = 'image/svg+xml';
|
|
15
20
|
|
|
16
21
|
// Types safe to preview inline with their real Content-Type.
|
|
17
22
|
const INLINE_SAFE_PREFIXES = ['image/', 'video/', 'audio/'];
|
|
@@ -25,7 +30,7 @@ const INLINE_SAFE_EXACT = new Set(['application/pdf']);
|
|
|
25
30
|
export function safeServedContentType(mimeType) {
|
|
26
31
|
const type = String(mimeType || '').trim().toLowerCase().split(';')[0].slice(0, 127)
|
|
27
32
|
|| 'application/octet-stream';
|
|
28
|
-
if (ACTIVE_MARKUP_PATTERN.test(type)) {
|
|
33
|
+
if (type !== SVG_TYPE && ACTIVE_MARKUP_PATTERN.test(type)) {
|
|
29
34
|
// Render markup/script content as its own source text instead of executing it.
|
|
30
35
|
return { contentType: 'text/plain; charset=utf-8', inlineSafe: false, neutralized: true };
|
|
31
36
|
}
|
|
@@ -34,6 +39,45 @@ export function safeServedContentType(mimeType) {
|
|
|
34
39
|
return { contentType: type, inlineSafe, neutralized: false };
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
// Printable ASCII, the only characters a quoted `filename="…"` may hold once
|
|
43
|
+
// quotes and backslashes are gone. Anything else goes into `filename*`.
|
|
44
|
+
const NON_ASCII_PATTERN = /[^\x20-\x7e]/g;
|
|
45
|
+
// A surrogate without its partner: not a character, and encodeURIComponent
|
|
46
|
+
// throws on it.
|
|
47
|
+
const LONE_SURROGATE_PATTERN = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
48
|
+
|
|
49
|
+
/** RFC 5987 `ext-value` encoding: UTF-8 percent-encoded, attr-char kept. */
|
|
50
|
+
function encodeRfc5987(value) {
|
|
51
|
+
return encodeURIComponent(value)
|
|
52
|
+
// encodeURIComponent leaves these alone, but RFC 5987 does not allow them bare.
|
|
53
|
+
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `Content-Disposition` value for `fileName`, RFC 6266 style: a quoted
|
|
58
|
+
* `filename` every client can read, built from the name's ASCII characters
|
|
59
|
+
* (never empty: `file` plus the extension when nothing is left), and, when the
|
|
60
|
+
* name has characters outside ASCII, `filename*=UTF-8''…` carrying the whole
|
|
61
|
+
* name percent-encoded. The result holds nothing a header may not: no
|
|
62
|
+
* control characters, no quotes inside the quoted string, and no character
|
|
63
|
+
* outside Latin-1, which Node's `setHeader` rejects (and which used to make a
|
|
64
|
+
* file with a Japanese or emoji name unservable).
|
|
65
|
+
*/
|
|
66
|
+
export function contentDispositionValue(disposition, fileName) {
|
|
67
|
+
const name = String(fileName || '')
|
|
68
|
+
.replace(LONE_SURROGATE_PATTERN, '')
|
|
69
|
+
.replace(/[\u0000-\u001f\u007f"\\]/g, '')
|
|
70
|
+
.slice(0, 255);
|
|
71
|
+
if (!name) return disposition;
|
|
72
|
+
const ascii = name.replace(NON_ASCII_PATTERN, '').trim();
|
|
73
|
+
if (ascii === name) return `${disposition}; filename="${name}"`;
|
|
74
|
+
const dot = name.lastIndexOf('.');
|
|
75
|
+
const stem = (dot > 0 ? name.slice(0, dot) : name).replace(NON_ASCII_PATTERN, '').replace(/^[\s.]+|[\s.]+$/g, '') || 'file';
|
|
76
|
+
const ext = (dot > 0 ? name.slice(dot) : '').replace(NON_ASCII_PATTERN, '').trim();
|
|
77
|
+
const fallback = `${stem}${ext.length > 1 ? ext : ''}`;
|
|
78
|
+
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987(name)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
37
81
|
/**
|
|
38
82
|
* Apply the safe Content-Type plus the hardening headers to a response that is
|
|
39
83
|
* about to stream stored bytes. Returns the resolved descriptor so callers can
|
|
@@ -46,12 +90,18 @@ export function applySafeServedContentHeaders(res, mimeType, { fileName = '' } =
|
|
|
46
90
|
// Belt-and-braces: a served file is never part of the app UI, so forbid it
|
|
47
91
|
// from loading anything or being framed, and sandbox it so it cannot run
|
|
48
92
|
// script or navigate even if a browser would otherwise treat it as active.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
// A PDF is the exception: browsers refuse to show a sandboxed PDF in their
|
|
94
|
+
// built-in viewer, so it gets no `sandbox` and may be framed by this origin.
|
|
95
|
+
// PDF viewers do not run document script, so the sandbox bought nothing there.
|
|
96
|
+
res.setHeader(
|
|
97
|
+
'Content-Security-Policy',
|
|
98
|
+
resolved.contentType === 'application/pdf'
|
|
99
|
+
? "default-src 'none'; frame-ancestors 'self'"
|
|
100
|
+
: "default-src 'none'; sandbox; frame-ancestors 'none'",
|
|
101
|
+
);
|
|
52
102
|
res.setHeader(
|
|
53
103
|
'Content-Disposition',
|
|
54
|
-
|
|
104
|
+
contentDispositionValue(resolved.inlineSafe ? 'inline' : 'attachment', fileName),
|
|
55
105
|
);
|
|
56
106
|
return resolved;
|
|
57
107
|
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What each session of this relay is doing right now: a turn, background
|
|
5
|
+
* tasks only, or nothing, with the tasks themselves and when anything last
|
|
6
|
+
* moved.
|
|
7
|
+
*
|
|
8
|
+
* `list_sessions` / `read_session` read it as `state` and `tasks[]`; the
|
|
9
|
+
* relay event service reads its edges (a task started or ended) and its quiet
|
|
10
|
+
* time (stalled). Memory only: the background-task set is republished by the
|
|
11
|
+
* workers on every change, sub-agent runs are read from the database, and a
|
|
12
|
+
* restart forgets only the recently finished tasks and the activity clock
|
|
13
|
+
* (which then starts at the restart, so nothing reads as stalled at boot).
|
|
14
|
+
*
|
|
15
|
+
* Sources:
|
|
16
|
+
* - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
|
|
17
|
+
* a new task id is a start, a task gone from the set has ended, a changed
|
|
18
|
+
* task (tool call, summary, tokens) is activity.
|
|
19
|
+
* - `noteTaskSettled` — how a task ended, where the worker says so (the
|
|
20
|
+
* Claude worker's task_notification).
|
|
21
|
+
* - `noteSubagentRun` — every sub-agent run status the relay stores; a
|
|
22
|
+
* background run's terminal status is how its task ended, and a
|
|
23
|
+
* background run with no task in the set (Copilot, Cursor) ends here.
|
|
24
|
+
* - `touch` — any activity, thought, stream or timeline post.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
28
|
+
|
|
29
|
+
export const SESSION_STATES = Object.freeze(['running_turn', 'background_only', 'idle']);
|
|
30
|
+
|
|
31
|
+
// How long a task gone from the set waits for the word on how it ended.
|
|
32
|
+
export const TASK_END_GRACE_MS = 5_000;
|
|
33
|
+
// A `running` run row not touched for this long is not running work: its
|
|
34
|
+
// worker's record of its end never arrived (live: rows months old still
|
|
35
|
+
// `running`, counted as a running task by every reader, so a subscription
|
|
36
|
+
// to all sessions never saw all_idle and reported the session stalled
|
|
37
|
+
// again and again). The sweep in stale-subagent-run-sweep.mjs closes such
|
|
38
|
+
// rows; this bound keeps them out of the state meanwhile.
|
|
39
|
+
export const STALE_RUNNING_RUN_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
40
|
+
const RECENT_TASKS_MAX = 5;
|
|
41
|
+
const RECENT_TASK_TTL_MS = 60 * 60_000;
|
|
42
|
+
const SHELL_TASK_TYPES = new Set(['local_bash', 'bash', 'shell', 'monitor', 'local_monitor']);
|
|
43
|
+
const EXIT_CODE_PATTERN = /exit(?:ed)?(?: with)?(?: code| status)?[:\s]+(-?\d{1,3})\b/i;
|
|
44
|
+
|
|
45
|
+
function toText(value) {
|
|
46
|
+
return String(value ?? '').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isoOf(ms) {
|
|
50
|
+
return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function msOf(value) {
|
|
54
|
+
if (value === null || value === undefined || value === '') return 0;
|
|
55
|
+
const number = Number(value);
|
|
56
|
+
if (Number.isFinite(number) && number > 0) return number;
|
|
57
|
+
const parsed = Date.parse(String(value));
|
|
58
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function taskKindOf(taskType) {
|
|
62
|
+
return SHELL_TASK_TYPES.has(toText(taskType).toLowerCase()) ? 'shell' : 'subagent';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An exit code a task summary names ("Exit code 2", "exited with 1"), else null. */
|
|
66
|
+
export function exitCodeFromSummary(summary) {
|
|
67
|
+
const match = EXIT_CODE_PATTERN.exec(String(summary || ''));
|
|
68
|
+
if (!match) return null;
|
|
69
|
+
const code = Number(match[1]);
|
|
70
|
+
return Number.isInteger(code) ? code : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The status words of a task: running, completed, failed, stopped. */
|
|
74
|
+
export function normalizeTaskStatus(status) {
|
|
75
|
+
const value = normalizeSubagentRunStatus(status);
|
|
76
|
+
if (value === 'cancelled') return 'stopped';
|
|
77
|
+
if (value === 'running' || value === 'completed' || value === 'failed') return value;
|
|
78
|
+
return value ? 'completed' : 'running';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function taskSignature(task) {
|
|
82
|
+
return JSON.stringify([
|
|
83
|
+
task.summary || null,
|
|
84
|
+
task.lastToolName || null,
|
|
85
|
+
task.lastToolCall || null,
|
|
86
|
+
task.totalTokens ?? null,
|
|
87
|
+
task.workflowProgress ? JSON.stringify(task.workflowProgress).length : 0,
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSessionActivityTracker({
|
|
92
|
+
db = null,
|
|
93
|
+
now = () => Date.now(),
|
|
94
|
+
setTimer = (fn, ms) => {
|
|
95
|
+
const timer = setTimeout(fn, ms);
|
|
96
|
+
timer.unref?.();
|
|
97
|
+
return timer;
|
|
98
|
+
},
|
|
99
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
100
|
+
onEdge = () => {},
|
|
101
|
+
logger = console,
|
|
102
|
+
} = {}) {
|
|
103
|
+
const startedAtMs = now();
|
|
104
|
+
// conversationId → { live: Map<taskId, entry>, recent: [], lastActivityAt }
|
|
105
|
+
const sessions = new Map();
|
|
106
|
+
// Edges already reported for an end, by `${conversationId}:${key}`.
|
|
107
|
+
const endedKeys = new Map();
|
|
108
|
+
|
|
109
|
+
let listRuns = null;
|
|
110
|
+
let getQueueRows = null;
|
|
111
|
+
if (db) {
|
|
112
|
+
try {
|
|
113
|
+
listRuns = db.prepare(`
|
|
114
|
+
SELECT * FROM subagent_runs
|
|
115
|
+
WHERE conversation_id = ? AND (status = 'running' OR COALESCE(completed_at, updated_at) >= ?)
|
|
116
|
+
AND parent_subagent_id IS NULL
|
|
117
|
+
ORDER BY started_at ASC
|
|
118
|
+
LIMIT 50
|
|
119
|
+
`);
|
|
120
|
+
} catch {}
|
|
121
|
+
try {
|
|
122
|
+
getQueueRows = db.prepare(`
|
|
123
|
+
SELECT id, status, timestamp, processing_at, next_attempt_at, usage_limit_pause
|
|
124
|
+
FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')
|
|
125
|
+
`);
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function session(conversationId) {
|
|
130
|
+
const id = toText(conversationId);
|
|
131
|
+
let entry = sessions.get(id);
|
|
132
|
+
if (!entry) {
|
|
133
|
+
entry = { live: new Map(), recent: [], lastActivityAt: 0 };
|
|
134
|
+
sessions.set(id, entry);
|
|
135
|
+
}
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function emitEdge(type, conversationId, task) {
|
|
140
|
+
try {
|
|
141
|
+
onEdge({ type, conversationId, task: publicTask(task) });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
try { logger?.warn?.(`[activity] edge handler failed: ${error?.message || error}`); } catch {}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function markEnded(conversationId, task) {
|
|
148
|
+
const at = now();
|
|
149
|
+
for (const key of [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']) {
|
|
150
|
+
if (key) endedKeys.set(`${conversationId}:${key}`, at);
|
|
151
|
+
}
|
|
152
|
+
if (endedKeys.size > 2000) {
|
|
153
|
+
for (const [key, when] of endedKeys) {
|
|
154
|
+
if (at - when > RECENT_TASK_TTL_MS) endedKeys.delete(key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function wasEnded(conversationId, task) {
|
|
160
|
+
return [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']
|
|
161
|
+
.some((key) => key && endedKeys.has(`${conversationId}:${key}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function publicTask(task) {
|
|
165
|
+
return {
|
|
166
|
+
id: task.taskId || task.toolUseId || null,
|
|
167
|
+
kind: task.kind,
|
|
168
|
+
type: task.taskType || null,
|
|
169
|
+
description: task.description || '',
|
|
170
|
+
startedAt: isoOf(task.startedAtMs),
|
|
171
|
+
lastActivityAt: isoOf(task.lastActivityAtMs || task.startedAtMs),
|
|
172
|
+
status: task.status,
|
|
173
|
+
...(task.exitCode !== null && task.exitCode !== undefined ? { exitCode: task.exitCode } : {}),
|
|
174
|
+
...(task.status !== 'running' && task.statusReported === false ? { statusReported: false } : {}),
|
|
175
|
+
...(task.summary ? { summary: task.summary } : {}),
|
|
176
|
+
...(task.endedAtMs ? { endedAt: isoOf(task.endedAtMs) } : {}),
|
|
177
|
+
...(task.toolUseId ? { runId: task.toolUseId } : {}),
|
|
178
|
+
...(task.model ? { model: task.model } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true } = {}) {
|
|
183
|
+
if (task.graceTimer) {
|
|
184
|
+
clearTimer(task.graceTimer);
|
|
185
|
+
task.graceTimer = null;
|
|
186
|
+
}
|
|
187
|
+
const entry = session(conversationId);
|
|
188
|
+
entry.live.delete(task.taskId);
|
|
189
|
+
if (wasEnded(conversationId, task)) return;
|
|
190
|
+
task.status = normalizeTaskStatus(status || 'completed');
|
|
191
|
+
task.statusReported = reported;
|
|
192
|
+
if (summary) task.summary = toText(summary).slice(0, 500);
|
|
193
|
+
const code = Number.isInteger(exitCode) ? exitCode : exitCodeFromSummary(task.summary);
|
|
194
|
+
task.exitCode = code;
|
|
195
|
+
task.endedAtMs = now();
|
|
196
|
+
markEnded(conversationId, task);
|
|
197
|
+
entry.recent.unshift(task);
|
|
198
|
+
entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
|
|
199
|
+
entry.lastActivityAt = Math.max(entry.lastActivityAt, task.endedAtMs);
|
|
200
|
+
const failed = task.status === 'failed' || task.status === 'stopped' || (code !== null && code !== 0);
|
|
201
|
+
emitEdge(failed ? 'task_failed' : 'task_finished', conversationId, task);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function runStatus(runId) {
|
|
205
|
+
if (!db || !runId) return null;
|
|
206
|
+
try {
|
|
207
|
+
const row = db.prepare(`SELECT status FROM subagent_runs WHERE id = ?`).get(runId);
|
|
208
|
+
return row ? normalizeSubagentRunStatus(row.status) : null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A task left the set: settle it now if its end is known, else after the grace. */
|
|
215
|
+
function taskLeft(conversationId, task) {
|
|
216
|
+
if (task.settled) {
|
|
217
|
+
finishTask(conversationId, task, task.settled);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const status = runStatus(task.toolUseId);
|
|
221
|
+
if (status && isTerminalSubagentRunStatus(status)) {
|
|
222
|
+
finishTask(conversationId, task, { status });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
task.leftAtMs = now();
|
|
226
|
+
task.graceTimer = setTimer(() => {
|
|
227
|
+
task.graceTimer = null;
|
|
228
|
+
const late = runStatus(task.toolUseId);
|
|
229
|
+
if (late && isTerminalSubagentRunStatus(late)) finishTask(conversationId, task, { status: late });
|
|
230
|
+
else finishTask(conversationId, task, { status: 'completed', reported: false });
|
|
231
|
+
}, TASK_END_GRACE_MS);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function noteTaskSet(conversationId, tasks) {
|
|
235
|
+
const id = toText(conversationId);
|
|
236
|
+
if (!id) return;
|
|
237
|
+
const entry = session(id);
|
|
238
|
+
const at = now();
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
for (const raw of Array.isArray(tasks) ? tasks : []) {
|
|
241
|
+
const taskId = toText(raw?.taskId);
|
|
242
|
+
if (!taskId) continue;
|
|
243
|
+
seen.add(taskId);
|
|
244
|
+
const signature = taskSignature(raw);
|
|
245
|
+
const known = entry.live.get(taskId);
|
|
246
|
+
if (known && !known.leftAtMs) {
|
|
247
|
+
if (known.signature !== signature) {
|
|
248
|
+
known.signature = signature;
|
|
249
|
+
known.lastActivityAtMs = at;
|
|
250
|
+
entry.lastActivityAt = at;
|
|
251
|
+
}
|
|
252
|
+
known.description = toText(raw.description) || known.description;
|
|
253
|
+
known.toolUseId = toText(raw.toolUseId) || known.toolUseId;
|
|
254
|
+
known.summary = toText(raw.summary) || known.summary;
|
|
255
|
+
known.model = toText(raw.model) || known.model;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (known?.leftAtMs) {
|
|
259
|
+
// Back in the set after it left: it did not end after all.
|
|
260
|
+
if (known.graceTimer) clearTimer(known.graceTimer);
|
|
261
|
+
known.graceTimer = null;
|
|
262
|
+
known.leftAtMs = 0;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Settled already (its notice came before the set dropped it).
|
|
266
|
+
if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) continue;
|
|
267
|
+
const task = {
|
|
268
|
+
taskId,
|
|
269
|
+
toolUseId: toText(raw.toolUseId) || null,
|
|
270
|
+
taskType: toText(raw.taskType),
|
|
271
|
+
kind: taskKindOf(raw.taskType),
|
|
272
|
+
description: toText(raw.description).slice(0, 500),
|
|
273
|
+
startedAtMs: msOf(raw.startedAt) || at,
|
|
274
|
+
lastActivityAtMs: at,
|
|
275
|
+
summary: toText(raw.summary) || null,
|
|
276
|
+
model: toText(raw.model) || null,
|
|
277
|
+
status: 'running',
|
|
278
|
+
signature,
|
|
279
|
+
settled: null,
|
|
280
|
+
graceTimer: null,
|
|
281
|
+
leftAtMs: 0,
|
|
282
|
+
};
|
|
283
|
+
entry.live.set(taskId, task);
|
|
284
|
+
entry.lastActivityAt = at;
|
|
285
|
+
emitEdge('task_started', id, task);
|
|
286
|
+
}
|
|
287
|
+
for (const task of [...entry.live.values()]) {
|
|
288
|
+
if (seen.has(task.taskId) || task.leftAtMs) continue;
|
|
289
|
+
taskLeft(id, task);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function noteTaskSettled(conversationId, { taskId, toolUseId, status, summary, exitCode } = {}) {
|
|
294
|
+
const id = toText(conversationId);
|
|
295
|
+
if (!id) return;
|
|
296
|
+
const entry = session(id);
|
|
297
|
+
const key = toText(taskId);
|
|
298
|
+
const run = toText(toolUseId);
|
|
299
|
+
const task = (key && entry.live.get(key))
|
|
300
|
+
|| [...entry.live.values()].find((candidate) => run && candidate.toolUseId === run)
|
|
301
|
+
|| null;
|
|
302
|
+
const settled = {
|
|
303
|
+
status: normalizeTaskStatus(status || 'completed'),
|
|
304
|
+
summary: toText(summary) || null,
|
|
305
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
306
|
+
};
|
|
307
|
+
entry.lastActivityAt = now();
|
|
308
|
+
if (!task) return;
|
|
309
|
+
task.settled = settled;
|
|
310
|
+
// Gone from the set already, or about to: settle now either way. A task
|
|
311
|
+
// still listed would leave with this status anyway.
|
|
312
|
+
finishTask(id, task, settled);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function noteSubagentRun(run) {
|
|
316
|
+
const id = toText(run?.conversation_id || run?.conversationId);
|
|
317
|
+
const runId = toText(run?.id || run?.subagentRunId);
|
|
318
|
+
if (!id || !runId) return;
|
|
319
|
+
const entry = session(id);
|
|
320
|
+
entry.lastActivityAt = now();
|
|
321
|
+
const status = normalizeSubagentRunStatus(run?.status);
|
|
322
|
+
if (!isTerminalSubagentRunStatus(status)) return;
|
|
323
|
+
const task = [...entry.live.values()].find((candidate) => candidate.toolUseId === runId) || null;
|
|
324
|
+
if (task) {
|
|
325
|
+
// The run's end is the task's: finish it once it has left the set,
|
|
326
|
+
// which the grace timer is waiting for; a task still listed keeps
|
|
327
|
+
// waiting for its own departure.
|
|
328
|
+
if (task.leftAtMs) finishTask(id, task, { status });
|
|
329
|
+
else task.settled = task.settled || { status };
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const background = Number(run?.background) === 1 || run?.background === true;
|
|
333
|
+
if (!background || toText(run?.parent_subagent_id || run?.parentSubagentId)) return;
|
|
334
|
+
const synthetic = {
|
|
335
|
+
taskId: `run:${runId}`,
|
|
336
|
+
toolUseId: runId,
|
|
337
|
+
taskType: toText(run?.subagent_type) || 'agent',
|
|
338
|
+
kind: 'subagent',
|
|
339
|
+
description: toText(run?.display_name || run?.displayName) || 'sub-agent',
|
|
340
|
+
startedAtMs: msOf(run?.started_at) || now(),
|
|
341
|
+
lastActivityAtMs: now(),
|
|
342
|
+
summary: null,
|
|
343
|
+
model: toText(run?.model) || null,
|
|
344
|
+
status: 'running',
|
|
345
|
+
};
|
|
346
|
+
if (wasEnded(id, synthetic)) return;
|
|
347
|
+
finishTask(id, synthetic, { status });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function touch(conversationId) {
|
|
351
|
+
const id = toText(conversationId);
|
|
352
|
+
if (!id) return;
|
|
353
|
+
session(id).lastActivityAt = now();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function forget(conversationId) {
|
|
357
|
+
const id = toText(conversationId);
|
|
358
|
+
const entry = sessions.get(id);
|
|
359
|
+
if (!entry) return;
|
|
360
|
+
for (const task of entry.live.values()) {
|
|
361
|
+
if (task.graceTimer) clearTimer(task.graceTimer);
|
|
362
|
+
}
|
|
363
|
+
sessions.delete(id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The worker died: its tasks went with it, with no edge (the error says it). */
|
|
367
|
+
function noteWorkerDied(conversationId) {
|
|
368
|
+
const id = toText(conversationId);
|
|
369
|
+
const entry = sessions.get(id);
|
|
370
|
+
if (!entry) return;
|
|
371
|
+
for (const task of [...entry.live.values()]) {
|
|
372
|
+
if (task.graceTimer) clearTimer(task.graceTimer);
|
|
373
|
+
entry.live.delete(task.taskId);
|
|
374
|
+
task.status = 'stopped';
|
|
375
|
+
task.statusReported = false;
|
|
376
|
+
task.endedAtMs = now();
|
|
377
|
+
markEnded(id, task);
|
|
378
|
+
entry.recent.unshift(task);
|
|
379
|
+
}
|
|
380
|
+
entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function turnState(conversationId) {
|
|
384
|
+
if (!getQueueRows) return { running: false, sinceMs: 0 };
|
|
385
|
+
let rows = [];
|
|
386
|
+
try {
|
|
387
|
+
rows = getQueueRows.all(conversationId);
|
|
388
|
+
} catch {
|
|
389
|
+
return { running: false, sinceMs: 0 };
|
|
390
|
+
}
|
|
391
|
+
const at = now();
|
|
392
|
+
let running = false;
|
|
393
|
+
let sinceMs = 0;
|
|
394
|
+
for (const row of rows) {
|
|
395
|
+
const held = row.usage_limit_pause && msOf(row.next_attempt_at) > at;
|
|
396
|
+
if (held) continue;
|
|
397
|
+
running = true;
|
|
398
|
+
const since = msOf(row.processing_at) || msOf(row.timestamp);
|
|
399
|
+
sinceMs = Math.max(sinceMs, since);
|
|
400
|
+
}
|
|
401
|
+
return { running, sinceMs };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function runTasks(conversationId, coveredRunIds) {
|
|
405
|
+
if (!listRuns) return [];
|
|
406
|
+
let rows = [];
|
|
407
|
+
try {
|
|
408
|
+
rows = listRuns.all(conversationId, new Date(now() - RECENT_TASK_TTL_MS).toISOString());
|
|
409
|
+
} catch {
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
const staleBefore = now() - STALE_RUNNING_RUN_MAX_AGE_MS;
|
|
413
|
+
const staleRunning = (row) => normalizeTaskStatus(row.status) === 'running'
|
|
414
|
+
&& msOf(row.updated_at || row.started_at) < staleBefore;
|
|
415
|
+
return rows
|
|
416
|
+
.filter((row) => !coveredRunIds.has(toText(row.id)) && !staleRunning(row))
|
|
417
|
+
.map((row) => {
|
|
418
|
+
const status = normalizeTaskStatus(row.status);
|
|
419
|
+
return {
|
|
420
|
+
id: toText(row.task_id) || toText(row.id),
|
|
421
|
+
kind: 'subagent',
|
|
422
|
+
type: toText(row.subagent_type) || null,
|
|
423
|
+
description: toText(row.display_name) || 'sub-agent',
|
|
424
|
+
startedAt: row.started_at || null,
|
|
425
|
+
lastActivityAt: row.updated_at || row.started_at || null,
|
|
426
|
+
status,
|
|
427
|
+
...(status !== 'running' && row.completed_at ? { endedAt: row.completed_at } : {}),
|
|
428
|
+
runId: toText(row.id),
|
|
429
|
+
...(row.model ? { model: row.model } : {}),
|
|
430
|
+
...(Number(row.background) === 1 ? {} : { inTurn: true }),
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* `{ state, tasks, lastActivityAt, turnSince }` of one conversation.
|
|
437
|
+
* `tasks` lists the running ones, then up to five that ended within the
|
|
438
|
+
* hour, newest first.
|
|
439
|
+
*/
|
|
440
|
+
function describe(conversationId) {
|
|
441
|
+
const id = toText(conversationId);
|
|
442
|
+
const entry = sessions.get(id) || null;
|
|
443
|
+
const at = now();
|
|
444
|
+
const live = entry ? [...entry.live.values()].filter((task) => !task.leftAtMs) : [];
|
|
445
|
+
const recent = entry
|
|
446
|
+
? entry.recent.filter((task) => at - (task.endedAtMs || 0) <= RECENT_TASK_TTL_MS)
|
|
447
|
+
: [];
|
|
448
|
+
const covered = new Set([...live, ...recent, ...(entry ? [...entry.live.values()] : [])]
|
|
449
|
+
.map((task) => toText(task.toolUseId))
|
|
450
|
+
.filter(Boolean));
|
|
451
|
+
const fromRuns = runTasks(id, covered);
|
|
452
|
+
const tasks = [
|
|
453
|
+
...live.map(publicTask),
|
|
454
|
+
...fromRuns.filter((task) => task.status === 'running'),
|
|
455
|
+
...[...recent.map(publicTask), ...fromRuns.filter((task) => task.status !== 'running')]
|
|
456
|
+
.sort((a, b) => msOf(b.endedAt) - msOf(a.endedAt))
|
|
457
|
+
.slice(0, RECENT_TASKS_MAX),
|
|
458
|
+
];
|
|
459
|
+
const turn = turnState(id);
|
|
460
|
+
const runningTasks = tasks.filter((task) => task.status === 'running');
|
|
461
|
+
const state = turn.running ? 'running_turn' : (runningTasks.length ? 'background_only' : 'idle');
|
|
462
|
+
const taskActivity = Math.max(0, ...runningTasks.map((task) => msOf(task.lastActivityAt)));
|
|
463
|
+
const lastActivityMs = Math.max(entry?.lastActivityAt || 0, taskActivity, turn.sinceMs, startedAtMs);
|
|
464
|
+
return {
|
|
465
|
+
state,
|
|
466
|
+
tasks,
|
|
467
|
+
runningTaskCount: runningTasks.length,
|
|
468
|
+
lastActivityAt: isoOf(lastActivityMs),
|
|
469
|
+
turnSince: isoOf(turn.sinceMs),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function knownConversationIds() {
|
|
474
|
+
return [...sessions.keys()];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
noteTaskSet,
|
|
479
|
+
noteTaskSettled,
|
|
480
|
+
noteSubagentRun,
|
|
481
|
+
noteWorkerDied,
|
|
482
|
+
touch,
|
|
483
|
+
forget,
|
|
484
|
+
describe,
|
|
485
|
+
knownConversationIds,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
@@ -144,6 +144,22 @@ export function createSessionHistoryRefreshService({
|
|
|
144
144
|
WHERE conversation_id = ? AND hidden_from_shares = 1
|
|
145
145
|
`)
|
|
146
146
|
: null;
|
|
147
|
+
// Pins are carried across the replace the same way as hidden marks.
|
|
148
|
+
const messagesSupportPins = messageColumns.has('pinned_at');
|
|
149
|
+
const listPinnedMessageMarks = messagesSupportPins
|
|
150
|
+
? db.prepare(`
|
|
151
|
+
SELECT id, role, text, timestamp, pinned_at
|
|
152
|
+
FROM messages
|
|
153
|
+
WHERE conversation_id = ? AND pinned_at IS NOT NULL
|
|
154
|
+
`)
|
|
155
|
+
: null;
|
|
156
|
+
const restoreMessagePin = messagesSupportPins
|
|
157
|
+
? db.prepare(`
|
|
158
|
+
UPDATE messages
|
|
159
|
+
SET pinned_at = ?
|
|
160
|
+
WHERE conversation_id = ? AND id = ?
|
|
161
|
+
`)
|
|
162
|
+
: null;
|
|
147
163
|
const restoreHiddenMessageVisibility = messagesSupportShareVisibility
|
|
148
164
|
? db.prepare(`
|
|
149
165
|
UPDATE messages
|
|
@@ -218,6 +234,11 @@ export function createSessionHistoryRefreshService({
|
|
|
218
234
|
// replaces those ids, so the rows must go with the messages they annotated
|
|
219
235
|
// (same lifecycle as subagent_runs). The fallback guards dbs opened before
|
|
220
236
|
// the table existed.
|
|
237
|
+
// The sub-agent timeline goes with its runs. Null on a database the 0009
|
|
238
|
+
// migration has not reached (no table, nothing to delete).
|
|
239
|
+
const deleteConversationSubagentTimeline = typeof stmts?.deleteConvSubagentTimeline?.run === 'function'
|
|
240
|
+
? stmts.deleteConvSubagentTimeline
|
|
241
|
+
: null;
|
|
221
242
|
const deleteConversationWorkflowRuns = typeof stmts?.deleteConvWorkflowRuns?.run === 'function'
|
|
222
243
|
? stmts.deleteConvWorkflowRuns
|
|
223
244
|
: db.prepare(`DELETE FROM workflow_runs WHERE conversation_id = ?`);
|
|
@@ -409,6 +430,7 @@ export function createSessionHistoryRefreshService({
|
|
|
409
430
|
deleteConversationThoughts.run(conversationId);
|
|
410
431
|
deleteConversationStreamEvents.run(conversationId);
|
|
411
432
|
deleteConversationSubagentRuns.run(conversationId);
|
|
433
|
+
deleteConversationSubagentTimeline?.run(conversationId);
|
|
412
434
|
deleteConversationWorkflowRuns.run(conversationId);
|
|
413
435
|
});
|
|
414
436
|
|
|
@@ -418,12 +440,14 @@ export function createSessionHistoryRefreshService({
|
|
|
418
440
|
|
|
419
441
|
const replaceRetrievableHistoryTx = db.transaction((conversationId, messages = []) => {
|
|
420
442
|
const hiddenMessages = listHiddenMessageVisibility?.all(conversationId) || [];
|
|
443
|
+
const pinnedMessages = listPinnedMessageMarks?.all(conversationId) || [];
|
|
421
444
|
const attachmentCandidates = collectAttachmentCandidates(conversationId);
|
|
422
445
|
deleteConversationMessages.run(conversationId);
|
|
423
446
|
deleteConversationActivity.run(conversationId);
|
|
424
447
|
deleteConversationThoughts.run(conversationId);
|
|
425
448
|
deleteConversationStreamEvents.run(conversationId);
|
|
426
449
|
deleteConversationSubagentRuns.run(conversationId);
|
|
450
|
+
deleteConversationSubagentTimeline?.run(conversationId);
|
|
427
451
|
deleteConversationWorkflowRuns.run(conversationId);
|
|
428
452
|
insertRebuiltMessages(conversationId, messages, attachmentCandidates);
|
|
429
453
|
const rebuiltMessages = ensureArray(messages);
|
|
@@ -437,6 +461,13 @@ export function createSessionHistoryRefreshService({
|
|
|
437
461
|
resolvedId,
|
|
438
462
|
);
|
|
439
463
|
}
|
|
464
|
+
// Its own consumed set: a message can be both hidden and pinned.
|
|
465
|
+
const consumedPinnedIds = new Set();
|
|
466
|
+
for (const pinnedMessage of pinnedMessages) {
|
|
467
|
+
const resolvedId = resolveHiddenMessageId(pinnedMessage, rebuiltMessages, consumedPinnedIds);
|
|
468
|
+
if (!resolvedId) continue;
|
|
469
|
+
restoreMessagePin.run(pinnedMessage.pinned_at, conversationId, resolvedId);
|
|
470
|
+
}
|
|
440
471
|
});
|
|
441
472
|
|
|
442
473
|
function evaluateRefreshIdleState(conversationId) {
|