@hellcoder/companion 0.109.1-preview.20260725063347.e26b91a → 0.109.1

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.109.1-preview.20260725063347.e26b91a",
3
+ "version": "0.109.1",
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",
@@ -81,9 +81,15 @@ describe("SessionStateMachine", () => {
81
81
  it("initializing -> terminated", () =>
82
82
  expectValidTransition("initializing", "terminated"));
83
83
 
84
- // ready -> streaming, compacting, reconnecting, terminated
84
+ // ready -> streaming, awaiting_permission, compacting, reconnecting, terminated
85
85
  it("ready -> streaming", () =>
86
86
  expectValidTransition("ready", "streaming"));
87
+ // Reattach replays a pending permission while the session is idle: the CLI
88
+ // emits system_init (phase -> ready) and the resumed process immediately
89
+ // re-requests the permission it was already blocked on. This edge must be
90
+ // legal or the request is dropped and the phase stays "ready".
91
+ it("ready -> awaiting_permission", () =>
92
+ expectValidTransition("ready", "awaiting_permission"));
87
93
  it("ready -> compacting", () =>
88
94
  expectValidTransition("ready", "compacting"));
89
95
  it("ready -> reconnecting", () =>
@@ -561,6 +567,46 @@ describe("SessionStateMachine", () => {
561
567
  }
562
568
  });
563
569
 
570
+ it("handles a permission replayed on reattach: reconnecting -> initializing -> ready -> awaiting_permission", () => {
571
+ // Regression: a session that drops while awaiting permission gets its
572
+ // pending request replayed by the resumed CLI. ws-bridge forces the phase
573
+ // to "ready" on system_init, so the replayed request arrives in "ready".
574
+ // If that edge is illegal the transition is blocked, which has two
575
+ // consequences beyond the dropped request:
576
+ // 1. No transition event fires, so no session_phase frame is broadcast
577
+ // and the client keeps showing the session as idle while it is in
578
+ // fact blocked on an approval dialog.
579
+ // 2. handleCLIClose derives interruptedMidTurn from the phase, so a
580
+ // second drop is logged as a clean idle disconnect and the in-flight
581
+ // turn is orphaned rather than flagged.
582
+ const reattached = new SessionStateMachine("reattach", "awaiting_permission");
583
+ const events: SessionTransitionEvent[] = [];
584
+ reattached.onTransition((e) => events.push(e));
585
+
586
+ // Socket drops while the approval dialog is still open.
587
+ expect(reattached.transition("reconnecting", "cli_ws_closed")).toBe(true);
588
+ // CLI reattaches and re-initializes.
589
+ expect(reattached.transition("initializing", "cli-ws-reconnected")).toBe(true);
590
+ expect(reattached.transition("ready", "system_init")).toBe(true);
591
+
592
+ // The resumed CLI immediately re-requests the still-pending permission.
593
+ expect(reattached.transition("awaiting_permission", "permission_requested")).toBe(true);
594
+ expect(reattached.phase).toBe("awaiting_permission");
595
+
596
+ // The transition must emit an event — this is what drives the
597
+ // session_phase broadcast that moves the client off "idle".
598
+ expect(events.map((e) => `${e.from}->${e.to}`)).toEqual([
599
+ "awaiting_permission->reconnecting",
600
+ "reconnecting->initializing",
601
+ "initializing->ready",
602
+ "ready->awaiting_permission",
603
+ ]);
604
+
605
+ // A subsequent drop is now correctly attributable to a mid-turn state.
606
+ expect(reattached.canRespondToPermission()).toBe(true);
607
+ expect(reattached.isIdle()).toBe(false);
608
+ });
609
+
564
610
  it("handles early user message: starting -> streaming -> initializing -> ready", () => {
565
611
  // When a user sends a message before the CLI connects, the session
566
612
  // transitions starting -> streaming. When the CLI later connects,
@@ -55,6 +55,12 @@ export const VALID_TRANSITIONS: ReadonlyMap<
55
55
  "ready",
56
56
  new Set<SessionPhase>([
57
57
  "streaming",
58
+ // A permission request can arrive while the session is idle: on reattach
59
+ // the CLI emits system_init (forcing phase -> ready), then the resumed
60
+ // process immediately re-requests its still-pending permission. Without
61
+ // this edge the transition is blocked and the phase stays "ready", so the
62
+ // session reads as idle while it is in fact awaiting approval.
63
+ "awaiting_permission",
58
64
  "compacting",
59
65
  "reconnecting",
60
66
  "terminated",
@@ -1858,6 +1858,71 @@ describe("CLI message routing", () => {
1858
1858
  expect(permBroadcast.request.tool_name).toBe("Bash");
1859
1859
  });
1860
1860
 
1861
+ // Regression: a permission request arriving while the session is idle must
1862
+ // move the phase to awaiting_permission AND reach the browser as a
1863
+ // session_phase frame.
1864
+ //
1865
+ // This is the reattach path: a session drops while a permission dialog is
1866
+ // open, the CLI is resumed, it emits system.init (which forces phase ->
1867
+ // ready), and the resumed process immediately re-requests the permission it
1868
+ // was already blocked on. So the request lands in "ready", not "streaming".
1869
+ //
1870
+ // Before the ready -> awaiting_permission edge was added to VALID_TRANSITIONS
1871
+ // the transition was blocked. The permission itself still rendered (it is
1872
+ // stored and broadcast independently), but no transition event fired, so no
1873
+ // session_phase frame was emitted and the client stayed on the "idle" status
1874
+ // set by the preceding system.init — showing an idle session that was in fact
1875
+ // blocked on an approval dialog.
1876
+ it("control_request: broadcasts session_phase when a permission arrives in the ready phase", async () => {
1877
+ const cli = makeCliSocket("s1");
1878
+ bridge.handleCLIOpen(cli, "s1");
1879
+
1880
+ // system.init drives the session to "ready", as it does on every reattach.
1881
+ await bridge.handleCLIMessage(cli, makeInitMsg());
1882
+ const session = bridge.getSession("s1")!;
1883
+ expect(session.stateMachine.phase).toBe("ready");
1884
+
1885
+ const browser = makeBrowserSocket("s1");
1886
+ bridge.handleBrowserOpen(browser, "s1");
1887
+ // Drop the connect-time frames so we only assert on what the permission
1888
+ // request itself produces.
1889
+ browser.send.mockClear();
1890
+
1891
+ // The resumed CLI replays its pending permission while still idle.
1892
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1893
+ type: "control_request",
1894
+ request_id: "req-reattach",
1895
+ request: {
1896
+ subtype: "can_use_tool",
1897
+ tool_name: "Edit",
1898
+ input: { file_path: "/test.ts" },
1899
+ tool_use_id: "tu-reattach",
1900
+ },
1901
+ }));
1902
+
1903
+ // The transition is legal, so the phase actually advances.
1904
+ expect(session.stateMachine.phase).toBe("awaiting_permission");
1905
+
1906
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1907
+
1908
+ // The frame that unsticks the client status. src/ws.ts maps
1909
+ // awaiting_permission -> "running"; without this frame the session renders
1910
+ // as idle.
1911
+ expect(calls).toContainEqual(
1912
+ expect.objectContaining({
1913
+ type: "session_phase",
1914
+ phase: "awaiting_permission",
1915
+ previousPhase: "ready",
1916
+ }),
1917
+ );
1918
+
1919
+ // The dialog still renders — this part was never broken, and asserting it
1920
+ // here guards against a fix that trades one for the other.
1921
+ const permBroadcast = calls.find((c: any) => c.type === "permission_request");
1922
+ expect(permBroadcast).toBeDefined();
1923
+ expect(permBroadcast.request.request_id).toBe("req-reattach");
1924
+ });
1925
+
1861
1926
  it("tool_progress: broadcasts", async () => {
1862
1927
  const msg = JSON.stringify({
1863
1928
  type: "tool_progress",