@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.
|
|
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
|
+
});
|
package/server/claude-adapter.ts
CHANGED
|
@@ -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");
|
package/server/cli-launcher.ts
CHANGED
|
@@ -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 });
|