@hellcoder/companion 0.104.4-preview.20260619104321.2961437 → 0.105.0

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 (28) hide show
  1. package/dist/assets/{AgentsPage-H1WOCJ_U.js → AgentsPage-BeDMkE2_.js} +1 -1
  2. package/dist/assets/{CronManager-Cqnqj1KO.js → CronManager-B_DSvQtN.js} +1 -1
  3. package/dist/assets/{IntegrationsPage-Nd6In9Av.js → IntegrationsPage-ClbDqoEP.js} +1 -1
  4. package/dist/assets/{LinearOAuthSettingsPage-D7RQfyFI.js → LinearOAuthSettingsPage-BEeYBnSb.js} +1 -1
  5. package/dist/assets/{LinearSettingsPage-CGVs5jG6.js → LinearSettingsPage-DN2raNtR.js} +1 -1
  6. package/dist/assets/{Playground-M3TI9s_p.js → Playground-Dlv72b_8.js} +1 -1
  7. package/dist/assets/{PromptsPage-QXhg1mXT.js → PromptsPage-BOxtGtfT.js} +1 -1
  8. package/dist/assets/{RunsPage-C1XiccgQ.js → RunsPage-CYUUHysG.js} +1 -1
  9. package/dist/assets/{SandboxManager-DYmKXcTM.js → SandboxManager-B90xK3cu.js} +1 -1
  10. package/dist/assets/{SettingsPage-DaJfAHDI.js → SettingsPage-SvStjnbd.js} +1 -1
  11. package/dist/assets/{TailscalePage-CmH09EMI.js → TailscalePage-C5J3VAqt.js} +1 -1
  12. package/dist/assets/index-CDV255Wl.js +136 -0
  13. package/dist/assets/{sw-register-DkUWP5b0.js → sw-register-BPlfMfHI.js} +1 -1
  14. package/dist/index.html +1 -1
  15. package/dist/sw.js +1 -1
  16. package/package.json +1 -1
  17. package/server/claude-adapter.ts +18 -8
  18. package/server/cli-launcher.test.ts +92 -0
  19. package/server/cli-launcher.ts +54 -1
  20. package/server/event-bus-types.ts +10 -2
  21. package/server/session-orchestrator.test.ts +65 -0
  22. package/server/session-orchestrator.ts +40 -1
  23. package/server/session-types.test.ts +86 -0
  24. package/server/session-types.ts +57 -0
  25. package/server/ws-bridge-types.ts +20 -0
  26. package/server/ws-bridge.test.ts +178 -0
  27. package/server/ws-bridge.ts +59 -0
  28. package/dist/assets/index-CKx0s4-A.js +0 -134
