@kelceyp/caw-server 1.0.230 → 1.0.231

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.
@@ -98,19 +98,15 @@ writeFileSync(hookScriptPath, hookScript, { mode: 0o755 });
98
98
 
99
99
  // --- Write settings file with Stop hook config ---
100
100
 
101
+ const hookEntry = {
102
+ matcher: '',
103
+ hooks: [{ type: 'command', command: hookScriptPath }]
104
+ };
105
+
101
106
  const settings = {
102
107
  hooks: {
103
- Stop: [
104
- {
105
- matcher: '',
106
- hooks: [
107
- {
108
- type: 'command',
109
- command: hookScriptPath
110
- }
111
- ]
112
- }
113
- ]
108
+ Stop: [hookEntry],
109
+ StopFailure: [hookEntry]
114
110
  }
115
111
  };
116
112
  writeFileSync(settingsPath, JSON.stringify(settings));
@@ -156,7 +152,7 @@ ensureNonInteractiveSetup(process.cwd());
156
152
 
157
153
  // --- Deferred teardown state ---
158
154
 
159
- const WATCHDOG_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
155
+ const WATCHDOG_TIMEOUT_MS = 90 * 60 * 1000; // 90 minutes — covers full ScheduleWakeup range (up to 3600s) with 30m headroom
160
156
 
161
157
  let watchdogTimer = null;
162
158
  let teardownInitiated = false;
