termify-agent 1.0.28 → 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/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/package.json +1 -1
- package/scripts/postinstall.js +260 -100
- package/hooks/termify-auto-working.js +0 -41
|
@@ -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
|
+
});
|