@kelceyp/caw-server 1.0.273 → 1.0.274

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.
@@ -24,6 +24,7 @@ import { join } from 'path';
24
24
  import { tmpdir, homedir } from 'os';
25
25
  import { execSync } from 'child_process';
26
26
  import { readSentinelPayload, hasPendingWork } from './pty-deferred-teardown.mjs';
27
+ import { emitResult as emitResultLine } from './pty-emit-result.mjs';
27
28
 
28
29
  // --- Arg parsing ---
29
30
 
@@ -152,11 +153,35 @@ ensureNonInteractiveSetup(process.cwd());
152
153
 
153
154
  // --- Deferred teardown state ---
154
155
 
155
- const WATCHDOG_TIMEOUT_MS = 90 * 60 * 1000; // 90 minutes — covers full ScheduleWakeup range (up to 3600s) with 30m headroom
156
+ // 90 minutes — covers full ScheduleWakeup range (up to 3600s) with 30m headroom.
157
+ // The env override (`CAW_PTY_WATCHDOG_TIMEOUT_MS`) exists only so integration
158
+ // tests can exercise the watchdog force-kill path without waiting the full 90m.
159
+ const WATCHDOG_TIMEOUT_MS = Number.parseInt(process.env.CAW_PTY_WATCHDOG_TIMEOUT_MS, 10) || (90 * 60 * 1000);
160
+
161
+ // D11 test-only env overrides (default off). Precedent: CAW_PTY_WATCHDOG_TIMEOUT_MS above.
162
+ // CAW_PTY_DISABLE_TRANSCRIPT_TAIL: skip all tailTranscript() work (both the
163
+ // 1s polling loop's calls and the final onExit call), so wrapper stdout
164
+ // carries no `transcript` lines. Used to prove downstream (Task 5) fallback
165
+ // consumes `result` instead of the missing transcript stream.
166
+ // CAW_PTY_SUPPRESS_STOP_FALLBACK: blank the emitted `result` field on the
167
+ // success branch of pty-emit-result (only when hookData is non-null and
168
+ // the success branch runs). Used to prove the AC-5 gate downstream.
169
+ const DISABLE_TRANSCRIPT_TAIL = process.env.CAW_PTY_DISABLE_TRANSCRIPT_TAIL === '1';
170
+ const SUPPRESS_STOP_FALLBACK = process.env.CAW_PTY_SUPPRESS_STOP_FALLBACK === '1';
156
171
 
157
172
  let watchdogTimer = null;
158
173
  let teardownInitiated = false;
159
174
 
175
+ // Duration capture: wall clock from `pty.spawn` to top of `teardown()`.
176
+ // `endedAt` is set by whichever teardown path fires first (normal SIGUSR1
177
+ // completion or watchdog force-kill — both routed through `teardown()`,
178
+ // per design Decision D1). The prompt-too-long branch in `onExit` does NOT
179
+ // go through `teardown()`, so `endedAt` remains null and no `durationMs`
180
+ // is emitted (per Decision D3 — service appends the pre-existing `'0'`
181
+ // duration sentinel).
182
+ let startedAt = null;
183
+ let endedAt = null;
184
+
160
185
  // Forward declarations (assigned after ptyProcess is created)
161
186
  let ptyProcess = null;
162
187
 
