@hellcoder/companion 0.113.4-preview.20260730033722.28543e1 → 0.113.4

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.113.4-preview.20260730033722.28543e1",
3
+ "version": "0.113.4",
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",
@@ -496,13 +496,20 @@ export class ClaudeAdapter implements IBackendAdapter {
496
496
  // a wedge that may be hypothetical.
497
497
  const wedgeKillEnabled = getSettings().wedgeKillEnabled !== false;
498
498
  if (proc && proc.exitCode === null && !proc.killed && !wedgeKillEnabled) {
499
+ const cliStdoutOpen = stdoutFdOpen(proc.pid);
499
500
  log.info("claude-adapter", "stdout closed but wedge-kill is disabled; leaving process alone", {
500
501
  sessionId: this.sessionId,
501
502
  pid: proc.pid,
502
- cliStdoutOpen: stdoutFdOpen(proc.pid),
503
+ cliStdoutOpen,
503
504
  proc: captureProcState(proc.pid),
504
505
  stderrTail: this.stderrTailForLog(),
505
506
  });
507
+ // Emit the reader-side-EOF WARN on this path too. It previously fired
508
+ // only from the two kill branches, so operators running with the
509
+ // wedge-kill switch off — the recommended mitigation — saw these events
510
+ // only as the benign-sounding info line above and had no signal that the
511
+ // EOF originated in the server's own read side.
512
+ this.warnIfReaderSideEof(cliStdoutOpen, proc.pid, false);
506
513
  } else if (proc && proc.exitCode === null && !proc.killed) {
507
514
  // Which grace applies is decided by whether teardown is actually in
508
515
  // progress, NOT by whether the last message happened to be a `result`.
@@ -654,12 +661,22 @@ export class ClaudeAdapter implements IBackendAdapter {
654
661
  * the warning attributes the root cause so these events can be counted and
655
662
  * the reader-side failure fixed where it lives.
656
663
  */
657
- private warnIfReaderSideEof(cliStdoutOpen: boolean | null, pid: number | undefined): void {
664
+ private warnIfReaderSideEof(
665
+ cliStdoutOpen: boolean | null,
666
+ pid: number | undefined,
667
+ killing = true,
668
+ ): void {
658
669
  if (cliStdoutOpen !== true) return;
659
- log.warn("claude-adapter", "CLI still holds its stdout open — EOF was reader-side (server stream failure), not a CLI wedge; killing anyway so --resume recovery can proceed", {
660
- sessionId: this.sessionId,
661
- pid,
662
- });
670
+ log.warn(
671
+ "claude-adapter",
672
+ killing
673
+ ? "CLI still holds its stdout open — EOF was reader-side (server stream failure), not a CLI wedge; killing anyway so --resume recovery can proceed"
674
+ : "CLI still holds its stdout open — EOF was reader-side (server stream failure), not a CLI wedge; leaving the process alone, but the transport is gone and the session will still relaunch",
675
+ {
676
+ sessionId: this.sessionId,
677
+ pid,
678
+ },
679
+ );
663
680
  }
664
681
 
665
682
  private checkSilence(): void {
@@ -87,10 +87,23 @@ export interface Session {
87
87
  */
88
88
  turnAwaitingResult?: boolean;
89
89
  /**
90
- * One-shot guard for the mid-answer continuation nudge, so a turn that keeps
91
- * dying cannot be nudged in a loop. Reset when a new user_message is sent.
90
+ * Number of mid-answer continuation nudges sent since the turn last produced
91
+ * output, so a turn that keeps dying cannot be nudged in a loop.
92
+ *
93
+ * Deliberately counts *consecutive unproductive* nudges rather than nudges
94
+ * per turn: this was a one-shot boolean, which capped recovery at one nudge
95
+ * for the whole turn. A second stream failure in the same turn — common on a
96
+ * loaded host, where the server's own stdout reader EOFs mid-answer — then had
97
+ * no recovery path at all (`inFlightUserTurn` was already cleared by the first
98
+ * token, so the replay branch is skipped by design), and the session idled
99
+ * until a human typed "continue".
100
+ *
101
+ * Reset to 0 whenever the turn produces output, because a nudge that was
102
+ * followed by output demonstrably worked: the next interruption is a fresh
103
+ * failure, not a loop. Only a nudge that yields nothing counts toward
104
+ * MAX_CONTINUATION_NUDGES. Reset when a new user_message is sent.
92
105
  */
93
- continuationSent?: boolean;
106
+ continuationAttempts?: number;
94
107
  /**
95
108
  * Set when the last turn failed authentication (expired/invalid credentials).
96
109
  * Auto-relaunch is skipped while this is set — relaunching cannot fix auth —
@@ -2120,8 +2120,11 @@ describe("CLI message routing", () => {
2120
2120
  expect(nudge.content).not.toBe("do a big task");
2121
2121
  });
2122
2122
 
2123
- it("does not nudge twice for the same turn", async () => {
2124
- // A turn that keeps dying must not accumulate nudges.
2123
+ it("does not nudge twice without intervening output", async () => {
2124
+ // A turn that keeps dying with nothing to show must not accumulate nudges:
2125
+ // that shape is indistinguishable from a poison turn that dies on every
2126
+ // resume. The budget only refills once the turn actually produces output
2127
+ // (see the "nudges again after the continued turn produced output" test).
2125
2128
  const { session } = await startTurnWithOutput("s1");
2126
2129
  const sent: string[] = [];
2127
2130
  const attach = () => bridge.attachBackendAdapter("s1", {
@@ -2169,6 +2172,122 @@ describe("CLI message routing", () => {
2169
2172
  .filter((m) => m.type === "user_message" && /continue from where/i.test(m.content || ""));
2170
2173
  expect(nudges).toHaveLength(0);
2171
2174
  });
2175
+
2176
+ /**
2177
+ * The production failure this budget change exists for.
2178
+ *
2179
+ * The nudge guard used to be a one-shot boolean scoped to the whole turn, so
2180
+ * a turn interrupted a SECOND time got no recovery at all: the replay branch
2181
+ * is skipped by design once output has begun (`inFlightUserTurn` is cleared
2182
+ * by the first token), and the boolean blocked the nudge branch. The banner
2183
+ * cleared, the session went idle with a half-written answer, and a human had
2184
+ * to type "continue".
2185
+ *
2186
+ * Observed on companion 0.113.3 (session afdbec35): prompt at 04:20:20 →
2187
+ * stream EOF → nudge at 04:23:32 → the resumed turn streamed for minutes →
2188
+ * stream EOF again → silent reattach → 9m38s dead until a human typed
2189
+ * "continue" at 04:33:12. Both EOFs were reader-side (the CLI still held
2190
+ * fd 1), i.e. the turn was healthy and genuinely worth continuing.
2191
+ */
2192
+ /**
2193
+ * Attaches a mock adapter the way a relaunch does, capturing both what the
2194
+ * bridge sends and the `onBrowserMessage` handler it registers. Emitting
2195
+ * through that handler is how backend output actually reaches the session
2196
+ * state — `handleCLIMessage` only routes for a real ClaudeAdapter, so it
2197
+ * silently drops once a mock is attached.
2198
+ */
2199
+ function attachMockAdapter(sessionId: string) {
2200
+ const sent: string[] = [];
2201
+ let handler: ((msg: unknown) => void) | undefined;
2202
+ bridge.attachBackendAdapter(sessionId, {
2203
+ isConnected: () => true,
2204
+ send: (m: unknown) => { sent.push(typeof m === "string" ? m : JSON.stringify(m)); return true; },
2205
+ disconnect: async () => {},
2206
+ onBrowserMessage: (cb: (msg: unknown) => void) => { handler = cb; },
2207
+ onSessionMeta: () => {},
2208
+ onDisconnect: () => {},
2209
+ onInitError: () => {},
2210
+ } as never, "claude");
2211
+ return {
2212
+ sent,
2213
+ emit: (msg: unknown) => handler?.(msg),
2214
+ nudges: () => sent
2215
+ .map((m) => JSON.parse(m))
2216
+ .filter((m) => m.type === "user_message" && /continue from where/i.test(m.content || "")),
2217
+ };
2218
+ }
2219
+
2220
+ it("nudges again after the continued turn produced output", async () => {
2221
+ const { session } = await startTurnWithOutput("s1");
2222
+
2223
+ // First interruption → nudge, and the budget is now spent.
2224
+ const first = attachMockAdapter("s1");
2225
+ expect(first.nudges()).toHaveLength(1);
2226
+ expect(session.continuationAttempts).toBe(1);
2227
+
2228
+ // The resumed turn makes real progress. That proves the nudge worked, so
2229
+ // the budget refills — the next failure is a new one, not a loop.
2230
+ first.emit({
2231
+ type: "stream_event",
2232
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "more work" } },
2233
+ session_id: "cli-123",
2234
+ uuid: "se-2",
2235
+ });
2236
+ expect(session.continuationAttempts).toBe(0);
2237
+
2238
+ // Second interruption → must nudge again instead of stranding the turn.
2239
+ // Under the old one-shot boolean this produced zero nudges and the session
2240
+ // idled until a human typed "continue".
2241
+ const second = attachMockAdapter("s1");
2242
+ expect(second.nudges()).toHaveLength(1);
2243
+ expect(session.turnAwaitingResult).toBe(true);
2244
+ });
2245
+
2246
+ it("refills the budget on assistant output, not just stream_event", async () => {
2247
+ // Non-streaming output surfaces as an `assistant` message with no
2248
+ // preceding stream_event, so both handlers must refill the budget or those
2249
+ // turns keep the one-nudge-per-turn bug.
2250
+ const { session } = await startTurnWithOutput("s1");
2251
+
2252
+ const first = attachMockAdapter("s1");
2253
+ expect(session.continuationAttempts).toBe(1);
2254
+
2255
+ first.emit({
2256
+ type: "assistant",
2257
+ message: { role: "assistant", content: [{ type: "text", text: "partial answer" }] },
2258
+ session_id: "cli-123",
2259
+ uuid: "a-1",
2260
+ });
2261
+ expect(session.continuationAttempts).toBe(0);
2262
+
2263
+ expect(attachMockAdapter("s1").nudges()).toHaveLength(1);
2264
+ });
2265
+
2266
+ it("stops nudging when the resumed turn dies without producing output", async () => {
2267
+ // The loop protection the budget preserves: repeated nudges that yield
2268
+ // nothing are indistinguishable from a poison turn, so they must stop at
2269
+ // MAX_CONTINUATION_NUDGES (default 1) rather than retry forever.
2270
+ const { session } = await startTurnWithOutput("s1");
2271
+
2272
+ expect(attachMockAdapter("s1").nudges()).toHaveLength(1);
2273
+ expect(attachMockAdapter("s1").nudges()).toHaveLength(0);
2274
+ expect(attachMockAdapter("s1").nudges()).toHaveLength(0);
2275
+ expect(session.continuationAttempts).toBe(1);
2276
+ });
2277
+
2278
+ it("resets the nudge budget when a new user turn starts", async () => {
2279
+ // A fresh prompt is a fresh turn: whatever happened to the previous one
2280
+ // must not eat this turn's recovery.
2281
+ const { browser, session } = await startTurnWithOutput("s1");
2282
+ attachMockAdapter("s1");
2283
+ expect(session.continuationAttempts).toBe(1);
2284
+
2285
+ await bridge.handleBrowserMessage(browser, JSON.stringify({
2286
+ type: "user_message",
2287
+ content: "next question",
2288
+ }));
2289
+ expect(session.continuationAttempts).toBe(0);
2290
+ });
2172
2291
  });
2173
2292
 
2174
2293
  it("tool_progress: broadcasts", async () => {
@@ -64,6 +64,23 @@ const RETRYABLE_BACKEND_MESSAGE_TYPES = new Set<BrowserOutgoingMessage["type"]>(
64
64
  "mcp_set_servers",
65
65
  ]);
66
66
 
67
+ /**
68
+ * How many continuation nudges a mid-answer-interrupted turn may get *without
69
+ * producing any output in between*. The counter resets on the turn's next output
70
+ * (see `continuationAttempts`), so this only bounds a turn that dies again
71
+ * having emitted nothing — not a turn that is interrupted repeatedly while
72
+ * making progress.
73
+ *
74
+ * Default 1 keeps the loop protection exactly as strict as the boolean it
75
+ * replaces for the ambiguous case (nudge → died with nothing to show → could be
76
+ * a poison turn), while making the budget *per interruption* instead of per
77
+ * turn. Raise it on hosts where the transport flaps hard enough that a resumed
78
+ * turn sometimes dies before its first token.
79
+ */
80
+ const MAX_CONTINUATION_NUDGES = Number(
81
+ process.env.COMPANION_MAX_CONTINUATION_NUDGES || "1",
82
+ );
83
+
67
84
  export class WsBridge {
68
85
  private static readonly PROCESSED_CLIENT_MSG_ID_LIMIT = 1000;
69
86
  /** Maximum number of queued browser→backend messages per session to prevent unbounded memory growth. */
@@ -565,6 +582,10 @@ export class WsBridge {
565
582
  // The turn is producing output — it was delivered, so drop the in-flight
566
583
  // replay copy (no need to re-send it after a future relaunch).
567
584
  session.inFlightUserTurn = undefined;
585
+ // Output after a continuation nudge proves the nudge worked, so give the
586
+ // turn a fresh nudge budget: a later interruption is a new failure, not
587
+ // the same one looping. Guarded so the common path writes nothing.
588
+ if (session.continuationAttempts) session.continuationAttempts = 0;
568
589
  const assistantMsg = { ...msg, timestamp: msg.timestamp || Date.now() };
569
590
  this.appendHistory(session, assistantMsg);
570
591
  this.persistSession(session);
@@ -573,6 +594,10 @@ export class WsBridge {
573
594
 
574
595
  if (msg.type === "stream_event") {
575
596
  session.inFlightUserTurn = undefined;
597
+ // Same as the assistant branch: progress clears the nudge budget. This is
598
+ // the earliest output signal, so it is what makes a resumed turn that
599
+ // streams for minutes before failing again eligible for another nudge.
600
+ if (session.continuationAttempts) session.continuationAttempts = 0;
576
601
  companionBus.emit("message:stream_event", { sessionId: session.id, message: msg });
577
602
  }
578
603
 
@@ -794,7 +819,7 @@ export class WsBridge {
794
819
  // relaunch, defeating inFlightTurnReplayed.
795
820
  !session.inFlightUserTurn &&
796
821
  session.turnAwaitingResult &&
797
- !session.continuationSent
822
+ (session.continuationAttempts ?? 0) < MAX_CONTINUATION_NUDGES
798
823
  ) {
799
824
  // The turn died AFTER it had begun answering. `--resume` restores the
800
825
  // transcript but does not restart the turn, so without this the session
@@ -803,13 +828,24 @@ export class WsBridge {
803
828
  // Re-sending the original prompt is wrong here: work already happened and
804
829
  // replaying it would redo tool calls. A continuation nudge asks the model
805
830
  // to finish from where the transcript stops instead.
806
- session.continuationSent = true;
831
+ //
832
+ // Budgeted per *unproductive* nudge, not per turn: the counter resets as
833
+ // soon as the continued turn emits output (see the assistant/stream_event
834
+ // handlers), so a turn interrupted repeatedly still recovers each time
835
+ // while a turn that dies without producing anything stops after
836
+ // MAX_CONTINUATION_NUDGES. Unbounded looping is not a risk here anyway —
837
+ // a nudge can only fire on adapter attach, and attaches are already
838
+ // rate-limited by the orchestrator's MAX_AUTO_RELAUNCHES and
839
+ // MAX_RELAUNCHES_PER_WINDOW caps.
840
+ session.continuationAttempts = (session.continuationAttempts ?? 0) + 1;
807
841
  session.pendingMessages.unshift(JSON.stringify({
808
842
  type: "user_message",
809
843
  content: "Your previous response was interrupted before it finished. Continue from where you left off — do not repeat work that is already in the transcript.",
810
844
  }));
811
845
  log.info("ws-bridge", "Nudging interrupted turn to continue after relaunch", {
812
846
  sessionId,
847
+ attempt: session.continuationAttempts,
848
+ maxAttempts: MAX_CONTINUATION_NUDGES,
813
849
  });
814
850
  this.broadcastToBrowsers(session, {
815
851
  type: "error",
@@ -1350,7 +1386,7 @@ export class WsBridge {
1350
1386
  // token: this stays set until the turn actually finishes, so an interrupt
1351
1387
  // *mid-answer* is still recognisable as an unfinished turn.
1352
1388
  session.turnAwaitingResult = true;
1353
- session.continuationSent = false;
1389
+ session.continuationAttempts = 0;
1354
1390
  // A fresh turn is an attempt to make progress (e.g. after the user
1355
1391
  // re-authenticated), so clear any stale auth-block — a genuine crash on
1356
1392
  // this new turn should be allowed to auto-relaunch again.