termify-agent 1.0.40 → 1.0.41

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.
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAgEA;;;GAGG;AACH,wBAAsB,KAAK,CAAC,OAAO,EAAE;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC,OAAO,CAAC,CAuFnB;AAED;;GAEG;AACH,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAK5C"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAsHA;;;GAGG;AACH,wBAAsB,KAAK,CAAC,OAAO,EAAE;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC,OAAO,CAAC,CAgGnB;AAED;;GAEG;AACH,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAK5C"}
package/dist/auth.js CHANGED
@@ -1,10 +1,56 @@
1
1
  import open from 'open';
2
2
  import ora from 'ora';
3
3
  import chalk from 'chalk';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
4
7
  import { getConfig, setAccessToken, setServerUrl } from './config.js';
5
8
  import { getMachineId, getSystemInfo } from './utils/machine-id.js';
6
9
  import { logger } from './utils/logger.js';
7
10
  const VERSION = '1.0.5';
11
+ /**
12
+ * Write ~/.termify/config.json with the server endpoint and CLI token.
13
+ * Merges with existing config to avoid losing other endpoints.
14
+ */
15
+ function writeTermifyCliConfig(serverUrl, cliToken) {
16
+ const termifyDir = join(homedir(), '.termify');
17
+ const configPath = join(termifyDir, 'config.json');
18
+ mkdirSync(termifyDir, { recursive: true });
19
+ // Derive a short name from the URL
20
+ const isLocal = serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1');
21
+ const endpointName = isLocal ? 'local' : 'prod';
22
+ // Read existing config if present
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ let config = { endpoints: [] };
25
+ if (existsSync(configPath)) {
26
+ try {
27
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
28
+ }
29
+ catch { /* corrupted, overwrite */ }
30
+ }
31
+ // Migrate legacy format
32
+ if (!Array.isArray(config.endpoints)) {
33
+ config.endpoints = [];
34
+ if (config.token && config.apiUrl) {
35
+ config.endpoints.push({ name: 'default', apiUrl: config.apiUrl, token: config.token });
36
+ }
37
+ delete config.token;
38
+ delete config.apiUrl;
39
+ }
40
+ // Upsert endpoint by URL
41
+ const existing = config.endpoints.findIndex(
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ (ep) => ep.apiUrl === serverUrl);
44
+ const entry = { name: endpointName, apiUrl: serverUrl, token: cliToken };
45
+ if (existing >= 0) {
46
+ config.endpoints[existing] = entry;
47
+ }
48
+ else {
49
+ config.endpoints.push(entry);
50
+ }
51
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
52
+ logger.debug(`CLI config written to ${configPath}`);
53
+ }
8
54
  /**
9
55
  * Attempt to verify a pairing code with the server
10
56
  */
