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.
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termify-agent",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "Termify Agent CLI - Connect your local terminal to Termify",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",