termify-agent 1.0.27 → 1.0.29

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,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Termify Response Sync Hook (Stop / AfterAgent)
4
+ *
5
+ * Syncs the assistant's last response to the Termify conversation
6
+ * when Claude Code / Gemini finishes its turn.
7
+ *
8
+ * Also sends remaining thinking blocks and final text as stream events
9
+ * so the full reasoning process is visible in the Termify UI.
10
+ *
11
+ * Uses transcript_path hash to find the session file created by termify-sync.js.
12
+ *
13
+ * Token: ~/.termify/config.json
14
+ * Session: /tmp/termify-session-{hash}.json
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ /**
22
+ * Fix Gemini CLI streaming bug where prompt_response contains the response
23
+ * text duplicated (full_response + partial_repeat_from_midpoint).
24
+ * Detects and removes the duplicated tail.
25
+ */
26
+ function deduplicateGeminiResponse(text) {
27
+ if (!text || text.length < 200) return text;
28
+
29
+ const len = text.length;
30
+
31
+ // Scan for a split point where the tail matches earlier content
32
+ for (let splitAt = Math.floor(len * 0.35); splitAt <= Math.floor(len * 0.65); splitAt++) {
33
+ const tail = text.slice(splitAt);
34
+
35
+ // The duplicated portion must be significant (>25% of total)
36
+ if (tail.length < len * 0.25) continue;
37
+
38
+ // Use first 80 chars of tail as search needle
39
+ const needleLen = Math.min(80, tail.length);
40
+ const needle = tail.slice(0, needleLen);
41
+
42
+ // Search for this needle BEFORE the split point
43
+ const idx = text.indexOf(needle);
44
+ if (idx >= 0 && idx < splitAt) {
45
+ // Verify longer match (at least 100 chars must match)
46
+ const matchLen = Math.min(tail.length, len - idx, 300);
47
+ if (matchLen > 100 && tail.slice(0, matchLen) === text.slice(idx, idx + matchLen)) {
48
+ return text.slice(0, splitAt).trimEnd();
49
+ }
50
+ }
51
+ }
52
+
53
+ return text;
54
+ }
55
+
56
+ /**
57
+ * Extract ALL thinking blocks from the current assistant turn in the transcript.
58
+ */
59
+ function extractAllThinking(transcriptPath) {
60
+ try {
61
+ const data = fs.readFileSync(transcriptPath, 'utf-8');
62
+ const lines = data.trim().split('\n');
63
+
64
+ const thinkings = [];
65
+ for (let i = lines.length - 1; i >= 0; i--) {
66
+ try {
67
+ const entry = JSON.parse(lines[i]);
68
+ // Stop at user message (end of current assistant turn)
69
+ if (entry.type === 'user' && !entry.toolUseResult && !entry.sourceToolAssistantUUID) break;
70
+ if (entry.type === 'assistant' && entry.message) {
71
+ let msg;
72
+ try {
73
+ msg = typeof entry.message === 'string' ? JSON.parse(entry.message) : entry.message;
74
+ } catch {
75
+ // message might use Python-style single quotes, try eval-like parse
76
+ try {
77
+ msg = typeof entry.message === 'string' ? JSON.parse(entry.message.replace(/'/g, '"')) : entry.message;
78
+ } catch { continue; }
79
+ }
80
+ const content = msg?.content || [];
81
+ for (const block of content) {
82
+ if (block?.type === 'thinking' && block.thinking) {
83
+ thinkings.unshift(block.thinking);
84
+ }
85
+ }
86
+ }
87
+ } catch {}
88
+ }
89
+
90
+ return thinkings;
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ // Skip sync when running inside a Termify-managed chat terminal
97
+ if (process.env.TERMIFY_CONVERSATION_ID) {
98
+ process.exit(0);
99
+ }
100
+
101
+ let input = '';
102
+ process.stdin.on('data', (chunk) => { input += chunk; });
103
+ process.stdin.on('end', async () => {
104
+ let hookData;
105
+ try {
106
+ hookData = JSON.parse(input);
107
+ } catch {
108
+ process.exit(0);
109
+ return;
110
+ }
111
+
112
+ // Prevent infinite loops
113
+ if (hookData?.stop_hook_active) {
114
+ process.exit(0);
115
+ return;
116
+ }
117
+
118
+ const transcriptPath = hookData?.transcript_path;
119
+ const hookEvent = hookData?.hook_event_name || '';
120
+
121
+ // Claude Code: last_assistant_message, Gemini CLI: prompt_response
122
+ let lastMessage = hookData?.last_assistant_message || hookData?.prompt_response;
123
+
124
+ if (!transcriptPath || !lastMessage) {
125
+ process.exit(0);
126
+ return;
127
+ }
128
+
129
+ // Trim leading whitespace from Gemini responses (they often start with spaces)
130
+ lastMessage = lastMessage.trimStart();
131
+
132
+ // Fix Gemini CLI streaming duplication bug
133
+ if (hookEvent === 'AfterAgent') {
134
+ lastMessage = deduplicateGeminiResponse(lastMessage);
135
+ }
136
+
137
+ // Find session file
138
+ const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
139
+ const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
140
+
141
+ let session;
142
+ try {
143
+ session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
144
+ } catch {
145
+ process.exit(0);
146
+ return;
147
+ }
148
+
149
+ if (!session.conversationId) {
150
+ process.exit(0);
151
+ return;
152
+ }
153
+
154
+ // Read Termify config
155
+ let config;
156
+ try {
157
+ const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
158
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
159
+ } catch {
160
+ process.exit(0);
161
+ return;
162
+ }
163
+
164
+ if (!config.token) {
165
+ process.exit(0);
166
+ return;
167
+ }
168
+
169
+ const apiUrl = config.apiUrl || 'http://localhost:3001';
170
+
171
+ const headers = {
172
+ 'Content-Type': 'application/json',
173
+ 'Authorization': `Bearer ${config.token}`,
174
+ };
175
+
176
+ // CRITICAL PATH FIRST: complete message + set idle (atomic DB transaction).
177
+ // This must run before anything else so the conversation doesn't get stuck
178
+ // if Claude Code kills this hook process early.
179
+ try {
180
+ if (session.assistantMessageId) {
181
+ const completeRes = await fetch(
182
+ `${apiUrl}/api/chat/conversations/${session.conversationId}/messages/${session.assistantMessageId}/complete`,
183
+ {
184
+ method: 'POST',
185
+ headers,
186
+ body: JSON.stringify({ content: lastMessage }),
187
+ signal: AbortSignal.timeout(5000),
188
+ }
189
+ );
190
+ if (!completeRes.ok) throw new Error('complete failed');
191
+ } else {
192
+ // No streaming message — create a completed one + set idle in parallel
193
+ await Promise.allSettled([
194
+ fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}/messages`, {
195
+ method: 'POST',
196
+ headers,
197
+ body: JSON.stringify({ role: 'assistant', content: lastMessage }),
198
+ signal: AbortSignal.timeout(5000),
199
+ }),
200
+ fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}`, {
201
+ method: 'PATCH',
202
+ headers,
203
+ body: JSON.stringify({ status: 'idle' }),
204
+ signal: AbortSignal.timeout(3000),
205
+ }),
206
+ ]);
207
+ }
208
+ } catch {
209
+ // /complete failed — fallback: create message + PATCH idle in parallel
210
+ await Promise.allSettled([
211
+ fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}/messages`, {
212
+ method: 'POST',
213
+ headers,
214
+ body: JSON.stringify({ role: 'assistant', content: lastMessage }),
215
+ signal: AbortSignal.timeout(5000),
216
+ }),
217
+ fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}`, {
218
+ method: 'PATCH',
219
+ headers,
220
+ body: JSON.stringify({ status: 'idle' }),
221
+ signal: AbortSignal.timeout(3000),
222
+ }),
223
+ ]);
224
+ }
225
+
226
+ // NON-CRITICAL: Send stream events for real-time UI update.
227
+ // If this fails, the message is already completed in DB — no harm done.
228
+ if (session.assistantMessageId) {
229
+ const eventsUrl = `${apiUrl}/api/chat/conversations/${session.conversationId}/messages/${session.assistantMessageId}/events`;
230
+ fetch(eventsUrl, {
231
+ method: 'POST',
232
+ headers,
233
+ body: JSON.stringify({
234
+ events: [
235
+ { type: 'text', content: lastMessage },
236
+ { type: 'status', status: 'done' },
237
+ ],
238
+ }),
239
+ signal: AbortSignal.timeout(3000),
240
+ }).catch(() => {});
241
+ }
242
+
243
+ process.exit(0);
244
+ });
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Termify Sync Hook (UserPromptSubmit / BeforeAgent)
4
+ *
5
+ * Syncs Claude Code & Gemini conversations to Termify chat.
6
+ * On each prompt: creates/reuses a conversation and adds the user message.
7
+ *
8
+ * Handles compressed context: Claude Code sometimes sends the full compressed
9
+ * transcript as "prompt". We extract just the user's actual message.
10
+ *
11
+ * Token: ~/.termify/config.json
12
+ * Session: /tmp/termify-session-{hash}.json
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+
19
+ /**
20
+ * Extract the actual user message from a prompt that might contain
21
+ * compressed transcript or system-reminder tags.
22
+ */
23
+ /**
24
+ * Detect if the prompt is a compacted/compressed context rather than a real user message.
25
+ * Claude Code sends the full compressed transcript as prompt when context is compacted.
26
+ */
27
+ function isCompactedContext(raw) {
28
+ if (!raw) return false;
29
+ // Compacted prompts are huge and contain summary markers
30
+ if (raw.length > 5000 && raw.includes('<system-reminder>')) return true;
31
+ // Starts with the transcript compaction header
32
+ if (raw.startsWith('This session is being continued from a previous conversation')) return true;
33
+ // Contains compaction-specific markers
34
+ if (raw.includes('conversation that ran out of context') && raw.includes('summary below')) return true;
35
+ return false;
36
+ }
37
+
38
+ function extractUserMessage(raw) {
39
+ if (!raw) return null;
40
+
41
+ // Skip compacted context entirely — not a real user message
42
+ if (isCompactedContext(raw)) return null;
43
+
44
+ // Clean prompt (no system markers, reasonable size) — use as-is
45
+ if (raw.length < 2000 && !raw.includes('<system-reminder>') && !raw.startsWith('===')) {
46
+ return raw.trim();
47
+ }
48
+
49
+ // Compressed context: extract the last user message after all system tags
50
+ // Pattern: ...system stuff...\n</system-reminder>\nactual user message
51
+ const parts = raw.split('</system-reminder>');
52
+ if (parts.length > 1) {
53
+ const lastPart = parts[parts.length - 1].trim();
54
+ // Must look like a real message, not continuation instructions
55
+ if (lastPart && lastPart.length > 0 && lastPart.length < 5000 && !isCompactedContext(lastPart)) {
56
+ return lastPart;
57
+ }
58
+ }
59
+
60
+ // Fallback: if starts with === Transcript, skip entirely
61
+ if (raw.startsWith('===')) {
62
+ return null;
63
+ }
64
+
65
+ // Last resort: take first 500 chars
66
+ return raw.substring(0, 500).trim() || null;
67
+ }
68
+
69
+ /**
70
+ * Detect which CLI is running from hook event name.
71
+ */
72
+ function detectProvider(hookData) {
73
+ const event = hookData?.hook_event_name;
74
+ if (event === 'BeforeAgent') return 'gemini';
75
+ // Default to claude for Claude Code
76
+ return 'claude';
77
+ }
78
+
79
+ // Flag: running inside a Termify-managed chat terminal
80
+ const isTermifyManaged = !!process.env.TERMIFY_CONVERSATION_ID;
81
+
82
+ let input = '';
83
+ process.stdin.on('data', (chunk) => { input += chunk; });
84
+ process.stdin.on('end', async () => {
85
+ let hookData;
86
+ try {
87
+ hookData = JSON.parse(input);
88
+ } catch {
89
+ process.exit(0);
90
+ return;
91
+ }
92
+
93
+ const transcriptPath = hookData?.transcript_path;
94
+ if (!transcriptPath) {
95
+ process.exit(0);
96
+ return;
97
+ }
98
+
99
+ // Session key from transcript_path
100
+ const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
101
+ const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
102
+
103
+ // ── Termify-managed: create session file from env vars (no API calls) ──
104
+ if (isTermifyManaged) {
105
+ fs.writeFileSync(sessionFile, JSON.stringify({
106
+ conversationId: process.env.TERMIFY_CONVERSATION_ID,
107
+ assistantMessageId: process.env.TERMIFY_MESSAGE_ID || null,
108
+ token: process.env.TERMIFY_TOKEN || null,
109
+ apiUrl: process.env.TERMIFY_API_URL || 'http://localhost:3001',
110
+ termifyManaged: true,
111
+ provider: 'claude',
112
+ thinkingsSent: 0,
113
+ updatedAt: new Date().toISOString(),
114
+ }));
115
+ process.exit(0);
116
+ return;
117
+ }
118
+
119
+ // ── External CLI: normal flow (create conversation, messages, etc.) ──
120
+
121
+ const rawPrompt = hookData?.prompt;
122
+ if (!rawPrompt) {
123
+ process.exit(0);
124
+ return;
125
+ }
126
+
127
+ const prompt = extractUserMessage(rawPrompt);
128
+ if (!prompt) {
129
+ // Compacted context or unparseable prompt — don't create messages.
130
+ // But update the session file to clear the old assistantMessageId
131
+ // so the Stop hook creates a fresh message for the next response.
132
+ if (session?.conversationId) {
133
+ fs.writeFileSync(sessionFile, JSON.stringify({
134
+ ...session,
135
+ assistantMessageId: null,
136
+ updatedAt: new Date().toISOString(),
137
+ }));
138
+ }
139
+ process.exit(0);
140
+ return;
141
+ }
142
+
143
+ // Read Termify config
144
+ let config;
145
+ try {
146
+ const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
147
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
148
+ } catch {
149
+ process.exit(0);
150
+ return;
151
+ }
152
+
153
+ if (!config.token) {
154
+ process.exit(0);
155
+ return;
156
+ }
157
+
158
+ const apiUrl = config.apiUrl || 'http://localhost:3001';
159
+ const provider = detectProvider(hookData);
160
+
161
+ let session = null;
162
+ try {
163
+ session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
164
+ } catch {}
165
+
166
+ const headers = {
167
+ 'Content-Type': 'application/json',
168
+ 'Authorization': `Bearer ${config.token}`,
169
+ };
170
+
171
+ try {
172
+ let conversationId = session?.conversationId;
173
+
174
+ if (!conversationId) {
175
+ const firstLine = prompt.split('\n')[0].trim();
176
+ const title = firstLine.length > 80 ? firstLine.substring(0, 77) + '...' : firstLine;
177
+ const res = await fetch(`${apiUrl}/api/chat/conversations`, {
178
+ method: 'POST',
179
+ headers,
180
+ body: JSON.stringify({ title, provider }),
181
+ signal: AbortSignal.timeout(5000),
182
+ });
183
+ if (!res.ok) {
184
+ process.exit(0);
185
+ return;
186
+ }
187
+ const json = await res.json();
188
+ conversationId = json.data?.id;
189
+ }
190
+
191
+ // Add user message (clean text only)
192
+ await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages`, {
193
+ method: 'POST',
194
+ headers,
195
+ body: JSON.stringify({ role: 'user', content: prompt }),
196
+ signal: AbortSignal.timeout(5000),
197
+ });
198
+
199
+ // Create streaming assistant message placeholder
200
+ let assistantMessageId = null;
201
+ try {
202
+ const assistantRes = await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages`, {
203
+ method: 'POST',
204
+ headers,
205
+ body: JSON.stringify({ role: 'assistant', content: '', status: 'streaming' }),
206
+ signal: AbortSignal.timeout(5000),
207
+ });
208
+ if (assistantRes.ok) {
209
+ const assistantJson = await assistantRes.json();
210
+ assistantMessageId = assistantJson.data?.id || null;
211
+ }
212
+ } catch {}
213
+
214
+ // Set conversation to working so frontend reconciliation doesn't show error
215
+ try {
216
+ await fetch(`${apiUrl}/api/chat/conversations/${conversationId}`, {
217
+ method: 'PATCH',
218
+ headers,
219
+ body: JSON.stringify({ status: 'working' }),
220
+ signal: AbortSignal.timeout(3000),
221
+ });
222
+ } catch {}
223
+
224
+ // Send initial thinking status event
225
+ if (assistantMessageId) {
226
+ try {
227
+ await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages/${assistantMessageId}/events`, {
228
+ method: 'POST',
229
+ headers,
230
+ body: JSON.stringify({ events: [{ type: 'status', status: 'thinking' }] }),
231
+ signal: AbortSignal.timeout(3000),
232
+ });
233
+ } catch {}
234
+ }
235
+
236
+ fs.writeFileSync(sessionFile, JSON.stringify({
237
+ conversationId,
238
+ assistantMessageId,
239
+ transcriptPath,
240
+ provider,
241
+ thinkingsSent: 0,
242
+ updatedAt: new Date().toISOString(),
243
+ }));
244
+ } catch {}
245
+
246
+ process.exit(0);
247
+ });
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Termify Tool Hook (PreToolUse / PostToolUse)
4
+ *
5
+ * Sends real-time events to Termify as Claude/Gemini works:
6
+ * - Thinking content as `text` events (italic markdown) → appears in message bubble
7
+ * - Tool calls as `tool_use` events → appears in ThinkingCollapsible
8
+ * - Tool results as `tool_result` events → appears in ThinkingCollapsible
9
+ *
10
+ * The `text` events replace each other (appendStreamEventReplacingText),
11
+ * so the message bubble always shows the latest accumulated thinking.
12
+ * When the Stop hook fires, the final response replaces the thinking.
13
+ *
14
+ * Token: ~/.termify/config.json
15
+ * Session: /tmp/termify-session-{hash}.json
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+
22
+ /**
23
+ * Read the transcript JSONL and extract ALL thinking blocks from the
24
+ * current assistant turn. Returns them in order.
25
+ */
26
+ function extractAllThinkingFromTranscript(transcriptPath) {
27
+ try {
28
+ const data = fs.readFileSync(transcriptPath, 'utf-8');
29
+ const lines = data.trim().split('\n');
30
+
31
+ const thinkings = [];
32
+ for (let i = lines.length - 1; i >= 0; i--) {
33
+ try {
34
+ const entry = JSON.parse(lines[i]);
35
+ // Stop at user message that isn't a tool result
36
+ if (entry.type === 'user' && !entry.toolUseResult && !entry.sourceToolAssistantUUID) break;
37
+ if (entry.type === 'assistant' && entry.message) {
38
+ const msg = entry.message;
39
+ const content = msg?.content || [];
40
+ for (const block of content) {
41
+ if (block?.type === 'thinking' && block.thinking) {
42
+ thinkings.unshift(block.thinking);
43
+ }
44
+ }
45
+ }
46
+ } catch {}
47
+ }
48
+
49
+ return thinkings;
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ // Flag: Termify-managed terminal (hooks handle thinking, parser handles tools/text)
56
+ const isTermifyManaged = !!process.env.TERMIFY_CONVERSATION_ID;
57
+
58
+ let input = '';
59
+ process.stdin.on('data', (chunk) => { input += chunk; });
60
+ process.stdin.on('end', async () => {
61
+ let hookData;
62
+ try {
63
+ hookData = JSON.parse(input);
64
+ } catch {
65
+ process.exit(0);
66
+ return;
67
+ }
68
+
69
+ const transcriptPath = hookData?.transcript_path;
70
+ const hookEvent = hookData?.hook_event_name;
71
+
72
+ if (!transcriptPath || !hookEvent) {
73
+ process.exit(0);
74
+ return;
75
+ }
76
+
77
+ // Find session file
78
+ const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
79
+ const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
80
+
81
+ let session;
82
+ try {
83
+ session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
84
+ } catch {
85
+ process.exit(0);
86
+ return;
87
+ }
88
+
89
+ if (!session.conversationId || !session.assistantMessageId) {
90
+ process.exit(0);
91
+ return;
92
+ }
93
+
94
+ // Auth: for Termify-managed, use session token; otherwise use config
95
+ let authToken = session.token;
96
+ let apiUrl = session.apiUrl;
97
+
98
+ if (!authToken || !apiUrl) {
99
+ // Fallback: read from Termify config
100
+ try {
101
+ const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
102
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
103
+ authToken = authToken || config.token;
104
+ apiUrl = apiUrl || config.apiUrl || 'http://localhost:3001';
105
+ } catch {
106
+ process.exit(0);
107
+ return;
108
+ }
109
+ }
110
+
111
+ if (!authToken) {
112
+ process.exit(0);
113
+ return;
114
+ }
115
+
116
+ const headers = {
117
+ 'Content-Type': 'application/json',
118
+ 'Authorization': `Bearer ${authToken}`,
119
+ };
120
+
121
+ const eventsUrl = `${apiUrl}/api/chat/conversations/${session.conversationId}/messages/${session.assistantMessageId}/events`;
122
+
123
+ try {
124
+ if (hookEvent === 'PreToolUse') {
125
+ const toolName = hookData?.tool_name || 'Unknown';
126
+ const toolInput = hookData?.tool_input || {};
127
+
128
+ // Detect AskUserQuestion / ExitPlanMode → signal needs_input to Termify
129
+ if (!session.termifyManaged && (toolName === 'AskUserQuestion' || toolName === 'ExitPlanMode')) {
130
+ const reason = toolName === 'AskUserQuestion'
131
+ ? (toolInput?.questions?.[0]?.question || 'Waiting for user input')
132
+ : 'Plan ready for approval';
133
+ fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}/needs-input`, {
134
+ method: 'POST',
135
+ headers,
136
+ body: JSON.stringify({ reason }),
137
+ signal: AbortSignal.timeout(3000),
138
+ }).catch(() => {});
139
+ }
140
+
141
+ // Extract ALL thinking from transcript and build italic markdown
142
+ const allThinkings = extractAllThinkingFromTranscript(transcriptPath);
143
+
144
+ if (session.termifyManaged) {
145
+ // Termify-managed: parser handles everything (thinking + tools + text).
146
+ // No-op — events posted here would be overwritten by finalizeExecution anyway.
147
+ } else {
148
+ // External CLI: send thinking text + tool_use events
149
+ const events = [];
150
+ if (allThinkings.length > 0) {
151
+ const thinkingText = allThinkings
152
+ .map(t => t.length > 4000 ? t.substring(0, 4000) + '...' : t)
153
+ .join('\n\n');
154
+ events.push({ type: 'text', content: '*' + thinkingText + '*' });
155
+ }
156
+ events.push({ type: 'tool_use', tool: toolName, args: toolInput });
157
+
158
+ await fetch(eventsUrl, {
159
+ method: 'POST',
160
+ headers,
161
+ body: JSON.stringify({ events }),
162
+ signal: AbortSignal.timeout(3000),
163
+ });
164
+ }
165
+ } else if (hookEvent === 'PostToolUse') {
166
+ // For Termify-managed: skip tool_result (parser handles it)
167
+ if (session.termifyManaged) {
168
+ // Termify-managed: parser handles everything. No-op.
169
+ } else {
170
+ // External CLI: send tool_result
171
+ const toolResponse = hookData?.tool_response;
172
+ let output = '';
173
+ if (typeof toolResponse === 'string') {
174
+ output = toolResponse.substring(0, 500);
175
+ } else if (toolResponse != null) {
176
+ output = JSON.stringify(toolResponse).substring(0, 500);
177
+ }
178
+ await fetch(eventsUrl, {
179
+ method: 'POST',
180
+ headers,
181
+ body: JSON.stringify({
182
+ events: [{ type: 'tool_result', output }],
183
+ }),
184
+ signal: AbortSignal.timeout(3000),
185
+ });
186
+ }
187
+ }
188
+ } catch {
189
+ // Silently fail - don't block Claude Code
190
+ }
191
+
192
+ process.exit(0);
193
+ });