@@ -172,7 +197,9 @@ const startOrResetWatchdog = () => {
172
197
  const teardown = () => {
173
198
  if (teardownInitiated) return;
174
199
  teardownInitiated = true;
200
+ endedAt = Date.now();
175
201
  log('Teardown: stop received, no pending work, killing claude');
202
+ log(`Duration: ${startedAt !== null ? (endedAt - startedAt) : 'unknown'} ms (startedAt=${startedAt}, endedAt=${endedAt})`);
176
203
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
177
204
  stopPolling();
178
205
  if (ptyProcess) ptyProcess.kill('SIGINT');
@@ -233,6 +260,7 @@ log(`Prompt length: ${prompt.length} chars`);
233
260
 
234
261
  // --- Spawn claude via PTY ---
235
262
 
263
+ startedAt = Date.now();
236
264
  ptyProcess = pty.spawn(claudePath, claudeArgs, {
237
265
  name: 'xterm-256color',
238
266
  cols: 120,
@@ -272,10 +300,38 @@ let transcriptPathResolutionAttempts = 0;
272
300
  const processedEntryUUIDs = new Set();
273
301
  let lastReadEntryUUID = null;
274
302
 
303
+ // Snapshot the pre-existing line count on --resume so only new content streams.
304
+ // Shared by the cwd-hash-hit branch and the D14 hook-path adoption branch —
305
+ // keeping the snapshot in one place avoids duplicating the readFileSync/count
306
+ // logic and satisfies the checklist rule ("Do not duplicate the snapshot
307
+ // logic into the D14 branch").
308
+ const snapshotResumeBaseline = (path) => {
309
+ if (!resumeId) return;
310
+ try {
311
+ const content = readFileSync(path, 'utf-8');
312
+ transcriptLinesRead = content.trim().split('\n').filter(Boolean).length;
313
+ log(`Resume: skipping ${transcriptLinesRead} existing transcript lines`);
314
+ } catch { /* ignore */ }
315
+ };
316
+
275
317
  // Resolve transcript path, handling macOS /tmp → /private/tmp symlinks.
276
318
  // Returns null if not yet found (retried on next poll).
277
- const resolveTranscriptPath = () => {
278
- if (transcriptPath) return transcriptPath;
319
+ //
320
+ // D14 (guarded hook-path adoption): callers may pass an `overridePath` — the
321
+ // Stop hook's `hookData.transcript_path`. When supplied, the function bypasses
322
+ // the cwd-hash candidate search and adopts the override path directly (after
323
+ // an existsSync guard). The caller must already have gated on `!transcriptPath`
324
+ // so this is only used when the polling loop never memoised a path — which
325
+ // preserves today's behaviour on every turn the hash convention worked for.
326
+ const resolveTranscriptPath = (overridePath = null) => {
327
+ if (!overridePath && transcriptPath) return transcriptPath;
328
+
329
+ if (overridePath) {
330
+ if (!existsSync(overridePath)) return null;
331
+ transcriptPath = overridePath;
332
+ snapshotResumeBaseline(overridePath);
333
+ return transcriptPath;
334
+ }
279
335
 
280
336
  transcriptPathResolutionAttempts++;
281
337
  const claudeProjectsDir = join(homedir(), '.claude', 'projects');
@@ -289,14 +345,7 @@ const resolveTranscriptPath = () => {
289
345
  if (existsSync(candidate)) {
290
346
  transcriptPath = candidate;
291
347
  log(`Transcript found (attempt ${transcriptPathResolutionAttempts}): ${transcriptPath}`);
292
- // On resume: snapshot existing line count so only new content streams
293
- if (resumeId) {
294
- try {
295
- const content = readFileSync(candidate, 'utf-8');
296
- transcriptLinesRead = content.trim().split('\n').filter(Boolean).length;
297
- log(`Resume: skipping ${transcriptLinesRead} existing transcript lines`);
298
- } catch { /* ignore */ }
299
- }
348
+ snapshotResumeBaseline(candidate);
300
349
  return transcriptPath;
301
350
  }
302
351
  }
@@ -308,6 +357,11 @@ const resolveTranscriptPath = () => {
308
357
 
309
358
  // Read new transcript entries and forward to stdout. Returns count of new lines emitted.
310
359
  const tailTranscript = () => {
360
+ // D11 test-only override: skip all tail work so wrapper stdout carries no
361
+ // `transcript` lines. Both the 1s polling loop's calls and the final
362
+ // onExit call short-circuit here — the fallback-consumption case downstream
363
+ // (Task 5) needs a wire with no transcript stream at all.
364
+ if (DISABLE_TRANSCRIPT_TAIL) return 0;
311
365
  const path = resolveTranscriptPath();
312
366
  if (!path) return 0;
313
367
 
@@ -376,40 +430,37 @@ const tailTranscript = () => {
376
430
  };
377
431
 
378
432
  // --- Sentinel / result emission ---
379
- // Called from onExit after polling stops. Reads sentinel written by Stop hook.
433
+ // Called from onExit after polling stops. The Stop-hook sentinel is read once
434
+ // at the onExit seam (hoisted out of this delegator) so the parsed payload
435
+ // drives both the D14 hook-path adoption guard AND this emission. On the
436
+ // null-hookData path (ENOENT / JSON-parse failure): resultEmitted /
437
+ // failureEmitted stay false and the prompt-too-long branch below fires as
438
+ // today.
380
439
 
381
440
  let resultEmitted = false;
382
441
  let failureEmitted = false;
383
442
 
384
- const emitResult = () => {
443
+ const emitResult = (hookData) => {
385
444
  if (resultEmitted || failureEmitted) return;
386
- if (!existsSync(sentinelPath)) return;
387
-
388
- try {
389
- const hookData = JSON.parse(readFileSync(sentinelPath, 'utf-8'));
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
- }
410
- } catch (err) {
411
- log(`Error reading sentinel: ${err.message}`);
412
- }
445
+ if (!hookData) return;
446
+
447
+ // durationMs: wall clock from pty.spawn to teardown(). Both the normal
448
+ // SIGUSR1 path and the watchdog force-kill path route through teardown()
449
+ // (Decision D1), so both report an honest real elapsed time. Only omitted
450
+ // when startedAt/endedAt are unset — which never happens on the success
451
+ // branch because teardown() runs before onExit calls emitResult().
452
+ const { emitted } = emitResultLine({
453
+ hookData,
454
+ transcriptPath,
455
+ effectiveSessionId,
456
+ startedAt,
457
+ endedAt,
458
+ alreadyEmitted: resultEmitted || failureEmitted,
459
+ suppressFallback: SUPPRESS_STOP_FALLBACK,
460
+ log
461
+ });
462
+ if (emitted === 'result') resultEmitted = true;
463
+ else if (emitted === 'error') failureEmitted = true;
413
464
  };
414
465
 
415
466
  // --- Transcript poll interval ---
@@ -449,14 +500,42 @@ ptyProcess.onExit(({ exitCode }) => {
449
500
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
450
501
  stopPolling();
451
502
 
503
+ // Hoisted Stop-hook sentinel read at the onExit → pre-emitResult seam.
504
+ // The parsed payload (or null on ENOENT / JSON-parse failure) drives both
505
+ // the D14 hook-path adoption guard below and the emitResult() delegator —
506
+ // the extracted pty-emit-result.mjs no longer re-reads the sentinel from
507
+ // disk. Null-hookData path: D14 guard is a no-op (`hookData?.transcript_path`
508
+ // short-circuits); emitResult() no-ops on the null input; prompt-too-long
509
+ // branch below fires as today (resultEmitted/failureEmitted stay false).
510
+ const hookData = readSentinelPayload(sentinelPath);
511
+
512
+ // D14 (guarded hook-path adoption): when the 1s polling loop never
513
+ // memoised a `transcriptPath` (cwd-hash convention produced nothing —
514
+ // e.g., a client that writes elsewhere, an out-of-tree working directory
515
+ // hashed differently, or a project-scoped transcript root), adopt the
516
+ // Stop hook's `hookData.transcript_path` for the final tail. Gated on
517
+ // `!transcriptPath` so every turn the hash convention worked for keeps
518
+ // its pre-D14 behaviour byte-for-byte.
519
+ if (hookData?.transcript_path && !transcriptPath) {
520
+ const adopted = resolveTranscriptPath(hookData.transcript_path);
521
+ if (adopted) {
522
+ log(`Final tail using hook-supplied transcript_path (hash resolution produced nothing, hook-supplied=${adopted})`);
523
+ }
524
+ }
525
+
452
526
  // Do a final transcript tail to capture any last entries
453
527
  tailTranscript();
454
528
 
455
- // Read sentinel and emit result or error (tempDir still present — delete after)
456
- emitResult();
529
+ // Emit result or error from the pre-parsed sentinel payload (tempDir still
530
+ // present — deleted below)
531
+ emitResult(hookData);
457
532
 
458
- // Prompt-too-long detection: no sentinel written, ttyBuffer has the error text
533
+ // Prompt-too-long detection: no sentinel written, ttyBuffer has the error text.
534
+ // teardown() never ran on this path (Stop hook did not fire), so `endedAt` is
535
+ // unset and `durationMs` is deliberately omitted from the emission — the
536
+ // service appends the pre-existing `'0'` duration sentinel (Decision D3).
459
537
  if (!resultEmitted && !failureEmitted && ttyBuffer.includes('Prompt is too long')) {
538
+ log('Prompt-too-long: omitting durationMs (teardown() never ran, endedAt unset)');
460
539
  process.stdout.write(JSON.stringify({
461
540
  type: 'error',
462
541
  error: 'prompt_too_long',