@causyne/agent 1.10.13 → 1.10.18

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.
Files changed (76) hide show
  1. package/docs/ARCHITECTURE.md +118 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +46 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +123 -21
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/browserRuntime.js +88 -0
  12. package/src/browser/browserRuntime.ts +107 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/cliMode.js +13 -1
  23. package/src/commands.js +37 -8
  24. package/src/config.d.ts +2 -0
  25. package/src/decisionPolicy.js +68 -0
  26. package/src/decisionPolicy.ts +106 -0
  27. package/src/hooks.js +121 -107
  28. package/src/hooks.ts +193 -0
  29. package/src/implementationPlan.js +50 -0
  30. package/src/implementationPlan.ts +59 -0
  31. package/src/mcp/mcpClient.js +520 -132
  32. package/src/mcp/mcpClient.ts +584 -0
  33. package/src/mcp/mcpRegistry.js +399 -185
  34. package/src/mcp/mcpRegistry.ts +451 -0
  35. package/src/planCache.js +473 -450
  36. package/src/planCache.ts +840 -0
  37. package/src/plugins/pluginManager.js +40 -5
  38. package/src/plugins/pluginSkills.js +84 -0
  39. package/src/plugins/pluginSkills.ts +106 -0
  40. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  41. package/src/provisioning/capabilityProvisioner.js +5 -0
  42. package/src/provisioning/capabilityTools.js +2 -1
  43. package/src/sandbox.js +32 -2
  44. package/src/sessionJournal.js +471 -0
  45. package/src/sessionJournal.ts +496 -0
  46. package/src/sessionStore.js +3 -279
  47. package/src/skillsManager.js +18 -0
  48. package/src/subagentJournal.js +215 -0
  49. package/src/subagentJournal.ts +254 -0
  50. package/src/taskShape.js +179 -198
  51. package/src/taskShape.ts +259 -0
  52. package/src/toolPacks.js +7 -5
  53. package/src/tools/browserAutomator.js +45 -241
  54. package/src/tools/browserAutomator.ts +57 -0
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/registry.js +4 -3
  57. package/src/tools/requestDecision.js +87 -0
  58. package/src/tools/requestDecision.ts +107 -0
  59. package/src/tools/subagentOrchestrator.js +73 -12
  60. package/src/tools/updatePlan.js +37 -84
  61. package/src/tools/updatePlan.ts +48 -0
  62. package/src/tools/webFetch.js +183 -68
  63. package/src/tools/webFetch.ts +185 -0
  64. package/src/tools/webSearch.js +50 -75
  65. package/src/tools/webSearch.ts +56 -0
  66. package/src/tui/ask.js +41 -1
  67. package/src/tui/chatRenderer.js +28 -0
  68. package/src/tui/dialogs.js +91 -0
  69. package/src/tui/prompt.js +32 -3
  70. package/src/tui/toolDisplay.js +0 -1
  71. package/src/types/contracts.js +8 -0
  72. package/src/types/contracts.ts +263 -0
  73. package/src/utils/paths.d.ts +3 -0
  74. package/src/version.d.ts +1 -0
  75. package/src/mcp/jsonRpc.js +0 -165
  76. package/src/tools/todoWrite.js +0 -75
