deepflow 0.1.105 → 0.1.107

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,106 @@
1
+ /**
2
+ * Tests for stdin migration (test-hang-fix T2)
3
+ *
4
+ * Verifies that all 9 migrated hooks use readStdinIfMain instead of inline
5
+ * process.stdin.on listeners. The core behavioral guarantee: require()'ing
6
+ * any hook no longer hangs the process waiting for stdin.
7
+ *
8
+ * Detailed behavioral tests for individual hooks live in their own test files.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { test, describe, after } = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const { execFileSync } = require('node:child_process');
18
+
19
+ // Prevent the test runner from hanging on stdin after all tests complete.
20
+ // The execFileSync calls inherit pipe handles that can keep the event loop alive.
21
+ after(() => { process.stdin.destroy(); });
22
+
23
+ const HOOKS_DIR = path.resolve(__dirname);
24
+
25
+ /** All hooks that were migrated to readStdinIfMain */
26
+ const MIGRATED_HOOKS = [
27
+ 'df-command-usage.js',
28
+ 'df-execution-history.js',
29
+ 'df-explore-protocol.js',
30
+ 'df-snapshot-guard.js',
31
+ 'df-statusline.js',
32
+ 'df-subagent-registry.js',
33
+ 'df-tool-usage.js',
34
+ 'df-tool-usage-spike.js',
35
+ 'df-worktree-guard.js',
36
+ ];
37
+
38
+ describe('stdin migration — no-hang on require()', () => {
39
+ for (const hookFile of MIGRATED_HOOKS) {
40
+ const hookPath = path.join(HOOKS_DIR, hookFile);
41
+
42
+ test(`require("${hookFile}") completes without hanging`, () => {
43
+ // Spawn a child that require()s the hook and exits.
44
+ // If stdin listeners are still inline, this will hang until timeout.
45
+ const script = `require(${JSON.stringify(hookPath)}); process.exit(0);`;
46
+ execFileSync(
47
+ process.execPath,
48
+ ['-e', script],
49
+ {
50
+ encoding: 'utf8',
51
+ timeout: 3000, // 3s is generous — require should be <100ms
52
+ }
53
+ );
54
+ // If we reach here, require() didn't hang — that's the assertion.
55
+ assert.ok(true, `${hookFile} require() completed without timeout`);
56
+ });
57
+ }
58
+ });
59
+
60
+ describe('stdin migration — no inline process.stdin.on', () => {
61
+ for (const hookFile of MIGRATED_HOOKS) {
62
+ const hookPath = path.join(HOOKS_DIR, hookFile);
63
+
64
+ test(`${hookFile} has no direct process.stdin.on calls`, () => {
65
+ const content = fs.readFileSync(hookPath, 'utf8');
66
+ const matches = content.match(/process\.stdin\.on\s*\(/g);
67
+ assert.equal(
68
+ matches,
69
+ null,
70
+ `${hookFile} still contains process.stdin.on — migration incomplete`
71
+ );
72
+ });
73
+ }
74
+ });
75
+
76
+ describe('stdin migration — uses readStdinIfMain', () => {
77
+ for (const hookFile of MIGRATED_HOOKS) {
78
+ const hookPath = path.join(HOOKS_DIR, hookFile);
79
+
80
+ test(`${hookFile} imports readStdinIfMain`, () => {
81
+ const content = fs.readFileSync(hookPath, 'utf8');
82
+ assert.match(
83
+ content,
84
+ /readStdinIfMain/,
85
+ `${hookFile} does not reference readStdinIfMain`
86
+ );
87
+ });
88
+ }
89
+ });
90
+
91
+ describe('stdin migration — hook-stdin.js helper', () => {
92
+ test('hook-stdin.js exports readStdinIfMain function', () => {
93
+ const lib = require(path.join(HOOKS_DIR, 'lib', 'hook-stdin.js'));
94
+ assert.equal(typeof lib.readStdinIfMain, 'function');
95
+ });
96
+
97
+ test('readStdinIfMain is a no-op when caller is not main module', () => {
98
+ const lib = require(path.join(HOOKS_DIR, 'lib', 'hook-stdin.js'));
99
+ // When we require() hook-stdin from a test, module !== require.main
100
+ // so calling readStdinIfMain with our own module should be a no-op.
101
+ let callbackInvoked = false;
102
+ lib.readStdinIfMain(module, () => { callbackInvoked = true; });
103
+ // Give a tick for any async listener to fire (there shouldn't be one).
104
+ assert.equal(callbackInvoked, false, 'callback should not be invoked when not main module');
105
+ });
106
+ });
@@ -4,59 +4,53 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ const { readStdinIfMain } = require('./lib/hook-stdin');
7
8
 
8
- let raw = '';
9
- process.stdin.on('data', d => raw += d);
10
- process.stdin.on('end', () => {
11
- try {
12
- const event = JSON.parse(raw);
13
- const { session_id, agent_type, agent_id, agent_transcript_path } = event;
9
+ readStdinIfMain(module, (event) => {
10
+ const { session_id, agent_type, agent_id, agent_transcript_path } = event;
14
11
 
15
- // Parse subagent transcript to extract real model and token usage
16
- let model = 'unknown';
17
- let tokens_in = 0, tokens_out = 0, cache_read = 0, cache_creation = 0;
12
+ // Parse subagent transcript to extract real model and token usage
13
+ let model = 'unknown';
14
+ let tokens_in = 0, tokens_out = 0, cache_read = 0, cache_creation = 0;
18
15
 
19
- if (agent_transcript_path && fs.existsSync(agent_transcript_path)) {
20
- const lines = fs.readFileSync(agent_transcript_path, 'utf-8').split('\n');
21
- for (const line of lines) {
22
- const trimmed = line.trim();
23
- if (!trimmed) continue;
24
- try {
25
- const evt = JSON.parse(trimmed);
26
- const msg = evt.message || {};
27
- const usage = msg.usage || evt.usage;
28
- // Extract model from assistant messages
29
- const m = msg.model || evt.model;
30
- if (m && m !== 'unknown') model = m;
31
- // Accumulate tokens
32
- if (usage) {
33
- tokens_in += usage.input_tokens || 0;
34
- tokens_out += usage.output_tokens || 0;
35
- cache_read += usage.cache_read_input_tokens || usage.cache_read_tokens || 0;
36
- cache_creation += usage.cache_creation_input_tokens || usage.cache_creation_tokens || 0;
37
- }
38
- } catch { /* skip malformed lines */ }
39
- }
16
+ if (agent_transcript_path && fs.existsSync(agent_transcript_path)) {
17
+ const lines = fs.readFileSync(agent_transcript_path, 'utf-8').split('\n');
18
+ for (const line of lines) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed) continue;
21
+ try {
22
+ const evt = JSON.parse(trimmed);
23
+ const msg = evt.message || {};
24
+ const usage = msg.usage || evt.usage;
25
+ // Extract model from assistant messages
26
+ const m = msg.model || evt.model;
27
+ if (m && m !== 'unknown') model = m;
28
+ // Accumulate tokens
29
+ if (usage) {
30
+ tokens_in += usage.input_tokens || 0;
31
+ tokens_out += usage.output_tokens || 0;
32
+ cache_read += usage.cache_read_input_tokens || usage.cache_read_tokens || 0;
33
+ cache_creation += usage.cache_creation_input_tokens || usage.cache_creation_tokens || 0;
34
+ }
35
+ } catch { /* skip malformed lines */ }
40
36
  }
37
+ }
41
38
 
42
- // Strip version suffix from model (e.g. claude-haiku-4-5-20251001 → claude-haiku-4-5)
43
- model = model.replace(/-\d{8}$/, '').replace(/\[\d+[km]\]$/i, '');
39
+ // Strip version suffix from model (e.g. claude-haiku-4-5-20251001 → claude-haiku-4-5)
40
+ model = model.replace(/-\d{8}$/, '').replace(/\[\d+[km]\]$/i, '');
44
41
 
45
- const entry = {
46
- session_id,
47
- agent_type,
48
- agent_id,
49
- model,
50
- tokens_in,
51
- tokens_out,
52
- cache_read,
53
- cache_creation,
54
- timestamp: new Date().toISOString()
55
- };
42
+ const entry = {
43
+ session_id,
44
+ agent_type,
45
+ agent_id,
46
+ model,
47
+ tokens_in,
48
+ tokens_out,
49
+ cache_read,
50
+ cache_creation,
51
+ timestamp: new Date().toISOString()
52
+ };
56
53
 
57
- const registryPath = path.join(os.homedir(), '.claude', 'subagent-sessions.jsonl');
58
- fs.appendFileSync(registryPath, JSON.stringify(entry) + '\n');
59
- } catch {
60
- process.exit(0);
61
- }
54
+ const registryPath = path.join(os.homedir(), '.claude', 'subagent-sessions.jsonl');
55
+ fs.appendFileSync(registryPath, JSON.stringify(entry) + '\n');
62
56
  });
@@ -11,31 +11,20 @@
11
11
  'use strict';
12
12
 
13
13
  const fs = require('fs');
14
+ const { readStdinIfMain } = require('./lib/hook-stdin');
14
15
 
15
- let raw = '';
16
- process.stdin.setEncoding('utf8');
17
- process.stdin.on('data', chunk => { raw += chunk; });
18
- process.stdin.on('end', () => {
19
- try {
20
- // Write raw payload for inspection
21
- fs.writeFileSync('/tmp/df-posttooluse-payload.json', raw);
22
-
23
- // Also append a minimal summary line for quick review
24
- const data = JSON.parse(raw);
25
- const summary = {
26
- hook_event_name: data.hook_event_name,
27
- tool_name: data.tool_name,
28
- tool_use_id: data.tool_use_id,
29
- session_id: data.session_id,
30
- cwd: data.cwd,
31
- permission_mode: data.permission_mode,
32
- tool_input_keys: data.tool_input ? Object.keys(data.tool_input) : [],
33
- tool_response_keys: data.tool_response ? Object.keys(data.tool_response) : [],
34
- transcript_path: data.transcript_path,
35
- };
36
- fs.appendFileSync('/tmp/df-posttooluse-summary.jsonl', JSON.stringify(summary) + '\n');
37
- } catch (_e) {
38
- // Fail silently — never break tool execution
39
- }
40
- process.exit(0);
16
+ readStdinIfMain(module, (data) => {
17
+ // Also append a minimal summary line for quick review
18
+ const summary = {
19
+ hook_event_name: data.hook_event_name,
20
+ tool_name: data.tool_name,
21
+ tool_use_id: data.tool_use_id,
22
+ session_id: data.session_id,
23
+ cwd: data.cwd,
24
+ permission_mode: data.permission_mode,
25
+ tool_input_keys: data.tool_input ? Object.keys(data.tool_input) : [],
26
+ tool_response_keys: data.tool_response ? Object.keys(data.tool_response) : [],
27
+ transcript_path: data.transcript_path,
28
+ };
29
+ fs.appendFileSync('/tmp/df-posttooluse-summary.jsonl', JSON.stringify(summary) + '\n');
41
30
  });
@@ -15,6 +15,7 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
+ const { readStdinIfMain } = require('./lib/hook-stdin');
18
19
 
19
20
  const TOOL_USAGE_LOG = path.join(os.homedir(), '.claude', 'tool-usage.jsonl');
20
21
 
@@ -49,56 +50,45 @@ function extractTaskId(cwd) {
49
50
  return taskMatch ? taskMatch[1].toUpperCase() : null;
50
51
  }
51
52
 
52
- // Read all stdin, then process
53
- let raw = '';
54
- process.stdin.setEncoding('utf8');
55
- process.stdin.on('data', chunk => { raw += chunk; });
56
- process.stdin.on('end', () => {
57
- try {
58
- const data = JSON.parse(raw);
59
-
60
- const toolName = data.tool_name || null;
61
- const toolResponse = data.tool_response;
62
- const cwd = data.cwd || '';
63
-
64
- let activeCommand = null;
65
- try {
66
- const markerPath = path.join(cwd || process.cwd(), '.deepflow', 'active-command.json');
67
- const markerRaw = fs.readFileSync(markerPath, 'utf8');
68
- activeCommand = JSON.parse(markerRaw).command || null;
69
- } catch (_e) { /* no marker or unreadable — null */ }
53
+ readStdinIfMain(module, (data) => {
54
+ const toolName = data.tool_name || null;
55
+ const toolResponse = data.tool_response;
56
+ const cwd = data.cwd || '';
70
57
 
71
- // Extract a compact tool_input summary per tool type
72
- const ti = data.tool_input || {};
73
- let inputSummary = null;
74
- if (toolName === 'Bash') inputSummary = ti.command || null;
75
- else if (toolName === 'LSP') inputSummary = `${ti.operation || '?'}:${(ti.filePath || '').split('/').pop()}:${ti.line || '?'}`;
76
- else if (toolName === 'Read') inputSummary = (ti.file_path || '').split('/').pop() + (ti.offset ? `:${ti.offset}-${ti.offset + (ti.limit || 0)}` : '');
77
- else if (toolName === 'Grep') inputSummary = ti.pattern || null;
78
- else if (toolName === 'Glob') inputSummary = ti.pattern || null;
79
- else if (toolName === 'Agent') inputSummary = `${ti.subagent_type || '?'}/${ti.model || '?'}`;
80
- else if (toolName === 'Edit' || toolName === 'Write') inputSummary = (ti.file_path || '').split('/').pop();
58
+ let activeCommand = null;
59
+ try {
60
+ const markerPath = path.join(cwd || process.cwd(), '.deepflow', 'active-command.json');
61
+ const markerRaw = fs.readFileSync(markerPath, 'utf8');
62
+ activeCommand = JSON.parse(markerRaw).command || null;
63
+ } catch (_e) { /* no marker or unreadable null */ }
81
64
 
82
- const record = {
83
- timestamp: new Date().toISOString(),
84
- session_id: data.session_id || null,
85
- tool_name: toolName,
86
- input: inputSummary,
87
- output_size_est_tokens: Math.ceil(JSON.stringify(toolResponse).length / 4),
88
- project: cwd ? path.basename(cwd) : null,
89
- phase: inferPhase(cwd),
90
- task_id: extractTaskId(cwd),
91
- active_command: activeCommand,
92
- };
65
+ // Extract a compact tool_input summary per tool type
66
+ const ti = data.tool_input || {};
67
+ let inputSummary = null;
68
+ if (toolName === 'Bash') inputSummary = ti.command || null;
69
+ else if (toolName === 'LSP') inputSummary = `${ti.operation || '?'}:${(ti.filePath || '').split('/').pop()}:${ti.line || '?'}`;
70
+ else if (toolName === 'Read') inputSummary = (ti.file_path || '').split('/').pop() + (ti.offset ? `:${ti.offset}-${ti.offset + (ti.limit || 0)}` : '');
71
+ else if (toolName === 'Grep') inputSummary = ti.pattern || null;
72
+ else if (toolName === 'Glob') inputSummary = ti.pattern || null;
73
+ else if (toolName === 'Agent') inputSummary = `${ti.subagent_type || '?'}/${ti.model || '?'}`;
74
+ else if (toolName === 'Edit' || toolName === 'Write') inputSummary = (ti.file_path || '').split('/').pop();
93
75
 
94
- const logDir = path.dirname(TOOL_USAGE_LOG);
95
- if (!fs.existsSync(logDir)) {
96
- fs.mkdirSync(logDir, { recursive: true });
97
- }
76
+ const record = {
77
+ timestamp: new Date().toISOString(),
78
+ session_id: data.session_id || null,
79
+ tool_name: toolName,
80
+ input: inputSummary,
81
+ output_size_est_tokens: Math.ceil(JSON.stringify(toolResponse).length / 4),
82
+ project: cwd ? path.basename(cwd) : null,
83
+ phase: inferPhase(cwd),
84
+ task_id: extractTaskId(cwd),
85
+ active_command: activeCommand,
86
+ };
98
87
 
99
- fs.appendFileSync(TOOL_USAGE_LOG, JSON.stringify(record) + '\n');
100
- } catch (_e) {
101
- // Fail silently never break tool execution
88
+ const logDir = path.dirname(TOOL_USAGE_LOG);
89
+ if (!fs.existsSync(logDir)) {
90
+ fs.mkdirSync(logDir, { recursive: true });
102
91
  }
103
- process.exit(0);
92
+
93
+ fs.appendFileSync(TOOL_USAGE_LOG, JSON.stringify(record) + '\n');
104
94
  });
@@ -19,6 +19,7 @@
19
19
  'use strict';
20
20
 
21
21
  const { execFileSync } = require('child_process');
22
+ const { readStdinIfMain } = require('./lib/hook-stdin');
22
23
 
23
24
  // Paths that are always allowed regardless of worktree state
24
25
  const ALLOWLIST = [
@@ -56,47 +57,38 @@ function dfWorktreeExists(cwd) {
56
57
  }
57
58
  }
58
59
 
59
- let raw = '';
60
- process.stdin.setEncoding('utf8');
61
- process.stdin.on('data', chunk => { raw += chunk; });
62
- process.stdin.on('end', () => {
63
- try {
64
- const data = JSON.parse(raw);
65
- const toolName = data.tool_name || '';
66
-
67
- // Only guard Write and Edit
68
- if (toolName !== 'Write' && toolName !== 'Edit') {
69
- process.exit(0);
70
- }
60
+ readStdinIfMain(module, (data) => {
61
+ const toolName = data.tool_name || '';
71
62
 
72
- const filePath = (data.tool_input && data.tool_input.file_path) || '';
73
- const cwd = data.cwd || process.cwd();
63
+ // Only guard Write and Edit
64
+ if (toolName !== 'Write' && toolName !== 'Edit') {
65
+ return;
66
+ }
74
67
 
75
- // Allowlisted paths always pass
76
- if (isAllowlisted(filePath)) {
77
- process.exit(0);
78
- }
68
+ const filePath = (data.tool_input && data.tool_input.file_path) || '';
69
+ const cwd = data.cwd || process.cwd();
79
70
 
80
- const branch = currentBranch(cwd);
71
+ // Allowlisted paths always pass
72
+ if (isAllowlisted(filePath)) {
73
+ return;
74
+ }
81
75
 
82
- // Only guard when on main/master
83
- if (branch !== 'main' && branch !== 'master') {
84
- process.exit(0);
85
- }
76
+ const branch = currentBranch(cwd);
86
77
 
87
- // Block only when a df/* worktree branch exists
88
- if (!dfWorktreeExists(cwd)) {
89
- process.exit(0);
90
- }
78
+ // Only guard when on main/master
79
+ if (branch !== 'main' && branch !== 'master') {
80
+ return;
81
+ }
91
82
 
92
- // All conditions met block the write
93
- console.error(
94
- `[df-worktree-guard] Blocked ${toolName} to "${filePath}" on main branch ` +
95
- `while df/* worktree exists. Make changes inside the worktree branch instead.`
96
- );
97
- process.exit(1);
98
- } catch (_e) {
99
- // Parse or unexpected error — fail open so we never break non-deepflow projects
100
- process.exit(0);
83
+ // Block only when a df/* worktree branch exists
84
+ if (!dfWorktreeExists(cwd)) {
85
+ return;
101
86
  }
87
+
88
+ // All conditions met — block the write
89
+ console.error(
90
+ `[df-worktree-guard] Blocked ${toolName} to "${filePath}" on main branch ` +
91
+ `while df/* worktree exists. Make changes inside the worktree branch instead.`
92
+ );
93
+ process.exit(1);
102
94
  });
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared stdin helper for deepflow hooks.
5
+ *
6
+ * Usage in a hook file:
7
+ * const { readStdinIfMain } = require('./lib/hook-stdin');
8
+ * readStdinIfMain(module, (data) => { ... });
9
+ *
10
+ * The helper checks if the CALLING hook is the main module (i.e. run directly,
11
+ * not required by a test). This prevents test files from hanging on stdin.
12
+ */
13
+
14
+ /**
15
+ * readStdinIfMain(callerModule, callback)
16
+ *
17
+ * @param {NodeModule} callerModule Pass `module` from the calling hook file.
18
+ * @param {function(Object): void} callback Called with the parsed JSON payload.
19
+ * If stdin is not valid JSON the process exits 0 without calling callback.
20
+ */
21
+ function readStdinIfMain(callerModule, callback) {
22
+ if (require.main !== callerModule) {
23
+ // Being required (e.g. by a test) — do not read stdin.
24
+ return;
25
+ }
26
+
27
+ let raw = '';
28
+ process.stdin.setEncoding('utf8');
29
+ process.stdin.on('data', chunk => { raw += chunk; });
30
+ process.stdin.on('end', () => {
31
+ let payload;
32
+ try {
33
+ payload = JSON.parse(raw);
34
+ } catch (_e) {
35
+ // Invalid JSON — exit 0 to avoid breaking Claude Code.
36
+ process.exit(0);
37
+ }
38
+ try {
39
+ callback(payload);
40
+ } catch (_e) {
41
+ // Never break Claude Code on hook errors.
42
+ }
43
+ process.exit(0);
44
+ });
45
+ }
46
+
47
+ module.exports = { readStdinIfMain };