deepflow 0.1.92 → 0.1.93
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.
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
let raw = '';
|
|
8
|
+
process.stdin.on('data', d => raw += d);
|
|
9
|
+
process.stdin.on('end', () => {
|
|
10
|
+
try {
|
|
11
|
+
const event = JSON.parse(raw);
|
|
12
|
+
|
|
13
|
+
// Extract required fields from SubagentStop event
|
|
14
|
+
const { session_id, agent_type, agent_id } = event;
|
|
15
|
+
|
|
16
|
+
// Generate timestamp
|
|
17
|
+
const timestamp = new Date().toISOString();
|
|
18
|
+
|
|
19
|
+
// Build registry entry
|
|
20
|
+
const entry = {
|
|
21
|
+
session_id,
|
|
22
|
+
agent_type,
|
|
23
|
+
agent_id,
|
|
24
|
+
timestamp
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Append to registry file (fire-and-forget)
|
|
28
|
+
const registryPath = path.join(os.homedir(), '.claude', 'subagent-sessions.jsonl');
|
|
29
|
+
fs.appendFileSync(registryPath, JSON.stringify(entry) + '\n');
|
|
30
|
+
} catch {
|
|
31
|
+
// Exit 0 on any error (fail-open)
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hooks/df-subagent-registry.js
|
|
3
|
+
*
|
|
4
|
+
* Tests the SubagentStop hook that reads event JSON from stdin,
|
|
5
|
+
* extracts session_id/agent_type/agent_id, generates a timestamp,
|
|
6
|
+
* and appends a JSON line to ~/.claude/subagent-sessions.jsonl.
|
|
7
|
+
* Fire-and-forget, fail-open (exit 0 on error).
|
|
8
|
+
*
|
|
9
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const { execFileSync } = require('node:child_process');
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const HOOK_PATH = path.resolve(__dirname, 'df-subagent-registry.js');
|
|
26
|
+
|
|
27
|
+
function makeTmpDir() {
|
|
28
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-subagent-registry-test-'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rmrf(dir) {
|
|
32
|
+
if (fs.existsSync(dir)) {
|
|
33
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run the subagent registry hook as a child process with JSON piped to stdin.
|
|
39
|
+
* Overrides HOME so the registry file lands in our tmp dir.
|
|
40
|
+
* Returns { stdout, stderr, code }.
|
|
41
|
+
*/
|
|
42
|
+
function runHook(input, { home } = {}) {
|
|
43
|
+
const json = typeof input === 'string' ? input : JSON.stringify(input);
|
|
44
|
+
const env = { ...process.env };
|
|
45
|
+
if (home) {
|
|
46
|
+
env.HOME = home;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const stdout = execFileSync(
|
|
50
|
+
process.execPath,
|
|
51
|
+
[HOOK_PATH],
|
|
52
|
+
{
|
|
53
|
+
input: json,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
timeout: 5000,
|
|
56
|
+
env,
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
return { stdout, stderr: '', code: 0 };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return {
|
|
62
|
+
stdout: err.stdout || '',
|
|
63
|
+
stderr: err.stderr || '',
|
|
64
|
+
code: err.status ?? 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the registry file and return parsed JSON lines.
|
|
71
|
+
*/
|
|
72
|
+
function readRegistry(home) {
|
|
73
|
+
const registryPath = path.join(home, '.claude', 'subagent-sessions.jsonl');
|
|
74
|
+
if (!fs.existsSync(registryPath)) return [];
|
|
75
|
+
const content = fs.readFileSync(registryPath, 'utf8').trim();
|
|
76
|
+
if (!content) return [];
|
|
77
|
+
return content.split('\n').map(line => JSON.parse(line));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// 1. Valid SubagentStop event — appends correct JSON line
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe('df-subagent-registry — valid SubagentStop event', () => {
|
|
85
|
+
let tmpHome;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
tmpHome = makeTmpDir();
|
|
89
|
+
// Create ~/.claude directory so appendFileSync works
|
|
90
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
rmrf(tmpHome);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('appends JSON line with session_id, agent_type, agent_id, timestamp', () => {
|
|
98
|
+
const event = {
|
|
99
|
+
session_id: 'sess-abc-123',
|
|
100
|
+
agent_type: 'reasoner',
|
|
101
|
+
agent_id: 'agent-xyz-789',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const result = runHook(event, { home: tmpHome });
|
|
105
|
+
assert.equal(result.code, 0);
|
|
106
|
+
|
|
107
|
+
const entries = readRegistry(tmpHome);
|
|
108
|
+
assert.equal(entries.length, 1);
|
|
109
|
+
assert.equal(entries[0].session_id, 'sess-abc-123');
|
|
110
|
+
assert.equal(entries[0].agent_type, 'reasoner');
|
|
111
|
+
assert.equal(entries[0].agent_id, 'agent-xyz-789');
|
|
112
|
+
assert.ok(entries[0].timestamp, 'timestamp field should be present');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('timestamp is ISO-8601 format', () => {
|
|
116
|
+
const event = {
|
|
117
|
+
session_id: 'sess-ts-check',
|
|
118
|
+
agent_type: 'worker',
|
|
119
|
+
agent_id: 'agent-ts-001',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = runHook(event, { home: tmpHome });
|
|
123
|
+
assert.equal(result.code, 0);
|
|
124
|
+
|
|
125
|
+
const entries = readRegistry(tmpHome);
|
|
126
|
+
assert.equal(entries.length, 1);
|
|
127
|
+
|
|
128
|
+
// ISO-8601 format: YYYY-MM-DDTHH:mm:ss.sssZ
|
|
129
|
+
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
130
|
+
assert.match(entries[0].timestamp, isoRegex, 'timestamp should be ISO-8601');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('multiple invocations append multiple lines', () => {
|
|
134
|
+
const event1 = { session_id: 'sess-1', agent_type: 'reasoner', agent_id: 'a1' };
|
|
135
|
+
const event2 = { session_id: 'sess-2', agent_type: 'worker', agent_id: 'a2' };
|
|
136
|
+
|
|
137
|
+
runHook(event1, { home: tmpHome });
|
|
138
|
+
runHook(event2, { home: tmpHome });
|
|
139
|
+
|
|
140
|
+
const entries = readRegistry(tmpHome);
|
|
141
|
+
assert.equal(entries.length, 2);
|
|
142
|
+
assert.equal(entries[0].session_id, 'sess-1');
|
|
143
|
+
assert.equal(entries[1].session_id, 'sess-2');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('entry only contains session_id, agent_type, agent_id, and timestamp', () => {
|
|
147
|
+
const event = {
|
|
148
|
+
session_id: 'sess-fields',
|
|
149
|
+
agent_type: 'qa',
|
|
150
|
+
agent_id: 'agent-f',
|
|
151
|
+
extra_field: 'should-not-appear',
|
|
152
|
+
nested: { foo: 'bar' },
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const result = runHook(event, { home: tmpHome });
|
|
156
|
+
assert.equal(result.code, 0);
|
|
157
|
+
|
|
158
|
+
const entries = readRegistry(tmpHome);
|
|
159
|
+
assert.equal(entries.length, 1);
|
|
160
|
+
const keys = Object.keys(entries[0]).sort();
|
|
161
|
+
assert.deepEqual(keys, ['agent_id', 'agent_type', 'session_id', 'timestamp']);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// 2. Registry file creation
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
describe('df-subagent-registry — file creation', () => {
|
|
170
|
+
let tmpHome;
|
|
171
|
+
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
tmpHome = makeTmpDir();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
afterEach(() => {
|
|
177
|
+
rmrf(tmpHome);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('creates registry file if ~/.claude directory exists but file does not', () => {
|
|
181
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
182
|
+
const event = { session_id: 's1', agent_type: 'r', agent_id: 'a1' };
|
|
183
|
+
|
|
184
|
+
const result = runHook(event, { home: tmpHome });
|
|
185
|
+
assert.equal(result.code, 0);
|
|
186
|
+
|
|
187
|
+
const registryPath = path.join(tmpHome, '.claude', 'subagent-sessions.jsonl');
|
|
188
|
+
assert.ok(fs.existsSync(registryPath), 'registry file should be created');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('exits 0 when ~/.claude directory does not exist (fail-open)', () => {
|
|
192
|
+
// No .claude directory — appendFileSync will throw ENOENT
|
|
193
|
+
const event = { session_id: 's1', agent_type: 'r', agent_id: 'a1' };
|
|
194
|
+
|
|
195
|
+
const result = runHook(event, { home: tmpHome });
|
|
196
|
+
assert.equal(result.code, 0, 'should exit 0 even when directory is missing');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// 3. Missing fields — fail-open (exit 0)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
describe('df-subagent-registry — missing fields (fail-open)', () => {
|
|
205
|
+
let tmpHome;
|
|
206
|
+
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
tmpHome = makeTmpDir();
|
|
209
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
rmrf(tmpHome);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('exits 0 when session_id is missing', () => {
|
|
217
|
+
const event = { agent_type: 'reasoner', agent_id: 'a1' };
|
|
218
|
+
const result = runHook(event, { home: tmpHome });
|
|
219
|
+
assert.equal(result.code, 0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('exits 0 when agent_type is missing', () => {
|
|
223
|
+
const event = { session_id: 's1', agent_id: 'a1' };
|
|
224
|
+
const result = runHook(event, { home: tmpHome });
|
|
225
|
+
assert.equal(result.code, 0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('exits 0 when agent_id is missing', () => {
|
|
229
|
+
const event = { session_id: 's1', agent_type: 'reasoner' };
|
|
230
|
+
const result = runHook(event, { home: tmpHome });
|
|
231
|
+
assert.equal(result.code, 0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('exits 0 with empty object', () => {
|
|
235
|
+
const result = runHook({}, { home: tmpHome });
|
|
236
|
+
assert.equal(result.code, 0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('still writes entry with undefined fields when fields are missing', () => {
|
|
240
|
+
const event = { session_id: 's1' };
|
|
241
|
+
runHook(event, { home: tmpHome });
|
|
242
|
+
|
|
243
|
+
const entries = readRegistry(tmpHome);
|
|
244
|
+
// The hook destructures and writes whatever it gets — undefined becomes null/omitted in JSON
|
|
245
|
+
assert.equal(entries.length, 1);
|
|
246
|
+
assert.equal(entries[0].session_id, 's1');
|
|
247
|
+
assert.ok(entries[0].timestamp, 'timestamp should still be present');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// 4. Invalid JSON stdin — fail-open (exit 0)
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
describe('df-subagent-registry — invalid JSON stdin', () => {
|
|
256
|
+
test('exits 0 on completely invalid JSON', () => {
|
|
257
|
+
const result = runHook('not valid json{{{');
|
|
258
|
+
assert.equal(result.code, 0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('exits 0 on empty stdin', () => {
|
|
262
|
+
const result = runHook('');
|
|
263
|
+
assert.equal(result.code, 0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('exits 0 on truncated JSON', () => {
|
|
267
|
+
const result = runHook('{"session_id": "s1", "agent_type":');
|
|
268
|
+
assert.equal(result.code, 0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('exits 0 on non-object JSON (array)', () => {
|
|
272
|
+
// JSON.parse succeeds but destructuring an array yields undefined fields
|
|
273
|
+
// appendFileSync may still work — either way, exit 0
|
|
274
|
+
let tmpHome = makeTmpDir();
|
|
275
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
276
|
+
const result = runHook('[1, 2, 3]', { home: tmpHome });
|
|
277
|
+
assert.equal(result.code, 0);
|
|
278
|
+
rmrf(tmpHome);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('exits 0 on JSON null', () => {
|
|
282
|
+
const result = runHook('null');
|
|
283
|
+
assert.equal(result.code, 0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// 5. Edge cases
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
describe('df-subagent-registry — edge cases', () => {
|
|
292
|
+
let tmpHome;
|
|
293
|
+
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
tmpHome = makeTmpDir();
|
|
296
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
afterEach(() => {
|
|
300
|
+
rmrf(tmpHome);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('appended line is valid JSONL (ends with newline)', () => {
|
|
304
|
+
const event = { session_id: 's1', agent_type: 'r', agent_id: 'a1' };
|
|
305
|
+
runHook(event, { home: tmpHome });
|
|
306
|
+
|
|
307
|
+
const registryPath = path.join(tmpHome, '.claude', 'subagent-sessions.jsonl');
|
|
308
|
+
const raw = fs.readFileSync(registryPath, 'utf8');
|
|
309
|
+
assert.ok(raw.endsWith('\n'), 'registry entry should end with newline');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('handles special characters in field values', () => {
|
|
313
|
+
const event = {
|
|
314
|
+
session_id: 'sess-with-"quotes"-and-\\backslash',
|
|
315
|
+
agent_type: 'type/with/slashes',
|
|
316
|
+
agent_id: 'id with spaces & symbols!@#',
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const result = runHook(event, { home: tmpHome });
|
|
320
|
+
assert.equal(result.code, 0);
|
|
321
|
+
|
|
322
|
+
const entries = readRegistry(tmpHome);
|
|
323
|
+
assert.equal(entries.length, 1);
|
|
324
|
+
assert.equal(entries[0].session_id, 'sess-with-"quotes"-and-\\backslash');
|
|
325
|
+
assert.equal(entries[0].agent_type, 'type/with/slashes');
|
|
326
|
+
assert.equal(entries[0].agent_id, 'id with spaces & symbols!@#');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('writes to correct path: ~/.claude/subagent-sessions.jsonl', () => {
|
|
330
|
+
const event = { session_id: 's1', agent_type: 'r', agent_id: 'a1' };
|
|
331
|
+
runHook(event, { home: tmpHome });
|
|
332
|
+
|
|
333
|
+
const expectedPath = path.join(tmpHome, '.claude', 'subagent-sessions.jsonl');
|
|
334
|
+
assert.ok(fs.existsSync(expectedPath), 'file should exist at expected path');
|
|
335
|
+
|
|
336
|
+
// Verify no other jsonl files were created
|
|
337
|
+
const claudeDir = path.join(tmpHome, '.claude');
|
|
338
|
+
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.jsonl'));
|
|
339
|
+
assert.equal(files.length, 1);
|
|
340
|
+
assert.equal(files[0], 'subagent-sessions.jsonl');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('each appended line is independently parseable JSON', () => {
|
|
344
|
+
for (let i = 0; i < 3; i++) {
|
|
345
|
+
runHook({ session_id: `s${i}`, agent_type: 'r', agent_id: `a${i}` }, { home: tmpHome });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const registryPath = path.join(tmpHome, '.claude', 'subagent-sessions.jsonl');
|
|
349
|
+
const lines = fs.readFileSync(registryPath, 'utf8').trim().split('\n');
|
|
350
|
+
assert.equal(lines.length, 3);
|
|
351
|
+
|
|
352
|
+
lines.forEach((line, i) => {
|
|
353
|
+
const parsed = JSON.parse(line);
|
|
354
|
+
assert.equal(parsed.session_id, `s${i}`);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
});
|