claude-code-session-manager 0.124.1 → 0.125.1

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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +4 -3
  2. package/dist/assets/{AgentLibrary-qmAgadiS.js → AgentLibrary-DjEH5SoY.js} +1 -1
  3. package/dist/assets/{Button-CkuaU6cA.js → Button-B8NLivk-.js} +1 -1
  4. package/dist/assets/{DataModel-DEVHxW5I.js → DataModel-CHiBk8m_.js} +1 -1
  5. package/dist/assets/{History-C-qw4Gx4.js → History-DttgrJO0.js} +1 -1
  6. package/dist/assets/{Hooks-DzAnCyjJ.js → Hooks-Bvy0BH1u.js} +1 -1
  7. package/dist/assets/{Library-DExZ29wM.js → Library-UbBOgkkP.js} +1 -1
  8. package/dist/assets/{ListDetail-DiQk-Hf9.js → ListDetail-xOyOpVZX.js} +1 -1
  9. package/dist/assets/{McpServers-C0nqX36_.js → McpServers-CoGL0ddv.js} +1 -1
  10. package/dist/assets/{Memory-D4KAWPAC.js → Memory-Cise856d.js} +1 -1
  11. package/dist/assets/{Panel-xv9ToSjl.js → Panel-Cj72VO5s.js} +1 -1
  12. package/dist/assets/{Permissions-0yEyqwZU.js → Permissions-BoJ9AB-G.js} +1 -1
  13. package/dist/assets/{Plugins-CB28SGGJ.js → Plugins-DfT24sAZ.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-9i4gXDvI.js → ProvenanceBadge-ChkIslT4.js} +1 -1
  15. package/dist/assets/{SaveBar-mSVRXpml.js → SaveBar-RCXr0jey.js} +1 -1
  16. package/dist/assets/{Scheduler-BoUetIC0.js → Scheduler-B70tknnx.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-B8tdQIKs.js → ScopeSwitcher-p_tgO5Sa.js} +1 -1
  18. package/dist/assets/{Settings-D3jfKwaK.js → Settings-wHiAxTfI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DJ4JJRt3.js → SkillReferenceGraph-D_Pt35vb.js} +1 -1
  20. package/dist/assets/{Skills-P9HHxDCR.js → Skills-DUvOrgw9.js} +1 -1
  21. package/dist/assets/{SystemPrompt-B_tnbBZ2.js → SystemPrompt-BrNIdy68.js} +1 -1
  22. package/dist/assets/{TagLibrary-CI8NHRKi.js → TagLibrary-CDRatxXG.js} +1 -1
  23. package/dist/assets/{TiptapBody-9LktXPiD.js → TiptapBody-BF8PxUDj.js} +1 -1
  24. package/dist/assets/{Toggle-BwsUsAA7.js → Toggle-CQH8NGXW.js} +1 -1
  25. package/dist/assets/{ViewTabs-CY5gMZ_N.js → ViewTabs-DwnxqCVs.js} +1 -1
  26. package/dist/assets/{historyTotals-_dIaxmPX.js → historyTotals-KINLTHQ4.js} +1 -1
  27. package/dist/assets/{index-d4i3X1x3.js → index-CfR3NU2t.js} +3 -3
  28. package/dist/assets/{settingsSchema-Dg0Xa9RR.js → settingsSchema-BUcHqLnt.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-CpoFkSyj.js → skillFrontmatter-BwwkyuMD.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-CFPprfWG.js → useAgentsDirWatch-Coem9Wg-.js} +1 -1
  31. package/dist/index.html +1 -1
  32. package/dist-web/assets/app.js +1 -1
  33. package/package.json +3 -1
  34. package/plugins/session-manager-dev/.claude-plugin/plugin.json +4 -3
  35. package/plugins/session-manager-dev/skills/develop/SKILL.md +2 -1
  36. package/scripts/README.md +3 -1
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/chatRunner.cjs +11 -5
  39. package/src/main/docEdit.cjs +8 -4
  40. package/src/main/ipcSchemas.cjs +4 -1
  41. package/src/main/lib/classifyPromptTicket.cjs +9 -4
  42. package/src/main/lib/claude/runClaudeP.cjs +7 -4
  43. package/src/main/lib/epic/epicSweepValidator.cjs +1 -1
  44. package/src/main/lib/epic/epicValidationHook.cjs +1 -1
  45. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  46. package/src/main/lib/process/childStdin.cjs +39 -0
  47. package/src/main/lib/process/childWithLog.cjs +15 -1
  48. package/src/main/lib/scheduler/launchFailure.cjs +37 -1
  49. package/src/main/scheduler.cjs +111 -30
  50. package/src/main/supervisor.cjs +6 -2
  51. package/src/main/webRemote.cjs +6 -0
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * childStdin.cjs — write a prompt/text to a child's stdin and close it.
5
+ *
6
+ * Why prompts go on stdin, not argv: Linux caps a single argument at
7
+ * MAX_ARG_STRLEN (131072 bytes) and Windows cmd.exe caps a command line at
8
+ * 8191 characters; a larger argv element fails the spawn with E2BIG
9
+ * (incident 2026-10-26, spawn E2BIG). Ending the pipe also satisfies the rule
10
+ * that `claude -p`'s stdin must be closed (claude CLI 2.1.302 read a 204 KB
11
+ * prompt from stdin and exited 0).
12
+ *
13
+ * The EPIPE handling lives here, in one place: a child that exits without
14
+ * reading raises an 'error' on the pipe; it is logged once and swallowed.
15
+ */
16
+
17
+ /**
18
+ * @param {{ stdin?: any } | null | undefined} child
19
+ * @param {string} text
20
+ * @param {{ log?: (msg: string) => void }} [opts]
21
+ * @returns {boolean} true when the text was handed to child.stdin
22
+ */
23
+ function feedChildStdin(child, text, { log } = {}) {
24
+ if (!child || !child.stdin) return false;
25
+ let logged = false;
26
+ // Listener FIRST: the write can fail asynchronously with EPIPE.
27
+ child.stdin.on('error', (err) => {
28
+ if (logged) return;
29
+ logged = true;
30
+ if (typeof log !== 'function') return;
31
+ try {
32
+ log(`[childStdin] stdin write failed (${(err && (err.code || err.message)) || 'unknown'}): the child exited before reading its input\n`);
33
+ } catch { /* logging must never throw */ }
34
+ });
35
+ child.stdin.end(text, 'utf8');
36
+ return true;
37
+ }
38
+
39
+ module.exports = { feedChildStdin };
@@ -53,6 +53,7 @@
53
53
  const fs = require('node:fs');
