@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4
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/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +652 -613
- package/apps/agent-terminal/app.js +143 -67
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +172 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +189 -159
- package/apps/agent-terminal/right-workspace-dock.js +652 -521
- package/apps/agent-terminal/user-question-card.js +68 -0
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +15 -13
- package/docs/sdk/openapi.yaml +16 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1461 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/user-questions.js +67 -0
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2408 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- package/src/sdk-wave-e.ts +1 -1
|
@@ -1,393 +1,393 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Native task transcript collector - the read side of native task history.
|
|
4
|
-
// When a bound subagent reaches a real terminal signal (a SubagentStop hook
|
|
5
|
-
// trace, a terminal SDK task status, or the main reply ending while an agent
|
|
6
|
-
// task is already terminal), the collector performs ONE public SDK read
|
|
7
|
-
// (getSubagentMessages) for that agent, normalizes the snapshot through the
|
|
8
|
-
// redaction boundary (native-task-messages + the trace module's credential
|
|
9
|
-
// masking), and publishes it as claude/native-task-transcript facts through
|
|
10
|
-
// the SAME durable publish gate as every other native fact. It has no
|
|
11
|
-
// authority of its own: the agent identity comes exclusively from the
|
|
12
|
-
// registry's trusted bindings, the read uses only the installed SDK's public
|
|
13
|
-
// entry point with the query's real cwd and the process's inherited config
|
|
14
|
-
// root, and dedup keys/cursors are consumed only after the gate proves the
|
|
15
|
-
// fact durable. Every scheduled read freezes its notification-time Service
|
|
16
|
-
// owner and query context: a later query or a whole Service transfer can
|
|
17
|
-
// never redirect, republish or un-drop it. No polling, no private transcript
|
|
18
|
-
// parsing, no worker, no process.env mutation, no parent abort on read
|
|
19
|
-
// failure.
|
|
20
|
-
|
|
21
|
-
const { normalizeTaskMessages } = require('./native-task-messages');
|
|
22
|
-
const { createSecretRedactor } = require('./native-task-trace');
|
|
23
|
-
const { NATIVE_TASK_HISTORY_READER_LIMITS } = require('./native-task-history-reader');
|
|
24
|
-
|
|
25
|
-
const SDK_PAGE_LIMIT = 1000;
|
|
26
|
-
const MAX_READ_PAGES = 20;
|
|
27
|
-
const CHUNK_BYTES = 256 * 1024;
|
|
28
|
-
const CHUNK_ENVELOPE_HEADROOM = 2048;
|
|
29
|
-
const MAX_RETRIES = 2;
|
|
30
|
-
const RETRY_DELAYS_MS = [250, 1000];
|
|
31
|
-
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'stopped']);
|
|
32
|
-
// A built chunk must stay servable by the history reader: the reader refuses
|
|
33
|
-
// (HTTP 413 ROW_TOO_LARGE) any single row above its page byte cap, so a fact
|
|
34
|
-
// that big could never page back. The headroom covers the journal row
|
|
35
|
-
// envelope (sequence stamping and framing) wrapped around the event.
|
|
36
|
-
const READ_ROW_HEADROOM = 4096;
|
|
37
|
-
const MAX_SERVABLE_EVENT_BYTES = Math.max(0, NATIVE_TASK_HISTORY_READER_LIMITS.maxPageBytes - READ_ROW_HEADROOM);
|
|
38
|
-
|
|
39
|
-
// Fixed, content-free reason codes for the explicit read-state facts. The
|
|
40
|
-
// marker events carry no SDK error text and no paths.
|
|
41
|
-
const READ_REASONS = {
|
|
42
|
-
apiAbsent: 'NATIVE_TASK_TRANSCRIPT_API_ABSENT',
|
|
43
|
-
configRootMismatch: 'NATIVE_TASK_TRANSCRIPT_CONFIG_ROOT_MISMATCH',
|
|
44
|
-
cwdMismatch: 'NATIVE_TASK_TRANSCRIPT_CWD_MISMATCH',
|
|
45
|
-
readFailed: 'NATIVE_TASK_TRANSCRIPT_READ_FAILED',
|
|
46
|
-
redactFailed: 'NATIVE_TASK_TRANSCRIPT_REDACT_FAILED',
|
|
47
|
-
normalizeRefused: 'NATIVE_TASK_TRANSCRIPT_NORMALIZE_REFUSED',
|
|
48
|
-
entryOversize: 'NATIVE_TASK_TRANSCRIPT_ENTRY_OVERSIZE',
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
function byteLength(value) { return Buffer.byteLength(JSON.stringify(value)); }
|
|
52
|
-
|
|
53
|
-
// Precise Service ownership identity: the runtime epoch and the committer
|
|
54
|
-
// closure the read was initiated under. "Some binding currently exists" is
|
|
55
|
-
// not an owner - only the exact same epoch AND the exact same committer
|
|
56
|
-
// reference (or the very same identity object) counts. A null on either
|
|
57
|
-
// side never matches a live binding on the other.
|
|
58
|
-
function sameOwner(captured, current) {
|
|
59
|
-
if (captured === current) return true;
|
|
60
|
-
if (!captured || !current) return false;
|
|
61
|
-
return typeof captured.committer === 'function' && typeof current.committer === 'function'
|
|
62
|
-
&& captured.epoch === current.epoch && captured.committer === current.committer;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function createNativeTaskTranscriptCollector({ isLive, getRuntime, isBound, emit, sdkImport, redactSource, queryContext, ownerIdentity } = {}) {
|
|
66
|
-
if (typeof isLive !== 'function' || typeof getRuntime !== 'function' || typeof isBound !== 'function'
|
|
67
|
-
|| typeof emit !== 'function' || typeof sdkImport !== 'function' || typeof redactSource !== 'function'
|
|
68
|
-
|| typeof queryContext !== 'function') {
|
|
69
|
-
throw new Error('createNativeTaskTranscriptCollector requires live/runtime/binding/emit/sdk/redaction/context accessors');
|
|
70
|
-
}
|
|
71
|
-
if (ownerIdentity !== undefined && typeof ownerIdentity !== 'function') {
|
|
72
|
-
throw new Error('createNativeTaskTranscriptCollector requires the owner identity accessor to be a function when provided');
|
|
73
|
-
}
|
|
74
|
-
// Per-task publish state. The dedup sets are consumed ONLY after the gate
|
|
75
|
-
// accepted a chunk: a refused fact never marks content as published.
|
|
76
|
-
const tasks = new Map();
|
|
77
|
-
function taskState(taskKey) {
|
|
78
|
-
let entry = tasks.get(taskKey);
|
|
79
|
-
if (!entry) {
|
|
80
|
-
entry = { publishedUuids: new Set(), publishedStepIds: new Set(), publishedModelIds: new Set(), markerReasons: new Set(), emptyPublished: false, inFlight: false, rerun: null, retries: 0 };
|
|
81
|
-
tasks.set(taskKey, entry);
|
|
82
|
-
}
|
|
83
|
-
return entry;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Trusted lookup: only bindings the registry itself established.
|
|
87
|
-
function bindingFor(sdkSessionId, taskId) {
|
|
88
|
-
const runtime = getRuntime();
|
|
89
|
-
if (!runtime || typeof runtime.agentTranscriptBindings !== 'function') return null;
|
|
90
|
-
try {
|
|
91
|
-
const bindings = runtime.agentTranscriptBindings();
|
|
92
|
-
return bindings.find(binding => binding.sdkSessionId === sdkSessionId && binding.taskId === taskId) || null;
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
function terminalBindings(sdkSessionId) {
|
|
98
|
-
const runtime = getRuntime();
|
|
99
|
-
if (!runtime || typeof runtime.agentTranscriptBindings !== 'function') return [];
|
|
100
|
-
try {
|
|
101
|
-
return runtime.agentTranscriptBindings().filter(binding => (!sdkSessionId || binding.sdkSessionId === sdkSessionId)
|
|
102
|
-
&& TERMINAL_STATUSES.has(String(binding.status || '')));
|
|
103
|
-
} catch {
|
|
104
|
-
return [];
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Everything a scheduled read needs is frozen at NOTIFICATION time: the
|
|
109
|
-
// precise Service owner, the query context and the redaction source of the
|
|
110
|
-
// transport that produced the trigger. A later query (or a whole Service
|
|
111
|
-
// transfer) can never rewrite what an already-scheduled read uses; a
|
|
112
|
-
// collector that cannot even read one of these captures nothing instead of
|
|
113
|
-
// guessing.
|
|
114
|
-
function captureFrame(capture) {
|
|
115
|
-
const frame = { capture, owner: null, context: null, secrets: null, secretsFailed: false };
|
|
116
|
-
try { frame.owner = typeof ownerIdentity === 'function' ? ownerIdentity() : null; } catch { frame.owner = null; }
|
|
117
|
-
try { frame.context = queryContext(); } catch { frame.context = null; }
|
|
118
|
-
try { frame.secrets = redactSource(); } catch { frame.secrets = null; frame.secretsFailed = true; }
|
|
119
|
-
return frame;
|
|
120
|
-
}
|
|
121
|
-
function currentOwner() {
|
|
122
|
-
try { return typeof ownerIdentity === 'function' ? ownerIdentity() : null; } catch { return null; }
|
|
123
|
-
}
|
|
124
|
-
function ownerValid(frame) { return sameOwner(frame.owner, currentOwner()); }
|
|
125
|
-
// The redactor reads only the frame-frozen secrets; a source that failed
|
|
126
|
-
// at capture time keeps failing here so the normalizer fails closed.
|
|
127
|
-
function frameRedactor(frame) {
|
|
128
|
-
return createSecretRedactor(() => {
|
|
129
|
-
if (frame.secretsFailed) throw new Error('redaction source unavailable at capture');
|
|
130
|
-
return frame.secrets;
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function schedule(sdkSessionId, taskId, capture) {
|
|
135
|
-
if (!isLive()) return;
|
|
136
|
-
const binding = bindingFor(sdkSessionId, taskId);
|
|
137
|
-
if (!binding) return;
|
|
138
|
-
const frame = captureFrame(capture);
|
|
139
|
-
const taskKey = JSON.stringify([sdkSessionId, taskId]);
|
|
140
|
-
const entry = taskState(taskKey);
|
|
141
|
-
if (entry.inFlight) { entry.rerun = frame; return; }
|
|
142
|
-
entry.inFlight = true;
|
|
143
|
-
void run(taskKey, binding, frame).catch(() => {
|
|
144
|
-
const current = tasks.get(taskKey);
|
|
145
|
-
if (current) { current.inFlight = false; current.rerun = null; }
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function run(taskKey, initialBinding, frame) {
|
|
150
|
-
const entry = tasks.get(taskKey);
|
|
151
|
-
try {
|
|
152
|
-
await attempt(taskKey, initialBinding, frame);
|
|
153
|
-
} finally {
|
|
154
|
-
const rerunFrame = entry.rerun;
|
|
155
|
-
entry.rerun = null;
|
|
156
|
-
if (rerunFrame && isLive()) {
|
|
157
|
-
// The queued rerun keeps the notification-time frame it was
|
|
158
|
-
// requested under: after an ownership transfer it is a stale request
|
|
159
|
-
// and stops before any read or publish, while a same-owner repeat
|
|
160
|
-
// re-reads normally.
|
|
161
|
-
const binding = bindingFor(initialBinding.sdkSessionId, initialBinding.taskId);
|
|
162
|
-
if (binding) {
|
|
163
|
-
void run(taskKey, binding, rerunFrame).catch(() => {
|
|
164
|
-
const current = tasks.get(taskKey);
|
|
165
|
-
if (current) { current.inFlight = false; current.rerun = null; }
|
|
166
|
-
});
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
entry.inFlight = false;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// A full snapshot read: bounded explicit pages off the public API until a
|
|
175
|
-
// short page (complete) or the read budget (honestly incomplete). The
|
|
176
|
-
// query context is the frame-frozen one, never the current holder.
|
|
177
|
-
async function readSnapshot(binding, frame) {
|
|
178
|
-
const sdk = await sdkImport();
|
|
179
|
-
if (!sdk || typeof sdk.getSubagentMessages !== 'function') return { status: 'unavailable', reason: READ_REASONS.apiAbsent };
|
|
180
|
-
const context = frame.context;
|
|
181
|
-
if (!context || typeof context.cwd !== 'string' || context.cwd === '') return { status: 'unavailable', reason: READ_REASONS.cwdMismatch };
|
|
182
|
-
if ((process.env.CLAUDE_CONFIG_DIR || null) !== (context.configRoot || null)) {
|
|
183
|
-
return { status: 'unavailable', reason: READ_REASONS.configRootMismatch };
|
|
184
|
-
}
|
|
185
|
-
const live = bindingFor(binding.sdkSessionId, binding.taskId);
|
|
186
|
-
if (!live) return { status: 'stale' };
|
|
187
|
-
const messages = [];
|
|
188
|
-
let complete = false;
|
|
189
|
-
try {
|
|
190
|
-
for (let page = 0; page < MAX_READ_PAGES; page += 1) {
|
|
191
|
-
const slice = await sdk.getSubagentMessages(binding.sdkSessionId, binding.taskId, {
|
|
192
|
-
dir: context.cwd, limit: SDK_PAGE_LIMIT, offset: messages.length,
|
|
193
|
-
});
|
|
194
|
-
if (!Array.isArray(slice)) throw new Error('non-array page');
|
|
195
|
-
messages.push(...slice);
|
|
196
|
-
if (slice.length < SDK_PAGE_LIMIT) { complete = true; break; }
|
|
197
|
-
}
|
|
198
|
-
} catch {
|
|
199
|
-
return { status: 'retry' };
|
|
200
|
-
}
|
|
201
|
-
return { status: 'ok', messages, complete, cwd: context.cwd };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function markerEvent(binding, scope, capture, readStatus, reason) {
|
|
205
|
-
return {
|
|
206
|
-
type: 'claude/native-task-transcript',
|
|
207
|
-
source: 'sdk-public-read',
|
|
208
|
-
scope: { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId },
|
|
209
|
-
sdkSessionId: binding.sdkSessionId,
|
|
210
|
-
taskId: binding.taskId,
|
|
211
|
-
taskRef: binding.taskRef ? { ...binding.taskRef } : null,
|
|
212
|
-
ownerRunId: binding.ownerRunId || null,
|
|
213
|
-
capture,
|
|
214
|
-
read: { status: readStatus, reason, at: new Date().toISOString() },
|
|
215
|
-
entries: [],
|
|
216
|
-
toolSteps: [],
|
|
217
|
-
usage: { records: [], summary: null },
|
|
218
|
-
diagnostics: [],
|
|
219
|
-
chunk: { index: 0, count: 0, complete: false },
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function attempt(taskKey, binding, frame) {
|
|
224
|
-
const entry = tasks.get(taskKey);
|
|
225
|
-
// Every read, retry and rerun starts by re-proving the precise Service
|
|
226
|
-
// owner it was initiated under; a transferred or unbound owner stops
|
|
227
|
-
// here without publishing and without consuming anything.
|
|
228
|
-
if (!ownerValid(frame)) return;
|
|
229
|
-
let read;
|
|
230
|
-
try {
|
|
231
|
-
read = await readSnapshot(binding, frame);
|
|
232
|
-
} catch {
|
|
233
|
-
read = { status: 'retry' };
|
|
234
|
-
}
|
|
235
|
-
if (read.status === 'stale') return;
|
|
236
|
-
if (read.status === 'retry') {
|
|
237
|
-
if (entry.retries < MAX_RETRIES && isLive() && ownerValid(frame)) {
|
|
238
|
-
entry.retries += 1;
|
|
239
|
-
await new Promise(resolve => setTimeout(resolve, RETRY_DELAYS_MS[Math.min(entry.retries - 1, RETRY_DELAYS_MS.length - 1)]));
|
|
240
|
-
if (!isLive() || !ownerValid(frame)) { entry.retries = 0; return; }
|
|
241
|
-
return attempt(taskKey, binding, frame);
|
|
242
|
-
}
|
|
243
|
-
entry.retries = 0;
|
|
244
|
-
await publishMarker(taskKey, binding, frame, 'failed', READ_REASONS.readFailed);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
entry.retries = 0;
|
|
248
|
-
if (read.status === 'unavailable') {
|
|
249
|
-
await publishMarker(taskKey, binding, frame, 'unavailable', read.reason);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
let normalized;
|
|
253
|
-
try {
|
|
254
|
-
normalized = normalizeTaskMessages(read.messages, { redact: frameRedactor(frame) });
|
|
255
|
-
} catch (error) {
|
|
256
|
-
const reason = error && (error.code === 'NATIVE_TASK_MESSAGES_REDACT_REQUIRED' || error.code === 'NATIVE_TASK_MESSAGES_REDACT_FAILED')
|
|
257
|
-
? READ_REASONS.redactFailed
|
|
258
|
-
: READ_REASONS.normalizeRefused;
|
|
259
|
-
await publishMarker(taskKey, binding, frame, 'failed', reason);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
await publishChunks(taskKey, binding, frame, normalized, read.complete);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async function publishMarker(taskKey, binding, frame, readStatus, reason) {
|
|
266
|
-
const entry = tasks.get(taskKey);
|
|
267
|
-
if (entry.markerReasons.has(reason)) return;
|
|
268
|
-
const runtime = getRuntime();
|
|
269
|
-
const scope = runtime && typeof runtime.scope === 'function' ? runtime.scope() : null;
|
|
270
|
-
if (!scope || !isLive() || !isBound() || !ownerValid(frame)) return;
|
|
271
|
-
const event = markerEvent(binding, scope, frame.capture, readStatus, reason);
|
|
272
|
-
if (emit(event) === true) entry.markerReasons.add(reason);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Delta chunks: entries never published before, grouped under the byte
|
|
276
|
-
// budget, with the steps/usage records whose first contributing entry sits
|
|
277
|
-
// in the group. Cursor sets are consumed only after the gate accepted the
|
|
278
|
-
// chunk; a refusal stops the whole publish with nothing consumed.
|
|
279
|
-
async function publishChunks(taskKey, binding, frame, normalized, snapshotComplete) {
|
|
280
|
-
const entry = tasks.get(taskKey);
|
|
281
|
-
const runtime = getRuntime();
|
|
282
|
-
const scope = runtime && typeof runtime.scope === 'function' ? runtime.scope() : null;
|
|
283
|
-
if (!scope || !isLive() || !isBound() || !ownerValid(frame)) return;
|
|
284
|
-
const deltaEntries = normalized.entries.filter(item => !entry.publishedUuids.has(item.uuid));
|
|
285
|
-
if (deltaEntries.length === 0) {
|
|
286
|
-
// An empty first read is an honest fact: the SDK published no messages
|
|
287
|
-
// for this agent yet the binding is real and terminal. Published once
|
|
288
|
-
// and only before any content ever landed; later still-empty reads add
|
|
289
|
-
// nothing.
|
|
290
|
-
if (entry.publishedUuids.size > 0 || entry.emptyPublished) return;
|
|
291
|
-
const event = baseEvent(binding, scope, frame.capture, normalized, snapshotComplete);
|
|
292
|
-
event.chunk = { index: 0, count: 1, complete: snapshotComplete };
|
|
293
|
-
if (emit(event) === true) entry.emptyPublished = true;
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const groups = [];
|
|
297
|
-
let current = [];
|
|
298
|
-
let currentBytes = CHUNK_ENVELOPE_HEADROOM;
|
|
299
|
-
for (const item of deltaEntries) {
|
|
300
|
-
const size = byteLength(item);
|
|
301
|
-
if (current.length > 0 && currentBytes + size > CHUNK_BYTES) {
|
|
302
|
-
groups.push(current);
|
|
303
|
-
current = [];
|
|
304
|
-
currentBytes = CHUNK_ENVELOPE_HEADROOM;
|
|
305
|
-
}
|
|
306
|
-
current.push(item);
|
|
307
|
-
currentBytes += size;
|
|
308
|
-
}
|
|
309
|
-
if (current.length > 0) groups.push(current);
|
|
310
|
-
const groupUuids = groups.map(group => new Set(group.map(item => item.uuid)));
|
|
311
|
-
let chunkIndex = 0;
|
|
312
|
-
for (const group of groups) {
|
|
313
|
-
if (!isLive() || !isBound() || !ownerValid(frame)) return;
|
|
314
|
-
const uuids = groupUuids[chunkIndex];
|
|
315
|
-
const steps = normalized.toolSteps.filter(step => !entry.publishedStepIds.has(step.toolUseId)
|
|
316
|
-
&& stepBelongsToGroup(step, uuids));
|
|
317
|
-
const records = normalized.usageRecords.filter(record => !entry.publishedModelIds.has(record.modelMessageId || 'noid:' + record.entryUuids[0])
|
|
318
|
-
&& record.entryUuids.some(uuid => uuids.has(uuid)));
|
|
319
|
-
const event = baseEvent(binding, scope, frame.capture, normalized, snapshotComplete);
|
|
320
|
-
event.entries = group;
|
|
321
|
-
event.toolSteps = steps;
|
|
322
|
-
event.usage = { records, summary: chunkIndex === groups.length - 1 && snapshotComplete ? normalized.usageSummary : null };
|
|
323
|
-
event.diagnostics = chunkIndex === groups.length - 1 ? normalized.diagnostics : [];
|
|
324
|
-
event.chunk = { index: chunkIndex, count: groups.length, complete: chunkIndex === groups.length - 1 && snapshotComplete };
|
|
325
|
-
// A chunk the history reader could never serve - its page byte cap
|
|
326
|
-
// refuses a row this large, so the fact could never page back - must
|
|
327
|
-
// not land as a fake success: one explicit unavailable marker is the
|
|
328
|
-
// honest outcome, no cursor is consumed and publishing stops.
|
|
329
|
-
if (byteLength(event) > MAX_SERVABLE_EVENT_BYTES) {
|
|
330
|
-
await publishMarker(taskKey, binding, frame, 'unavailable', READ_REASONS.entryOversize);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (emit(event) !== true) return;
|
|
334
|
-
for (const item of group) entry.publishedUuids.add(item.uuid);
|
|
335
|
-
for (const step of steps) entry.publishedStepIds.add(step.toolUseId);
|
|
336
|
-
for (const record of records) entry.publishedModelIds.add(record.modelMessageId || 'noid:' + record.entryUuids[0]);
|
|
337
|
-
chunkIndex += 1;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function stepBelongsToGroup(step, uuids) {
|
|
342
|
-
if (step.useEntryUuids.some(uuid => uuids.has(uuid))) return true;
|
|
343
|
-
return step.resultEntryUuid !== null && uuids.has(step.resultEntryUuid);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function baseEvent(binding, scope, capture, normalized, snapshotComplete) {
|
|
347
|
-
return {
|
|
348
|
-
type: 'claude/native-task-transcript',
|
|
349
|
-
source: 'sdk-public-read',
|
|
350
|
-
scope: { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId },
|
|
351
|
-
sdkSessionId: binding.sdkSessionId,
|
|
352
|
-
taskId: binding.taskId,
|
|
353
|
-
taskRef: binding.taskRef ? { ...binding.taskRef } : null,
|
|
354
|
-
ownerRunId: binding.ownerRunId || null,
|
|
355
|
-
capture,
|
|
356
|
-
read: { status: 'ok', at: new Date().toISOString() },
|
|
357
|
-
entries: [],
|
|
358
|
-
toolSteps: [],
|
|
359
|
-
usage: { records: [], summary: null },
|
|
360
|
-
diagnostics: [],
|
|
361
|
-
stats: snapshotComplete ? normalized.stats : null,
|
|
362
|
-
chunk: { index: 0, count: 0, complete: false },
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return {
|
|
367
|
-
// Real-event triggers. All of them resolve the target through the
|
|
368
|
-
// registry's trusted bindings; an unknown or unbound identity schedules
|
|
369
|
-
// nothing.
|
|
370
|
-
notifyTrace(trace) {
|
|
371
|
-
if (!trace || typeof trace !== 'object' || trace.type !== 'claude/native-trace') return;
|
|
372
|
-
if (trace.source !== 'sdk-hook' || trace.hookEvent !== 'SubagentStop') return;
|
|
373
|
-
const sdkSessionId = typeof trace.sdkSessionId === 'string' ? trace.sdkSessionId : null;
|
|
374
|
-
const agentId = typeof trace.nativeAgentId === 'string' ? trace.nativeAgentId : null;
|
|
375
|
-
if (sdkSessionId && agentId) schedule(sdkSessionId, agentId, 'subagent-stop');
|
|
376
|
-
},
|
|
377
|
-
notifySdkMessage(message) {
|
|
378
|
-
// Only a real SDK task event (the stream that just moved a registry
|
|
379
|
-
// task to a terminal status) is a trigger; prose and other system
|
|
380
|
-
// traffic schedule nothing.
|
|
381
|
-
if (!message || typeof message !== 'object' || message.type !== 'system') return;
|
|
382
|
-
if (typeof message.subtype !== 'string' || !message.subtype.startsWith('task_')) return;
|
|
383
|
-
const sdkSessionId = typeof message.session_id === 'string' && message.session_id !== '' ? message.session_id : null;
|
|
384
|
-
if (!sdkSessionId) return;
|
|
385
|
-
for (const binding of terminalBindings(sdkSessionId)) schedule(binding.sdkSessionId, binding.taskId, 'sdk-terminal');
|
|
386
|
-
},
|
|
387
|
-
sweep(capture) {
|
|
388
|
-
for (const binding of terminalBindings(null)) schedule(binding.sdkSessionId, binding.taskId, capture || 'turn-end-sweep');
|
|
389
|
-
},
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
module.exports = { createNativeTaskTranscriptCollector };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Native task transcript collector - the read side of native task history.
|
|
4
|
+
// When a bound subagent reaches a real terminal signal (a SubagentStop hook
|
|
5
|
+
// trace, a terminal SDK task status, or the main reply ending while an agent
|
|
6
|
+
// task is already terminal), the collector performs ONE public SDK read
|
|
7
|
+
// (getSubagentMessages) for that agent, normalizes the snapshot through the
|
|
8
|
+
// redaction boundary (native-task-messages + the trace module's credential
|
|
9
|
+
// masking), and publishes it as claude/native-task-transcript facts through
|
|
10
|
+
// the SAME durable publish gate as every other native fact. It has no
|
|
11
|
+
// authority of its own: the agent identity comes exclusively from the
|
|
12
|
+
// registry's trusted bindings, the read uses only the installed SDK's public
|
|
13
|
+
// entry point with the query's real cwd and the process's inherited config
|
|
14
|
+
// root, and dedup keys/cursors are consumed only after the gate proves the
|
|
15
|
+
// fact durable. Every scheduled read freezes its notification-time Service
|
|
16
|
+
// owner and query context: a later query or a whole Service transfer can
|
|
17
|
+
// never redirect, republish or un-drop it. No polling, no private transcript
|
|
18
|
+
// parsing, no worker, no process.env mutation, no parent abort on read
|
|
19
|
+
// failure.
|
|
20
|
+
|
|
21
|
+
const { normalizeTaskMessages } = require('./native-task-messages');
|
|
22
|
+
const { createSecretRedactor } = require('./native-task-trace');
|
|
23
|
+
const { NATIVE_TASK_HISTORY_READER_LIMITS } = require('./native-task-history-reader');
|
|
24
|
+
|
|
25
|
+
const SDK_PAGE_LIMIT = 1000;
|
|
26
|
+
const MAX_READ_PAGES = 20;
|
|
27
|
+
const CHUNK_BYTES = 256 * 1024;
|
|
28
|
+
const CHUNK_ENVELOPE_HEADROOM = 2048;
|
|
29
|
+
const MAX_RETRIES = 2;
|
|
30
|
+
const RETRY_DELAYS_MS = [250, 1000];
|
|
31
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'stopped']);
|
|
32
|
+
// A built chunk must stay servable by the history reader: the reader refuses
|
|
33
|
+
// (HTTP 413 ROW_TOO_LARGE) any single row above its page byte cap, so a fact
|
|
34
|
+
// that big could never page back. The headroom covers the journal row
|
|
35
|
+
// envelope (sequence stamping and framing) wrapped around the event.
|
|
36
|
+
const READ_ROW_HEADROOM = 4096;
|
|
37
|
+
const MAX_SERVABLE_EVENT_BYTES = Math.max(0, NATIVE_TASK_HISTORY_READER_LIMITS.maxPageBytes - READ_ROW_HEADROOM);
|
|
38
|
+
|
|
39
|
+
// Fixed, content-free reason codes for the explicit read-state facts. The
|
|
40
|
+
// marker events carry no SDK error text and no paths.
|
|
41
|
+
const READ_REASONS = {
|
|
42
|
+
apiAbsent: 'NATIVE_TASK_TRANSCRIPT_API_ABSENT',
|
|
43
|
+
configRootMismatch: 'NATIVE_TASK_TRANSCRIPT_CONFIG_ROOT_MISMATCH',
|
|
44
|
+
cwdMismatch: 'NATIVE_TASK_TRANSCRIPT_CWD_MISMATCH',
|
|
45
|
+
readFailed: 'NATIVE_TASK_TRANSCRIPT_READ_FAILED',
|
|
46
|
+
redactFailed: 'NATIVE_TASK_TRANSCRIPT_REDACT_FAILED',
|
|
47
|
+
normalizeRefused: 'NATIVE_TASK_TRANSCRIPT_NORMALIZE_REFUSED',
|
|
48
|
+
entryOversize: 'NATIVE_TASK_TRANSCRIPT_ENTRY_OVERSIZE',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function byteLength(value) { return Buffer.byteLength(JSON.stringify(value)); }
|
|
52
|
+
|
|
53
|
+
// Precise Service ownership identity: the runtime epoch and the committer
|
|
54
|
+
// closure the read was initiated under. "Some binding currently exists" is
|
|
55
|
+
// not an owner - only the exact same epoch AND the exact same committer
|
|
56
|
+
// reference (or the very same identity object) counts. A null on either
|
|
57
|
+
// side never matches a live binding on the other.
|
|
58
|
+
function sameOwner(captured, current) {
|
|
59
|
+
if (captured === current) return true;
|
|
60
|
+
if (!captured || !current) return false;
|
|
61
|
+
return typeof captured.committer === 'function' && typeof current.committer === 'function'
|
|
62
|
+
&& captured.epoch === current.epoch && captured.committer === current.committer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createNativeTaskTranscriptCollector({ isLive, getRuntime, isBound, emit, sdkImport, redactSource, queryContext, ownerIdentity } = {}) {
|
|
66
|
+
if (typeof isLive !== 'function' || typeof getRuntime !== 'function' || typeof isBound !== 'function'
|
|
67
|
+
|| typeof emit !== 'function' || typeof sdkImport !== 'function' || typeof redactSource !== 'function'
|
|
68
|
+
|| typeof queryContext !== 'function') {
|
|
69
|
+
throw new Error('createNativeTaskTranscriptCollector requires live/runtime/binding/emit/sdk/redaction/context accessors');
|
|
70
|
+
}
|
|
71
|
+
if (ownerIdentity !== undefined && typeof ownerIdentity !== 'function') {
|
|
72
|
+
throw new Error('createNativeTaskTranscriptCollector requires the owner identity accessor to be a function when provided');
|
|
73
|
+
}
|
|
74
|
+
// Per-task publish state. The dedup sets are consumed ONLY after the gate
|
|
75
|
+
// accepted a chunk: a refused fact never marks content as published.
|
|
76
|
+
const tasks = new Map();
|
|
77
|
+
function taskState(taskKey) {
|
|
78
|
+
let entry = tasks.get(taskKey);
|
|
79
|
+
if (!entry) {
|
|
80
|
+
entry = { publishedUuids: new Set(), publishedStepIds: new Set(), publishedModelIds: new Set(), markerReasons: new Set(), emptyPublished: false, inFlight: false, rerun: null, retries: 0 };
|
|
81
|
+
tasks.set(taskKey, entry);
|
|
82
|
+
}
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Trusted lookup: only bindings the registry itself established.
|
|
87
|
+
function bindingFor(sdkSessionId, taskId) {
|
|
88
|
+
const runtime = getRuntime();
|
|
89
|
+
if (!runtime || typeof runtime.agentTranscriptBindings !== 'function') return null;
|
|
90
|
+
try {
|
|
91
|
+
const bindings = runtime.agentTranscriptBindings();
|
|
92
|
+
return bindings.find(binding => binding.sdkSessionId === sdkSessionId && binding.taskId === taskId) || null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function terminalBindings(sdkSessionId) {
|
|
98
|
+
const runtime = getRuntime();
|
|
99
|
+
if (!runtime || typeof runtime.agentTranscriptBindings !== 'function') return [];
|
|
100
|
+
try {
|
|
101
|
+
return runtime.agentTranscriptBindings().filter(binding => (!sdkSessionId || binding.sdkSessionId === sdkSessionId)
|
|
102
|
+
&& TERMINAL_STATUSES.has(String(binding.status || '')));
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Everything a scheduled read needs is frozen at NOTIFICATION time: the
|
|
109
|
+
// precise Service owner, the query context and the redaction source of the
|
|
110
|
+
// transport that produced the trigger. A later query (or a whole Service
|
|
111
|
+
// transfer) can never rewrite what an already-scheduled read uses; a
|
|
112
|
+
// collector that cannot even read one of these captures nothing instead of
|
|
113
|
+
// guessing.
|
|
114
|
+
function captureFrame(capture) {
|
|
115
|
+
const frame = { capture, owner: null, context: null, secrets: null, secretsFailed: false };
|
|
116
|
+
try { frame.owner = typeof ownerIdentity === 'function' ? ownerIdentity() : null; } catch { frame.owner = null; }
|
|
117
|
+
try { frame.context = queryContext(); } catch { frame.context = null; }
|
|
118
|
+
try { frame.secrets = redactSource(); } catch { frame.secrets = null; frame.secretsFailed = true; }
|
|
119
|
+
return frame;
|
|
120
|
+
}
|
|
121
|
+
function currentOwner() {
|
|
122
|
+
try { return typeof ownerIdentity === 'function' ? ownerIdentity() : null; } catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
function ownerValid(frame) { return sameOwner(frame.owner, currentOwner()); }
|
|
125
|
+
// The redactor reads only the frame-frozen secrets; a source that failed
|
|
126
|
+
// at capture time keeps failing here so the normalizer fails closed.
|
|
127
|
+
function frameRedactor(frame) {
|
|
128
|
+
return createSecretRedactor(() => {
|
|
129
|
+
if (frame.secretsFailed) throw new Error('redaction source unavailable at capture');
|
|
130
|
+
return frame.secrets;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function schedule(sdkSessionId, taskId, capture) {
|
|
135
|
+
if (!isLive()) return;
|
|
136
|
+
const binding = bindingFor(sdkSessionId, taskId);
|
|
137
|
+
if (!binding) return;
|
|
138
|
+
const frame = captureFrame(capture);
|
|
139
|
+
const taskKey = JSON.stringify([sdkSessionId, taskId]);
|
|
140
|
+
const entry = taskState(taskKey);
|
|
141
|
+
if (entry.inFlight) { entry.rerun = frame; return; }
|
|
142
|
+
entry.inFlight = true;
|
|
143
|
+
void run(taskKey, binding, frame).catch(() => {
|
|
144
|
+
const current = tasks.get(taskKey);
|
|
145
|
+
if (current) { current.inFlight = false; current.rerun = null; }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function run(taskKey, initialBinding, frame) {
|
|
150
|
+
const entry = tasks.get(taskKey);
|
|
151
|
+
try {
|
|
152
|
+
await attempt(taskKey, initialBinding, frame);
|
|
153
|
+
} finally {
|
|
154
|
+
const rerunFrame = entry.rerun;
|
|
155
|
+
entry.rerun = null;
|
|
156
|
+
if (rerunFrame && isLive()) {
|
|
157
|
+
// The queued rerun keeps the notification-time frame it was
|
|
158
|
+
// requested under: after an ownership transfer it is a stale request
|
|
159
|
+
// and stops before any read or publish, while a same-owner repeat
|
|
160
|
+
// re-reads normally.
|
|
161
|
+
const binding = bindingFor(initialBinding.sdkSessionId, initialBinding.taskId);
|
|
162
|
+
if (binding) {
|
|
163
|
+
void run(taskKey, binding, rerunFrame).catch(() => {
|
|
164
|
+
const current = tasks.get(taskKey);
|
|
165
|
+
if (current) { current.inFlight = false; current.rerun = null; }
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
entry.inFlight = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A full snapshot read: bounded explicit pages off the public API until a
|
|
175
|
+
// short page (complete) or the read budget (honestly incomplete). The
|
|
176
|
+
// query context is the frame-frozen one, never the current holder.
|
|
177
|
+
async function readSnapshot(binding, frame) {
|
|
178
|
+
const sdk = await sdkImport();
|
|
179
|
+
if (!sdk || typeof sdk.getSubagentMessages !== 'function') return { status: 'unavailable', reason: READ_REASONS.apiAbsent };
|
|
180
|
+
const context = frame.context;
|
|
181
|
+
if (!context || typeof context.cwd !== 'string' || context.cwd === '') return { status: 'unavailable', reason: READ_REASONS.cwdMismatch };
|
|
182
|
+
if ((process.env.CLAUDE_CONFIG_DIR || null) !== (context.configRoot || null)) {
|
|
183
|
+
return { status: 'unavailable', reason: READ_REASONS.configRootMismatch };
|
|
184
|
+
}
|
|
185
|
+
const live = bindingFor(binding.sdkSessionId, binding.taskId);
|
|
186
|
+
if (!live) return { status: 'stale' };
|
|
187
|
+
const messages = [];
|
|
188
|
+
let complete = false;
|
|
189
|
+
try {
|
|
190
|
+
for (let page = 0; page < MAX_READ_PAGES; page += 1) {
|
|
191
|
+
const slice = await sdk.getSubagentMessages(binding.sdkSessionId, binding.taskId, {
|
|
192
|
+
dir: context.cwd, limit: SDK_PAGE_LIMIT, offset: messages.length,
|
|
193
|
+
});
|
|
194
|
+
if (!Array.isArray(slice)) throw new Error('non-array page');
|
|
195
|
+
messages.push(...slice);
|
|
196
|
+
if (slice.length < SDK_PAGE_LIMIT) { complete = true; break; }
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
return { status: 'retry' };
|
|
200
|
+
}
|
|
201
|
+
return { status: 'ok', messages, complete, cwd: context.cwd };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function markerEvent(binding, scope, capture, readStatus, reason) {
|
|
205
|
+
return {
|
|
206
|
+
type: 'claude/native-task-transcript',
|
|
207
|
+
source: 'sdk-public-read',
|
|
208
|
+
scope: { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId },
|
|
209
|
+
sdkSessionId: binding.sdkSessionId,
|
|
210
|
+
taskId: binding.taskId,
|
|
211
|
+
taskRef: binding.taskRef ? { ...binding.taskRef } : null,
|
|
212
|
+
ownerRunId: binding.ownerRunId || null,
|
|
213
|
+
capture,
|
|
214
|
+
read: { status: readStatus, reason, at: new Date().toISOString() },
|
|
215
|
+
entries: [],
|
|
216
|
+
toolSteps: [],
|
|
217
|
+
usage: { records: [], summary: null },
|
|
218
|
+
diagnostics: [],
|
|
219
|
+
chunk: { index: 0, count: 0, complete: false },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function attempt(taskKey, binding, frame) {
|
|
224
|
+
const entry = tasks.get(taskKey);
|
|
225
|
+
// Every read, retry and rerun starts by re-proving the precise Service
|
|
226
|
+
// owner it was initiated under; a transferred or unbound owner stops
|
|
227
|
+
// here without publishing and without consuming anything.
|
|
228
|
+
if (!ownerValid(frame)) return;
|
|
229
|
+
let read;
|
|
230
|
+
try {
|
|
231
|
+
read = await readSnapshot(binding, frame);
|
|
232
|
+
} catch {
|
|
233
|
+
read = { status: 'retry' };
|
|
234
|
+
}
|
|
235
|
+
if (read.status === 'stale') return;
|
|
236
|
+
if (read.status === 'retry') {
|
|
237
|
+
if (entry.retries < MAX_RETRIES && isLive() && ownerValid(frame)) {
|
|
238
|
+
entry.retries += 1;
|
|
239
|
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAYS_MS[Math.min(entry.retries - 1, RETRY_DELAYS_MS.length - 1)]));
|
|
240
|
+
if (!isLive() || !ownerValid(frame)) { entry.retries = 0; return; }
|
|
241
|
+
return attempt(taskKey, binding, frame);
|
|
242
|
+
}
|
|
243
|
+
entry.retries = 0;
|
|
244
|
+
await publishMarker(taskKey, binding, frame, 'failed', READ_REASONS.readFailed);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
entry.retries = 0;
|
|
248
|
+
if (read.status === 'unavailable') {
|
|
249
|
+
await publishMarker(taskKey, binding, frame, 'unavailable', read.reason);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let normalized;
|
|
253
|
+
try {
|
|
254
|
+
normalized = normalizeTaskMessages(read.messages, { redact: frameRedactor(frame) });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const reason = error && (error.code === 'NATIVE_TASK_MESSAGES_REDACT_REQUIRED' || error.code === 'NATIVE_TASK_MESSAGES_REDACT_FAILED')
|
|
257
|
+
? READ_REASONS.redactFailed
|
|
258
|
+
: READ_REASONS.normalizeRefused;
|
|
259
|
+
await publishMarker(taskKey, binding, frame, 'failed', reason);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
await publishChunks(taskKey, binding, frame, normalized, read.complete);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function publishMarker(taskKey, binding, frame, readStatus, reason) {
|
|
266
|
+
const entry = tasks.get(taskKey);
|
|
267
|
+
if (entry.markerReasons.has(reason)) return;
|
|
268
|
+
const runtime = getRuntime();
|
|
269
|
+
const scope = runtime && typeof runtime.scope === 'function' ? runtime.scope() : null;
|
|
270
|
+
if (!scope || !isLive() || !isBound() || !ownerValid(frame)) return;
|
|
271
|
+
const event = markerEvent(binding, scope, frame.capture, readStatus, reason);
|
|
272
|
+
if (emit(event) === true) entry.markerReasons.add(reason);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Delta chunks: entries never published before, grouped under the byte
|
|
276
|
+
// budget, with the steps/usage records whose first contributing entry sits
|
|
277
|
+
// in the group. Cursor sets are consumed only after the gate accepted the
|
|
278
|
+
// chunk; a refusal stops the whole publish with nothing consumed.
|
|
279
|
+
async function publishChunks(taskKey, binding, frame, normalized, snapshotComplete) {
|
|
280
|
+
const entry = tasks.get(taskKey);
|
|
281
|
+
const runtime = getRuntime();
|
|
282
|
+
const scope = runtime && typeof runtime.scope === 'function' ? runtime.scope() : null;
|
|
283
|
+
if (!scope || !isLive() || !isBound() || !ownerValid(frame)) return;
|
|
284
|
+
const deltaEntries = normalized.entries.filter(item => !entry.publishedUuids.has(item.uuid));
|
|
285
|
+
if (deltaEntries.length === 0) {
|
|
286
|
+
// An empty first read is an honest fact: the SDK published no messages
|
|
287
|
+
// for this agent yet the binding is real and terminal. Published once
|
|
288
|
+
// and only before any content ever landed; later still-empty reads add
|
|
289
|
+
// nothing.
|
|
290
|
+
if (entry.publishedUuids.size > 0 || entry.emptyPublished) return;
|
|
291
|
+
const event = baseEvent(binding, scope, frame.capture, normalized, snapshotComplete);
|
|
292
|
+
event.chunk = { index: 0, count: 1, complete: snapshotComplete };
|
|
293
|
+
if (emit(event) === true) entry.emptyPublished = true;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const groups = [];
|
|
297
|
+
let current = [];
|
|
298
|
+
let currentBytes = CHUNK_ENVELOPE_HEADROOM;
|
|
299
|
+
for (const item of deltaEntries) {
|
|
300
|
+
const size = byteLength(item);
|
|
301
|
+
if (current.length > 0 && currentBytes + size > CHUNK_BYTES) {
|
|
302
|
+
groups.push(current);
|
|
303
|
+
current = [];
|
|
304
|
+
currentBytes = CHUNK_ENVELOPE_HEADROOM;
|
|
305
|
+
}
|
|
306
|
+
current.push(item);
|
|
307
|
+
currentBytes += size;
|
|
308
|
+
}
|
|
309
|
+
if (current.length > 0) groups.push(current);
|
|
310
|
+
const groupUuids = groups.map(group => new Set(group.map(item => item.uuid)));
|
|
311
|
+
let chunkIndex = 0;
|
|
312
|
+
for (const group of groups) {
|
|
313
|
+
if (!isLive() || !isBound() || !ownerValid(frame)) return;
|
|
314
|
+
const uuids = groupUuids[chunkIndex];
|
|
315
|
+
const steps = normalized.toolSteps.filter(step => !entry.publishedStepIds.has(step.toolUseId)
|
|
316
|
+
&& stepBelongsToGroup(step, uuids));
|
|
317
|
+
const records = normalized.usageRecords.filter(record => !entry.publishedModelIds.has(record.modelMessageId || 'noid:' + record.entryUuids[0])
|
|
318
|
+
&& record.entryUuids.some(uuid => uuids.has(uuid)));
|
|
319
|
+
const event = baseEvent(binding, scope, frame.capture, normalized, snapshotComplete);
|
|
320
|
+
event.entries = group;
|
|
321
|
+
event.toolSteps = steps;
|
|
322
|
+
event.usage = { records, summary: chunkIndex === groups.length - 1 && snapshotComplete ? normalized.usageSummary : null };
|
|
323
|
+
event.diagnostics = chunkIndex === groups.length - 1 ? normalized.diagnostics : [];
|
|
324
|
+
event.chunk = { index: chunkIndex, count: groups.length, complete: chunkIndex === groups.length - 1 && snapshotComplete };
|
|
325
|
+
// A chunk the history reader could never serve - its page byte cap
|
|
326
|
+
// refuses a row this large, so the fact could never page back - must
|
|
327
|
+
// not land as a fake success: one explicit unavailable marker is the
|
|
328
|
+
// honest outcome, no cursor is consumed and publishing stops.
|
|
329
|
+
if (byteLength(event) > MAX_SERVABLE_EVENT_BYTES) {
|
|
330
|
+
await publishMarker(taskKey, binding, frame, 'unavailable', READ_REASONS.entryOversize);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (emit(event) !== true) return;
|
|
334
|
+
for (const item of group) entry.publishedUuids.add(item.uuid);
|
|
335
|
+
for (const step of steps) entry.publishedStepIds.add(step.toolUseId);
|
|
336
|
+
for (const record of records) entry.publishedModelIds.add(record.modelMessageId || 'noid:' + record.entryUuids[0]);
|
|
337
|
+
chunkIndex += 1;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function stepBelongsToGroup(step, uuids) {
|
|
342
|
+
if (step.useEntryUuids.some(uuid => uuids.has(uuid))) return true;
|
|
343
|
+
return step.resultEntryUuid !== null && uuids.has(step.resultEntryUuid);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function baseEvent(binding, scope, capture, normalized, snapshotComplete) {
|
|
347
|
+
return {
|
|
348
|
+
type: 'claude/native-task-transcript',
|
|
349
|
+
source: 'sdk-public-read',
|
|
350
|
+
scope: { storeScope: scope.storeScope, contextId: scope.contextId, parentSessionId: scope.parentSessionId },
|
|
351
|
+
sdkSessionId: binding.sdkSessionId,
|
|
352
|
+
taskId: binding.taskId,
|
|
353
|
+
taskRef: binding.taskRef ? { ...binding.taskRef } : null,
|
|
354
|
+
ownerRunId: binding.ownerRunId || null,
|
|
355
|
+
capture,
|
|
356
|
+
read: { status: 'ok', at: new Date().toISOString() },
|
|
357
|
+
entries: [],
|
|
358
|
+
toolSteps: [],
|
|
359
|
+
usage: { records: [], summary: null },
|
|
360
|
+
diagnostics: [],
|
|
361
|
+
stats: snapshotComplete ? normalized.stats : null,
|
|
362
|
+
chunk: { index: 0, count: 0, complete: false },
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
// Real-event triggers. All of them resolve the target through the
|
|
368
|
+
// registry's trusted bindings; an unknown or unbound identity schedules
|
|
369
|
+
// nothing.
|
|
370
|
+
notifyTrace(trace) {
|
|
371
|
+
if (!trace || typeof trace !== 'object' || trace.type !== 'claude/native-trace') return;
|
|
372
|
+
if (trace.source !== 'sdk-hook' || trace.hookEvent !== 'SubagentStop') return;
|
|
373
|
+
const sdkSessionId = typeof trace.sdkSessionId === 'string' ? trace.sdkSessionId : null;
|
|
374
|
+
const agentId = typeof trace.nativeAgentId === 'string' ? trace.nativeAgentId : null;
|
|
375
|
+
if (sdkSessionId && agentId) schedule(sdkSessionId, agentId, 'subagent-stop');
|
|
376
|
+
},
|
|
377
|
+
notifySdkMessage(message) {
|
|
378
|
+
// Only a real SDK task event (the stream that just moved a registry
|
|
379
|
+
// task to a terminal status) is a trigger; prose and other system
|
|
380
|
+
// traffic schedule nothing.
|
|
381
|
+
if (!message || typeof message !== 'object' || message.type !== 'system') return;
|
|
382
|
+
if (typeof message.subtype !== 'string' || !message.subtype.startsWith('task_')) return;
|
|
383
|
+
const sdkSessionId = typeof message.session_id === 'string' && message.session_id !== '' ? message.session_id : null;
|
|
384
|
+
if (!sdkSessionId) return;
|
|
385
|
+
for (const binding of terminalBindings(sdkSessionId)) schedule(binding.sdkSessionId, binding.taskId, 'sdk-terminal');
|
|
386
|
+
},
|
|
387
|
+
sweep(capture) {
|
|
388
|
+
for (const binding of terminalBindings(null)) schedule(binding.sdkSessionId, binding.taskId, capture || 'turn-end-sweep');
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
module.exports = { createNativeTaskTranscriptCollector };
|