@hellcoder/companion 0.112.0 → 0.112.1-preview.20260728132124.bf82019

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellcoder/companion",
3
- "version": "0.112.0",
3
+ "version": "0.112.1-preview.20260728132124.bf82019",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -2221,3 +2221,101 @@ describe("orphaned descendant reaping", () => {
2221
2221
  expect(signalled).toEqual([]);
2222
2222
  });
2223
2223
  });
2224
+
2225
+ /**
2226
+ * stderr on the kill path.
2227
+ *
2228
+ * The launcher already captures a rolling stderr tail to classify *exit*
2229
+ * reasons, but the wedge path kills the process rather than letting it exit, so
2230
+ * that buffer was never surfaced. Every wedge warning therefore reported kernel
2231
+ * state (S/ep_poll, RSS, fds) and nothing about what the CLI itself said before
2232
+ * dropping stdout — which is the one piece of evidence that could explain a
2233
+ * wedge rather than merely report it. In production 35 of 36 kills were
2234
+ * `no_descendants` on healthy sleeping processes with no explanation available.
2235
+ */
2236
+ describe("stderr tail on kill warnings", () => {
2237
+ let warnSpy: ReturnType<typeof vi.spyOn>;
2238
+
2239
+ beforeEach(() => {
2240
+ warnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
2241
+ });
2242
+
2243
+ afterEach(() => {
2244
+ warnSpy.mockRestore();
2245
+ vi.useRealTimers();
2246
+ });
2247
+
2248
+ const wedgeWarning = () =>
2249
+ warnSpy.mock.calls.find((c: unknown[]) => String(c[1]).includes("killing wedged process"));
2250
+
2251
+ it("includes the CLI's stderr in the wedge-kill warning", async () => {
2252
+ vi.useFakeTimers();
2253
+ const adapter = new ClaudeAdapter("stderr-session", {
2254
+ getStderrTail: () => "FATAL: upstream stream closed unexpectedly",
2255
+ });
2256
+ const { proc, endStdout } = createMockProc();
2257
+ adapter.attachStdio(proc);
2258
+
2259
+ endStdout();
2260
+ await vi.advanceTimersByTimeAsync(11_000);
2261
+
2262
+ const call = wedgeWarning();
2263
+ expect(call).toBeDefined();
2264
+ expect(call?.[2]).toMatchObject({ stderrTail: "FATAL: upstream stream closed unexpectedly" });
2265
+ });
2266
+
2267
+ it("omits the field entirely when the CLI said nothing", async () => {
2268
+ // A quiet failure should stay quiet rather than log an empty string.
2269
+ vi.useFakeTimers();
2270
+ const adapter = new ClaudeAdapter("stderr-quiet", { getStderrTail: () => " " });
2271
+ const { proc, endStdout } = createMockProc();
2272
+ adapter.attachStdio(proc);
2273
+
2274
+ endStdout();
2275
+ await vi.advanceTimersByTimeAsync(11_000);
2276
+
2277
+ expect(wedgeWarning()?.[2]).toMatchObject({ stderrTail: undefined });
2278
+ });
2279
+
2280
+ it("caps a chatty process so it cannot flood the log", async () => {
2281
+ vi.useFakeTimers();
2282
+ const adapter = new ClaudeAdapter("stderr-loud", { getStderrTail: () => "x".repeat(5000) });
2283
+ const { proc, endStdout } = createMockProc();
2284
+ adapter.attachStdio(proc);
2285
+
2286
+ endStdout();
2287
+ await vi.advanceTimersByTimeAsync(11_000);
2288
+
2289
+ const tail = (wedgeWarning()?.[2] as { stderrTail?: string })?.stderrTail;
2290
+ expect(tail).toHaveLength(600);
2291
+ });
2292
+
2293
+ it("still kills when the stderr reader throws", async () => {
2294
+ // Diagnostics must never be able to block recovery.
2295
+ vi.useFakeTimers();
2296
+ const adapter = new ClaudeAdapter("stderr-throws", {
2297
+ getStderrTail: () => { throw new Error("launcher gone"); },
2298
+ });
2299
+ const { proc, endStdout } = createMockProc();
2300
+ adapter.attachStdio(proc);
2301
+
2302
+ endStdout();
2303
+ await vi.advanceTimersByTimeAsync(11_000);
2304
+
2305
+ expect(proc.kill).toHaveBeenCalled();
2306
+ expect(wedgeWarning()?.[2]).toMatchObject({ stderrTail: undefined });
2307
+ });
2308
+
2309
+ it("works when no stderr reader is supplied at all", async () => {
2310
+ vi.useFakeTimers();
2311
+ const adapter = new ClaudeAdapter("stderr-absent");
2312
+ const { proc, endStdout } = createMockProc();
2313
+ adapter.attachStdio(proc);
2314
+
2315
+ endStdout();
2316
+ await vi.advanceTimersByTimeAsync(11_000);
2317
+
2318
+ expect(proc.kill).toHaveBeenCalled();
2319
+ expect(wedgeWarning()).toBeDefined();
2320
+ });
2321
+ });
@@ -241,6 +241,7 @@ export class ClaudeAdapter implements IBackendAdapter {
241
241
  * must be killed so recovery can proceed). Transport-level noise
242
242
  * (`keep_alive`, `system` status/keepalive) does not reset it; any
243
243
  * substantive turn activity does. */
244
+ private getStderrTail: (() => string) | null = null;
244
245
  private lastInboundWasResult = false;