54
54
  const { spawn, execFile } = require('node:child_process');
55
55
  const { killTree } = require('./killTree.cjs');
56
+ const { feedChildStdin } = require('./childStdin.cjs');
56
57
 
57
58
  // Injectable for tests (enumeration-failure coverage).
58
59
  const deps = { execFile };
@@ -205,6 +206,16 @@ function sweepChildProcessGroup(child, detached, safeLog) {
205
206
  .then((leaked) => { kill(); return leaked; });
206
207
  }
207
208
 
209
+ /**
210
+ * Spawn a child with its stdout/stderr on the log fd and supervise it.
211
+ *
212
+ * `spawn.stdinText` (optional string): when given, the child's stdin is a pipe
213
+ * that receives the text (UTF-8) and is then closed; otherwise stdin is
214
+ * 'ignore'. It exists because argv is a poor channel for large input: Linux
215
+ * caps a single argument at MAX_ARG_STRLEN (131072 bytes, spawn E2BIG) and
216
+ * Windows cmd.exe caps a command line at 8191 characters. A stdin error
217
+ * (EPIPE when the child never reads) is logged once and otherwise ignored.
218
+ */
208
219
  function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
209
220
  const startedAt = Date.now();
210
221
 
@@ -296,11 +307,12 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
296
307
 
297
308
  // Attempt synchronous spawn. On failure, call handleDone immediately so the
