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,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Termify Sync for Codex CLI (notify hook)
|
|
4
|
+
*
|
|
5
|
+
* Syncs Codex conversations to Termify chat.
|
|
6
|
+
* Codex passes JSON as argv[1] on "agent-turn-complete" events.
|
|
7
|
+
*
|
|
8
|
+
* Payload: { type, thread-id, turn-id, cwd, input-messages, last-assistant-message }
|
|
9
|
+
*
|
|
10
|
+
* Token: ~/.termify/config.json
|
|
11
|
+
* Session: /tmp/termify-session-codex-{thread-id}.json
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
// Skip sync when running inside a Termify-managed chat terminal
|
|
18
|
+
if (process.env.TERMIFY_CONVERSATION_ID) {
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
(async () => {
|
|
23
|
+
let payload;
|
|
24
|
+
try {
|
|
25
|
+
payload = JSON.parse(process.argv[2] || '{}');
|
|
26
|
+
} catch {
|
|
27
|
+
process.exit(0);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (payload.type !== 'agent-turn-complete') {
|
|
32
|
+
process.exit(0);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const threadId = payload['thread-id'];
|
|
37
|
+
const lastMessage = payload['last-assistant-message'];
|
|
38
|
+
const inputMessages = payload['input-messages'];
|
|
39
|
+
|
|
40
|
+
if (!threadId || !lastMessage) {
|
|
41
|
+
process.exit(0);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read Termify config
|
|
46
|
+
let config;
|
|
47
|
+
try {
|
|
48
|
+
const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
|
|
49
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
50
|
+
} catch {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!config.token) {
|
|
56
|
+
process.exit(0);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const apiUrl = config.apiUrl || 'http://localhost:3001';
|
|
61
|
+
const headers = {
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
'Authorization': `Bearer ${config.token}`,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Session file keyed by thread-id
|
|
67
|
+
const sessionFile = path.join('/tmp', `termify-session-codex-${threadId}.json`);
|
|
68
|
+
|
|
69
|
+
let session = null;
|
|
70
|
+
try {
|
|
71
|
+
session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
let conversationId = session?.conversationId;
|
|
76
|
+
|
|
77
|
+
if (!conversationId) {
|
|
78
|
+
// Extract user prompt for conversation title
|
|
79
|
+
let userPrompt = '';
|
|
80
|
+
if (typeof inputMessages === 'string') {
|
|
81
|
+
userPrompt = inputMessages;
|
|
82
|
+
} else if (Array.isArray(inputMessages) && inputMessages.length > 0) {
|
|
83
|
+
const first = inputMessages[0];
|
|
84
|
+
userPrompt = typeof first === 'string' ? first : (first?.content || first?.text || '');
|
|
85
|
+
}
|
|
86
|
+
userPrompt = userPrompt.trim();
|
|
87
|
+
|
|
88
|
+
const firstLine = (userPrompt || 'Codex session').split('\n')[0].trim();
|
|
89
|
+
const title = firstLine.length > 80 ? firstLine.substring(0, 77) + '...' : firstLine;
|
|
90
|
+
|
|
91
|
+
// Create conversation
|
|
92
|
+
const res = await fetch(`${apiUrl}/api/chat/conversations`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers,
|
|
95
|
+
body: JSON.stringify({ title, provider: 'codex' }),
|
|
96
|
+
signal: AbortSignal.timeout(5000),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
process.exit(0);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const json = await res.json();
|
|
103
|
+
conversationId = json.data?.id;
|
|
104
|
+
|
|
105
|
+
// Add user message
|
|
106
|
+
if (userPrompt) {
|
|
107
|
+
await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers,
|
|
110
|
+
body: JSON.stringify({ role: 'user', content: userPrompt }),
|
|
111
|
+
signal: AbortSignal.timeout(5000),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Save session
|
|
116
|
+
fs.writeFileSync(sessionFile, JSON.stringify({
|
|
117
|
+
conversationId,
|
|
118
|
+
threadId,
|
|
119
|
+
provider: 'codex',
|
|
120
|
+
updatedAt: new Date().toISOString(),
|
|
121
|
+
}));
|
|
122
|
+
} else {
|
|
123
|
+
// Existing conversation — check if there are new user messages to sync
|
|
124
|
+
// input-messages contains the latest user prompt that triggered this turn
|
|
125
|
+
let userPrompt = '';
|
|
126
|
+
if (typeof inputMessages === 'string') {
|
|
127
|
+
userPrompt = inputMessages;
|
|
128
|
+
} else if (Array.isArray(inputMessages) && inputMessages.length > 0) {
|
|
129
|
+
const last = inputMessages[inputMessages.length - 1];
|
|
130
|
+
userPrompt = typeof last === 'string' ? last : (last?.content || last?.text || '');
|
|
131
|
+
}
|
|
132
|
+
userPrompt = userPrompt.trim();
|
|
133
|
+
|
|
134
|
+
if (userPrompt && userPrompt !== session.lastUserPrompt) {
|
|
135
|
+
await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers,
|
|
138
|
+
body: JSON.stringify({ role: 'user', content: userPrompt }),
|
|
139
|
+
signal: AbortSignal.timeout(5000),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Update session with last prompt to avoid duplicates
|
|
144
|
+
session.lastUserPrompt = userPrompt;
|
|
145
|
+
session.updatedAt = new Date().toISOString();
|
|
146
|
+
fs.writeFileSync(sessionFile, JSON.stringify(session));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Add assistant response
|
|
150
|
+
await fetch(`${apiUrl}/api/chat/conversations/${conversationId}/messages`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers,
|
|
153
|
+
body: JSON.stringify({ role: 'assistant', content: lastMessage.trim() }),
|
|
154
|
+
signal: AbortSignal.timeout(5000),
|
|
155
|
+
});
|
|
156
|
+
} catch {
|
|
157
|
+
// Silently fail
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
process.exit(0);
|
|
161
|
+
})();
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Termify Needs-Input Hook (PreToolUse - ExitPlanMode, EnterPlanMode, etc.)
|
|
4
|
+
*
|
|
5
|
+
* When Claude Code invokes tools that pause for user input in the terminal
|
|
6
|
+
* (plan approval, plan mode entry, etc.), this hook signals Termify to:
|
|
7
|
+
* 1. Pause execution watchdogs (prevents 90s idle timeout)
|
|
8
|
+
* 2. Set conversation status to needs_input
|
|
9
|
+
* 3. Send notification to the user
|
|
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
|
+
const TOOL_REASONS = {
|
|
20
|
+
ExitPlanMode: 'Claude has a plan ready for your approval',
|
|
21
|
+
EnterPlanMode: 'Claude wants to enter plan mode',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let input = '';
|
|
25
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
26
|
+
process.stdin.on('end', async () => {
|
|
27
|
+
let hookData;
|
|
28
|
+
try {
|
|
29
|
+
hookData = JSON.parse(input);
|
|
30
|
+
} catch {
|
|
31
|
+
process.exit(0);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const transcriptPath = hookData?.transcript_path;
|
|
36
|
+
const toolName = hookData?.tool_name;
|
|
37
|
+
|
|
38
|
+
if (!transcriptPath || !toolName) {
|
|
39
|
+
process.exit(0);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Find session file
|
|
44
|
+
const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
|
|
45
|
+
const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
|
|
46
|
+
|
|
47
|
+
let session;
|
|
48
|
+
try {
|
|
49
|
+
session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
50
|
+
} catch {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!session.conversationId) {
|
|
56
|
+
process.exit(0);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Auth: use session token or config
|
|
61
|
+
let authToken = session.token;
|
|
62
|
+
let apiUrl = session.apiUrl;
|
|
63
|
+
|
|
64
|
+
if (!authToken || !apiUrl) {
|
|
65
|
+
try {
|
|
66
|
+
const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
|
|
67
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
68
|
+
authToken = authToken || config.token;
|
|
69
|
+
apiUrl = apiUrl || config.apiUrl || 'http://localhost:3001';
|
|
70
|
+
} catch {
|
|
71
|
+
process.exit(0);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!authToken) {
|
|
77
|
+
process.exit(0);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const reason = TOOL_REASONS[toolName] || `Waiting for input (${toolName})`;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await fetch(
|
|
85
|
+
`${apiUrl}/api/chat/conversations/${session.conversationId}/needs-input`,
|
|
86
|
+
{
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'Content-Type': 'application/json',
|
|
90
|
+
'Authorization': `Bearer ${authToken}`,
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ reason }),
|
|
93
|
+
signal: AbortSignal.timeout(3000),
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
// Silently fail — don't block Claude Code
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
process.exit(0);
|
|
101
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Termify Question Hook (PreToolUse - AskUserQuestion)
|
|
4
|
+
*
|
|
5
|
+
* When Claude Code invokes AskUserQuestion, this hook:
|
|
6
|
+
* 1. Sends the question as a stream event to Termify
|
|
7
|
+
* 2. Registers the pending question on the server
|
|
8
|
+
* 3. Long-polls for the user's answer from Termify UI
|
|
9
|
+
* 4. If answered, pre-fills the response via updatedInput
|
|
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
|
+
let input = '';
|
|
20
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
21
|
+
process.stdin.on('end', async () => {
|
|
22
|
+
let hookData;
|
|
23
|
+
try {
|
|
24
|
+
hookData = JSON.parse(input);
|
|
25
|
+
} catch {
|
|
26
|
+
process.exit(0);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const transcriptPath = hookData?.transcript_path;
|
|
31
|
+
const toolInput = hookData?.tool_input;
|
|
32
|
+
|
|
33
|
+
if (!transcriptPath || !toolInput?.questions) {
|
|
34
|
+
process.exit(0);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Find session file
|
|
39
|
+
const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
|
|
40
|
+
const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
|
|
41
|
+
|
|
42
|
+
let session;
|
|
43
|
+
try {
|
|
44
|
+
session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
45
|
+
} catch {
|
|
46
|
+
process.exit(0);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!session.conversationId || !session.assistantMessageId) {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read Termify config
|
|
56
|
+
let config;
|
|
57
|
+
try {
|
|
58
|
+
const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
|
|
59
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
60
|
+
} catch {
|
|
61
|
+
process.exit(0);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!config.token) {
|
|
66
|
+
process.exit(0);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const apiUrl = config.apiUrl || 'http://localhost:3001';
|
|
71
|
+
const headers = {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'Authorization': `Bearer ${config.token}`,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const questionId = crypto.randomUUID();
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// 1. Send question event to Termify stream
|
|
80
|
+
await fetch(
|
|
81
|
+
`${apiUrl}/api/chat/conversations/${session.conversationId}/messages/${session.assistantMessageId}/events`,
|
|
82
|
+
{
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers,
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
events: [{ type: 'question', questionId, questions: toolInput.questions }],
|
|
87
|
+
}),
|
|
88
|
+
signal: AbortSignal.timeout(3000),
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// 2. Register pending question on server
|
|
93
|
+
await fetch(
|
|
94
|
+
`${apiUrl}/api/chat/conversations/${session.conversationId}/question`,
|
|
95
|
+
{
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
questionId,
|
|
100
|
+
questions: toolInput.questions,
|
|
101
|
+
messageId: session.assistantMessageId,
|
|
102
|
+
}),
|
|
103
|
+
signal: AbortSignal.timeout(3000),
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// 3. Signal needs-input: pause execution watchdog + set status + notify
|
|
108
|
+
const firstQuestion = toolInput.questions[0]?.question || 'Waiting for your answer';
|
|
109
|
+
await fetch(
|
|
110
|
+
`${apiUrl}/api/chat/conversations/${session.conversationId}/needs-input`,
|
|
111
|
+
{
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers,
|
|
114
|
+
body: JSON.stringify({ reason: firstQuestion }),
|
|
115
|
+
signal: AbortSignal.timeout(3000),
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// 4. Long-poll for the user's answer (timeout 120s)
|
|
120
|
+
const response = await fetch(
|
|
121
|
+
`${apiUrl}/api/chat/conversations/${session.conversationId}/question/${questionId}/response`,
|
|
122
|
+
{
|
|
123
|
+
headers,
|
|
124
|
+
signal: AbortSignal.timeout(120000),
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (response.ok) {
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
if (data.answer) {
|
|
131
|
+
// 4. Pre-fill the answer in the tool input
|
|
132
|
+
console.log(JSON.stringify({
|
|
133
|
+
hookSpecificOutput: {
|
|
134
|
+
permissionDecision: 'allow',
|
|
135
|
+
updatedInput: { ...toolInput, answers: data.answer },
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
process.exit(0);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Timeout or error: fall through to terminal
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Fallback: let the question modal appear in the terminal
|
|
147
|
+
process.exit(0);
|
|
148
|
+
});
|
|
@@ -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
|
+
});
|