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.
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +62 -0
- package/dist/agent.js.map +1 -1
- package/dist/git-service.d.ts +20 -0
- package/dist/git-service.d.ts.map +1 -1
- package/dist/git-service.js +71 -0
- package/dist/git-service.js.map +1 -1
- package/dist/pty-manager.d.ts +32 -0
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +45 -0
- package/dist/pty-manager.js.map +1 -1
- package/dist/ws-client.d.ts +36 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +38 -0
- package/dist/ws-client.js.map +1 -1
- package/hooks/termify-codex-notify.js +161 -0
- package/hooks/termify-needs-input-hook.js +101 -0
- package/hooks/termify-question-hook.js +148 -0
- package/hooks/termify-response.js +244 -0
- package/hooks/termify-sync.js +247 -0
- package/hooks/termify-tool-hook.js +193 -0
- package/mcp/termify-mcp-bundle.mjs +1406 -261
- package/package.json +1 -1
- package/scripts/postinstall.js +260 -100
- package/hooks/CLAUDE.md +0 -12
- package/hooks/termify-auto-working.js +0 -41
- package/scripts/CLAUDE.md +0 -12
|
@@ -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
|
+
});
|