@cueloop/daemon 0.1.0-alpha.36 → 0.1.0-alpha.38

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": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.36",
3
+ "version": "0.1.0-alpha.38",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@cueloop/schema": "0.1.0-alpha.36",
31
+ "@cueloop/schema": "0.1.0-alpha.38",
32
32
  "valibot": "^1.4.2"
33
33
  },
34
34
  "engines": {
package/src/api.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  type WorkspaceKey,
23
23
  } from "@cueloop/schema";
24
24
  import { SessionStore } from "./store";
25
+ import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
25
26
  import { DaemonError } from "./errors";
26
27
 
27
28
  export type EventName =
@@ -40,6 +41,7 @@ type EventListener = (event: DaemonEvent) => void;
40
41
 
41
42
  export class DaemonCore {
42
43
  readonly store: SessionStore;
44
+ readonly herdrTabs: HerdrTabStore;
43
45
  private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
44
46
  private listeners = new Set<EventListener>();
45
47
  private seq = 0;
@@ -47,6 +49,16 @@ export class DaemonCore {
47
49
  constructor(home: string) {
48
50
  this.store = new SessionStore(home);
49
51
  this.store.recover();
52
+ this.herdrTabs = new HerdrTabStore(home);
53
+ }
54
+
55
+ /** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
56
+ herdrGetTab(sessionId: string): HerdrTabHandle | null {
57
+ return this.herdrTabs.get(sessionId);
58
+ }
59
+
60
+ herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
61
+ this.herdrTabs.set(sessionId, handle);
50
62
  }
51
63
 
52
64
  onEvent(listener: EventListener): () => void {
@@ -190,6 +202,7 @@ export class DaemonCore {
190
202
  /** Remove a session for good (inbox delete); resolved or pending, both go. */
191
203
  sessionDelete(id: string): void {
192
204
  if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
205
+ this.herdrTabs.delete(id);
193
206
  this.emit("inbox.changed", id);
194
207
  }
195
208
 
package/src/client.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  WorkspaceKey,
15
15
  } from "@cueloop/schema";
16
16
  import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
17
+ import type { HerdrTabHandle } from "./herdr-tab-store";
17
18
  import { cueloopHome, socketPath } from "./paths";
18
19
 
19
20
  export type { EventFrame } from "./protocol";
@@ -225,6 +226,13 @@ export class DaemonClient implements SessionClient {
225
226
  ): Promise<ReviewSession> {
226
227
  return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
227
228
  }
229
+ /** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
230
+ herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
231
+ return this.request("herdr.getTab", { id });
232
+ }
233
+ async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
234
+ await this.request("herdr.setTab", { id, ...handle });
235
+ }
228
236
  shutdown(): Promise<void> {
229
237
  return this.request("daemon.shutdown", {});
230
238
  }
@@ -1,4 +1,4 @@
1
- /** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, and the gating wrapper is checked for its three outcomes (no-op outside herdr, no-op on revision, full open for a new session). */
1
+ /** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, openHerdrPane returns the tab handle, and openHerdrPaneForReview opens+records for a new review, focuses a still-alive recorded tab, and reopens a dead one - collision-free by pane id. */
2
2
 
3
3
  import { afterAll, describe, expect, test } from "bun:test";
4
4
  import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -6,23 +6,30 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import type { ReviewSession } from "@cueloop/schema";
8
8
  import { openHerdrPane, openHerdrPaneForReview } from "./herdr-pane";
9
+ import type { HerdrTabPersistence } from "./herdr-pane";
10
+ import type { HerdrTabHandle } from "./herdr-tab-store";
9
11
 
10
12
  const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
11
13
 
12
14
  /**
13
- * A stub herdr binary. It appends its argv to logPath and, when the verb is
14
- * `tab create`, prints a JSON result carrying the pane id on stdout so the
15
- * helper can drive send-text and send-keys. The printed shape mirrors the
16
- * REAL herdr `tab create` output ({ result: { root_pane: { pane_id } } },
17
- * verified against herdr 0.8.0) - an earlier stub invented a different shape
18
- * and let a parser that never matched real herdr pass the suite.
15
+ * A stub herdr binary. It logs argv, prints the tab-create result carrying both
16
+ * pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
17
+ * per `paneAlive` so the liveness branch can be exercised.
19
18
  */
20
- function makeStub(name: string, paneId = "w1:p2"): { binPath: string; logPath: string } {
19
+ function makeStub(name: string, paneAlive = false): { binPath: string; logPath: string } {
21
20
  const logPath = join(dir, `${name}.log`);
22
21
  const binPath = join(dir, `${name}.sh`);
22
+ const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
23
23
  writeFileSync(
24
24
  binPath,
25
- `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nif [ "$1" = "tab" ] && [ "$2" = "create" ]; then\n printf '{"result":{"root_pane":{"pane_id":"${paneId}"}}}'\nfi\n`,
25
+ `#!/bin/sh
26
+ printf '%s\\n' "$*" >> "${logPath}"
27
+ if [ "$1" = "tab" ] && [ "$2" = "create" ]; then
28
+ printf '{"result":{"root_pane":{"pane_id":"w1:p2","tab_id":"w1:t2"}}}'
29
+ fi
30
+ if [ "$1" = "pane" ] && [ "$2" = "get" ]; then ${paneGet}; fi
31
+ exit 0
32
+ `,
26
33
  );
27
34
  chmodSync(binPath, 0o755);
28
35
  return { binPath, logPath };
@@ -33,6 +40,23 @@ function readLines(logPath: string): string[] {
33
40
  return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
34
41
  }
35
42
 
43
+ /** In-memory persistence double: records what openHerdrPaneForReview saves. */
44
+ function fakePersistence(initial: HerdrTabHandle | null = null): {
45
+ persistence: HerdrTabPersistence;
46
+ saved: () => HerdrTabHandle | null;
47
+ } {
48
+ let stored = initial;
49
+ return {
50
+ persistence: {
51
+ herdrGetTab: async () => stored,
52
+ herdrSetTab: async (_sessionId, handle) => {
53
+ stored = handle;
54
+ },
55
+ },
56
+ saved: () => stored,
57
+ };
58
+ }
59
+
36
60
  function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
37
61
  return {
38
62
  schemaVersion: "1",
@@ -49,12 +73,12 @@ function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
49
73
  }
50
74
 
51
75
  describe("openHerdrPane", () => {
52
- test("creates a focused tab, types the cueloop command, and presses enter", () => {
76
+ test("creates a focused tab, launches the review, and returns the handle", () => {
53
77
  // Arrange
54
78
  const stub = makeStub("open");
55
79
 
56
80
  // Act
57
- const ok = openHerdrPane({
81
+ const handle = openHerdrPane({
58
82
  sessionId: "ses_abc",
59
83
  cwd: "/repo/work",
60
84
  binPath: stub.binPath,
@@ -62,7 +86,7 @@ describe("openHerdrPane", () => {
62
86
  });
63
87
 
64
88
  // Assert
65
- expect(ok).toBeTrue();
89
+ expect(handle).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
66
90
  expect(readLines(stub.logPath)).toEqual([
67
91
  "tab create --cwd /repo/work --label Rollout Plan --focus",
68
92
  "pane send-text w1:p2 cueloop ses_abc",
@@ -70,20 +94,18 @@ describe("openHerdrPane", () => {
70
94
  ]);
71
95
  });
72
96
 
73
- test("returns false and never throws on a broken binary", () => {
74
- // Act
75
- const ok = openHerdrPane({
76
- sessionId: "ses_abc",
77
- cwd: "/repo/work",
78
- binPath: join(dir, "missing-bin"),
79
- label: "x",
80
- });
81
-
82
- // Assert
83
- expect(ok).toBeFalse();
97
+ test("returns null and never throws on a broken binary", () => {
98
+ expect(
99
+ openHerdrPane({
100
+ sessionId: "ses_abc",
101
+ cwd: "/repo/work",
102
+ binPath: join(dir, "missing-bin"),
103
+ label: "x",
104
+ }),
105
+ ).toBeNull();
84
106
  });
85
107
 
86
- test("bails out when tab create yields no pane id - no send-text, no send-keys", () => {
108
+ test("returns null when tab create yields no ids - no send-text, no send-keys", () => {
87
109
  // Arrange
88
110
  const logPath = join(dir, "nopane.log");
89
111
  const binPath = join(dir, "nopane.sh");
@@ -94,22 +116,23 @@ describe("openHerdrPane", () => {
94
116
  chmodSync(binPath, 0o755);
95
117
 
96
118
  // Act
97
- const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
119
+ const handle = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
98
120
 
99
121
  // Assert
100
- expect(ok).toBeFalse();
122
+ expect(handle).toBeNull();
101
123
  expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
102
124
  });
103
125
  });
104
126
 
105
127
  describe("openHerdrPaneForReview", () => {
106
- test("opens a pane for a genuinely new session inside herdr", () => {
128
+ test("opens and records a tab for a review with no recorded tab", async () => {
107
129
  // Arrange
108
130
  const stub = makeStub("gated-new");
109
131
  const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
132
+ const store = fakePersistence(null);
110
133
 
111
134
  // Act
112
- openHerdrPaneForReview(newSession({ id: "ses_xyz" }), env);
135
+ await openHerdrPaneForReview(newSession({ id: "ses_xyz" }), store.persistence, env);
113
136
 
114
137
  // Assert
115
138
  expect(readLines(stub.logPath)).toEqual([
@@ -117,35 +140,72 @@ describe("openHerdrPaneForReview", () => {
117
140
  "pane send-text w1:p2 cueloop ses_xyz",
118
141
  "pane send-keys w1:p2 enter",
119
142
  ]);
143
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
120
144
  });
121
145
 
122
- test("no-op outside herdr - no herdr process is spawned", () => {
146
+ test("no-op outside herdr - no herdr process is spawned", async () => {
123
147
  // Arrange
124
148
  const stub = makeStub("gated-outside");
149
+ const store = fakePersistence(null);
125
150
 
126
151
  // Act
127
- openHerdrPaneForReview(newSession(), { HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath });
152
+ await openHerdrPaneForReview(newSession(), store.persistence, {
153
+ HERDR_PANE_ID: "w1:p1",
154
+ HERDR_BIN_PATH: stub.binPath,
155
+ });
128
156
 
129
157
  // Assert
130
158
  expect(existsSync(stub.logPath)).toBeFalse();
159
+ expect(store.saved()).toBeNull();
131
160
  });
132
161
 
133
- test("no-op for a revision - the already open pane is reused, not spammed", () => {
162
+ test("focuses the recorded tab when its pane is still alive, without reopening", async () => {
134
163
  // Arrange
135
- const stub = makeStub("gated-revision");
164
+ const stub = makeStub("gated-alive", true);
136
165
  const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
137
- const revised = newSession({
138
- revisions: [
139
- { revision: 1, content: "# P", submittedAt: "now" },
140
- { revision: 2, content: "# P2", submittedAt: "now" },
141
- ],
142
- });
166
+ const store = fakePersistence({ tabId: "w9:t9", paneId: "w9:p9" });
143
167
 
144
168
  // Act
145
- openHerdrPaneForReview(revised, env);
169
+ await openHerdrPaneForReview(newSession(), store.persistence, env);
146
170
 
147
171
  // Assert
148
- expect(existsSync(stub.logPath)).toBeFalse();
172
+ const lines = readLines(stub.logPath);
173
+ expect(lines).toContain("pane get w9:p9");
174
+ expect(lines).toContain("tab focus w9:t9");
175
+ expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
176
+ expect(store.saved()).toEqual({ tabId: "w9:t9", paneId: "w9:p9" });
177
+ });
178
+
179
+ test("a persistence failure never escapes - review creation stays best-effort", async () => {
180
+ // Arrange - the daemon store rejects; the opener must still resolve quietly
181
+ const stub = makeStub("gated-persist-fail");
182
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
183
+ const persistence: HerdrTabPersistence = {
184
+ herdrGetTab: async () => null,
185
+ herdrSetTab: async () => {
186
+ throw new Error("disk full");
187
+ },
188
+ };
189
+
190
+ // Act & Assert - no throw
191
+ await openHerdrPaneForReview(newSession(), persistence, env);
192
+ expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
193
+ });
194
+
195
+ test("reopens and re-records when the recorded pane is dead", async () => {
196
+ // Arrange
197
+ const stub = makeStub("gated-dead", false);
198
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
199
+ const store = fakePersistence({ tabId: "old:t", paneId: "old:p" });
200
+
201
+ // Act
202
+ await openHerdrPaneForReview(newSession({ id: "ses_re" }), store.persistence, env);
203
+
204
+ // Assert
205
+ const lines = readLines(stub.logPath);
206
+ expect(lines).toContain("pane get old:p");
207
+ expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
208
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
149
209
  });
150
210
  });
151
211
 
package/src/herdr-pane.ts CHANGED
@@ -1,100 +1,122 @@
1
1
  /**
2
- * Creation-side herdr pane opener: when a review is created from inside a
3
- * herdr pane, cueloop opens a NEW herdr tab that renders the review, so the
4
- * creator does not have to run a command by hand. This is the counterpart to
5
- * the reviewer-side focusHerdrPane in @cueloop/client. It lives here, beside
6
- * the shared review core (openReview), because both creation sites - the
7
- * Claude Code hook and the `cueloop session create` CLI - already depend on
8
- * this package and drive creation through it, so a single home reaches both
9
- * with no new cross-package dependency.
10
- *
11
- * A fresh tab hosts a plain shell with no command argument, so the review is
12
- * launched exactly as a human would: type `cueloop <sessionId>` into the new
13
- * pane and press enter. The herdr CLI is spawned synchronously because the
14
- * send-text and send-keys steps need the pane id that `tab create` returns.
15
- *
16
- * Best-effort like the rest of the herdr tier: a missing or broken binary, an
17
- * unexpected JSON shape, or any spawn failure is swallowed and never blocks
18
- * review creation. Each spawn is bounded by HERDR_SPAWN_TIMEOUT_MS so a hung
19
- * herdr binary cannot stall the review-creation path - a timeout kills the
20
- * child and reads as a non-zero exit, i.e. a best-effort bail-out.
2
+ * Opens a herdr tab that renders a review, from the creation sites (the Claude
3
+ * Code hook and `cueloop session create`) that run inside a herdr pane. On a
4
+ * resubmit it reopens only when the recorded pane is dead, so a live tab is
5
+ * focused rather than duplicated. Best-effort: any spawn failure is swallowed
6
+ * and never blocks review creation; each spawn is bounded by a timeout.
21
7
  */
22
8
 
23
9
  import { detectHerdr, type HerdrEnv, type ReviewSession } from "@cueloop/schema";
10
+ import type { HerdrTabHandle } from "./herdr-tab-store";
24
11
 
25
- /** Upper bound per herdr spawn, so a stuck binary can never block creation. */
26
12
  const HERDR_SPAWN_TIMEOUT_MS = 2000;
27
13
 
28
14
  export interface OpenHerdrPaneOptions {
29
- /** The review session id; `cueloop <sessionId>` opens it in the new pane. */
30
15
  sessionId: string;
31
- /** Working directory for the new tab; the review's cwd. */
32
16
  cwd: string;
33
- /** Absolute path to the herdr binary, from the ambient env contract. */
34
17
  binPath: string;
35
- /** Sidebar label for the new tab. */
36
18
  label: string;
37
19
  }
38
20
 
21
+ /** The daemon-side handle store, as the two methods this module needs. DaemonClient satisfies it. */
22
+ export interface HerdrTabPersistence {
23
+ herdrGetTab(sessionId: string): Promise<HerdrTabHandle | null>;
24
+ herdrSetTab(sessionId: string, handle: HerdrTabHandle): Promise<void>;
25
+ }
26
+
39
27
  /**
40
- * Open a herdr tab that renders the review. `tab create` prints a JSON result
41
- * shaped like `{ result: { root_pane: { pane_id } } }` (verified against
42
- * herdr 0.8.0; `pane get` uses a different `result.pane` nesting, which is
43
- * what focusHerdrPane reads). The pane id drives the two follow-up steps.
44
- * Returns true when the full open-and-launch sequence completed, false on any
28
+ * Open a focused tab and launch the review in it. `tab create` prints
29
+ * `{ result: { root_pane: { pane_id, tab_id } } }` (herdr 0.8.2); both ids are
30
+ * returned so a later resubmit can liveness-check and focus. Null on any
45
31
  * best-effort bail-out.
46
32
  */
47
- export function openHerdrPane(options: OpenHerdrPaneOptions): boolean {
33
+ export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | null {
48
34
  const { sessionId, cwd, binPath, label } = options;
49
35
  try {
50
36
  const created = Bun.spawnSync(
51
37
  [binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
52
- {
53
- stdout: "pipe",
54
- stderr: "ignore",
55
- timeout: HERDR_SPAWN_TIMEOUT_MS,
56
- },
38
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
57
39
  );
58
- if (created.exitCode !== 0) return false;
40
+ if (created.exitCode !== 0) return null;
59
41
  const parsed = JSON.parse(created.stdout.toString()) as {
60
- result?: { root_pane?: { pane_id?: string } };
42
+ result?: { root_pane?: { pane_id?: string; tab_id?: string } };
61
43
  };
62
44
  const paneId = parsed.result?.root_pane?.pane_id;
63
- if (!paneId) return false;
45
+ const tabId = parsed.result?.root_pane?.tab_id;
46
+ if (!paneId || !tabId) return null;
64
47
  // A fresh tab hosts a plain shell, so the review is typed in like a human.
65
48
  const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
66
49
  stdout: "ignore",
67
50
  stderr: "ignore",
68
51
  timeout: HERDR_SPAWN_TIMEOUT_MS,
69
52
  });
70
- if (typed.exitCode !== 0) return false;
71
- return (
72
- Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
73
- stdout: "ignore",
74
- stderr: "ignore",
75
- timeout: HERDR_SPAWN_TIMEOUT_MS,
76
- }).exitCode === 0
77
- );
53
+ if (typed.exitCode !== 0) return null;
54
+ const entered = Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
55
+ stdout: "ignore",
56
+ stderr: "ignore",
57
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
58
+ });
59
+ return entered.exitCode === 0 ? { tabId, paneId } : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** True when the pane still exists (`herdr pane get` returns it). */
66
+ function herdrPaneAlive(binPath: string, paneId: string): boolean {
67
+ try {
68
+ const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
69
+ stdout: "pipe",
70
+ stderr: "ignore",
71
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
72
+ });
73
+ if (got.exitCode !== 0) return false;
74
+ const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: unknown } };
75
+ return parsed.result?.pane != null;
78
76
  } catch {
79
77
  return false;
80
78
  }
81
79
  }
82
80
 
81
+ /** Bring an existing review tab to the front; best-effort. */
82
+ function focusHerdrTab(binPath: string, tabId: string): void {
83
+ try {
84
+ Bun.spawnSync([binPath, "tab", "focus", tabId], {
85
+ stdout: "ignore",
86
+ stderr: "ignore",
87
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
88
+ });
89
+ } catch {
90
+ // best-effort
91
+ }
92
+ }
93
+
83
94
  /**
84
- * Open a pane for a freshly created review, guarded for the one case that
85
- * wants it: inside herdr and genuinely new. A revision reuses the pane the
86
- * original review already opened, so a resubmit (revisions beyond the first)
87
- * never spawns another tab. A no-op outside herdr keeps a plain terminal
88
- * byte-identical.
95
+ * Ensure a herdr tab is rendering this review. Inside herdr only: focus the
96
+ * recorded tab if its pane is still alive, otherwise open a fresh one and record
97
+ * its handle. No-op outside herdr.
89
98
  */
90
- export function openHerdrPaneForReview(session: ReviewSession, env: HerdrEnv = process.env): void {
99
+ export async function openHerdrPaneForReview(
100
+ session: ReviewSession,
101
+ persistence: HerdrTabPersistence,
102
+ env: HerdrEnv = process.env,
103
+ ): Promise<void> {
91
104
  const herdr = detectHerdr(env);
92
105
  if (!herdr) return;
93
- if (session.revisions.length !== 1) return;
94
- openHerdrPane({
95
- sessionId: session.id,
96
- cwd: session.artifact.meta.cwd ?? process.cwd(),
97
- binPath: herdr.binPath,
98
- label: session.artifact.meta.title ?? session.id,
99
- });
106
+ try {
107
+ const recorded = await persistence.herdrGetTab(session.id);
108
+ if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
109
+ focusHerdrTab(herdr.binPath, recorded.tabId);
110
+ return;
111
+ }
112
+ const opened = openHerdrPane({
113
+ sessionId: session.id,
114
+ cwd: session.artifact.meta.cwd ?? process.cwd(),
115
+ binPath: herdr.binPath,
116
+ label: session.artifact.meta.title ?? session.id,
117
+ });
118
+ if (opened) await persistence.herdrSetTab(session.id, opened);
119
+ } catch {
120
+ // best-effort: a herdr spawn or a daemon persistence hiccup never blocks review creation
121
+ }
100
122
  }
@@ -0,0 +1,95 @@
1
+ /** herdr tab side-store: set/get/delete round-trips, survives a reload from disk, and ignores a corrupt file. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { HerdrTabStore } from "./herdr-tab-store";
8
+ import { herdrTabsPath } from "./paths";
9
+
10
+ let home: string;
11
+
12
+ beforeEach(() => {
13
+ home = mkdtempSync(join(tmpdir(), "cueloop-herdr-store-"));
14
+ });
15
+ afterEach(() => {
16
+ rmSync(home, { recursive: true, force: true });
17
+ });
18
+
19
+ describe("HerdrTabStore", () => {
20
+ test("set then get returns the handle; get for an unknown session is null", () => {
21
+ // Arrange
22
+ const store = new HerdrTabStore(home);
23
+
24
+ // Act
25
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
26
+
27
+ // Assert
28
+ expect(store.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
29
+ expect(store.get("ses_missing")).toBeNull();
30
+ });
31
+
32
+ test("handles survive a reload from disk", () => {
33
+ // Arrange
34
+ new HerdrTabStore(home).set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
35
+
36
+ // Act - a fresh store over the same home loads the persisted file
37
+ const reloaded = new HerdrTabStore(home);
38
+
39
+ // Assert
40
+ expect(reloaded.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
41
+ });
42
+
43
+ test("delete removes the handle", () => {
44
+ // Arrange
45
+ const store = new HerdrTabStore(home);
46
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
47
+
48
+ // Act
49
+ store.delete("ses_1");
50
+
51
+ // Assert
52
+ expect(store.get("ses_1")).toBeNull();
53
+ });
54
+
55
+ test("a corrupt file starts empty instead of throwing", () => {
56
+ // Arrange
57
+ writeFileSync(herdrTabsPath(home), "not json");
58
+
59
+ // Act
60
+ const store = new HerdrTabStore(home);
61
+
62
+ // Assert
63
+ expect(store.get("ses_1")).toBeNull();
64
+ });
65
+
66
+ test("malformed entries are dropped, well-formed ones survive", () => {
67
+ // Arrange - valid JSON, but entries with the wrong shape
68
+ writeFileSync(
69
+ herdrTabsPath(home),
70
+ JSON.stringify({
71
+ ses_ok: { tabId: "w1:t2", paneId: "w1:p2" },
72
+ ses_partial: { tabId: "w1:t2" },
73
+ ses_typed: { tabId: 1, paneId: 2 },
74
+ ses_null: null,
75
+ }),
76
+ );
77
+
78
+ // Act
79
+ const store = new HerdrTabStore(home);
80
+
81
+ // Assert
82
+ expect(store.get("ses_ok")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
83
+ expect(store.get("ses_partial")).toBeNull();
84
+ expect(store.get("ses_typed")).toBeNull();
85
+ expect(store.get("ses_null")).toBeNull();
86
+ });
87
+
88
+ test("a non-object top level starts empty", () => {
89
+ // Arrange
90
+ writeFileSync(herdrTabsPath(home), JSON.stringify(["not", "a", "map"]));
91
+
92
+ // Act & Assert
93
+ expect(new HerdrTabStore(home).get("ses_1")).toBeNull();
94
+ });
95
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * herdr adapter scratch, kept out of the core session record: the tab cueloop
3
+ * opened to render each review, keyed by session id. One JSON map, atomic write.
4
+ * The core `ReviewSession` never mentions herdr; this side-store is the whole
5
+ * leak boundary.
6
+ */
7
+
8
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { herdrTabsPath } from "./paths";
10
+
11
+ /** The tab and its root pane: pane id checks liveness, tab id focuses it. */
12
+ export interface HerdrTabHandle {
13
+ tabId: string;
14
+ paneId: string;
15
+ }
16
+
17
+ /** Guard a persisted entry - both ids must be present strings, or it is dropped. */
18
+ function isHerdrTabHandle(value: unknown): value is HerdrTabHandle {
19
+ return (
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ typeof (value as HerdrTabHandle).tabId === "string" &&
23
+ typeof (value as HerdrTabHandle).paneId === "string"
24
+ );
25
+ }
26
+
27
+ export class HerdrTabStore {
28
+ private tabs = new Map<string, HerdrTabHandle>();
29
+ private readonly path: string;
30
+
31
+ constructor(home: string) {
32
+ this.path = herdrTabsPath(home);
33
+ this.load();
34
+ }
35
+
36
+ private load(): void {
37
+ try {
38
+ const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8"));
39
+ if (typeof parsed !== "object" || parsed === null) return;
40
+ for (const [sessionId, value] of Object.entries(parsed)) {
41
+ if (isHerdrTabHandle(value))
42
+ this.tabs.set(sessionId, { tabId: value.tabId, paneId: value.paneId });
43
+ }
44
+ } catch {
45
+ // absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
46
+ }
47
+ }
48
+
49
+ private persist(): void {
50
+ const tempPath = this.path + ".tmp";
51
+ writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
52
+ renameSync(tempPath, this.path);
53
+ }
54
+
55
+ get(sessionId: string): HerdrTabHandle | null {
56
+ return this.tabs.get(sessionId) ?? null;
57
+ }
58
+
59
+ set(sessionId: string, handle: HerdrTabHandle): void {
60
+ this.tabs.set(sessionId, handle);
61
+ this.persist();
62
+ }
63
+
64
+ delete(sessionId: string): void {
65
+ if (this.tabs.delete(sessionId)) this.persist();
66
+ }
67
+ }
package/src/paths.ts CHANGED
@@ -25,3 +25,8 @@ export function pidPath(home = cueloopHome()): string {
25
25
  export function lockPath(home = cueloopHome()): string {
26
26
  return join(home, "cueloop.lock");
27
27
  }
28
+
29
+ /** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
30
+ export function herdrTabsPath(home = cueloopHome()): string {
31
+ return join(home, "herdr-tabs.json");
32
+ }
package/src/server.ts CHANGED
@@ -283,6 +283,13 @@ export class DaemonServer {
283
283
  const params = parseParams("session.submitRevision", request.params);
284
284
  return core.sessionSubmitRevision(params.id, params.content, params.addressedAnnotationIds);
285
285
  }
286
+ case "herdr.getTab":
287
+ return core.herdrGetTab(parseParams("herdr.getTab", request.params).id);
288
+ case "herdr.setTab": {
289
+ const params = parseParams("herdr.setTab", request.params);
290
+ core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
291
+ return {};
292
+ }
286
293
  default:
287
294
  throw new DaemonError("unknown_method", `unknown method ${request.method}`);
288
295
  }
package/src/validate.ts CHANGED
@@ -150,6 +150,9 @@ export const Params = {
150
150
  "events.subscribe": v.object({}),
151
151
  "daemon.ping": v.object({}),
152
152
  "daemon.shutdown": v.object({}),
153
+ // herdr adapter scratch: the review's opened tab, kept off the session record.
154
+ "herdr.getTab": v.object({ id: SessionId }),
155
+ "herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
153
156
  } as const;
154
157
 
155
158
  export type MethodName = keyof typeof Params;