@tekmidian/pai 0.7.0 → 0.7.2

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.
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { readFileSync } from 'fs';
4
4
  import { join, basename, dirname } from 'path';
5
+ import { connect } from 'net';
6
+ import { randomUUID } from 'crypto';
5
7
  import {
6
8
  sendNtfyNotification,
7
9
  getCurrentNotePath,
@@ -14,15 +16,148 @@ import {
14
16
  WorkItem
15
17
  } from '../lib/project-utils';
16
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const DAEMON_SOCKET = process.env.PAI_SOCKET ?? '/tmp/pai.sock';
24
+ const DAEMON_TIMEOUT_MS = 3_000;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helper: safely convert Claude content (string | Block[]) to plain text
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function contentToText(content: any): string {
31
+ if (typeof content === 'string') return content;
32
+ if (Array.isArray(content)) {
33
+ return content
34
+ .map((c) => {
35
+ if (typeof c === 'string') return c;
36
+ if (c?.text) return c.text;
37
+ if (c?.content) return String(c.content);
38
+ return '';
39
+ })
40
+ .join(' ')
41
+ .trim();
42
+ }
43
+ return '';
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helper: extract COMPLETED: line from the last assistant response
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function extractCompletedMessage(lines: string[]): string {
51
+ for (let i = lines.length - 1; i >= 0; i--) {
52
+ try {
53
+ const entry = JSON.parse(lines[i]);
54
+ if (entry.type === 'assistant' && entry.message?.content) {
55
+ const content = contentToText(entry.message.content);
56
+ const m = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
57
+ if (m) {
58
+ return m[1].trim().replace(/\*+/g, '').replace(/\[.*?\]/g, '').trim();
59
+ }
60
+ }
61
+ } catch {
62
+ // Skip invalid JSON
63
+ }
64
+ }
65
+ return '';
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Daemon IPC relay — fast path
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Try to enqueue work with the daemon over its Unix socket.
74
+ * Returns true on success, false if the daemon is unreachable.
75
+ * Times out after DAEMON_TIMEOUT_MS so the hook doesn't block.
76
+ */
77
+ function enqueueWithDaemon(payload: {
78
+ transcriptPath: string;
79
+ cwd: string;
80
+ message: string;
81
+ }): Promise<boolean> {
82
+ return new Promise((resolve) => {
83
+ let done = false;
84
+ let buffer = '';
85
+ let timer: ReturnType<typeof setTimeout> | null = null;
86
+
87
+ function finish(ok: boolean): void {
88
+ if (done) return;
89
+ done = true;
90
+ if (timer !== null) { clearTimeout(timer); timer = null; }
91
+ try { client.destroy(); } catch { /* ignore */ }
92
+ resolve(ok);
93
+ }
94
+
95
+ const client = connect(DAEMON_SOCKET, () => {
96
+ const msg = JSON.stringify({
97
+ id: randomUUID(),
98
+ method: 'work_queue_enqueue',
99
+ params: {
100
+ type: 'session-end',
101
+ priority: 2,
102
+ payload: {
103
+ transcriptPath: payload.transcriptPath,
104
+ cwd: payload.cwd,
105
+ message: payload.message,
106
+ },
107
+ },
108
+ }) + '\n';
109
+ client.write(msg);
110
+ });
111
+
112
+ client.on('data', (chunk: Buffer) => {
113
+ buffer += chunk.toString();
114
+ const nl = buffer.indexOf('\n');
115
+ if (nl === -1) return;
116
+ const line = buffer.slice(0, nl);
117
+ try {
118
+ const response = JSON.parse(line) as { ok: boolean; error?: string };
119
+ if (response.ok) {
120
+ console.error(`STOP-HOOK: Work enqueued with daemon (id=${(response as any).result?.id}).`);
121
+ finish(true);
122
+ } else {
123
+ console.error(`STOP-HOOK: Daemon rejected enqueue: ${response.error}`);
124
+ finish(false);
125
+ }
126
+ } catch {
127
+ finish(false);
128
+ }
129
+ });
130
+
131
+ client.on('error', (e: NodeJS.ErrnoException) => {
132
+ if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
133
+ console.error('STOP-HOOK: Daemon not running — falling back to direct execution.');
134
+ } else {
135
+ console.error(`STOP-HOOK: Daemon socket error: ${e.message}`);
136
+ }
137
+ finish(false);
138
+ });
139
+
140
+ client.on('end', () => { if (!done) finish(false); });
141
+
142
+ timer = setTimeout(() => {
143
+ console.error(`STOP-HOOK: Daemon timeout after ${DAEMON_TIMEOUT_MS}ms — falling back.`);
144
+ finish(false);
145
+ }, DAEMON_TIMEOUT_MS);
146
+ });
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Direct execution — fallback path (original stop-hook logic)
151
+ // ---------------------------------------------------------------------------
152
+
17
153
  /**
18
- * Extract work items from transcript for session note
19
- * Looks for SUMMARY, ACTIONS, RESULTS sections in assistant responses
154
+ * Extract work items from transcript for session note.
155
+ * Looks for SUMMARY, ACTIONS, RESULTS sections in assistant responses.
20
156
  */
21
157
  function extractWorkFromTranscript(lines: string[]): WorkItem[] {
22
158
  const workItems: WorkItem[] = [];
23
159
  const seenSummaries = new Set<string>();
24
160
 
25
- // Process all assistant messages to find work summaries
26
161
  for (const line of lines) {
27
162
  try {
28
163
  const entry = JSON.parse(line);
@@ -36,16 +171,14 @@ function extractWorkFromTranscript(lines: string[]): WorkItem[] {
36
171
  if (summary && !seenSummaries.has(summary) && summary.length > 5) {
37
172
  seenSummaries.add(summary);
38
173
 
39
- // Try to extract details from ACTIONS or RESULTS
174
+ // Try to extract details from ACTIONS section
40
175
  const details: string[] = [];
41
-
42
176
  const actionsMatch = content.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
43
177
  if (actionsMatch) {
44
- // Extract bullet points or numbered items
45
178
  const actionLines = actionsMatch[1].split('\n')
46
179
  .map(l => l.replace(/^[-*•]\s*/, '').replace(/^\d+\.\s*/, '').trim())
47
180
  .filter(l => l.length > 3 && l.length < 100);
48
- details.push(...actionLines.slice(0, 3)); // Max 3 action items
181
+ details.push(...actionLines.slice(0, 3));
49
182
  }
50
183
 
51
184
  workItems.push({
@@ -59,13 +192,10 @@ function extractWorkFromTranscript(lines: string[]): WorkItem[] {
59
192
  // Also look for COMPLETED: lines as backup
60
193
  const completedMatch = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
61
194
  if (completedMatch && workItems.length === 0) {
62
- const completed = completedMatch[1].trim().replace(/\*+/g, '');
195
+ const completed = completedMatch[1].trim().replace(/\*+/g, '').replace(/\[.*?\]/g, '');
63
196
  if (completed && !seenSummaries.has(completed) && completed.length > 5) {
64
197
  seenSummaries.add(completed);
65
- workItems.push({
66
- title: completed,
67
- completed: true
68
- });
198
+ workItems.push({ title: completed, completed: true });
69
199
  }
70
200
  }
71
201
  }
@@ -78,10 +208,9 @@ function extractWorkFromTranscript(lines: string[]): WorkItem[] {
78
208
  }
79
209
 
80
210
  /**
81
- * Generate 4-word tab title summarizing what was done
211
+ * Generate 4-word tab title summarizing what was done.
82
212
  */
83
213
  function generateTabTitle(prompt: string, completedLine?: string): string {
84
- // If we have a completed line, try to use it for a better summary
85
214
  if (completedLine) {
86
215
  const cleanCompleted = completedLine
87
216
  .replace(/\*+/g, '')
@@ -89,23 +218,18 @@ function generateTabTitle(prompt: string, completedLine?: string): string {
89
218
  .replace(/COMPLETED:\s*/gi, '')
90
219
  .trim();
91
220
 
92
- // Extract meaningful words from the completed line
93
221
  const completedWords = cleanCompleted.split(/\s+/)
94
222
  .filter(word => word.length > 2 &&
95
223
  !['the', 'and', 'but', 'for', 'are', 'with', 'his', 'her', 'this', 'that', 'you', 'can', 'will', 'have', 'been', 'your', 'from', 'they', 'were', 'said', 'what', 'them', 'just', 'told', 'how', 'does', 'into', 'about', 'completed'].includes(word.toLowerCase()))
96
224
  .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
97
225
 
98
226
  if (completedWords.length >= 2) {
99
- // Build a 4-word summary from completed line
100
227
  const summary = completedWords.slice(0, 4);
101
- while (summary.length < 4) {
102
- summary.push('Done');
103
- }
228
+ while (summary.length < 4) summary.push('Done');
104
229
  return summary.slice(0, 4).join(' ');
105
230
  }
106
231
  }
107
232
 
108
- // Fall back to parsing the prompt
109
233
  const cleanPrompt = prompt.replace(/[^\w\s]/g, ' ').trim();
110
234
  const words = cleanPrompt.split(/\s+/).filter(word =>
111
235
  word.length > 2 &&
@@ -113,103 +237,132 @@ function generateTabTitle(prompt: string, completedLine?: string): string {
113
237
  );
114
238
 
115
239
  const lowerPrompt = prompt.toLowerCase();
116
-
117
- // Find action verb if present
118
240
  const actionVerbs = ['test', 'rename', 'fix', 'debug', 'research', 'write', 'create', 'make', 'build', 'implement', 'analyze', 'review', 'update', 'modify', 'generate', 'develop', 'design', 'deploy', 'configure', 'setup', 'install', 'remove', 'delete', 'add', 'check', 'verify', 'validate', 'optimize', 'refactor', 'enhance', 'improve', 'send', 'email', 'help', 'updated', 'fixed', 'created', 'built', 'added'];
119
-
120
241
  let titleWords: string[] = [];
121
242
 
122
- // Check for action verb
123
243
  for (const verb of actionVerbs) {
124
244
  if (lowerPrompt.includes(verb)) {
125
- // Convert to past tense for summary
126
245
  let pastTense = verb;
127
246
  if (verb === 'write') pastTense = 'Wrote';
128
247
  else if (verb === 'make') pastTense = 'Made';
129
248
  else if (verb === 'send') pastTense = 'Sent';
130
249
  else if (verb.endsWith('e')) pastTense = verb.charAt(0).toUpperCase() + verb.slice(1, -1) + 'ed';
131
250
  else pastTense = verb.charAt(0).toUpperCase() + verb.slice(1) + 'ed';
132
-
133
251
  titleWords.push(pastTense);
134
252
  break;
135
253
  }
136
254
  }
137
255
 
138
- // Add most meaningful remaining words
139
256
  const remainingWords = words
140
257
  .filter(word => !actionVerbs.includes(word.toLowerCase()))
141
258
  .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
142
259
 
143
- // Fill up to 4 words total
144
260
  for (const word of remainingWords) {
145
- if (titleWords.length < 4) {
146
- titleWords.push(word);
147
- } else {
148
- break;
149
- }
261
+ if (titleWords.length < 4) titleWords.push(word);
262
+ else break;
150
263
  }
151
264
 
152
- // If we don't have enough words, add generic ones
153
- if (titleWords.length === 0) {
154
- titleWords.push('Completed');
155
- }
156
- if (titleWords.length === 1) {
157
- titleWords.push('Task');
158
- }
159
- if (titleWords.length === 2) {
160
- titleWords.push('Successfully');
161
- }
162
- if (titleWords.length === 3) {
163
- titleWords.push('Done');
164
- }
265
+ if (titleWords.length === 0) titleWords.push('Completed');
266
+ if (titleWords.length === 1) titleWords.push('Task');
267
+ if (titleWords.length === 2) titleWords.push('Successfully');
268
+ if (titleWords.length === 3) titleWords.push('Done');
165
269
 
166
270
  return titleWords.slice(0, 4).join(' ');
167
271
  }
168
272
 
169
273
  /**
170
- * Set terminal tab title (works with Kitty, Ghostty, iTerm2, etc.)
274
+ * Do the heavy work directly in the hook process.
275
+ * Used when the daemon is unreachable.
171
276
  */
172
- function setTerminalTabTitle(title: string): void {
173
- const term = process.env.TERM || '';
174
-
175
- if (term.includes('ghostty')) {
176
- process.stderr.write(`\x1b]2;${title}\x07`);
177
- process.stderr.write(`\x1b]0;${title}\x07`);
178
- process.stderr.write(`\x1b]7;${title}\x07`);
179
- process.stderr.write(`\x1b]2;${title}\x1b\\`);
180
- } else if (term.includes('kitty')) {
181
- process.stderr.write(`\x1b]0;${title}\x07`);
182
- process.stderr.write(`\x1b]2;${title}\x07`);
183
- process.stderr.write(`\x1b]30;${title}\x07`);
184
- } else {
185
- process.stderr.write(`\x1b]0;${title}\x07`);
186
- process.stderr.write(`\x1b]2;${title}\x07`);
277
+ async function executeDirectly(
278
+ lines: string[],
279
+ transcriptPath: string,
280
+ cwd: string,
281
+ message: string,
282
+ lastUserQuery: string
283
+ ): Promise<void> {
284
+ // Set terminal tab title
285
+ let tabTitle = message || '';
286
+ if (!tabTitle && lastUserQuery) {
287
+ tabTitle = generateTabTitle(lastUserQuery, '');
187
288
  }
188
289
 
189
- if (process.stderr.isTTY) {
190
- process.stderr.write('');
290
+ if (tabTitle) {
291
+ try {
292
+ const escapedTitle = tabTitle.replace(/'/g, "'\\''");
293
+ const { execSync } = await import('child_process');
294
+ execSync(`printf '\\033]0;${escapedTitle}\\007' >&2`);
295
+ execSync(`printf '\\033]2;${escapedTitle}\\007' >&2`);
296
+ execSync(`printf '\\033]30;${escapedTitle}\\007' >&2`);
297
+ console.error(`Tab title set to: "${tabTitle}"`);
298
+ } catch (e) {
299
+ console.error(`Failed to set tab title: ${e}`);
300
+ }
191
301
  }
192
- }
193
302
 
194
- // Helper to safely turn Claude content (string or array of blocks) into plain text
195
- function contentToText(content: any): string {
196
- if (typeof content === 'string') return content;
197
- if (Array.isArray(content)) {
198
- return content
199
- .map((c) => {
200
- if (typeof c === 'string') return c;
201
- if (c?.text) return c.text;
202
- if (c?.content) return String(c.content);
203
- return '';
204
- })
205
- .join(' ')
206
- .trim();
303
+ // Final tab title override
304
+ if (message) {
305
+ const finalTabTitle = message.slice(0, 50);
306
+ process.stderr.write(`\x1b]2;${finalTabTitle}\x07`);
307
+ }
308
+
309
+ // Finalize session note
310
+ try {
311
+ const notesInfo = findNotesDir(cwd);
312
+ const currentNotePath = getCurrentNotePath(notesInfo.path);
313
+
314
+ if (currentNotePath) {
315
+ const workItems = extractWorkFromTranscript(lines);
316
+ if (workItems.length > 0) {
317
+ addWorkToSessionNote(currentNotePath, workItems);
318
+ console.error(`Added ${workItems.length} work item(s) to session note`);
319
+ } else if (message) {
320
+ addWorkToSessionNote(currentNotePath, [{ title: message, completed: true }]);
321
+ console.error(`Added completion message to session note`);
322
+ }
323
+
324
+ const summary = message || 'Session completed.';
325
+ finalizeSessionNote(currentNotePath, summary);
326
+ console.error(`Session note finalized: ${basename(currentNotePath)}`);
327
+
328
+ try {
329
+ const stateLines: string[] = [];
330
+ stateLines.push(`Working directory: ${cwd}`);
331
+ if (workItems.length > 0) {
332
+ stateLines.push('', 'Work completed:');
333
+ for (const item of workItems.slice(0, 5)) {
334
+ stateLines.push(`- ${item.title}`);
335
+ }
336
+ }
337
+ if (message) {
338
+ stateLines.push('', `Last completed: ${message}`);
339
+ }
340
+ updateTodoContinue(cwd, basename(currentNotePath), stateLines.join('\n'), 'session-end');
341
+ } catch (todoError) {
342
+ console.error(`Could not update TODO.md: ${todoError}`);
343
+ }
344
+ }
345
+ } catch (noteError) {
346
+ console.error(`Could not finalize session note: ${noteError}`);
347
+ }
348
+
349
+ // Move session .jsonl files to sessions/
350
+ try {
351
+ const transcriptDir = dirname(transcriptPath);
352
+ const movedCount = moveSessionFilesToSessionsDir(transcriptDir);
353
+ if (movedCount > 0) {
354
+ console.error(`Moved ${movedCount} session file(s) to sessions/`);
355
+ }
356
+ } catch (moveError) {
357
+ console.error(`Could not move session files: ${moveError}`);
207
358
  }
208
- return '';
209
359
  }
210
360
 
361
+ // ---------------------------------------------------------------------------
362
+ // Main
363
+ // ---------------------------------------------------------------------------
364
+
211
365
  async function main() {
212
- // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)
213
366
  if (isProbeSession()) {
214
367
  process.exit(0);
215
368
  }
@@ -217,10 +370,9 @@ async function main() {
217
370
  const timestamp = new Date().toISOString();
218
371
  console.error(`\nSTOP-HOOK TRIGGERED AT ${timestamp}`);
219
372
 
220
- // Get input
373
+ // Read stdin
221
374
  let input = '';
222
375
  const decoder = new TextDecoder();
223
-
224
376
  try {
225
377
  for await (const chunk of process.stdin) {
226
378
  input += decoder.decode(chunk, { stream: true });
@@ -253,8 +405,8 @@ async function main() {
253
405
  process.exit(0);
254
406
  }
255
407
 
256
- // Read the transcript
257
- let transcript;
408
+ // Read transcript
409
+ let transcript: string;
258
410
  try {
259
411
  transcript = readFileSync(transcriptPath, 'utf-8');
260
412
  console.error(`Transcript loaded: ${transcript.split('\n').length} lines`);
@@ -263,10 +415,9 @@ async function main() {
263
415
  process.exit(0);
264
416
  }
265
417
 
266
- // Parse the JSON lines to find what happened in this session
267
418
  const lines = transcript.trim().split('\n');
268
419
 
269
- // Get the last user query for context
420
+ // Extract last user query for tab title / fallback
270
421
  let lastUserQuery = '';
271
422
  for (let i = lines.length - 1; i >= 0; i--) {
272
423
  try {
@@ -285,61 +436,26 @@ async function main() {
285
436
  }
286
437
  if (lastUserQuery) break;
287
438
  }
288
- } catch (e) {
439
+ } catch {
289
440
  // Skip invalid JSON
290
441
  }
291
442
  }
292
443
 
293
- // Extract the completion message from the last assistant response
294
- let message = '';
444
+ // Extract completion message
445
+ const message = extractCompletedMessage(lines);
295
446
 
296
- const lastResponse = lines[lines.length - 1];
297
- try {
298
- const entry = JSON.parse(lastResponse);
299
- if (entry.type === 'assistant' && entry.message?.content) {
300
- const content = contentToText(entry.message.content);
301
-
302
- // Look for COMPLETED line
303
- const completedMatch = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
304
- if (completedMatch) {
305
- message = completedMatch[1].trim()
306
- .replace(/\*+/g, '')
307
- .replace(/\[.*?\]/g, '')
308
- .trim();
309
- console.error(`COMPLETION: ${message}`);
310
- }
311
- }
312
- } catch (e) {
313
- console.error('Error parsing assistant response:', e);
314
- }
447
+ console.error(`User query: ${lastUserQuery || 'No query found'}`);
448
+ console.error(`Message: ${message || 'No completion message'}`);
315
449
 
316
- // Set tab title
450
+ // Always set terminal tab title immediately (fast, no daemon needed)
317
451
  let tabTitle = message || '';
318
-
319
452
  if (!tabTitle && lastUserQuery) {
320
- try {
321
- const entry = JSON.parse(lastResponse);
322
- if (entry.type === 'assistant' && entry.message?.content) {
323
- const content = contentToText(entry.message.content);
324
- const completedMatch = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/im);
325
- if (completedMatch) {
326
- tabTitle = completedMatch[1].trim()
327
- .replace(/\*+/g, '')
328
- .replace(/\[.*?\]/g, '')
329
- .trim();
330
- }
331
- }
332
- } catch (e) {}
333
-
334
- if (!tabTitle) {
335
- tabTitle = generateTabTitle(lastUserQuery, '');
336
- }
453
+ tabTitle = generateTabTitle(lastUserQuery, '');
337
454
  }
338
-
339
455
  if (tabTitle) {
340
456
  try {
341
- const escapedTitle = tabTitle.replace(/'/g, "'\\''");
342
457
  const { execSync } = await import('child_process');
458
+ const escapedTitle = tabTitle.replace(/'/g, "'\\''");
343
459
  execSync(`printf '\\033]0;${escapedTitle}\\007' >&2`);
344
460
  execSync(`printf '\\033]2;${escapedTitle}\\007' >&2`);
345
461
  execSync(`printf '\\033]30;${escapedTitle}\\007' >&2`);
@@ -348,85 +464,29 @@ async function main() {
348
464
  console.error(`Failed to set tab title: ${e}`);
349
465
  }
350
466
  }
351
-
352
- console.error(`User query: ${lastUserQuery || 'No query found'}`);
353
- console.error(`Message: ${message || 'No completion message'}`);
354
-
355
- // Final tab title override as the very last action
356
467
  if (message) {
357
- const finalTabTitle = message.slice(0, 50);
358
- process.stderr.write(`\x1b]2;${finalTabTitle}\x07`);
468
+ process.stderr.write(`\x1b]2;${message.slice(0, 50)}\x07`);
359
469
  }
360
470
 
361
- // Send ntfy.sh notification
471
+ // Send ntfy.sh notification (fast, fire-and-forget)
362
472
  if (message) {
363
473
  await sendNtfyNotification(message);
364
474
  } else {
365
475
  await sendNtfyNotification('Session ended');
366
476
  }
367
477
 
368
- // Finalize session note if one exists
369
- try {
370
- const notesInfo = findNotesDir(cwd);
371
- console.error(`Notes directory: ${notesInfo.path} (${notesInfo.isLocal ? 'local' : 'central'})`);
372
- const currentNotePath = getCurrentNotePath(notesInfo.path);
373
-
374
- if (currentNotePath) {
375
- // FIRST: Extract and add work items from transcript
376
- const workItems = extractWorkFromTranscript(lines);
377
- if (workItems.length > 0) {
378
- addWorkToSessionNote(currentNotePath, workItems);
379
- console.error(`Added ${workItems.length} work item(s) to session note`);
380
- } else {
381
- // If no structured work items found, at least add the completion message
382
- if (message) {
383
- addWorkToSessionNote(currentNotePath, [{
384
- title: message,
385
- completed: true
386
- }]);
387
- console.error(`Added completion message to session note`);
388
- }
389
- }
390
-
391
- // THEN: Finalize the note
392
- const summary = message || 'Session completed.';
393
- finalizeSessionNote(currentNotePath, summary);
394
- console.error(`Session note finalized: ${basename(currentNotePath)}`);
395
-
396
- // Update TODO.md ## Continue section so next session has context
397
- try {
398
- const stateLines: string[] = [];
399
- stateLines.push(`Working directory: ${cwd}`);
400
- if (workItems.length > 0) {
401
- stateLines.push('');
402
- stateLines.push('Work completed:');
403
- for (const item of workItems.slice(0, 5)) {
404
- stateLines.push(`- ${item.title}`);
405
- }
406
- }
407
- if (message) {
408
- stateLines.push('');
409
- stateLines.push(`Last completed: ${message}`);
410
- }
411
- const state = stateLines.join('\n');
412
- updateTodoContinue(cwd, basename(currentNotePath), state, 'session-end');
413
- } catch (todoError) {
414
- console.error(`Could not update TODO.md: ${todoError}`);
415
- }
416
- }
417
- } catch (noteError) {
418
- console.error(`Could not finalize session note: ${noteError}`);
419
- }
420
-
421
- // Move all session .jsonl files to sessions/ subdirectory
422
- try {
423
- const transcriptDir = dirname(transcriptPath);
424
- const movedCount = moveSessionFilesToSessionsDir(transcriptDir);
425
- if (movedCount > 0) {
426
- console.error(`Moved ${movedCount} session file(s) to sessions/`);
427
- }
428
- } catch (moveError) {
429
- console.error(`Could not move session files: ${moveError}`);
478
+ // -----------------------------------------------------------------------
479
+ // Relay heavy work to daemon — fall back to direct execution if unavailable
480
+ // -----------------------------------------------------------------------
481
+ const relayed = await enqueueWithDaemon({
482
+ transcriptPath,
483
+ cwd,
484
+ message,
485
+ });
486
+
487
+ if (!relayed) {
488
+ console.error('STOP-HOOK: Using direct execution fallback.');
489
+ await executeDirectly(lines, transcriptPath, cwd, message, lastUserQuery);
430
490
  }
431
491
 
432
492
  console.error(`STOP-HOOK COMPLETED SUCCESSFULLY at ${new Date().toISOString()}\n`);