@kelceyp/caw-server 1.0.272 → 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.
- package/dist/embedding-worker.js +21 -8
- package/dist/main.js +492 -475
- package/dist/pty-wrapper.mjs +122 -43
- package/dist/public_html/main.js +392 -392
- package/dist/public_html/styles.css +1 -1
- package/package.json +2 -1
package/dist/pty-wrapper.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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.
|
|
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 (!
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
//
|
|
456
|
-
|
|
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',
|