@@ -139,6 +139,12 @@ export interface CLIResultMessage {
139
139
  is_error: boolean;
140
140
  result?: string;
141
141
  errors?: string[];
142
+ /**
143
+ * HTTP status of the underlying API error, when the turn failed at the API
144
+ * layer (e.g. 401 for expired/invalid credentials). Present on the wire but
145
+ * historically untyped — declared here so auth failures can be surfaced.
146
+ */
147
+ api_error_status?: number;
142
148
  duration_ms: number;
143
149
  duration_api_ms: number;
144
150
  num_turns: number;
@@ -165,6 +171,57 @@ export interface CLIResultMessage {
165
171
  session_id: string;
166
172
  }
167
173
 
174
+ /**
175
+ * User-facing guidance shown when a turn fails because the Claude credentials
176
+ * are missing/expired/invalid. Shared by the server (auth banner on relaunch
177
+ * skip) and the client (error bubble) so the wording stays consistent.
178
+ */
179
+ export const AUTH_EXPIRED_MESSAGE =
180
+ "Your Claude login has expired or is invalid — re-authenticate (run `claude login` in a terminal), then use Reconnect to resume this session.";
181
+
182
+ /**
183
+ * Text patterns that reliably indicate an authentication/authorization failure
184
+ * in free-form output (a CLI `result` message or a tail of stderr).
185
+ *
186
+ * Bare "401"/"403" and a standalone "unauthorized" are deliberately NOT matched:
187
+ * they appear in stack-trace line numbers, tool exit codes, file-permission
188
+ * messages and unrelated HTTP logs, and matching them would misclassify a
189
+ * RECOVERABLE crash as an auth failure — which suppresses auto-relaunch and
190
+ * leaves the session dead (a brand-new hang, the opposite of the intent). We
191
+ * require an explicit auth phrase, or a status code paired with
192
+ * unauthorized/forbidden. The structured `api_error_status` field (checked in
193
+ * isAuthErrorResult) remains the primary, reliable signal.
194
+ */
195
+ const AUTH_ERROR_TEXT_RE =
196
+ /invalid authentication credentials|authentication_failed|\b40[13]\s+(?:unauthorized|forbidden)\b|(?:unauthorized|forbidden)[^\n]{0,12}\b40[13]\b|please run\s+`?\/?login|claude login|credentials?\s+(?:expired|invalid|revoked)|oauth token|token\s+(?:expired|revoked)/i;
197
+
198
+ /**
199
+ * True when free-form text (a result message or stderr tail) looks like an
200
+ * auth/credential failure. Shared by isAuthErrorResult and the launcher's
201
+ * process-exit classifier so the two cannot drift.
202
+ */
203
+ export function looksLikeAuthErrorText(text: string): boolean {
204
+ return AUTH_ERROR_TEXT_RE.test(text);
205
+ }
206
+
207
+ /**
208
+ * True when a CLI `result` indicates an authentication/authorization failure.
209
+ * The CLI reports these as a result with `is_error` and either
210
+ * `api_error_status` 401/403 or an auth phrase in `result`/`errors` — it does
211
+ * NOT populate the `errors[]` array, which is why the old `errors?.length`
212
+ * gate silently swallowed expired-token results.
213
+ */
214
+ export function isAuthErrorResult(r: {
215
+ is_error?: boolean;
216
+ api_error_status?: number;
217
+ result?: string;
218
+ errors?: string[];
219
+ }): boolean {
220
+ if (!r.is_error) return false;
221
+ if (r.api_error_status === 401 || r.api_error_status === 403) return true;
222
+ return looksLikeAuthErrorText(`${r.result ?? ""} ${(r.errors ?? []).join(" ")}`);
223
+ }
224
+
168
225
  export interface CLIStreamEventMessage {
169
226
  type: "stream_event";
170
227
  event: unknown;
@@ -61,6 +61,26 @@ export interface Session {
61
61
  stateMachine: SessionStateMachine;
62
62
  /** Cleanup function for state machine transition listener — call on session teardown. */
63
63
  unsubscribeStateMachine?: () => void;
64
+ /**
65
+ * Serialized (JSON) user_message that has been dispatched to the backend but
66
+ * has not yet produced any model output (assistant/stream_event/result).
67
+ * If the CLI dies before responding, this is replayed to the relaunched
68
+ * `--resume` process so the prompt isn't silently lost. Cleared the moment
69
+ * any output for the turn arrives. Runtime-only (not persisted).
70
+ */
71
+ inFlightUserTurn?: string;
72
+ /**
73
+ * Guards against replaying a "poison" prompt that crashes the CLI on every
74
+ * resume: an in-flight turn is replayed at most once per turn. Reset when a
75
+ * new user_message is dispatched.
76
+ */
77
+ inFlightTurnReplayed?: boolean;
78
+ /**
79
+ * Set when the last turn failed authentication (expired/invalid credentials).
80
+ * Auto-relaunch is skipped while this is set — relaunching cannot fix auth —
81
+ * and a re-login banner is surfaced instead. Cleared on a successful result.
82
+ */
83
+ authBlocked?: boolean;
64
84
  }
65
85
 
66
86
  export type GitSessionKey =
@@ -547,6 +547,36 @@ describe("CLI handlers", () => {
547
547
  expect(session.state.model).toBe("claude-opus-4-6");
548
548
  });
549
549
 
550
+ it("clears authBlocked when the CLI (re)initializes via session_init", async () => {
551
+ // Regression: after an auth failure sets authBlocked=true, a freshly booted/
552
+ // reattached CLI (e.g. user re-ran `claude login` + Reconnect) must clear the
553
+ // block. Otherwise a later UNRELATED crash is wrongly treated as auth and
554
+ // denied keepalive relaunch, leaving the session silently dead.
555
+ mockExecSync.mockImplementation(() => { throw new Error("not a git repo"); });
556
+ const cli = makeCliSocket("s1");
557
+ bridge.handleCLIOpen(cli, "s1");
558
+ await bridge.handleCLIMessage(cli, makeInitMsg());
559
+
560
+ const session = bridge.getSession("s1")!;
561
+ session.authBlocked = true;
562
+
563
+ // Re-init through the adapter pipeline (the server-owned adapter path where
564
+ // the authBlocked clear lives).
565
+ const adapter = session.backendAdapter as any;
566
+ adapter.browserMessageCb({
567
+ type: "session_init",
568
+ session: {
569
+ session_id: "cli-internal",
570
+ model: "claude-sonnet-4-6",
571
+ cwd: "/test",
572
+ tools: [],
573
+ permissionMode: "default",
574
+ },
575
+ });
576
+
577
+ expect(session.authBlocked).toBe(false);
578
+ });
579
+
550
580
  it("handleCLIMessage: updates state from init (model, cwd, tools, permissionMode)", async () => {
551
581
  mockExecSync.mockImplementation(() => {
552
582
  throw new Error("not a git repo");
@@ -4795,3 +4825,151 @@ describe("User message during initializing phase", () => {
4795
4825
  expect(session.stateMachine.phase).toBe("streaming");
4796
4826
  });
4797
4827
  });
4828
+
4829
+ // ─── In-flight turn replay + auth surfacing ──────────────────────────────────
4830
+ // These cover the two reported failures: (A) a user prompt sent to a CLI that
4831
+ // dies before answering must be replayed after relaunch instead of being lost
4832
+ // ("no answers"); (B) a 401 auth result must flag the session and surface an
4833
+ // auth banner instead of silently looping.
4834
+ describe("in-flight user turn replay", () => {
4835
+ function makeAdapter(opts: { connected?: boolean; send?: any } = {}) {
4836
+ let onBrowserMessage: ((msg: any) => void) | undefined;
4837
+ const send = opts.send ?? vi.fn(() => true);
4838
+ const adapter = {
4839
+ isConnected: () => opts.connected ?? true,
4840
+ send,
4841
+ disconnect: async () => {},
4842
+ onBrowserMessage: (cb: (msg: any) => void) => { onBrowserMessage = cb; },
4843
+ onSessionMeta: () => {},
4844
+ onDisconnect: () => {},
4845
+ onInitError: () => {},
4846
+ };
4847
+ return { adapter, send, fire: (msg: any) => onBrowserMessage?.(msg) };
4848
+ }
4849
+
4850
+ it("sets inFlightUserTurn on dispatch and clears it when the backend produces output", () => {
4851
+ const browser = makeBrowserSocket("inflight-1");
4852
+ bridge.handleBrowserOpen(browser, "inflight-1");
4853
+ const { adapter, fire } = makeAdapter();
4854
+ bridge.attachBackendAdapter("inflight-1", adapter as any, "codex");
4855
+
4856
+ bridge.handleBrowserMessage(browser, JSON.stringify({ type: "user_message", content: "do the thing" }));
4857
+
4858
+ const session = bridge.getSession("inflight-1")!;
4859
+ expect(session.inFlightUserTurn).toBeDefined();
4860
+ expect(JSON.parse(session.inFlightUserTurn!).content).toBe("do the thing");
4861
+ expect(session.inFlightTurnReplayed).toBe(false);
4862
+
4863
+ // First sign of output for the turn clears the replay copy.
4864
+ fire({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi" } }, parent_tool_use_id: null });
4865
+ expect(session.inFlightUserTurn).toBeUndefined();
4866
+ });
4867
+
4868
+ it("replays an unanswered in-flight turn through the relaunched adapter", () => {
4869
+ const browser = makeBrowserSocket("inflight-2");
4870
+ bridge.handleBrowserOpen(browser, "inflight-2");
4871
+ const first = makeAdapter();
4872
+ bridge.attachBackendAdapter("inflight-2", first.adapter as any, "codex");
4873
+
4874
+ bridge.handleBrowserMessage(browser, JSON.stringify({ type: "user_message", content: "unanswered prompt" }));
4875
+ const session = bridge.getSession("inflight-2")!;
4876
+ expect(session.inFlightUserTurn).toBeDefined();
4877
+ // It was delivered (send returned true), so it is NOT sitting in the queue.
4878
+ expect(session.pendingMessages).toHaveLength(0);
4879
+
4880
+ // CLI dies before answering → a fresh adapter attaches (relaunch).
4881
+ const second = makeAdapter();
4882
+ bridge.attachBackendAdapter("inflight-2", second.adapter as any, "codex");
4883
+
4884
+ const replayed = (second.send.mock.calls as any[][]).find(([m]) => m?.type === "user_message");
4885
+ expect(replayed?.[0]?.content).toBe("unanswered prompt");
4886
+ expect(session.inFlightTurnReplayed).toBe(true);
4887
+ expect(session.pendingMessages).toHaveLength(0);
4888
+ });
4889
+
4890
+ it("does not replay the same in-flight turn twice (poison-prompt guard)", () => {
4891
+ const browser = makeBrowserSocket("inflight-3");
4892
+ bridge.handleBrowserOpen(browser, "inflight-3");
4893
+ bridge.attachBackendAdapter("inflight-3", makeAdapter().adapter as any, "codex");
4894
+ bridge.handleBrowserMessage(browser, JSON.stringify({ type: "user_message", content: "poison" }));
4895
+
4896
+ // First relaunch replays it.
4897
+ bridge.attachBackendAdapter("inflight-3", makeAdapter().adapter as any, "codex");
4898
+ const session = bridge.getSession("inflight-3")!;
4899
+ expect(session.inFlightTurnReplayed).toBe(true);
4900
+
4901
+ // Second relaunch (still no answer) must NOT replay again.
4902
+ const third = makeAdapter();
4903
+ bridge.attachBackendAdapter("inflight-3", third.adapter as any, "codex");
4904
+ const replayedAgain = (third.send.mock.calls as any[][]).find(([m]) => m?.type === "user_message");
4905
+ expect(replayedAgain).toBeUndefined();
4906
+ });
4907
+
4908
+ it("flags authBlocked on a 401 result and forwards it without a duplicate auth_status bubble", () => {
4909
+ const browser = makeBrowserSocket("auth-1");
4910
+ bridge.handleBrowserOpen(browser, "auth-1");
4911
+ const { adapter, fire } = makeAdapter();
4912
+ bridge.attachBackendAdapter("auth-1", adapter as any, "codex");
4913
+ browser.send.mockClear();
4914
+
4915
+ fire({
4916
+ type: "result",
4917
+ data: {
4918
+ type: "result",
4919
+ subtype: "success",
4920
+ is_error: true,
4921
+ api_error_status: 401,
4922
+ result: "Failed to authenticate. API Error: 401 Invalid authentication credentials",
4923
+ duration_ms: 1,
4924
+ duration_api_ms: 0,
4925
+ num_turns: 1,
4926
+ total_cost_usd: 0,
4927
+ stop_reason: null,
4928
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
4929
+ uuid: "u-auth",
4930
+ session_id: "auth-1",
4931
+ },
4932
+ });
4933
+
4934
+ const session = bridge.getSession("auth-1")!;
4935
+ // The session is flagged so auto-relaunch is skipped (the orchestrator
4936
+ // surfaces the re-login banner when it declines to relaunch).
4937
+ expect(session.authBlocked).toBe(true);
4938
+ const broadcast = (browser.send.mock.calls as any[][]).map(([raw]) => JSON.parse(raw));
4939
+ // The raw result is forwarded so the client renders the re-login banner via
4940
+ // describeResultError (guidance + the specific error detail)...
4941
+ expect(broadcast.some((m: any) => m.type === "result")).toBe(true);
4942
+ // ...and we do NOT also emit an auth_status bubble on this path — that would
4943
+ // duplicate the same guidance in a second stacked system message.
4944
+ expect(broadcast.some((m: any) => m.type === "auth_status")).toBe(false);
4945
+ });
4946
+
4947
+ it("clears authBlocked after a subsequent successful result", () => {
4948
+ const browser = makeBrowserSocket("auth-2");
4949
+ bridge.handleBrowserOpen(browser, "auth-2");
4950
+ const { adapter, fire } = makeAdapter();
4951
+ bridge.attachBackendAdapter("auth-2", adapter as any, "codex");
4952
+
4953
+ const session = bridge.getSession("auth-2")!;
4954
+ session.authBlocked = true;
4955
+
4956
+ fire({
4957
+ type: "result",
4958
+ data: {
4959
+ type: "result",
4960
+ subtype: "success",
4961
+ is_error: false,
4962
+ duration_ms: 1,
4963
+ duration_api_ms: 0,
4964
+ num_turns: 1,
4965
+ total_cost_usd: 0,
4966
+ stop_reason: "end_turn",
4967
+ usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
4968
+ uuid: "u-ok",
4969
+ session_id: "auth-2",
4970
+ },
4971
+ });
4972
+
4973
+ expect(session.authBlocked).toBe(false);
4974
+ });
4975
+ });
@@ -7,6 +7,7 @@ import type {
7
7
  BackendType,
8
8
  McpServerConfig,
9
9
  } from "./session-types.js";
10
+ import { isAuthErrorResult } from "./session-types.js";
10
11
  import type { SessionStore } from "./session-store.js";
11
12
  import type { IBackendAdapter } from "./backend-adapter.js";
12
13
  import { ClaudeAdapter } from "./claude-adapter.js";
@@ -442,6 +443,12 @@ export class WsBridge {
442
443
  ...cwdOverride,
443
444
  backend_type: session.backendType,
444
445
  };
446
+ // A freshly booted/reattached CLI invalidates any prior auth-block: the
447
+ // credentials may have been refreshed out-of-band (e.g. `claude login`
448
+ // + Reconnect). Clear it here so a later unrelated crash isn't wrongly
449
+ // treated as an auth failure and denied keepalive relaunch. authBlocked
450
+ // is re-set the moment another auth-error result arrives.
451
+ session.authBlocked = false;
445
452
  this.refreshGitInfo(session, { notifyPoller: true });
446
453
  this.broadcastToBrowsers(session, { type: "session_init", session: session.state });
447
454
  session.stateMachine.transition("ready", "system_init");
@@ -501,6 +508,9 @@ export class WsBridge {
501
508
 
502
509
  // -- assistant: append to history, notify listeners ------------------
503
510
  if (msg.type === "assistant") {
511
+ // The turn is producing output — it was delivered, so drop the in-flight
512
+ // replay copy (no need to re-send it after a future relaunch).
513
+ session.inFlightUserTurn = undefined;
504
514
  const assistantMsg = { ...msg, timestamp: msg.timestamp || Date.now() };
505
515
  this.appendHistory(session, assistantMsg);
506
516
  this.persistSession(session);
@@ -508,12 +518,29 @@ export class WsBridge {
508
518
  }
509
519
 
510
520
  if (msg.type === "stream_event") {
521
+ session.inFlightUserTurn = undefined;
511
522
  companionBus.emit("message:stream_event", { sessionId: session.id, message: msg });
512
523
  }
513
524
 
514
525
  // -- result: update session cost/turns, refresh git, notify listeners
515
526
  if (msg.type === "result") {
516
527
  const resultData = msg.data;
528
+ // The turn completed (even if it errored) — no replay needed.
529
+ session.inFlightUserTurn = undefined;
530
+ // Detect auth failures (expired/invalid credentials). The CLI keeps
531
+ // running in stream-json mode, so this is the primary auth signal — not
532
+ // a process exit. Flag the session so auto-relaunch is skipped (the
533
+ // orchestrator surfaces a re-login banner when it declines to relaunch).
534
+ // We do NOT broadcast auth_status here: the raw result is forwarded to
535
+ // browsers below (catch-all broadcast) and the client renders it via
536
+ // describeResultError, which already includes the re-login guidance plus
537
+ // the specific error detail — a second auth_status bubble would just
538
+ // duplicate that same guidance.
539
+ if (isAuthErrorResult(resultData)) {
540
+ session.authBlocked = true;
541
+ } else if (!resultData.is_error) {
542
+ session.authBlocked = false;
543
+ }
517
544
  session.state.total_cost_usd = resultData.total_cost_usd;
518
545
  session.state.num_turns = resultData.num_turns;
519
546
  if (typeof resultData.total_lines_added === "number") {
@@ -674,6 +701,28 @@ export class WsBridge {
674
701
  this.broadcastToBrowsers(session, { type: "error", message: error });
675
702
  });
676
703
 
704
+ // Replay a turn that was dispatched but never answered before the previous
705
+ // CLI died. --resume restores the conversation context but does NOT re-run
706
+ // an unanswered prompt, so without this the relaunched session sits idle
707
+ // forever ("no answer"). Replay at most once per turn to avoid a poison
708
+ // prompt looping the CLI. Prepended so it runs ahead of any later queue.
709
+ if (
710
+ !isLegacyWsClaude &&
711
+ session.inFlightUserTurn &&
712
+ !session.inFlightTurnReplayed &&
713
+ !session.pendingMessages.includes(session.inFlightUserTurn)
714
+ ) {
715
+ session.pendingMessages.unshift(session.inFlightUserTurn);
716
+ session.inFlightTurnReplayed = true;
717
+ log.info("ws-bridge", "Replaying in-flight user turn after relaunch", {
718
+ sessionId,
719
+ });
720
+ this.broadcastToBrowsers(session, {
721
+ type: "error",
722
+ message: "Reconnected — re-sending your last message, which the previous session didn't answer.",
723
+ });
724
+ }
725
+
677
726
  // Flush pending messages for server-owned backends (Codex + stdio Claude),
678
727
  // where attachment is the transport-open event. The legacy WS Claude
679
728
  // transport flushes in handleCLIOpen after attachWebSocket instead.
@@ -1170,6 +1219,16 @@ export class WsBridge {
1170
1219
  }
1171
1220
  this.persistSession(session);
1172
1221
  this.broadcastToBrowsers(session, userMessage);
1222
+ // Track this turn as in-flight so it can be replayed if the CLI dies
1223
+ // before answering. Stored as the original outgoing message so the
1224
+ // relaunch flush can re-send it verbatim. A new turn resets the
1225
+ // one-shot replay guard.
1226
+ session.inFlightUserTurn = JSON.stringify(msg);
1227
+ session.inFlightTurnReplayed = false;
1228
+ // A fresh turn is an attempt to make progress (e.g. after the user
1229
+ // re-authenticated), so clear any stale auth-block — a genuine crash on
1230
+ // this new turn should be allowed to auto-relaunch again.
1231
+ session.authBlocked = false;
1173
1232
  }
1174
1233
 
1175
1234
  // -- permission_response: populate updatedInput fallback from pending, then remove -------