245
246
  /** Timestamp of the last inbound frame of any kind, for the silence probe. */
246
247
  private lastInboundAt = Date.now();
@@ -304,10 +305,19 @@ export class ClaudeAdapter implements IBackendAdapter {
304
305
  * `<cwd>/.companion-uploads/`. Optional because tests may construct
305
306
  * without it; the bridge should always pass it. */
306
307
  cwd?: string;
308
+ /** Reads the rolling stderr tail for this session.
309
+ *
310
+ * The launcher already captures stderr to classify exit reasons, but on
311
+ * the wedge path — where the process is killed rather than exiting — that
312
+ * buffer was never surfaced. It is the only record of what the CLI said
313
+ * before it dropped its stdout, so it is the one piece of evidence that
314
+ * can explain a wedge rather than just report it. */
315
+ getStderrTail?: () => string;
307
316
  },
308
317
  ) {
309
318
  this.sessionId = sessionId;
310
319
  this.recorder = opts?.recorder ?? null;
320
+ this.getStderrTail = opts?.getStderrTail ?? null;
311
321
  this.onActivityUpdate = opts?.onActivityUpdate ?? null;
312
322
  this.sessionCwd = opts?.cwd ?? null;
313
323
  }
@@ -489,6 +499,7 @@ export class ClaudeAdapter implements IBackendAdapter {
489
499
  sessionId: this.sessionId,
490
500
  pid: proc.pid,
491
501
  proc: captureProcState(proc.pid),
502
+ stderrTail: this.stderrTailForLog(),
492
503
  });
493
504
  } else if (proc && proc.exitCode === null && !proc.killed) {
494
505
  // Which grace applies is decided by whether teardown is actually in
@@ -527,6 +538,7 @@ export class ClaudeAdapter implements IBackendAdapter {
527
538
  graceReason: this.lastInboundWasResult ? "result" : "descendants_alive",
528
539
  teardownOutcome: this.lastTeardownOutcome,
529
540
  proc: captureProcState(proc.pid),
541
+ stderrTail: this.stderrTailForLog(),
530
542
  });
531
543
  await this.killWithEscalation(proc);
532
544
  }
@@ -553,6 +565,7 @@ export class ClaudeAdapter implements IBackendAdapter {
553
565
  graceMs: STDOUT_CLOSE_GRACE_MS,
554
566
  graceReason: "no_descendants",
555
567
  proc: captureProcState(proc.pid),
568
+ stderrTail: this.stderrTailForLog(),
556
569
  });
557
570
  await this.killWithEscalation(proc);
558
571
  }
@@ -608,6 +621,21 @@ export class ClaudeAdapter implements IBackendAdapter {
608
621
  for (const id of results) this.pendingToolUses.delete(id);
609
622
  }
610
623
 
624
+ /**
625
+ * Last few hundred bytes the CLI wrote to stderr, for kill/stall warnings.
626
+ *
627
+ * Trimmed and length-capped so a chatty process cannot flood the log, and
628
+ * omitted entirely when empty so quiet failures stay quiet.
629
+ */
630
+ private stderrTailForLog(): string | undefined {
631
+ try {
632
+ const tail = this.getStderrTail?.().trim();
633
+ return tail ? tail.slice(-600) : undefined;
634
+ } catch {
635
+ return undefined;
636
+ }
637
+ }
638
+
611
639
  private checkSilence(): void {
612
640
  if (this.transportKind !== "stdio" || !this.stdioConnected) return;
613
641
  if (getSettings().silenceProbeEnabled === false) return;
@@ -628,6 +656,7 @@ export class ClaudeAdapter implements IBackendAdapter {
628
656
  turnSilentForMs: now - this.lastTurnOutputAt,
629
657
  transportSilentForMs: now - this.lastInboundAt,
630
658
  proc: captureProcState(this.stdioProc?.pid),
659
+ stderrTail: this.stderrTailForLog(),
631
660
  });
632
661
  this.stopSilenceProbe();
633
662
  // Same handoff as the silence probe: relaunch replays the in-flight turn.
@@ -661,6 +690,7 @@ export class ClaudeAdapter implements IBackendAdapter {
661
690
  silentForMs: now - this.lastInboundAt,
662
691
  probeUnansweredForMs: now - this.probeSentAt,
663
692
  proc: captureProcState(this.stdioProc?.pid),
693
+ stderrTail: this.stderrTailForLog(),
664
694
  });
665
695
  this.stopSilenceProbe();
666
696
  // Hand off to the existing recovery path, which relaunches and replays
@@ -787,6 +817,7 @@ export class ClaudeAdapter implements IBackendAdapter {
787
817
  sessionId: this.sessionId,
788
818
  pid: proc.pid,
789
819
  afterMs: SIGKILL_ESCALATION_MS,
820
+ stderrTail: this.stderrTailForLog(),
790
821
  });
791
822
  try {
792
823
  proc.kill("SIGKILL");
@@ -633,6 +633,8 @@ export class CliLauncher {
633
633
  const adapter = new ClaudeAdapter(sessionId, {
634
634
  recorder: this.recorder ?? undefined,
635
635
  cwd: info.cwd,
636
+ // Lets the adapter quote the CLI's own stderr in its kill/stall warnings.
637
+ getStderrTail: () => this.stderrTails.get(sessionId) ?? "",
636
638
  });
637
639
  adapter.attachStdio(proc);
638
640
  companionBus.emit("backend:claude-adapter-created", { sessionId, adapter });