@@ -1,279 +1,3 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import crypto from 'crypto';
5
- import { causyneHome } from './utils/paths.js';
6
-
7
- // Chat sessions. One conversation, one file, scoped to one workspace.
8
- //
9
- // WHAT THIS REPLACES, AND WHY IT HAD TO GO.
10
- //
11
- // There was exactly one `~/.causyne/memory/chat_history.json`. No id, no
12
- // workspace key, no time bound. Every invocation of Causyne, in every directory,
13
- // on every project, loaded the SAME conversation. So:
14
- //
15
- // work on Causyne in ~/Causyne -> history saved
16
- // weeks later, open ~/Desktop/3d -> the SAME history loads
17
- // type "continue" -> the agent resumes the Causyne
18
- // conversation, in the Blender directory,
19
- // and goes scanning the old project
20
- //
21
- // That is not a memory feature working. It is one unbounded conversation
22
- // impersonating a memory, and it gets worse every day it is used: context from
23
- // months-old work leaks into today's task, and work from one client's project
24
- // leaks into another's.
25
- //
26
- // THE MODEL, taken from opencode's SessionV2.Info (id, projectID, title,
27
- // time.created/updated/archived): a session belongs to a WORKSPACE and is
28
- // resumed only when it is both the same workspace and recent. A different
29
- // directory is a different conversation. An old conversation in the same
30
- // directory is still there — listed, switchable — but it does not silently
31
- // become the thing "continue" continues.
32
-
33
- const SESSION_DIR = path.join(causyneHome(), 'sessions');
34
- const LEGACY_HISTORY = path.join(causyneHome(), 'memory', 'chat_history.json');
35
- const POINTER_FILE = path.join(SESSION_DIR, 'active.json');
36
-
37
- /**
38
- * How long a session stays resumable in its own workspace.
39
- *
40
- * Past this, returning to the directory starts a NEW conversation rather than
41
- * reviving one whose subject you have forgotten. The old one is still listed
42
- * and can be resumed by name — the point is that it stops being the default.
43
- */
44
- const RESUME_WINDOW_MS = 12 * 60 * 60 * 1000;
45
-
46
- /** Hard ceiling on retained sessions. The oldest are archived out first. */
47
- const MAX_SESSIONS = 50;
48
-
49
- function ensureDir() {
50
- if (!fs.existsSync(SESSION_DIR)) fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
51
- }
52
-
53
- /** A stable, filesystem-safe key for a workspace path. */
54
- function workspaceKey(workspace) {
55
- return crypto.createHash('sha256').update(String(workspace || '')).digest('hex').slice(0, 12);
56
- }
57
-
58
- function sessionFile(id) {
59
- const safe = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '');
60
- return path.join(SESSION_DIR, `${safe}.json`);
61
- }
62
-
63
- /**
64
- * A short human title from the first thing the user actually asked for.
65
- *
66
- * `chat_history.json` had no title at all, which is why nothing could ever be
67
- * listed or chosen. A session you cannot name is a session you cannot leave.
68
- */
69
- export function titleFrom(text) {
70
- const clean = String(text || '').replace(/\s+/g, ' ').trim();
71
- if (!clean) return 'Untitled';
72
- return clean.length > 60 ? clean.slice(0, 57) + '…' : clean;
73
- }
74
-
75
- function readSession(id) {
76
- try {
77
- const file = sessionFile(id);
78
- if (!fs.existsSync(file)) return null;
79
- return JSON.parse(fs.readFileSync(file, 'utf8'));
80
- } catch (e) {
81
- return null; // a corrupt session degrades to "no session", never to a crash
82
- }
83
- }
84
-
85
- function writeSession(session) {
86
- try {
87
- ensureDir();
88
- fs.writeFileSync(sessionFile(session.id), JSON.stringify(session, null, 2), { mode: 0o600 });
89
- return true;
90
- } catch (e) {
91
- return false; // fail open: losing a session must not fail the task
92
- }
93
- }
94
-
95
- /** Every session, newest first. Messages are NOT loaded — this is the index. */
96
- export function listSessions() {
97
- try {
98
- ensureDir();
99
- const rows = [];
100
- for (const name of fs.readdirSync(SESSION_DIR)) {
101
- if (!name.endsWith('.json') || name === 'active.json') continue;
102
- const session = readSession(name.replace(/\.json$/, ''));
103
- if (!session) continue;
104
- rows.push({
105
- id: session.id,
106
- title: session.title || 'Untitled',
107
- workspace: session.workspace || '',
108
- createdAt: session.createdAt || 0,
109
- updatedAt: session.updatedAt || 0,
110
- messageCount: Array.isArray(session.messages) ? session.messages.length : 0
111
- });
112
- }
113
- return rows.sort((a, b) => b.updatedAt - a.updatedAt);
114
- } catch (e) {
115
- return [];
116
- }
117
- }
118
-
119
- /**
120
- * Retire the oldest sessions past MAX_SESSIONS.
121
- *
122
- * Called at session resolution rather than on a timer — nothing may run in the
123
- * background (ZM-01), and a session boundary is often enough for a ceiling
124
- * measured in dozens.
125
- */
126
- export function pruneSessions(max = MAX_SESSIONS) {
127
- try {
128
- const all = listSessions();
129
- let removed = 0;
130
- for (const row of all.slice(max)) {
131
- try { fs.unlinkSync(sessionFile(row.id)); removed++; } catch (e) { /* skip */ }
132
- }
133
- return removed;
134
- } catch (e) {
135
- return 0;
136
- }
137
- }
138
-
139
- export function createSession(workspace, title = 'Untitled') {
140
- const now = Date.now();
141
- const session = {
142
- id: `s_${now.toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
143
- workspace: String(workspace || ''),
144
- workspaceKey: workspaceKey(workspace),
145
- title,
146
- createdAt: now,
147
- updatedAt: now,
148
- messages: []
149
- };
150
- writeSession(session);
151
- setActiveSession(session.id);
152
- pruneSessions();
153
- return session;
154
- }
155
-
156
- export function getActiveSessionId() {
157
- try {
158
- if (!fs.existsSync(POINTER_FILE)) return null;
159
- return JSON.parse(fs.readFileSync(POINTER_FILE, 'utf8')).id || null;
160
- } catch (e) {
161
- return null;
162
- }
163
- }
164
-
165
- export function setActiveSession(id) {
166
- try {
167
- ensureDir();
168
- fs.writeFileSync(POINTER_FILE, JSON.stringify({ id, at: Date.now() }, null, 2), { mode: 0o600 });
169
- return true;
170
- } catch (e) {
171
- return false;
172
- }
173
- }
174
-
175
- /**
176
- * The session this workspace should be using right now.
177
- *
178
- * THE RULE THAT FIXES THE BOOMERANG: resume only when the workspace matches AND
179
- * the conversation is recent. Anything else starts fresh. A different directory
180
- * is a different conversation, full stop.
181
- *
182
- * @param {string} workspace Usually process.cwd().
183
- * @returns {object} The session, with its messages loaded.
184
- */
185
- export function resolveSession(workspace) {
186
- ensureDir();
187
- migrateLegacyHistory(workspace);
188
-
189
- const key = workspaceKey(workspace);
190
- const now = Date.now();
191
-
192
- const candidate = listSessions()
193
- .filter(row => row.workspace && workspaceKey(row.workspace) === key)
194
- .find(row => now - row.updatedAt < RESUME_WINDOW_MS);
195
-
196
- if (candidate) {
197
- const session = readSession(candidate.id);
198
- if (session) {
199
- setActiveSession(session.id);
200
- return session;
201
- }
202
- }
203
-
204
- return createSession(workspace);
205
- }
206
-
207
- /** Load a specific session by id, making it active. */
208
- export function switchSession(id) {
209
- const session = readSession(id);
210
- if (!session) return null;
211
- setActiveSession(session.id);
212
- return session;
213
- }
214
-
215
- /** Persist a session's messages. Title is set once, from the first user turn. */
216
- export function saveSessionMessages(id, messages) {
217
- const session = readSession(id);
218
- if (!session) return false;
219
-
220
- session.messages = Array.isArray(messages) ? messages : [];
221
- session.updatedAt = Date.now();
222
-
223
- if (!session.title || session.title === 'Untitled') {
224
- const firstUser = session.messages.find(m => m.role === 'user' && typeof m.content === 'string');
225
- if (firstUser) session.title = titleFrom(firstUser.content);
226
- }
227
-
228
- return writeSession(session);
229
- }
230
-
231
- export function deleteSession(id) {
232
- try {
233
- fs.unlinkSync(sessionFile(id));
234
- if (getActiveSessionId() === id) setActiveSession(null);
235
- return true;
236
- } catch (e) {
237
- return false;
238
- }
239
- }
240
-
241
- /**
242
- * Move the one global history into a session, once.
243
- *
244
- * Nothing is thrown away — the conversation that existed becomes a session
245
- * belonging to the workspace it is first seen from, and the legacy file is
246
- * renamed rather than deleted so it can be recovered if the migration is wrong.
247
- */
248
- export function migrateLegacyHistory(workspace) {
249
- try {
250
- if (!fs.existsSync(LEGACY_HISTORY)) return null;
251
-
252
- const raw = fs.readFileSync(LEGACY_HISTORY, 'utf8');
253
- const messages = JSON.parse(raw);
254
- if (!Array.isArray(messages) || messages.length === 0) {
255
- fs.renameSync(LEGACY_HISTORY, LEGACY_HISTORY + '.migrated');
256
- return null;
257
- }
258
-
259
- const firstUser = messages.find(m => m.role === 'user' && typeof m.content === 'string');
260
- const session = createSession(workspace, titleFrom(firstUser ? firstUser.content : 'Imported conversation'));
261
- session.messages = messages.filter(m => m.role !== 'system');
262
- session.createdAt = fs.statSync(LEGACY_HISTORY).mtimeMs;
263
- // NO WORKSPACE, deliberately. The legacy file belonged to every directory
264
- // at once, so attaching it to whichever one happens to migrate it first
265
- // would just recreate the boomerang somewhere arbitrary. It stays listed
266
- // and switchable by name; it is never what `continue` continues.
267
- session.workspace = '';
268
- session.workspaceKey = '';
269
- session.title = session.title === 'Untitled' ? 'Imported conversation' : session.title + ' (imported)';
270
- writeSession(session);
271
-
272
- fs.renameSync(LEGACY_HISTORY, LEGACY_HISTORY + '.migrated');
273
- return session;
274
- } catch (e) {
275
- return null;
276
- }
277
- }
278
-
279
- export { SESSION_DIR, RESUME_WINDOW_MS, MAX_SESSIONS };
1
+ // Compatibility surface during the incremental TypeScript migration.
2
+ // `npm run build` compiles sessionJournal.ts to readable ESM beside this file.
3
+ export * from './sessionJournal.js';
@@ -3,6 +3,7 @@ import path from 'path';
3
3
 
4
4
  import { COMPILED_SKILLS } from './compiledSkills.js';
5
5
  import { loadUserSkills } from './userSkills.js';
6
+ import { getPluginSkills } from './plugins/pluginManager.js';
6
7
 
7
8
  // User skills are read from disk, and the disk is slow relative to how often
8
9
  // these functions are called (every completion keystroke asks for the
@@ -259,6 +260,19 @@ export function listCompiledSkills() {
259
260
  });
260
261
  }
261
262
 
263
+ // Plugin skills live in a plugin-owned namespace (`plugin-<name>/...`) and
264
+ // appear only after explicit digest-pinned enablement. A user's own skill can
265
+ // still shadow one, which preserves local authority over every instruction.
266
+ for (const skill of getPluginSkills()) {
267
+ const key = `${skill.domain}/${skill.id}`;
268
+ if (seen.has(key)) continue;
269
+ seen.add(key);
270
+ out.push({
271
+ domain: skill.domain, id: skill.id, name: skill.name,
272
+ description: skill.description, source: 'plugin'
273
+ });
274
+ }
275
+
262
276
  for (const [domain, skills] of Object.entries(COMPILED_SKILLS)) {
263
277
  for (const [id, data] of Object.entries(skills)) {
264
278
  if (seen.has(`${domain}/${id}`)) continue;
@@ -278,6 +292,10 @@ export function readCompiledSkill(domain, skillId) {
278
292
  .find((skill) => skill.domain === domain && skill.id === skillId);
279
293
  if (own) return own.content;
280
294
 
295
+ const pluginSkill = getPluginSkills()
296
+ .find((skill) => skill.domain === domain && skill.id === skillId);
297
+ if (pluginSkill) return pluginSkill.content;
298
+
281
299
  const domainSkills = COMPILED_SKILLS[domain];
282
300
  if (!domainSkills) return `Error: Domain ${domain} not found in compiled skills.`;
283
301
 
@@ -0,0 +1,215 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { causyneHome } from './utils/paths.js';
4
+ const ROOT = path.join(causyneHome(), 'subagents');
5
+ const SAFE_ID = /^sub_[a-zA-Z0-9_-]{1,120}$/;
6
+ const MAX_MESSAGES = 500;
7
+ const MAX_CONTENT_BYTES = 128 * 1024;
8
+ const MAX_EVENT_BYTES = 512 * 1024;
9
+ const MAX_JOURNAL_BYTES = 16 * 1024 * 1024;
10
+ function ensureRoot() {
11
+ if (!fs.existsSync(ROOT))
12
+ fs.mkdirSync(ROOT, { recursive: true, mode: 0o700 });
13
+ }
14
+ function safeId(value) {
15
+ const id = String(value ?? '');
16
+ return SAFE_ID.test(id) ? id : null;
17
+ }
18
+ export function subagentJournalFile(id) {
19
+ const safe = safeId(id);
20
+ return safe ? path.join(ROOT, `${safe}.jsonl`) : null;
21
+ }
22
+ function boundedText(value, maximumBytes) {
23
+ const text = String(value ?? '');
24
+ if (Buffer.byteLength(text, 'utf8') <= maximumBytes)
25
+ return text;
26
+ let end = Math.min(text.length, maximumBytes);
27
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), 'utf8') > maximumBytes)
28
+ end -= 1;
29
+ return `${text.slice(0, end)}\n[truncated by Causyne]`;
30
+ }
31
+ function boundedMessage(value) {
32
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
33
+ return null;
34
+ const input = value;
35
+ if (typeof input.role !== 'string')
36
+ return null;
37
+ let content = input.content;
38
+ if (typeof content === 'string') {
39
+ content = boundedText(content, MAX_CONTENT_BYTES);
40
+ }
41
+ else {
42
+ try {
43
+ const serialized = JSON.stringify(content);
44
+ content = Buffer.byteLength(serialized, 'utf8') <= MAX_CONTENT_BYTES
45
+ ? JSON.parse(serialized)
46
+ : '[structured content omitted: exceeds sidechain limit]';
47
+ }
48
+ catch {
49
+ content = '[unserializable content omitted]';
50
+ }
51
+ }
52
+ const message = { role: input.role, content };
53
+ for (const key of ['name', 'tool_call_id']) {
54
+ if (typeof input[key] === 'string')
55
+ message[key] = input[key];
56
+ }
57
+ return message;
58
+ }
59
+ function appendEvent(file, event) {
60
+ try {
61
+ const line = `${JSON.stringify(event)}\n`;
62
+ if (Buffer.byteLength(line, 'utf8') > MAX_EVENT_BYTES)
63
+ return false;
64
+ fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600, flag: 'a' });
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ export function startSubagentJournal(input) {
72
+ const file = subagentJournalFile(input.subagentId);
73
+ if (!file)
74
+ return false;
75
+ try {
76
+ ensureRoot();
77
+ const event = {
78
+ version: 1,
79
+ type: 'subagent.started',
80
+ at: Date.now(),
81
+ subagentId: input.subagentId,
82
+ name: boundedText(input.name, 256),
83
+ prompt: boundedText(input.prompt, 32 * 1024),
84
+ profile: input.profile ?? null,
85
+ toolScope: input.toolScope.slice(0, 64),
86
+ maxSteps: input.maxSteps,
87
+ maxTokens: input.maxTokens
88
+ };
89
+ fs.writeFileSync(file, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ export function completeSubagentJournal(id, messages, result) {
97
+ const file = subagentJournalFile(id);
98
+ if (!file || !fs.existsSync(file))
99
+ return false;
100
+ let ok = true;
101
+ for (const value of messages.slice(-MAX_MESSAGES)) {
102
+ const message = boundedMessage(value);
103
+ if (!message)
104
+ continue;
105
+ ok = appendEvent(file, { version: 1, type: 'message.recorded', at: Date.now(), message }) && ok;
106
+ }
107
+ return appendEvent(file, {
108
+ version: 1,
109
+ type: 'subagent.completed',
110
+ at: Date.now(),
111
+ result
112
+ }) && ok;
113
+ }
114
+ export function failSubagentJournal(id, error) {
115
+ const file = subagentJournalFile(id);
116
+ if (!file || !fs.existsSync(file))
117
+ return false;
118
+ return appendEvent(file, {
119
+ version: 1,
120
+ type: 'subagent.failed',
121
+ at: Date.now(),
122
+ error: boundedText(error, 8 * 1024)
123
+ });
124
+ }
125
+ export function readSubagentJournal(id) {
126
+ const file = subagentJournalFile(id);
127
+ if (!file)
128
+ return null;
129
+ const requestedId = safeId(id);
130
+ if (!requestedId)
131
+ return null;
132
+ try {
133
+ const stat = fs.statSync(file);
134
+ if (!stat.isFile() || stat.size > MAX_JOURNAL_BYTES)
135
+ return null;
136
+ const text = fs.readFileSync(file, 'utf8');
137
+ const trailingNewline = text.endsWith('\n');
138
+ const lines = text.split('\n');
139
+ if (lines.at(-1) === '')
140
+ lines.pop();
141
+ let projection = null;
142
+ for (let index = 0; index < lines.length; index += 1) {
143
+ const line = lines[index];
144
+ if (!line)
145
+ continue;
146
+ let event;
147
+ try {
148
+ event = JSON.parse(line);
149
+ }
150
+ catch {
151
+ if (index === lines.length - 1 && !trailingNewline)
152
+ break;
153
+ return null;
154
+ }
155
+ if (event === null || typeof event !== 'object' || Array.isArray(event))
156
+ return null;
157
+ const record = event;
158
+ if (record.version !== 1 || typeof record.type !== 'string' || typeof record.at !== 'number')
159
+ return null;
160
+ if (record.type === 'subagent.started') {
161
+ if (projection
162
+ || typeof record.subagentId !== 'string'
163
+ || record.subagentId !== requestedId
164
+ || typeof record.name !== 'string'
165
+ || typeof record.prompt !== 'string'
166
+ || !Array.isArray(record.toolScope)
167
+ || typeof record.maxSteps !== 'number'
168
+ || typeof record.maxTokens !== 'number')
169
+ return null;
170
+ projection = {
171
+ subagentId: record.subagentId,
172
+ name: record.name,
173
+ prompt: record.prompt,
174
+ profile: typeof record.profile === 'string' ? record.profile : null,
175
+ toolScope: record.toolScope.filter((tool) => typeof tool === 'string'),
176
+ maxSteps: record.maxSteps,
177
+ maxTokens: record.maxTokens,
178
+ startedAt: record.at,
179
+ updatedAt: record.at,
180
+ status: 'running',
181
+ messages: [],
182
+ result: null,
183
+ error: null
184
+ };
185
+ continue;
186
+ }
187
+ if (!projection)
188
+ return null;
189
+ projection.updatedAt = record.at;
190
+ if (record.type === 'message.recorded') {
191
+ if (record.message === null || typeof record.message !== 'object' || Array.isArray(record.message))
192
+ return null;
193
+ projection.messages.push(record.message);
194
+ }
195
+ else if (record.type === 'subagent.completed') {
196
+ projection.status = 'completed';
197
+ projection.result = record.result !== null && typeof record.result === 'object' && !Array.isArray(record.result)
198
+ ? record.result
199
+ : {};
200
+ }
201
+ else if (record.type === 'subagent.failed') {
202
+ projection.status = 'failed';
203
+ projection.error = typeof record.error === 'string' ? record.error : 'unknown failure';
204
+ }
205
+ else {
206
+ return null;
207
+ }
208
+ }
209
+ return projection;
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ export { ROOT as SUBAGENT_JOURNAL_ROOT };