@@ -194,6 +190,11 @@ process.on('SIGUSR1', () => {
194
190
  teardown();
195
191
  return;
196
192
  }
193
+ if (payload.error) {
194
+ log(`StopFailure received: error=${payload.error}`);
195
+ teardown();
196
+ return;
197
+ }
197
198
  const bgCount = Array.isArray(payload.background_tasks) ? payload.background_tasks.length : 0;
198
199
  const cronCount = Array.isArray(payload.session_crons) ? payload.session_crons.length : 0;
199
200
  log(`Sentinel: background_tasks=${bgCount}, session_crons=${cronCount}`);
@@ -267,23 +268,27 @@ ptyProcess.onData((data) => {
267
268
 
268
269
  let transcriptPath = null;
269
270
  let transcriptLinesRead = 0;
271
+ let transcriptPathResolutionAttempts = 0;
272
+ const processedEntryUUIDs = new Set();
273
+ let lastReadEntryUUID = null;
270
274
 
271
275
  // Resolve transcript path, handling macOS /tmp → /private/tmp symlinks.
272
276
  // Returns null if not yet found (retried on next poll).
273
277
  const resolveTranscriptPath = () => {
274
278
  if (transcriptPath) return transcriptPath;
275
279
 
280
+ transcriptPathResolutionAttempts++;
276
281
  const claudeProjectsDir = join(homedir(), '.claude', 'projects');
277
282
  const cwd = process.cwd();
278
283
  const paths = new Set([cwd]);
279
284
  try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
280
285
 
281
286
  for (const dir of paths) {
282
- const hash = dir.replace(/\//g, '-');
287
+ const hash = dir.replace(/[/.]/g, '-');
283
288
  const candidate = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
284
289
  if (existsSync(candidate)) {
285
290
  transcriptPath = candidate;
286
- log(`Transcript found: ${transcriptPath}`);
291
+ log(`Transcript found (attempt ${transcriptPathResolutionAttempts}): ${transcriptPath}`);
287
292
  // On resume: snapshot existing line count so only new content streams
288
293
  if (resumeId) {
289
294
  try {
@@ -295,6 +300,9 @@ const resolveTranscriptPath = () => {
295
300
  return transcriptPath;
296
301
  }
297
302
  }
303
+ if (transcriptPathResolutionAttempts === 1 || transcriptPathResolutionAttempts % 10 === 0) {
304
+ log(`Transcript not found (attempt ${transcriptPathResolutionAttempts}), cwd=${cwd}, session=${effectiveSessionId}`);
305
+ }
298
306
  return null;
299
307
  };
300
308
 
@@ -307,19 +315,62 @@ const tailTranscript = () => {
307
315
  try {
308
316
  const content = readFileSync(path, 'utf-8');
309
317
  const lines = content.trim().split('\n').filter(Boolean);
318
+ const fileLineCount = lines.length;
319
+
320
+ if (fileLineCount < transcriptLinesRead) {
321
+ log(`tailTranscript: FILE SHRINK — transcriptLinesRead=${transcriptLinesRead}, fileLines=${fileLineCount} (file replaced or truncated), resetting to 0`);
322
+ transcriptLinesRead = 0;
323
+ } else if (transcriptLinesRead > 0 && lastReadEntryUUID) {
324
+ try {
325
+ const checkEntry = JSON.parse(lines[transcriptLinesRead - 1]);
326
+ if (checkEntry.uuid && checkEntry.uuid !== lastReadEntryUUID) {
327
+ log(`tailTranscript: FILE REPLACED — UUID mismatch at position ${transcriptLinesRead - 1} (expected ${lastReadEntryUUID}, got ${checkEntry.uuid}), resetting to 0`);
328
+ transcriptLinesRead = 0;
329
+ }
330
+ } catch { /* parse error during write — skip validation this poll */ }
331
+ }
332
+
333
+ const pending = fileLineCount - transcriptLinesRead;
334
+ if (pending > 0) {
335
+ log(`tailTranscript: ${pending} new lines (fileLines=${fileLineCount}, read=${transcriptLinesRead})`);
336
+ }
337
+
338
+ const typeCounts = {};
339
+ let skipped = 0;
310
340
  for (let i = transcriptLinesRead; i < lines.length; i++) {
311
341
  try {
312
342
  const entry = JSON.parse(lines[i]);
313
- if (entry.type === 'progress' && entry.data?.type === 'hook_progress') continue;
343
+ if (entry.type === 'progress' && entry.data?.type === 'hook_progress') {
344
+ skipped++;
345
+ continue;
346
+ }
347
+ if (entry.uuid && processedEntryUUIDs.has(entry.uuid)) {
348
+ skipped++;
349
+ continue;
350
+ }
314
351
  process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
352
+ if (entry.uuid) processedEntryUUIDs.add(entry.uuid);
315
353
  newLines++;
354
+ const key = entry.type || 'unknown';
355
+ typeCounts[key] = (typeCounts[key] || 0) + 1;
316
356
  } catch {
317
- // Skip unparseable lines
357
+ skipped++;
318
358
  }
319
359
  }
320
360
  transcriptLinesRead = lines.length;
321
- } catch {
322
- // File may not exist yet or be mid-write — retry on next poll
361
+ if (lines.length > 0) {
362
+ try {
363
+ const lastEntry = JSON.parse(lines[lines.length - 1]);
364
+ if (lastEntry.uuid) lastReadEntryUUID = lastEntry.uuid;
365
+ } catch { /* ignore */ }
366
+ }
367
+
368
+ if (newLines > 0 || skipped > 0) {
369
+ const types = Object.entries(typeCounts).map(([k, v]) => `${k}=${v}`).join(', ');
370
+ log(`tailTranscript: forwarded=${newLines}, skipped=${skipped}${types ? `, types: ${types}` : ''}`);
371
+ }
372
+ } catch (err) {
373
+ log(`tailTranscript: read error: ${err.message}`);
323
374
  }
324
375
  return newLines;
325
376
  };
@@ -328,24 +379,34 @@ const tailTranscript = () => {
328
379
  // Called from onExit after polling stops. Reads sentinel written by Stop hook.
329
380
 
330
381
  let resultEmitted = false;
382
+ let failureEmitted = false;
331
383
 
332
384
  const emitResult = () => {
333
- if (resultEmitted) return;
385
+ if (resultEmitted || failureEmitted) return;
334
386
  if (!existsSync(sentinelPath)) return;
335
387
 
336
388
  try {
337
389
  const hookData = JSON.parse(readFileSync(sentinelPath, 'utf-8'));
338
- const result = {
339
- type: 'result',
340
- result: hookData.last_assistant_message || '',
341
- metadata: {
342
- sessionId: hookData.session_id || effectiveSessionId,
343
- transcriptPath: hookData.transcript_path || transcriptPath || null,
344
- permissionMode: hookData.permission_mode || null
345
- }
346
- };
347
- process.stdout.write(JSON.stringify(result) + '\n');
348
- resultEmitted = true;
390
+
391
+ if (hookData.error) {
392
+ process.stdout.write(JSON.stringify({
393
+ type: 'error',
394
+ error: hookData.error,
395
+ message: hookData.last_assistant_message || ''
396
+ }) + '\n');
397
+ failureEmitted = true;
398
+ } else {
399
+ process.stdout.write(JSON.stringify({
400
+ type: 'result',
401
+ result: hookData.last_assistant_message || '',
402
+ metadata: {
403
+ sessionId: hookData.session_id || effectiveSessionId,
404
+ transcriptPath: hookData.transcript_path || transcriptPath || null,
405
+ permissionMode: hookData.permission_mode || null
406
+ }
407
+ }) + '\n');
408
+ resultEmitted = true;
409
+ }
349
410
  } catch (err) {
350
411
  log(`Error reading sentinel: ${err.message}`);
351
412
  }
@@ -391,16 +452,27 @@ ptyProcess.onExit(({ exitCode }) => {
391
452
  // Do a final transcript tail to capture any last entries
392
453
  tailTranscript();
393
454
 
394
- // Read sentinel and emit result (tempDir still present — delete after)
455
+ // Read sentinel and emit result or error (tempDir still present — delete after)
395
456
  emitResult();
396
457
 
458
+ // Prompt-too-long detection: no sentinel written, ttyBuffer has the error text
459
+ if (!resultEmitted && !failureEmitted && ttyBuffer.includes('Prompt is too long')) {
460
+ process.stdout.write(JSON.stringify({
461
+ type: 'error',
462
+ error: 'prompt_too_long',
463
+ message: 'Prompt is too long'
464
+ }) + '\n');
465
+ failureEmitted = true;
466
+ }
467
+
397
468
  // Clean up temp directory
398
469
  try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
399
470
 
400
471
  log(`Claude exited (code: ${exitCode})`);
401
472
 
402
- // Exit 0 if result was successfully emitted (includes SIGINT 130 remap),
403
- // or if claude itself exited cleanly. Otherwise propagate exit code.
473
+ if (failureEmitted) {
474
+ process.exit(1);
475
+ }
404
476
  if (resultEmitted || exitCode === 0) {
405
477
  process.exit(0);
406
478
  }