@kelceyp/caw-server 1.0.214 → 1.0.216

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.
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from 'fs';
2
+
3
+ export const readSentinelPayload = (sentinelPath) => {
4
+ try {
5
+ return JSON.parse(readFileSync(sentinelPath, 'utf-8'));
6
+ } catch {
7
+ return null;
8
+ }
9
+ };
10
+
11
+ export const hasPendingWork = (payload) => {
12
+ const tasks = payload?.background_tasks;
13
+ if (Array.isArray(tasks) && tasks.length > 0) return true;
14
+ const crons = payload?.session_crons;
15
+ if (Array.isArray(crons) && crons.length > 0) return true;
16
+ return false;
17
+ };
@@ -10,6 +10,7 @@
10
10
  * --session-id <uuid> Session ID (first run)
11
11
  * --resume <uuid> Resume session (continuation)
12
12
  * --model <model> Model override
13
+ * --effort <level> Effort level override
13
14
  * --skip-permissions Enable --dangerously-skip-permissions
14
15
  *
15
16
  * Stdin: The prompt text (piped by JobRunner before spawn)
@@ -22,6 +23,7 @@ import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathS
22
23
  import { join } from 'path';
23
24
  import { tmpdir, homedir } from 'os';
24
25
  import { execSync } from 'child_process';
26
+ import { readSentinelPayload, hasPendingWork } from './pty-deferred-teardown.mjs';
25
27
 
26
28
  // --- Arg parsing ---
27
29
 
@@ -35,6 +37,7 @@ const hasFlag = (name) => args.includes(name);
35
37
  const sessionId = getArg('--session-id');
36
38
  const resumeId = getArg('--resume');
37
39
  const model = getArg('--model');
40
+ const effortLevel = getArg('--effort');
38
41
  const skipPermissions = hasFlag('--skip-permissions');
39
42
 
40
43
  if (!sessionId && !resumeId) {
@@ -79,17 +82,16 @@ const sentinelPath = join(tempDir, 'result.json');
79
82
  const hookScriptPath = join(tempDir, 'stop-hook.sh');
80
83
  const settingsPath = join(tempDir, 'settings.json');
81
84
  const claudePidPath = join(tempDir, 'claude.pid');
85
+ const wrapperPidPath = join(tempDir, 'wrapper.pid');
82
86
 
83
87
  // --- Write Stop hook script ---
84
88
  // The hook: (1) cats Stop hook JSON (on stdin) to sentinel file, then
85
- // (2) SIGINTs the specific claude PID recorded in claude.pid.
86
- // Using a PID file avoids a fuzzy *claude* tree walk that could accidentally
87
- // SIGINT the calling agent process (e.g. when running under a Claude agent).
89
+ // (2) sends SIGUSR1 to the wrapper PID so it can check whether teardown is safe.
88
90
 
89
91
  const hookScript = [
90
92
  '#!/bin/bash',
91
93
  `cat > '${sentinelPath}'`,
92
- `kill -INT $(cat '${claudePidPath}') 2>/dev/null`,
94
+ `kill -USR1 $(cat '${wrapperPidPath}') 2>/dev/null`,
93
95
  ''
94
96
  ].join('\n');
95
97
  writeFileSync(hookScriptPath, hookScript, { mode: 0o755 });
@@ -152,6 +154,59 @@ const ensureNonInteractiveSetup = (cwd) => {
152
154
 
153
155
  ensureNonInteractiveSetup(process.cwd());
154
156
 
157
+ // --- Deferred teardown state ---
158
+
159
+ const WATCHDOG_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
160
+
161
+ let watchdogTimer = null;
162
+ let teardownInitiated = false;
163
+
164
+ // Forward declarations (assigned after ptyProcess is created)
165
+ let ptyProcess = null;
166
+
167
+ const startOrResetWatchdog = () => {
168
+ if (watchdogTimer) clearTimeout(watchdogTimer);
169
+ log(`Watchdog ${watchdogTimer ? 'reset' : 'armed'}: ${WATCHDOG_TIMEOUT_MS / 60000}m timeout`);
170
+ watchdogTimer = setTimeout(() => {
171
+ log(`Watchdog expired after ${WATCHDOG_TIMEOUT_MS / 60000}m with no progress, force-killing claude`);
172
+ teardown();
173
+ }, WATCHDOG_TIMEOUT_MS);
174
+ };
175
+
176
+ const teardown = () => {
177
+ if (teardownInitiated) return;
178
+ teardownInitiated = true;
179
+ log('Teardown: stop received, no pending work, killing claude');
180
+ if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
181
+ stopPolling();
182
+ if (ptyProcess) ptyProcess.kill('SIGINT');
183
+ };
184
+
185
+ // --- Register SIGUSR1 handler and write wrapper PID ---
186
+ // Must happen BEFORE spawning claude so the handler is ready when the first Stop fires.
187
+
188
+ process.on('SIGUSR1', () => {
189
+ log('Received SIGUSR1 (stop signal)');
190
+ tailTranscript();
191
+ const payload = readSentinelPayload(sentinelPath);
192
+ if (!payload) {
193
+ log(`Warning: failed to read sentinel, proceeding with teardown`);
194
+ teardown();
195
+ return;
196
+ }
197
+ const bgCount = Array.isArray(payload.background_tasks) ? payload.background_tasks.length : 0;
198
+ const cronCount = Array.isArray(payload.session_crons) ? payload.session_crons.length : 0;
199
+ log(`Sentinel: background_tasks=${bgCount}, session_crons=${cronCount}`);
200
+ if (hasPendingWork(payload)) {
201
+ log('Deferring teardown: pending background work');
202
+ startOrResetWatchdog();
203
+ return;
204
+ }
205
+ teardown();
206
+ });
207
+
208
+ writeFileSync(wrapperPidPath, String(process.pid));
209
+
155
210
  // --- Build claude args ---
156
211
 
157
212
  const claudeArgs = [];
@@ -166,6 +221,9 @@ if (skipPermissions) {
166
221
  if (model) {
167
222
  claudeArgs.push('--model', model);
168
223
  }
224
+ if (effortLevel) {
225
+ claudeArgs.push('--effort', effortLevel);
226
+ }
169
227
  claudeArgs.push('--settings', settingsPath);
170
228
  claudeArgs.push(prompt);
171
229
 
@@ -174,7 +232,7 @@ log(`Prompt length: ${prompt.length} chars`);
174
232
 
175
233
  // --- Spawn claude via PTY ---
176
234
 
177
- const ptyProcess = pty.spawn(claudePath, claudeArgs, {
235
+ ptyProcess = pty.spawn(claudePath, claudeArgs, {
178
236
  name: 'xterm-256color',
179
237
  cols: 120,
180
238
  rows: 40,
@@ -252,7 +310,6 @@ const tailTranscript = () => {
252
310
  for (let i = transcriptLinesRead; i < lines.length; i++) {
253
311
  try {
254
312
  const entry = JSON.parse(lines[i]);
255
- // Skip hook progress notifications (internal noise)
256
313
  if (entry.type === 'progress' && entry.data?.type === 'hook_progress') continue;
257
314
  process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
258
315
  newLines++;
@@ -327,7 +384,8 @@ ptyProcess.onExit(({ exitCode }) => {
327
384
  if (exited) return;
328
385
  exited = true;
329
386
 
330
- // Stop polling (sentinelPath still exists on disk at this point)
387
+ // Clear watchdog and stop polling (sentinelPath still exists on disk at this point)
388
+ if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
331
389
  stopPolling();
332
390
 
333
391
  // Do a final transcript tail to capture any last entries