@cueloop/daemon 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Creation-side herdr pane opener tests. The herdr binary is stubbed by a
3
+ * script that both records its argv to a log file and, for `tab create`,
4
+ * prints the JSON the helper reads to learn the new pane id. The gating
5
+ * wrapper is exercised for its three outcomes: no-op outside herdr, no-op for
6
+ * a revision (the open pane is reused), and a full open for a genuinely new
7
+ * session.
8
+ */
9
+
10
+ import { afterAll, describe, expect, test } from "bun:test";
11
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import type { ReviewSession } from "@cueloop/schema";
15
+ import { openHerdrPane, openHerdrPaneForReview } from "./herdr-pane";
16
+
17
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
18
+
19
+ /**
20
+ * A stub herdr binary. It appends its argv to logPath and, when the verb is
21
+ * `tab create`, prints a JSON result carrying paneId on stdout so the helper
22
+ * can drive send-text and send-keys.
23
+ */
24
+ function makeStub(name: string, paneId = "w1:p2"): { binPath: string; logPath: string } {
25
+ const logPath = join(dir, `${name}.log`);
26
+ const binPath = join(dir, `${name}.sh`);
27
+ writeFileSync(
28
+ binPath,
29
+ `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nif [ "$1" = "tab" ] && [ "$2" = "create" ]; then\n printf '{"result":{"pane":{"id":"${paneId}"}}}'\nfi\n`,
30
+ );
31
+ chmodSync(binPath, 0o755);
32
+ return { binPath, logPath };
33
+ }
34
+
35
+ function readLines(logPath: string): string[] {
36
+ if (!existsSync(logPath)) return [];
37
+ return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
38
+ }
39
+
40
+ function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
41
+ return {
42
+ schemaVersion: "1",
43
+ id: "ses_new1",
44
+ workspace: { repoRoot: "/repo", branch: "main" },
45
+ artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
46
+ revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
47
+ annotations: [],
48
+ verdict: null,
49
+ status: "pending",
50
+ createdAt: "now",
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ describe("openHerdrPane", () => {
56
+ test("creates a focused tab, types the cueloop command, and presses enter", () => {
57
+ const stub = makeStub("open");
58
+ const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath: stub.binPath, label: "Rollout Plan" });
59
+ expect(ok).toBeTrue();
60
+ expect(readLines(stub.logPath)).toEqual([
61
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
62
+ "pane send-text w1:p2 cueloop ses_abc",
63
+ "pane send-keys w1:p2 enter",
64
+ ]);
65
+ });
66
+
67
+ test("returns false and never throws on a broken binary", () => {
68
+ const ok = openHerdrPane({
69
+ sessionId: "ses_abc",
70
+ cwd: "/repo/work",
71
+ binPath: join(dir, "missing-bin"),
72
+ label: "x",
73
+ });
74
+ expect(ok).toBeFalse();
75
+ });
76
+
77
+ test("bails out when tab create yields no pane id - no send-text, no send-keys", () => {
78
+ const logPath = join(dir, "nopane.log");
79
+ const binPath = join(dir, "nopane.sh");
80
+ writeFileSync(binPath, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`);
81
+ chmodSync(binPath, 0o755);
82
+ const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
83
+ expect(ok).toBeFalse();
84
+ expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
85
+ });
86
+ });
87
+
88
+ describe("openHerdrPaneForReview", () => {
89
+ test("opens a pane for a genuinely new session inside herdr", () => {
90
+ const stub = makeStub("gated-new");
91
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
92
+ openHerdrPaneForReview(newSession({ id: "ses_xyz" }), env);
93
+ expect(readLines(stub.logPath)).toEqual([
94
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
95
+ "pane send-text w1:p2 cueloop ses_xyz",
96
+ "pane send-keys w1:p2 enter",
97
+ ]);
98
+ });
99
+
100
+ test("no-op outside herdr - no herdr process is spawned", () => {
101
+ const stub = makeStub("gated-outside");
102
+ openHerdrPaneForReview(newSession(), { HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath });
103
+ expect(existsSync(stub.logPath)).toBeFalse();
104
+ });
105
+
106
+ test("no-op for a revision - the already open pane is reused, not spammed", () => {
107
+ const stub = makeStub("gated-revision");
108
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
109
+ const revised = newSession({
110
+ revisions: [
111
+ { revision: 1, content: "# P", submittedAt: "now" },
112
+ { revision: 2, content: "# P2", submittedAt: "now" },
113
+ ],
114
+ });
115
+ openHerdrPaneForReview(revised, env);
116
+ expect(existsSync(stub.logPath)).toBeFalse();
117
+ });
118
+ });
119
+
120
+ afterAll(() => {
121
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
122
+ });
@@ -0,0 +1,94 @@
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.
21
+ */
22
+
23
+ import { detectHerdr, type HerdrEnv, type ReviewSession } from "@cueloop/schema";
24
+
25
+ /** Upper bound per herdr spawn, so a stuck binary can never block creation. */
26
+ const HERDR_SPAWN_TIMEOUT_MS = 2000;
27
+
28
+ export interface OpenHerdrPaneOptions {
29
+ /** The review session id; `cueloop <sessionId>` opens it in the new pane. */
30
+ sessionId: string;
31
+ /** Working directory for the new tab; the review's cwd. */
32
+ cwd: string;
33
+ /** Absolute path to the herdr binary, from the ambient env contract. */
34
+ binPath: string;
35
+ /** Sidebar label for the new tab. */
36
+ label: string;
37
+ }
38
+
39
+ /**
40
+ * Open a herdr tab that renders the review. `tab create` prints a JSON result
41
+ * shaped like `{ result: { pane: { id } } }` - the same `result.pane` nesting
42
+ * focusHerdrPane reads - and the pane id drives the two follow-up steps.
43
+ * Returns true when the full open-and-launch sequence completed, false on any
44
+ * best-effort bail-out.
45
+ */
46
+ export function openHerdrPane(options: OpenHerdrPaneOptions): boolean {
47
+ const { sessionId, cwd, binPath, label } = options;
48
+ try {
49
+ const created = Bun.spawnSync([binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"], {
50
+ stdout: "pipe",
51
+ stderr: "ignore",
52
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
53
+ });
54
+ if (created.exitCode !== 0) return false;
55
+ const parsed = JSON.parse(created.stdout.toString()) as { result?: { pane?: { id?: string } } };
56
+ const paneId = parsed.result?.pane?.id;
57
+ if (!paneId) return false;
58
+ // A fresh tab hosts a plain shell, so the review is typed in like a human.
59
+ const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
60
+ stdout: "ignore",
61
+ stderr: "ignore",
62
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
63
+ });
64
+ if (typed.exitCode !== 0) return false;
65
+ return (
66
+ Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
67
+ stdout: "ignore",
68
+ stderr: "ignore",
69
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
70
+ }).exitCode === 0
71
+ );
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Open a pane for a freshly created review, guarded for the one case that
79
+ * wants it: inside herdr and genuinely new. A revision reuses the pane the
80
+ * original review already opened, so a resubmit (revisions beyond the first)
81
+ * never spawns another tab. A no-op outside herdr keeps a plain terminal
82
+ * byte-identical.
83
+ */
84
+ export function openHerdrPaneForReview(session: ReviewSession, env: HerdrEnv = process.env): void {
85
+ const herdr = detectHerdr(env);
86
+ if (!herdr) return;
87
+ if (session.revisions.length !== 1) return;
88
+ openHerdrPane({
89
+ sessionId: session.id,
90
+ cwd: session.artifact.meta.cwd ?? process.cwd(),
91
+ binPath: herdr.binPath,
92
+ label: session.artifact.meta.title ?? session.id,
93
+ });
94
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
- export * from "./api";
2
- export * from "./errors";
3
- export * from "./validate";
4
- export * from "./store";
5
- export * from "./server";
6
- export * from "./protocol";
7
- export * from "./paths";
8
- export { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
1
+ /**
2
+ * The daemon's public surface. Consumers reach the client and the review
3
+ * helpers through the ./client and ./review subpaths; this barrel carries
4
+ * only the two names imported bare: the server and the home-dir resolver.
5
+ */
6
+
7
+ export { DaemonServer, type DaemonOptions } from "./server";
8
+ export { cueloopHome } from "./paths";
package/src/protocol.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Wire protocol (#14): newline-delimited JSON over the unix socket.
2
+ * Wire protocol: newline-delimited JSON over the unix socket.
3
3
  * Requests: { id, method, params } Responses: { id, result } | { id, error }
4
4
  * Events (push, after events.subscribe): { event, sessionId }
5
5
  */
@@ -25,16 +25,53 @@ export type Frame = Response | EventFrame;
25
25
 
26
26
  /** Incremental NDJSON splitter; tolerates partial writes. */
27
27
  export class LineBuffer {
28
- private buf = "";
28
+ private buffered = "";
29
29
 
30
30
  push(chunk: string, onLine: (line: string) => void): void {
31
- this.buf += chunk;
31
+ this.buffered += chunk;
32
32
  for (;;) {
33
- const nl = this.buf.indexOf("\n");
34
- if (nl === -1) return;
35
- const line = this.buf.slice(0, nl).trim();
36
- this.buf = this.buf.slice(nl + 1);
33
+ const newlineIndex = this.buffered.indexOf("\n");
34
+ if (newlineIndex === -1) return;
35
+ const line = this.buffered.slice(0, newlineIndex).trim();
36
+ this.buffered = this.buffered.slice(newlineIndex + 1);
37
37
  if (line) onLine(line);
38
38
  }
39
39
  }
40
40
  }
41
+
42
+ /** The surface both Bun.listen and Bun.connect sockets satisfy. */
43
+ interface WritableSocket {
44
+ write(data: Buffer): number;
45
+ }
46
+
47
+ /**
48
+ * Outbound writes honoring socket backpressure. A frame larger than the
49
+ * kernel buffer is written partially by the socket; the unwritten tail must
50
+ * wait for the drain callback or the frame truncates mid-line and the NDJSON
51
+ * stream corrupts for good. Frames never reorder: while a tail is pending,
52
+ * new writes append behind it.
53
+ */
54
+ export class BackpressureWriter {
55
+ private pending: Buffer | null = null;
56
+
57
+ constructor(private socket: WritableSocket) {}
58
+
59
+ write(data: string): void {
60
+ const bytes = Buffer.from(data, "utf8");
61
+ if (this.pending) {
62
+ this.pending = Buffer.concat([this.pending, bytes]);
63
+ return;
64
+ }
65
+ const written = Math.max(0, this.socket.write(bytes));
66
+ if (written < bytes.length) this.pending = bytes.subarray(written);
67
+ }
68
+
69
+ /** Call from the socket's drain handler. */
70
+ drain(): void {
71
+ if (!this.pending) return;
72
+ const tail = this.pending;
73
+ this.pending = null;
74
+ const written = Math.max(0, this.socket.write(tail));
75
+ if (written < tail.length) this.pending = tail.subarray(written);
76
+ }
77
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The shared review core against a real DaemonServer in a temp home:
3
+ * workspace resolution, title derivation, open-or-revise by agentSessionId,
4
+ * and both awaitVerdict shapes (one long-poll and the chunked loop with
5
+ * progress and abort).
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { ReviewSession } from "@cueloop/schema";
13
+ import { DaemonServer } from "./server";
14
+ import { DaemonClient } from "./client";
15
+ import { openReview, resolveWorkspace } from "./review";
16
+
17
+ const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
18
+
19
+ let home: string;
20
+ let server: DaemonServer;
21
+ let client: DaemonClient;
22
+
23
+ beforeEach(async () => {
24
+ home = mkdtempSync(join(tmpdir(), "cueloop-review-core-"));
25
+ server = new DaemonServer({ home, idleExitMs: 0 });
26
+ server.start();
27
+ client = await DaemonClient.connect({ home });
28
+ });
29
+ afterEach(() => {
30
+ client.close();
31
+ server.stop();
32
+ rmSync(home, { recursive: true, force: true });
33
+ });
34
+
35
+ function sh(args: string[], cwd: string): void {
36
+ const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
37
+ if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
38
+ }
39
+
40
+ describe("resolveWorkspace", () => {
41
+ test("a git repo resolves to its root and branch", async () => {
42
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
43
+ try {
44
+ sh(["git", "init", "-q", "-b", "main"], repo);
45
+ sh(["git", "config", "user.email", "t@t"], repo);
46
+ sh(["git", "config", "user.name", "t"], repo);
47
+ sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
48
+ const ws = await resolveWorkspace(repo);
49
+ expect(ws.branch).toBe("main");
50
+ // macOS tmpdirs can resolve through /private; compare the tail
51
+ expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
52
+ } finally {
53
+ rmSync(repo, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("outside a repo the cwd itself is the workspace, branch detached", async () => {
58
+ const ws = await resolveWorkspace(home);
59
+ expect(ws).toEqual({ repoRoot: home, branch: "detached" });
60
+ });
61
+ });
62
+
63
+ describe("openReview", () => {
64
+ test("shapes the artifact: agent, derived title, cwd", async () => {
65
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
66
+ const session = review.session;
67
+ expect(session.status).toBe("pending");
68
+ expect(session.artifact.type).toBe("plan");
69
+ expect(session.artifact.meta.agent).toBe("test-agent");
70
+ expect(session.artifact.meta.title).toBe("Rollout Plan");
71
+ expect(session.artifact.meta.cwd).toBe(home);
72
+ expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
73
+ });
74
+
75
+ test("an explicit title wins; diffs never derive one from content", async () => {
76
+ const titled = await openReview(client, { type: "plan", content: PLAN, cwd: home, title: "Custom" });
77
+ expect(titled.session.artifact.meta.title).toBe("Custom");
78
+ const diff = await openReview(client, { type: "diff", content: "# not a heading\n", cwd: home });
79
+ expect(diff.session.artifact.meta.title).toBeUndefined();
80
+ });
81
+
82
+ test("a pre-resolved workspace skips git resolution", async () => {
83
+ const ws = { repoRoot: "/elsewhere", branch: "feature" };
84
+ const review = await openReview(client, { type: "diff", content: "d", cwd: home, workspace: ws });
85
+ expect(review.session.workspace).toEqual(ws);
86
+ });
87
+
88
+ test("the same agentSessionId revises instead of opening a new session", async () => {
89
+ const first = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-1" });
90
+ const second = await openReview(client, {
91
+ type: "plan",
92
+ content: PLAN + "\nStage three.\n",
93
+ cwd: home,
94
+ agentSessionId: "agent-1",
95
+ });
96
+ expect(second.id).toBe(first.id);
97
+ expect(second.session.revisions.length).toBe(2);
98
+ expect(second.session.status).toBe("pending");
99
+ const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
100
+ expect(other.id).not.toBe(first.id);
101
+ });
102
+
103
+ test("per-file notes land as note annotations anchored at the file path", async () => {
104
+ const review = await openReview(client, {
105
+ type: "diff",
106
+ content: "d",
107
+ cwd: home,
108
+ notes: [
109
+ { path: "src/a.ts", body: "Renames b and adds c." },
110
+ { path: "src/b.ts", body: "Swaps the line." },
111
+ ],
112
+ });
113
+ const notes = review.session.annotations;
114
+ expect(notes.length).toBe(2);
115
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
116
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
117
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
118
+ });
119
+ });
120
+
121
+ describe("awaitVerdict: one long-poll (the hook shape)", () => {
122
+ test("times out to pending; a later wait collects the stored verdict", async () => {
123
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
124
+ expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
125
+ await client.sessionResolve(review.id, "approve", "Fine.");
126
+ const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
127
+ expect(verdict).not.toBe("pending");
128
+ if (verdict === "pending") throw new Error("unreachable");
129
+ expect(verdict.allow).toBe(true);
130
+ expect(verdict.feedback).toContain("Fine.");
131
+ expect(verdict.session.verdict!.kind).toBe("approve");
132
+ });
133
+
134
+ test("request_changes maps to allow=false with feedback.md attached", async () => {
135
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
136
+ const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
137
+ await client.sessionResolve(review.id, "request_changes", "One stage only.");
138
+ const verdict = await waiting;
139
+ if (verdict === "pending") throw new Error("expected a verdict");
140
+ expect(verdict.allow).toBe(false);
141
+ expect(verdict.feedback).toContain("# Review: request changes");
142
+ expect(verdict.feedback).toContain("One stage only.");
143
+ });
144
+ });
145
+
146
+ describe("awaitVerdict: chunked loop (the pi shape)", () => {
147
+ test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
148
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
149
+ const seen: ReviewSession[] = [];
150
+ const waiting = review.awaitVerdict({
151
+ timeoutMs: Infinity,
152
+ pollMs: 100,
153
+ onProgress: (progress) => seen.push(progress),
154
+ });
155
+ await client.sessionAnnotate(review.id, {
156
+ id: "a1",
157
+ kind: "comment",
158
+ anchor: { quote: "two stages", prefix: "in ", suffix: "." },
159
+ body: "Name them.",
160
+ });
161
+ // give the loop a chunk to observe the annotation before resolving
162
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++) await Bun.sleep(20);
163
+ expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
164
+ await client.sessionResolve(review.id, "request_changes", "Too vague.");
165
+ const verdict = await waiting;
166
+ if (verdict === "pending") throw new Error("expected a verdict");
167
+ expect(verdict.allow).toBe(false);
168
+ expect(verdict.session.annotations.length).toBe(1);
169
+ }, 15_000);
170
+
171
+ test("abort surfaces as pending and leaves the session collectable", async () => {
172
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
173
+ const controller = new AbortController();
174
+ const waiting = review.awaitVerdict({ timeoutMs: Infinity, pollMs: 100, signal: controller.signal });
175
+ controller.abort();
176
+ expect(await waiting).toBe("pending");
177
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
178
+ });
179
+
180
+ test("a finite budget runs out to pending", async () => {
181
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
182
+ expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
183
+ });
184
+ });