298
309
  // caller's onExit can resolve any pending Promise before we return null.
310
+ const stdinText = typeof spawnSpec.stdinText === 'string' ? spawnSpec.stdinText : null;
299
311
  let child;
300
312
  try {
301
313
  child = spawn(spawnSpec.command, spawnSpec.args, {
302
314
  ...spawnSpec.options,
303
- stdio: ['ignore', fd, fd],
315
+ stdio: [stdinText !== null ? 'pipe' : 'ignore', fd, fd],
304
316
  });
305
317
  } catch (e) {
306
318
  handleDone(-1, null, e, /* spawnFailed */ true);
@@ -309,6 +321,8 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
309
321
 
310
322
  ctx.child = child;
311
323
 
324
+ if (stdinText !== null) feedChildStdin(child, stdinText, { log: safeLog });
325
+
312
326
  child.on('error', (err) => handleDone(-1, null, err, false));
313
327
  child.on('exit', (code, signal) => handleDone(code, signal, undefined, false));
314
328
 
@@ -57,6 +57,38 @@ const LAUNCH_FAILURE_KINDS = Object.freeze({
57
57
  API_ERROR: 'api_error',
58
58
  });
59
59
 
60
+ /**
61
+ * Argv-size guard (incident 2026-10-26, social-signals-trader): an oversized
62
+ * `-p` argument made `spawn` fail with E2BIG (exit -1 in ~86 ms), which
63
+ * became `impl_failed:exit_-1` and spawned a fix-plan that failed the same
64
+ * way. Linux caps ONE argv string at MAX_ARG_STRLEN = 131072 bytes, counting
65
+ * the NUL terminator. The cause is one job's input, not the persona's
66
+ * environment, so this never arms the per-persona launch breaker.
67
+ */
68
+ const MAX_ARG_BYTES = 131072;
69
+
70
+ /** First argv element whose utf8 bytes + NUL exceed `limit`, else null. */
71
+ function findOversizedArg(args, limit = MAX_ARG_BYTES) {
72
+ if (!Array.isArray(args)) return null;
73
+ for (let index = 0; index < args.length; index++) {
74
+ const a = args[index];
75
+ if (typeof a !== 'string') continue;
76
+ const bytes = Buffer.byteLength(a, 'utf8') + 1;
77
+ if (bytes <= limit) continue;
78
+ let flag = '(positional)';
79
+ for (let j = index - 1; j >= 0; j--) {
80
+ if (typeof args[j] === 'string' && args[j].startsWith('--')) { flag = args[j]; break; }
81
+ }
82
+ return { index, flag, bytes };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** True when a spawn error message is the OS refusing an oversized argv. */
88
+ function isArgvTooLargeSpawnError(message) {
89
+ return /E2BIG|ENAMETOOLONG/.test(String(message ?? ''));
90
+ }
91
+
60
92
  /** Tail bytes scanned for the `result` event — same budget classifyRunOutcome uses. */
61
93
  const RESULT_TAIL_BYTES = 65536;
62
94
 
@@ -310,9 +342,10 @@ function evaluateLaunchGate(block, { now, claudeVersion } = {}) {
310
342
  * closed set so operators never have to open a transcript to tell a
311
343
  * non-start from an implementation failure from a verifier downgrade.
312
344
  */
313
- function deriveTerminalReason({ effectiveStatus, exitCode, verifyResult, sigtermOverride, worktreeIntegrationFailure, budgetKill }) {
345
+ function deriveTerminalReason({ effectiveStatus, exitCode, verifyResult, sigtermOverride, worktreeIntegrationFailure, budgetKill, launchError }) {
314
346
  if (worktreeIntegrationFailure) return 'worktree_integration_failed';
315
347
  if (effectiveStatus === 'completed') return 'completed';
348
+ if (launchError === 'argv_too_large') return 'launch_failure:argv_too_large';
316
349
  if (budgetKill) return 'budget_exceeded';
317
350
  if (sigtermOverride) return 'signal_kill_with_commit';
318
351
  if (exitCode === 143 || exitCode === 137) return 'signal_kill';
@@ -338,6 +371,9 @@ function writeOutcomeSidecar(runDir, slug, outcome) {
338
371
  }
339
372
 
340
373
  module.exports = {
374
+ MAX_ARG_BYTES,
375
+ findOversizedArg,
376
+ isArgvTooLargeSpawnError,
341
377
  LAUNCH_FAILURE_KINDS,
342
378
  LAUNCH_BLOCK_MAX_ATTEMPTS,
343
379
  LAUNCH_PROBE_STALE_MS,
@@ -3474,7 +3474,9 @@ const TRANSIENT_RETRY_CAP = 2;
3474
3474
  * transient (PRD 543/545 incident) — same bounded-retry treatment, not a
3475
3475
  * second parallel classifier.
3476
3476
  */
3477
- function classifyFailureOutcome({ exitCode, networkError, durationMs, transientRetries, newlyDirtyCount }) {
3477
+ function classifyFailureOutcome({ exitCode, networkError, durationMs, transientRetries, newlyDirtyCount, launchError }) {
3478
+ // The run never started (argv too large): not transient, not investigable.
3479
+ if (launchError) return { action: 'fail-launch', launchError };
3478
3480
  const signalTransient = (exitCode === 143 || exitCode === 137) && durationMs < IDLE_OUTPUT_KILL_MS;
3479
3481
  const networkTransient = networkError === true;
3480
3482
  const transient = signalTransient || networkTransient;
@@ -3677,37 +3679,87 @@ function capDirtyPaths(paths, cap = PRE_RUN_DIRTY_PATHS_CAP) {
3677
3679
  const FOREIGN_WIP_DELIMITER = '--- FOREIGN WORKING-TREE STATE (not your work) ---';
3678
3680
  const FOREIGN_WIP_END_DELIMITER = '--- END FOREIGN WORKING-TREE STATE ---';
3679
3681
 
3682
+ const FOREIGN_WIP_SECTION_MAX_BYTES = 16 * 1024;
3683
+ const FOREIGN_WIP_SUMMARY_RESERVE_BYTES = 1024;
3684
+ const FOREIGN_WIP_OMITTED_SENTENCE = 'The omitted paths are just as foreign: do not stage, commit, revert, or stash them either.';
3685
+
3686
+ /**
3687
+ * Pure: bound a foreign-WIP path list by count (PRE_RUN_DIRTY_PATHS_CAP) and
3688
+ * bytes. A trailing capDirtyPaths `+N more` marker is never listed; its N is
3689
+ * added to omittedCount. Omitted real paths are grouped by parent directory.
3690
+ * `fixedBytes` = byte length of the section's fixed sentences/delimiters.
3691
+ */
3692
+ function limitForeignWipPaths(paths, fixedBytes = 0) {
3693
+ const all = Array.isArray(paths) ? paths.filter(Boolean) : [];
3694
+ let omittedCount = 0;
3695
+ if (all.length && /^\+\d+ more$/.test(all[all.length - 1])) {
3696
+ omittedCount += Number(all.pop().slice(1).split(' ')[0]);
3697
+ }
3698
+ const budget = FOREIGN_WIP_SECTION_MAX_BYTES - fixedBytes - FOREIGN_WIP_SUMMARY_RESERVE_BYTES;
3699
+ const listed = [];
3700
+ let used = 0;
3701
+ let i = 0;
3702
+ for (; i < all.length && listed.length < PRE_RUN_DIRTY_PATHS_CAP; i++) {
3703
+ const lineBytes = Buffer.byteLength(` ${all[i]}\n`, 'utf8');
3704
+ if (used + lineBytes > budget) break;
3705
+ used += lineBytes;
3706
+ listed.push(all[i]);
3707
+ }
3708
+ const byDir = new Map();
3709
+ for (; i < all.length; i++) {
3710
+ const slash = all[i].lastIndexOf('/');
3711
+ const dir = slash >= 0 ? all[i].slice(0, slash + 1) : '(repo root)';
3712
+ byDir.set(dir, (byDir.get(dir) || 0) + 1);
3713
+ omittedCount += 1;
3714
+ }
3715
+ const omittedByDir = [...byDir.entries()].sort((x, y) => y[1] - x[1]);
3716
+ return { listed, omittedCount, omittedByDir };
3717
+ }
3718
+
3719
+ function foreignWipSummaryLine(omittedCount, omittedByDir) {
3720
+ const fmt = (n) => n.toLocaleString('en-US');
3721
+ const clip = (d) => (d.length > 120 ? `${d.slice(0, 117)}...` : d);
3722
+ const top = omittedByDir.slice(0, 5);
3723
+ const parts = top.map(([d, n]) => `${fmt(n)} under ${clip(d)}`);
3724
+ const rest = omittedByDir.slice(5).reduce((sum, [, n]) => sum + n, 0);
3725
+ if (rest > 0) parts.push(`${fmt(rest)} elsewhere`);
3726
+ const tail = parts.length ? `: ${parts.join(', ')}` : '';
3727
+ return ` ... and ${fmt(omittedCount)} more path(s) not listed${tail}`;
3728
+ }
3729
+
3680
3730
  /**
3681
3731
  * Pure: build the executor-prompt section warning about pre-existing dirty
3682
3732
  * paths this job does not own — either base WIP carried into an isolated
3683
3733
  * worktree (PRD 1094's carriedPaths, checked first since it's the more
3684
3734
  * specific/authoritative case) or the raw pre-run dirty snapshot of a shared
3685
3735
  * (non-isolated) tree. Returns '' when both lists are empty so a clean spawn
3686
- * produces a byte-identical prompt to before this section existed.
3736
+ * produces a byte-identical prompt to before this section existed. Both
3737
+ * branches are bounded by count and bytes (FOREIGN_WIP_SECTION_MAX_BYTES).
3687
3738
  */
3688
3739
  function buildForeignWipSection({ preRunDirtyPaths, carriedPaths } = {}) {
3689
3740
  const carried = Array.isArray(carriedPaths) ? carriedPaths.filter(Boolean) : [];
3690
- if (carried.length) {
3691
- return [
3692
- FOREIGN_WIP_DELIMITER,
3693
- 'This job is running in an isolated git worktree, but the following paths carry uncommitted base-tree work-in-progress that was carried into this checkout so the tree is self-consistent. The authoritative copy of these files lives in the MAIN tree, not this worktree.',
3694
- 'These files were already modified before this job started. They are NOT this job\'s work:',
3695
- ...carried.map((p) => ` ${p}`),
3696
- 'Do not stage, commit, revert, or stash these paths. A test failure confined to these paths is not this job\'s regression.',
3697
- FOREIGN_WIP_END_DELIMITER,
3698
- ].join('\n');
3699
- }
3700
3741
  const dirty = Array.isArray(preRunDirtyPaths) ? preRunDirtyPaths.filter(Boolean) : [];
3701
- if (dirty.length) {
3702
- return [
3703
- FOREIGN_WIP_DELIMITER,
3704
- 'This job is running in a SHARED working tree (not isolated in its own worktree). The following paths were already modified when this job started:',
3705
- ...dirty.map((p) => ` ${p}`),
3706
- 'These files are NOT this job\'s work. Do not stage, commit, revert, or stash them. A test failure confined to these paths is not this job\'s regression.',
3707
- FOREIGN_WIP_END_DELIMITER,
3708
- ].join('\n');
3709
- }
3710
- return '';
3742
+ if (!carried.length && !dirty.length) return '';
3743
+ const isCarried = carried.length > 0;
3744
+ const head = isCarried
3745
+ ? [
3746
+ FOREIGN_WIP_DELIMITER,
3747
+ 'This job is running in an isolated git worktree, but the following paths carry uncommitted base-tree work-in-progress that was carried into this checkout so the tree is self-consistent. The authoritative copy of these files lives in the MAIN tree, not this worktree.',
3748
+ 'These files were already modified before this job started. They are NOT this job\'s work:',
3749
+ ]
3750
+ : [
3751
+ FOREIGN_WIP_DELIMITER,
3752
+ 'This job is running in a SHARED working tree (not isolated in its own worktree). The following paths were already modified when this job started:',
3753
+ ];
3754
+ const tailLines = isCarried
3755
+ ? ['Do not stage, commit, revert, or stash these paths. A test failure confined to these paths is not this job\'s regression.', FOREIGN_WIP_END_DELIMITER]
3756
+ : ['These files are NOT this job\'s work. Do not stage, commit, revert, or stash them. A test failure confined to these paths is not this job\'s regression.', FOREIGN_WIP_END_DELIMITER];
3757
+ const fixedBytes = Buffer.byteLength([...head, ...tailLines, FOREIGN_WIP_OMITTED_SENTENCE].join('\n'), 'utf8') + 8;
3758
+ const { listed, omittedCount, omittedByDir } = limitForeignWipPaths(isCarried ? carried : dirty, fixedBytes);
3759
+ const omitted = omittedCount > 0
3760
+ ? [foreignWipSummaryLine(omittedCount, omittedByDir), FOREIGN_WIP_OMITTED_SENTENCE]
3761
+ : [];
3762
+ return [...head, ...listed.map((p) => ` ${p}`), ...omitted, ...tailLines].join('\n');
3711
3763
  }
3712
3764
 
3713
3765
  // A job whose validated BLOCKED_BY_FOREIGN_WIP claim keeps recurring against
@@ -4157,11 +4209,15 @@ const HEADLESS_DISALLOWED_TOOLS = Object.freeze([
4157
4209
  * inserted BEFORE that flag, never between `--disallowedTools` and its value.
4158
4210
  * `--disallowedTools` takes a variadic list, so another flag must always
4159
4211
  * follow it to end that list — `--output-format` does, right after. Never
4160
- * put `--disallowedTools` last or right before the prompt.
4212
+ * put `--disallowedTools` last.
4213
+ * The prompt is NOT in argv: callers send it on the child's stdin
4214
+ * (withChildAndLog's `stdinText`). Linux caps a single argv element at 128 KB,
4215
+ * and a large prompt failed with spawn E2BIG before the job started (the
4216
+ * 2026-10-26 incident). `claude -p` with no positional prompt reads stdin.
4161
4217
  */
4162
- function buildClaudeSpawnArgs({ prompt, model, effort, sessionId, resume, systemPrompt, toolScope }) {
4218
+ function buildClaudeSpawnArgs({ model, effort, sessionId, resume, systemPrompt, toolScope }) {
4163
4219
  return [
4164
- '-p', prompt,
4220
+ '-p',
4165
4221
  '--model', model,
4166
4222
  ...effortArgs(effort),
4167
4223
  ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : []),
@@ -4685,7 +4741,6 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4685
4741
  // Distinct comm (`sm-claude-job`) + slug-labelled argv0 for System Monitor.
4686
4742
  // Both keep the word `claude`, which the /\bclaude\b/ reaper gates need.
4687
4743
  const jobArgs = buildClaudeSpawnArgs({
4688
- prompt,
4689
4744
  model: personaResolution.model,
4690
4745
  effort: personaEffort,
4691
4746
  sessionId,
@@ -4695,6 +4750,17 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4695
4750
  });
4696
4751
  const jobSpawn = claudeSpawnTarget('job', job.slug, claudeBin, jobArgs);
4697
4752
 
4753
+ // Pre-spawn argv guard: one oversized argument makes spawn throw E2BIG.
4754
+ const oversized = launchFailure.findOversizedArg(jobSpawn.args || jobArgs);
4755
+ if (oversized) {
4756
+ const errMsg = `argv too large: ${oversized.flag} is ${oversized.bytes} bytes, over the ${launchFailure.MAX_ARG_BYTES}-byte per-argument limit; not spawned`;
4757
+ safeLog(`[scheduler] ${errMsg}\n`);
4758
+ closeFd();
4759
+ config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs: 0, ...SCHEDULER_META_IDENTITY, ...setupMeta });
4760
+ resolve({ exitCode: -1, durationMs: 0, error: errMsg, launchError: 'argv_too_large', sessionId });
4761
+ return;
4762
+ }
4763
+
4698
4764
  const { child } = withChildAndLog({
4699
4765
  fd,
4700
4766
  logPath,
@@ -4702,6 +4768,7 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4702
4768
  closeFd,
4703
4769
  spawn: {
4704
4770
  command: jobSpawn.command,
4771
+ stdinText: prompt,
4705
4772
  // Resume mode passes `--resume <sessionId>` (reconnect to the SAME
4706
4773
  // session) INSTEAD of `--session-id <sessionId>` (mint a new one) —
4707
4774
  // never both, see buildClaudeSpawnArgs.
@@ -4737,7 +4804,8 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
4737
4804
  // Sync write: inside a Promise executor callback; must flush meta
4738
4805
  // before resolve() so the spawnJob mutate() that follows sees it.
4739
4806
  config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs, leakedDescendants: leaked, ...SCHEDULER_META_IDENTITY, ...setupMeta, originSessionId, contextDigestApplied });
4740
- resolve({ exitCode: -1, durationMs, error: errMsg, leakedDescendants: leaked, sessionId });
4807
+ resolve({ exitCode: -1, durationMs, error: errMsg, leakedDescendants: leaked, sessionId,
4808
+ ...(launchFailure.isArgvTooLargeSpawnError(error?.message) ? { launchError: 'argv_too_large' } : {}) });
4741
4809
  return;
4742
4810
  }
4743
4811
 
@@ -5277,8 +5345,9 @@ async function spawnInvestigation(failedJob, runDir, { deadChild = null } = {})
5277
5345
  closeFd,
5278
5346
  spawn: {
5279
5347
  command: probeSpawn.command,
5348
+ stdinText: prompt,
5280
5349
  args: [
5281
- '-p', prompt,
5350
+ '-p',
5282
5351
  '--model', 'opus',
5283
5352
  '--dangerously-skip-permissions',
5284
5353
  ...headlessPermissionArgs(),
@@ -6790,7 +6859,7 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
6790
6859
  // says WHY it ended without anyone opening the transcript.
6791
6860
  s.jobs[i2].terminalReason = launchFailure.deriveTerminalReason({
6792
6861
  effectiveStatus, exitCode: res.exitCode, verifyResult, sigtermOverride, worktreeIntegrationFailure,
6793
- budgetKill: !!budgetKill,
6862
+ budgetKill: !!budgetKill, launchError: res.launchError,
6794
6863
  });
6795
6864
  delete s.jobs[i2].launchFailure;
6796
6865
  delete s.jobs[i2].heldReason;
@@ -7100,9 +7169,19 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
7100
7169
  durationMs: res.durationMs,
7101
7170
  transientRetries: retries,
7102
7171
  newlyDirtyCount,
7172
+ launchError: res.launchError,
7103
7173
  });
7104
7174
 
7105
- if (decision.action === 'retry') {
7175
+ if (decision.action === 'fail-launch') {
7176
+ console.log(`[scheduler] launch failure (${decision.launchError}) for ${job.slug} — run never started; no investigation`);
7177
+ await mutate((s) => {
7178
+ const i = s.jobs.findIndex((x) => x.slug === job.slug);
7179
+ if (i >= 0) {
7180
+ s.jobs[i].error = `launch failure (${decision.launchError}): ${res.error}; the run never started, so no investigation was spawned`;
7181
+ }
7182
+ });
7183
+ await broadcast({ flush: true });
7184
+ } else if (decision.action === 'retry') {
7106
7185
  console.log(`[scheduler] transient failure (${decision.transientKind} dur=${res.durationMs}ms) — auto-retry ${decision.retries + 1}/${TRANSIENT_RETRY_CAP} for ${job.slug}`);
7107
7186
  await mutate((s) => {
7108
7187
  const i = s.jobs.findIndex((x) => x.slug === job.slug);
@@ -11243,6 +11322,8 @@ module.exports = {
11243
11322
  LEFTOVER_PATHS_CAP,
11244
11323
  capDirtyPaths,
11245
11324
  buildForeignWipSection,
11325
+ limitForeignWipPaths,
11326
+ FOREIGN_WIP_SECTION_MAX_BYTES,
11246
11327
  PRE_RUN_DIRTY_PATHS_CAP,
11247
11328
  FOREIGN_WIP_DELIMITER,
11248
11329
  FOREIGN_WIP_END_DELIMITER,
@@ -19,6 +19,7 @@ const { ipcMain } = require('electron');
19
19
  const { resolveClaudeBin, claudeSpawnTarget } = require('./lib/claude/claudeBin.cjs');
20
20
  const { headlessPermissionArgs, ensureCliCapsProbed } = require('./lib/claude/claudeCliCaps.cjs');
21
21
  const { withProcRole } = require('./lib/process/cleanEnv.cjs');
22
+ const { feedChildStdin } = require('./lib/process/childStdin.cjs');
22
23
  const schedulerPaths = require('./lib/scheduler/schedulerPaths.cjs');
23
24
 
24
25
  const HOME = os.homedir();
@@ -247,7 +248,7 @@ async function runProbe(claudeBin, prompt) {
247
248
  try {
248
249
  const target = claudeSpawnTarget('aux', 'supervisor', claudeBin);
249
250
  child = spawn(target.command, [
250
- '-p', prompt,
251
+ '-p',
251
252
  '--model', 'claude-haiku-5-5',
252
253
  '--no-session-persistence',
253
254
  '--output-format', 'json',
@@ -256,7 +257,9 @@ async function runProbe(claudeBin, prompt) {
256
257
  ...headlessPermissionArgs(),
257
258
  '--strict-mcp-config',
258
259
  '--allowedTools', 'Bash',
259
- ], { env: withProcRole(process.env, 'aux'), stdio: ['ignore', 'pipe', 'pipe'], ...(target.argv0 ? { argv0: target.argv0 } : {}) });
260
+ ], { env: withProcRole(process.env, 'aux'), stdio: ['pipe', 'pipe', 'pipe'], ...(target.argv0 ? { argv0: target.argv0 } : {}) });
261
+ // Prompt on stdin, not argv: a >128 KB argv element fails spawn with E2BIG.
262
+ feedChildStdin(child, prompt);
260
263
  } catch (e) {
261
264
  console.error('[supervisor] probe spawn failed:', e?.message);
262
265
  resolve({ verdict: 'ok', action: 'none', targetPid: null, reason: `spawn failed: ${e?.message}`, costUsd: null });
@@ -530,4 +533,5 @@ module.exports = {
530
533
  applyAction,
531
534
  registerHandlers,
532
535
  parseProbeOutput,
536
+ runProbe,
533
537
  };
@@ -844,6 +844,12 @@ async function handleMessage(raw, device) {
844
844
  const live = ((await loadConfig()).devices || []).find((d) => d.deviceId === device.deviceId) || device;
845
845
  if (isPinnedPeerKey(live, browserPubKey)) {
846
846
  client.e2e = makeState('authenticated', sessionKey, null);
847
+ // ORDER IS A CONTRACT: the boxed event:remote:ready (sent inside
848
+ // finalizeE2eAuthenticated) deliberately goes out BEFORE the plaintext e2e:ready
849
+ // below. web/remote-app/src/ws.ts holds boxes that arrive before e2e:ready and
850
+ // decides on sas:pending only after draining them, so the early remote:ready
851
+ // skips the SAS prompt. Reordering brings back a flash of "confirm the 6-digit
852
+ // code". Pinned by src/main/__tests__/web-remote-pinned-ready-order.test.cjs.
847
853
  finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched', client);
848
854
  respond(id, undefined, 'e2e:ready', client);
849
855
  return;