@hellcoder/companion 0.104.3 → 0.104.4-preview.20260622030455.280fa4c
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/dist/assets/{AgentsPage-H1WOCJ_U.js → AgentsPage-C_uaskvu.js} +1 -1
- package/dist/assets/{CronManager-Cqnqj1KO.js → CronManager-_ScZYZUL.js} +1 -1
- package/dist/assets/{IntegrationsPage-Nd6In9Av.js → IntegrationsPage-j39LFDWj.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-D7RQfyFI.js → LinearOAuthSettingsPage-B437XSi7.js} +1 -1
- package/dist/assets/{LinearSettingsPage-CGVs5jG6.js → LinearSettingsPage-CrHvZJ2l.js} +1 -1
- package/dist/assets/{Playground-M3TI9s_p.js → Playground-Vo-LT0XD.js} +1 -1
- package/dist/assets/{PromptsPage-QXhg1mXT.js → PromptsPage-CBFfssun.js} +1 -1
- package/dist/assets/{RunsPage-C1XiccgQ.js → RunsPage-CzkUlLNh.js} +1 -1
- package/dist/assets/{SandboxManager-DYmKXcTM.js → SandboxManager-F4OKDTsR.js} +1 -1
- package/dist/assets/{SettingsPage-DaJfAHDI.js → SettingsPage-BMOYx-nn.js} +1 -1
- package/dist/assets/{TailscalePage-CmH09EMI.js → TailscalePage-Cs98ENqe.js} +1 -1
- package/dist/assets/index-BkJgPfVV.js +136 -0
- package/dist/assets/{sw-register-DkUWP5b0.js → sw-register-CNrT7Vew.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/claude-adapter.ts +18 -8
- package/server/cli-launcher.test.ts +92 -0
- package/server/cli-launcher.ts +54 -1
- package/server/event-bus-types.ts +10 -2
- package/server/session-orchestrator.test.ts +65 -0
- package/server/session-orchestrator.ts +40 -1
- package/server/session-types.test.ts +86 -0
- package/server/session-types.ts +57 -0
- package/server/ws-bridge-types.ts +20 -0
- package/server/ws-bridge.test.ts +178 -0
- package/server/ws-bridge.ts +59 -0
- package/dist/assets/index-CKx0s4-A.js +0 -134
package/server/session-types.ts
CHANGED
|
@@ -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 =
|
package/server/ws-bridge.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/server/ws-bridge.ts
CHANGED
|
@@ -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 -------
|