@@ -63,6 +109,9 @@ export async function login(options) {
63
109
  const result = await verifyPairingCode(serverUrl, options.pairingCode);
64
110
  if (result) {
65
111
  setAccessToken(result.accessToken, result.agentId);
112
+ if (result.cliToken) {
113
+ writeTermifyCliConfig(serverUrl, result.cliToken);
114
+ }
66
115
  spinner.succeed(chalk.green('Login successful!'));
67
116
  console.log(chalk.gray(` Agent ID: ${result.agentId}`));
68
117
  return true;
@@ -110,9 +159,15 @@ export async function login(options) {
110
159
  const result = await verifyPairingCode(serverUrl, cleanCode);
111
160
  if (result) {
112
161
  setAccessToken(result.accessToken, result.agentId);
162
+ if (result.cliToken) {
163
+ writeTermifyCliConfig(serverUrl, result.cliToken);
164
+ }
113
165
  spinner.succeed(chalk.green('Login successful!'));
114
166
  console.log(chalk.gray(` Agent ID: ${result.agentId}`));
115
167
  console.log(chalk.gray(` Config saved to: ${(await import('./config.js')).getConfigPath()}`));
168
+ if (result.cliToken) {
169
+ console.log(chalk.gray(` CLI config: ${join(homedir(), '.termify', 'config.json')}`));
170
+ }
116
171
  console.log(chalk.cyan('\nRun `termify-agent start` to connect.\n'));
117
172
  resolve(true);
118
173
  }
package/dist/auth.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
@@ -2,13 +2,10 @@
2
2
  /**
3
3
  * Termify Response Sync Hook (Stop / AfterAgent)
4
4
  *
5
- * Syncs the assistant's last response to the Termify conversation
5
+ * Syncs the assistant's last response to all Termify endpoints
6
6
  * when Claude Code / Gemini finishes its turn.
7
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.
8
+ * Supports multiple endpoints (local + prod) sends to all in parallel.
12
9
  *
13
10
  * Token: ~/.termify/config.json
14
11
  * Session: /tmp/termify-session-{hash}.json
@@ -18,79 +15,101 @@ const fs = require('fs');
18
15
  const path = require('path');
19
16
  const crypto = require('crypto');
20
17
 
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
18
  function deduplicateGeminiResponse(text) {
27
19
  if (!text || text.length < 200) return text;
28
-
29
20
  const len = text.length;
30
-
31
- // Scan for a split point where the tail matches earlier content
32
21
  for (let splitAt = Math.floor(len * 0.35); splitAt <= Math.floor(len * 0.65); splitAt++) {
33
22
  const tail = text.slice(splitAt);
34
-
35
- // The duplicated portion must be significant (>25% of total)
36
23
  if (tail.length < len * 0.25) continue;
37
-
38
- // Use first 80 chars of tail as search needle
39
24
  const needleLen = Math.min(80, tail.length);
40
25
  const needle = tail.slice(0, needleLen);
41
-
42
- // Search for this needle BEFORE the split point
43
26
  const idx = text.indexOf(needle);
44
27
  if (idx >= 0 && idx < splitAt) {
45
- // Verify longer match (at least 100 chars must match)
46
28
  const matchLen = Math.min(tail.length, len - idx, 300);
47
29
  if (matchLen > 100 && tail.slice(0, matchLen) === text.slice(idx, idx + matchLen)) {
48
30
  return text.slice(0, splitAt).trimEnd();
49
31
  }
50
32
  }
51
33
  }
52
-
53
34
  return text;
54
35
  }
55
36
 
56
- /**
57
- * Extract ALL thinking blocks from the current assistant turn in the transcript.
58
- */
59
- function extractAllThinking(transcriptPath) {
37
+ function extractTranscriptEvents(transcriptPath) {
38
+ const result = { thinkings: [], toolEvents: [] };
60
39
  try {
61
40
  const data = fs.readFileSync(transcriptPath, 'utf-8');
62
41
  const lines = data.trim().split('\n');
63
-
64
- const thinkings = [];
42
+ const entries = [];
65
43
  for (let i = lines.length - 1; i >= 0; i--) {
66
44
  try {
67
45
  const entry = JSON.parse(lines[i]);
68
- // Stop at user message (end of current assistant turn)
69
46
  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);
47
+ entries.unshift(entry);
48
+ } catch {}
49
+ }
50
+ for (const entry of entries) {
51
+ if (entry.type !== 'assistant' || !entry.message) continue;
52
+ let msg;
53
+ try {
54
+ msg = typeof entry.message === 'string' ? JSON.parse(entry.message) : entry.message;
55
+ } catch {
56
+ try {
57
+ msg = typeof entry.message === 'string' ? JSON.parse(entry.message.replace(/'/g, '"')) : entry.message;
58
+ } catch { continue; }
59
+ }
60
+ const content = msg?.content;
61
+ if (!Array.isArray(content)) continue;
62
+ for (const block of content) {
63
+ if (block?.type === 'thinking' && block.thinking) {
64
+ result.thinkings.push(block.thinking);
65
+ }
66
+ if (block?.type === 'tool_use' && block.name) {
67
+ const toolEvent = { type: 'tool_use', tool: block.name };
68
+ if (block.input) {
69
+ const displayKeys = ['file_path', 'command', 'pattern', 'query', 'description', 'url', 'prompt', 'skill', 'subagent_type', 'old_string', 'notebook_path'];
70
+ const slim = {};
71
+ for (const k of displayKeys) {
72
+ if (block.input[k] != null) {
73
+ const v = String(block.input[k]);
74
+ slim[k] = v.length > 200 ? v.substring(0, 197) + '...' : v;
75
+ }
84
76
  }
77
+ if (Object.keys(slim).length > 0) toolEvent.args = slim;
78
+ }
79
+ result.toolEvents.push(toolEvent);
80
+ if (block.name === 'AskUserQuestion' && block.input?.questions) {
81
+ result.toolEvents.push({
82
+ type: 'question',
83
+ questionId: block.id || `q-${Date.now()}`,
84
+ questions: block.input.questions,
85
+ });
85
86
  }
86
87
  }
87
- } catch {}
88
+ }
88
89
  }
90
+ } catch {}
91
+ return result;
92
+ }
89
93
 
90
- return thinkings;
91
- } catch {
92
- return [];
93
- }
94
+ /**
95
+ * Read endpoints from config. Supports both old and new format.
96
+ */
97
+ function readEndpoints() {
98
+ try {
99
+ const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
100
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
101
+ if (Array.isArray(config.endpoints) && config.endpoints.length > 0) {
102
+ return config.endpoints.map(ep => ({
103
+ name: ep.name || 'default',
104
+ apiUrl: ep.apiUrl,
105
+ token: ep.token,
106
+ })).filter(ep => ep.apiUrl && ep.token);
107
+ }
108
+ if (config.token) {
109
+ return [{ name: 'default', apiUrl: config.apiUrl || 'http://localhost:3001', token: config.token }];
110
+ }
111
+ } catch {}
112
+ return [];
94
113
  }
95
114
 
96
115
  // Skip sync when running inside a Termify-managed chat terminal
@@ -109,7 +128,6 @@ process.stdin.on('end', async () => {
109
128
  return;
110
129
  }
111
130
 
112
- // Prevent infinite loops
113
131
  if (hookData?.stop_hook_active) {
114
132
  process.exit(0);
115
133
  return;
@@ -117,8 +135,6 @@ process.stdin.on('end', async () => {
117
135
 
118
136
  const transcriptPath = hookData?.transcript_path;
119
137
  const hookEvent = hookData?.hook_event_name || '';
120
-
121
- // Claude Code: last_assistant_message, Gemini CLI: prompt_response
122
138
  let lastMessage = hookData?.last_assistant_message || hookData?.prompt_response;
123
139
 
124
140
  if (!transcriptPath || !lastMessage) {
@@ -126,15 +142,11 @@ process.stdin.on('end', async () => {
126
142
  return;
127
143
  }
128
144
 
129
- // Trim leading whitespace from Gemini responses (they often start with spaces)
130
145
  lastMessage = lastMessage.trimStart();
131
-
132
- // Fix Gemini CLI streaming duplication bug
133
146
  if (hookEvent === 'AfterAgent') {
134
147
  lastMessage = deduplicateGeminiResponse(lastMessage);
135
148
  }
136
149
 
137
- // Find session file
138
150
  const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
139
151
  const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
140
152
 
@@ -146,99 +158,114 @@ process.stdin.on('end', async () => {
146
158
  return;
147
159
  }
148
160
 
149
- if (!session.conversationId) {
150
- process.exit(0);
151
- return;
161
+ // Legacy single-endpoint session: convert to multi-endpoint format
162
+ if (session.conversationId && !session.endpoints) {
163
+ const endpoints = readEndpoints();
164
+ const defaultName = endpoints[0]?.name || 'default';
165
+ session.endpoints = {
166
+ [defaultName]: {
167
+ conversationId: session.conversationId,
168
+ assistantMessageId: session.assistantMessageId,
169
+ }
170
+ };
152
171
  }
153
172
 
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 {
173
+ if (!session.endpoints || Object.keys(session.endpoints).length === 0) {
160
174
  process.exit(0);
161
175
  return;
162
176
  }
163
177
 
164
- if (!config.token) {
178
+ const endpoints = readEndpoints();
179
+ if (endpoints.length === 0) {
165
180
  process.exit(0);
166
181
  return;
167
182
  }
168
183
 
169
- const apiUrl = config.apiUrl || 'http://localhost:3001';
184
+ const { thinkings, toolEvents } = extractTranscriptEvents(transcriptPath);
185
+ const alreadySent = session.thinkingsSent || 0;
170
186
 
171
- const headers = {
172
- 'Content-Type': 'application/json',
173
- 'Authorization': `Bearer ${config.token}`,
174
- };
187
+ /**
188
+ * Sync response to a single endpoint.
189
+ */
190
+ async function syncResponseToEndpoint(ep) {
191
+ const epState = session.endpoints[ep.name];
192
+ if (!epState?.conversationId) return;
175
193
 
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
194
+ const { conversationId, assistantMessageId } = epState;
195
+ const headers = {
196
+ 'Content-Type': 'application/json',
197
+ 'Authorization': `Bearer ${ep.token}`,
198
+ };
199
+
200
+ // CRITICAL: complete message + set idle
201
+ try {
202
+ if (assistantMessageId) {
203
+ const res = await fetch(
204
+ `${ep.apiUrl}/api/chat/conversations/${conversationId}/messages/${assistantMessageId}/complete`,
205
+ {
206
+ method: 'POST', headers,
207
+ body: JSON.stringify({ content: lastMessage }),
208
+ signal: AbortSignal.timeout(5000),
209
+ }
210
+ );
211
+ if (!res.ok) throw new Error('complete failed');
212
+ } else {
213
+ await Promise.allSettled([
214
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}/messages`, {
215
+ method: 'POST', headers,
216
+ body: JSON.stringify({ role: 'assistant', content: lastMessage }),
217
+ signal: AbortSignal.timeout(5000),
218
+ }),
219
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}`, {
220
+ method: 'PATCH', headers,
221
+ body: JSON.stringify({ status: 'idle' }),
222
+ signal: AbortSignal.timeout(3000),
223
+ }),
224
+ ]);
225
+ }
226
+ } catch {
227
+ // Fallback
193
228
  await Promise.allSettled([
194
- fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}/messages`, {
195
- method: 'POST',
196
- headers,
229
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}/messages`, {
230
+ method: 'POST', headers,
197
231
  body: JSON.stringify({ role: 'assistant', content: lastMessage }),
198
232
  signal: AbortSignal.timeout(5000),
199
233
  }),
200
- fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}`, {
201
- method: 'PATCH',
202
- headers,
234
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}`, {
235
+ method: 'PATCH', headers,
203
236
  body: JSON.stringify({ status: 'idle' }),
204
237
  signal: AbortSignal.timeout(3000),
205
238
  }),
206
239
  ]);
207
240
  }
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
241
 
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(() => {});
242
+ // NON-CRITICAL: stream events
243
+ if (assistantMessageId) {
244
+ const eventsUrl = `${ep.apiUrl}/api/chat/conversations/${conversationId}/messages/${assistantMessageId}/events`;
245
+ const events = [];
246
+ for (let i = alreadySent; i < thinkings.length; i++) {
247
+ events.push({ type: 'thinking', content: thinkings[i] });
248
+ }
249
+ for (const te of toolEvents) {
250
+ events.push(te);
251
+ }
252
+ events.push({ type: 'text', content: lastMessage });
253
+ events.push({ type: 'status', status: 'done' });
254
+
255
+ try {
256
+ await fetch(eventsUrl, {
257
+ method: 'POST', headers,
258
+ body: JSON.stringify({ events }),
259
+ signal: AbortSignal.timeout(5000),
260
+ });
261
+ } catch {}
262
+ }
241
263
  }
242
264
 
265
+ // Sync all endpoints in parallel
266
+ await Promise.allSettled(
267
+ endpoints.map(ep => syncResponseToEndpoint(ep))
268
+ );
269
+
243
270
  process.exit(0);
244
271
  });
@@ -5,8 +5,7 @@
5
5
  * Syncs Claude Code & Gemini conversations to Termify chat.
6
6
  * On each prompt: creates/reuses a conversation and adds the user message.
7
7
  *
8
- * Handles compressed context: Claude Code sometimes sends the full compressed
9
- * transcript as "prompt". We extract just the user's actual message.
8
+ * Supports multiple endpoints (local + prod) sends to all in parallel.
10
9
  *
11
10
  * Token: ~/.termify/config.json
12
11
  * Session: /tmp/termify-session-{hash}.json
@@ -16,66 +15,63 @@ const fs = require('fs');
16
15
  const path = require('path');
17
16
  const crypto = require('crypto');
18
17
 
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
18
  function isCompactedContext(raw) {
28
19
  if (!raw) return false;
29
- // Compacted prompts are huge and contain summary markers
30
20
  if (raw.length > 5000 && raw.includes('<system-reminder>')) return true;
31
- // Starts with the transcript compaction header
32
21
  if (raw.startsWith('This session is being continued from a previous conversation')) return true;
33
- // Contains compaction-specific markers
34
22
  if (raw.includes('conversation that ran out of context') && raw.includes('summary below')) return true;
35
23
  return false;
36
24
  }
37
25
 
38
26
  function extractUserMessage(raw) {
39
27
  if (!raw) return null;
40
-
41
- // Skip compacted context entirely — not a real user message
42
28
  if (isCompactedContext(raw)) return null;
43
-
44
- // Clean prompt (no system markers, reasonable size) — use as-is
45
29
  if (raw.length < 2000 && !raw.includes('<system-reminder>') && !raw.startsWith('===')) {
46
30
  return raw.trim();
47
31
  }
48
-
49
- // Compressed context: extract the last user message after all system tags
50
- // Pattern: ...system stuff...\n</system-reminder>\nactual user message
51
32
  const parts = raw.split('</system-reminder>');
52
33
  if (parts.length > 1) {
53
34
  const lastPart = parts[parts.length - 1].trim();
54
- // Must look like a real message, not continuation instructions
55
35
  if (lastPart && lastPart.length > 0 && lastPart.length < 5000 && !isCompactedContext(lastPart)) {
56
36
  return lastPart;
57
37
  }
58
38
  }
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
39
+ if (raw.startsWith('===')) return null;
66
40
  return raw.substring(0, 500).trim() || null;
67
41
  }
68
42
 
69
- /**
70
- * Detect which CLI is running from hook event name.
71
- */
72
43
  function detectProvider(hookData) {
73
44
  const event = hookData?.hook_event_name;
74
45
  if (event === 'BeforeAgent') return 'gemini';
75
- // Default to claude for Claude Code
76
46
  return 'claude';
77
47
  }
78
48
 
49
+ /**
50
+ * Read endpoints from config. Supports both old format (single token/apiUrl)
51
+ * and new format (endpoints array with per-endpoint tokens).
52
+ */
53
+ function readEndpoints() {
54
+ try {
55
+ const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
56
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
57
+
58
+ // New format: endpoints array
59
+ if (Array.isArray(config.endpoints) && config.endpoints.length > 0) {
60
+ return config.endpoints.map(ep => ({
61
+ name: ep.name || 'default',
62
+ apiUrl: ep.apiUrl,
63
+ token: ep.token,
64
+ })).filter(ep => ep.apiUrl && ep.token);
65
+ }
66
+
67
+ // Legacy format: single token + apiUrl
68
+ if (config.token) {
69
+ return [{ name: 'default', apiUrl: config.apiUrl || 'http://localhost:3001', token: config.token }];
70
+ }
71
+ } catch {}
72
+ return [];
73
+ }
74
+
79
75
  // Flag: running inside a Termify-managed chat terminal
80
76
  const isTermifyManaged = !!process.env.TERMIFY_CONVERSATION_ID;
81
77
 
@@ -96,7 +92,6 @@ process.stdin.on('end', async () => {
96
92
  return;
97
93
  }
98
94
 
99
- // Session key from transcript_path
100
95
  const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
101
96
  const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
102
97
 
@@ -116,132 +111,186 @@ process.stdin.on('end', async () => {
116
111
  return;
117
112
  }
118
113
 
119
- // ── External CLI: normal flow (create conversation, messages, etc.) ──
114
+ // ── External CLI: multi-endpoint sync ──
120
115
 
121
- const rawPrompt = hookData?.prompt;
122
- if (!rawPrompt) {
116
+ const endpoints = readEndpoints();
117
+ if (endpoints.length === 0) {
123
118
  process.exit(0);
124
119
  return;
125
120
  }
126
121
 
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;
122
+ // Read existing session
123
+ let session = null;
145
124
  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
- }
125
+ session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
126
+ } catch {}
152
127
 
153
- if (!config.token) {
128
+ const provider = detectProvider(hookData);
129
+ const rawPrompt = hookData?.prompt;
130
+ if (!rawPrompt) {
154
131
  process.exit(0);
155
132
  return;
156
133
  }
157
134
 
158
- const apiUrl = config.apiUrl || 'http://localhost:3001';
159
- const provider = detectProvider(hookData);
135
+ const prompt = extractUserMessage(rawPrompt);
160
136
 
161
- let session = null;
162
- try {
163
- session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
164
- } catch {}
137
+ /**
138
+ * Sync a single endpoint. Creates conversation if needed,
139
+ * adds user message, creates streaming assistant placeholder.
140
+ */
141
+ async function syncEndpoint(ep, existingConvoId) {
142
+ const headers = {
143
+ 'Content-Type': 'application/json',
144
+ 'Authorization': `Bearer ${ep.token}`,
145
+ };
165
146
 
166
- const headers = {
167
- 'Content-Type': 'application/json',
168
- 'Authorization': `Bearer ${config.token}`,
169
- };
147
+ if (!prompt) {
148
+ // Compacted context — just create new assistant message for streaming
149
+ if (existingConvoId) {
150
+ const prevMsgId = session?.endpoints?.[ep.name]?.assistantMessageId;
151
+ if (prevMsgId) {
152
+ try {
153
+ await fetch(`${ep.apiUrl}/api/chat/conversations/${existingConvoId}/messages/${prevMsgId}/complete`, {
154
+ method: 'POST', headers,
155
+ body: JSON.stringify({ content: '...' }),
156
+ signal: AbortSignal.timeout(3000),
157
+ });
158
+ } catch {}
159
+ }
170
160
 
171
- try {
172
- let conversationId = session?.conversationId;
161
+ let assistantMessageId = null;
162
+ try {
163
+ const res = await fetch(`${ep.apiUrl}/api/chat/conversations/${existingConvoId}/messages`, {
164
+ method: 'POST', headers,
165
+ body: JSON.stringify({ role: 'assistant', content: '', status: 'streaming' }),
166
+ signal: AbortSignal.timeout(5000),
167
+ });
168
+ if (res.ok) {
169
+ const json = await res.json();
170
+ assistantMessageId = json.data?.id || null;
171
+ }
172
+ } catch {}
173
+
174
+ try {
175
+ await fetch(`${ep.apiUrl}/api/chat/conversations/${existingConvoId}`, {
176
+ method: 'PATCH', headers,
177
+ body: JSON.stringify({ status: 'working' }),
178
+ signal: AbortSignal.timeout(3000),
179
+ });
180
+ } catch {}
181
+
182
+ if (assistantMessageId) {
183
+ try {
184
+ await fetch(`${ep.apiUrl}/api/chat/conversations/${existingConvoId}/messages/${assistantMessageId}/events`, {
185
+ method: 'POST', headers,
186
+ body: JSON.stringify({ events: [{ type: 'status', status: 'thinking' }] }),
187
+ signal: AbortSignal.timeout(3000),
188
+ });
189
+ } catch {}
190
+ }
191
+
192
+ return { conversationId: existingConvoId, assistantMessageId };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ // Normal flow: create conversation if needed, add user message
198
+ let conversationId = existingConvoId;
173
199
 
174
200
  if (!conversationId) {
175
201
  const firstLine = prompt.split('\n')[0].trim();
176
202
  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;
203
+ try {
204
+ const res = await fetch(`${ep.apiUrl}/api/chat/conversations`, {
205
+ method: 'POST', headers,
206
+ body: JSON.stringify({ title, provider }),
207
+ signal: AbortSignal.timeout(5000),
208
+ });
209
+ if (res.ok) {
210
+ const json = await res.json();
211
+ conversationId = json.data?.id;
212
+ }
213
+ } catch {}
189
214
  }
190
215
 
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
- });
216
+ if (!conversationId) return null;
217
+
218
+ // Add user message
219
+ try {
220
+ await fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}/messages`, {
221
+ method: 'POST', headers,
222
+ body: JSON.stringify({ role: 'user', content: prompt }),
223
+ signal: AbortSignal.timeout(5000),
224
+ });
225
+ } catch {}
198
226
 
199
- // Create streaming assistant message placeholder
227
+ // Create streaming assistant placeholder
200
228
  let assistantMessageId = null;
201
229
  try {
202
- const assistantRes = await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages`, {
203
- method: 'POST',
204
- headers,
230
+ const res = await fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}/messages`, {
231
+ method: 'POST', headers,
205
232
  body: JSON.stringify({ role: 'assistant', content: '', status: 'streaming' }),
206
233
  signal: AbortSignal.timeout(5000),
207
234
  });
208
- if (assistantRes.ok) {
209
- const assistantJson = await assistantRes.json();
210
- assistantMessageId = assistantJson.data?.id || null;
235
+ if (res.ok) {
236
+ const json = await res.json();
237
+ assistantMessageId = json.data?.id || null;
211
238
  }
212
239
  } catch {}
213
240
 
214
- // Set conversation to working so frontend reconciliation doesn't show error
241
+ // Set working status
215
242
  try {
216
- await fetch(`${apiUrl}/api/chat/conversations/${conversationId}`, {
217
- method: 'PATCH',
218
- headers,
243
+ await fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}`, {
244
+ method: 'PATCH', headers,
219
245
  body: JSON.stringify({ status: 'working' }),
220
246
  signal: AbortSignal.timeout(3000),
221
247
  });
222
248
  } catch {}
223
249
 
224
- // Send initial thinking status event
250
+ // Initial thinking event
225
251
  if (assistantMessageId) {
226
252
  try {
227
- await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages/${assistantMessageId}/events`, {
228
- method: 'POST',
229
- headers,
253
+ await fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}/messages/${assistantMessageId}/events`, {
254
+ method: 'POST', headers,
230
255
  body: JSON.stringify({ events: [{ type: 'status', status: 'thinking' }] }),
231
256
  signal: AbortSignal.timeout(3000),
232
257
  });
233
258
  } catch {}
234
259
  }
235
260
 
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 {}
261
+ return { conversationId, assistantMessageId };
262
+ }
263
+
264
+ // Sync all endpoints in parallel
265
+ const results = await Promise.allSettled(
266
+ endpoints.map(ep => {
267
+ const existingId = session?.endpoints?.[ep.name]?.conversationId || null;
268
+ return syncEndpoint(ep, existingId).then(r => ({ name: ep.name, ...r }));
269
+ })
270
+ );
271
+
272
+ // Build session with per-endpoint state
273
+ const endpointState = {};
274
+ for (const r of results) {
275
+ if (r.status === 'fulfilled' && r.value) {
276
+ const { name, conversationId, assistantMessageId } = r.value;
277
+ endpointState[name] = { conversationId, assistantMessageId };
278
+ } else if (r.status === 'fulfilled' && !r.value) {
279
+ // Keep previous state for this endpoint if sync returned null
280
+ const epName = endpoints[results.indexOf(r)]?.name;
281
+ if (epName && session?.endpoints?.[epName]) {
282
+ endpointState[epName] = session.endpoints[epName];
283
+ }
284
+ }
285
+ }
286
+
287
+ fs.writeFileSync(sessionFile, JSON.stringify({
288
+ endpoints: endpointState,
289
+ transcriptPath,
290
+ provider,
291
+ thinkingsSent: 0,
292
+ updatedAt: new Date().toISOString(),
293
+ }));
245
294
 
246
295
  process.exit(0);
247
296
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termify-agent",
3
- "version": "1.0.40",
3
+ "version": "1.0.41",
4
4
  "description": "Termify Agent CLI - Connect your local terminal to Termify",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",