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

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,14 +1,15 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.20",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
- "./client": "./src/client.ts"
8
+ "./client": "./src/client.ts",
9
+ "./review": "./src/review.ts"
9
10
  },
10
11
  "dependencies": {
11
- "@cueloop/schema": "0.1.0-alpha.2",
12
+ "@cueloop/schema": "0.1.0-alpha.20",
12
13
  "valibot": "^1.4.2"
13
14
  },
14
15
  "publishConfig": {
@@ -25,5 +26,10 @@
25
26
  "type": "git",
26
27
  "url": "git+https://github.com/mmurakaru/cueloop.git",
27
28
  "directory": "packages/daemon"
28
- }
29
- }
29
+ },
30
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/mmurakaru/cueloop/issues"
33
+ },
34
+ "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits"
35
+ }
package/src/api.test.ts CHANGED
@@ -24,42 +24,42 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
24
24
 
25
25
  describe("session lifecycle", () => {
26
26
  test("create → get → list", () => {
27
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
28
- expect(s.status).toBe("pending");
29
- expect(s.revisions.length).toBe(1);
30
- expect(core.sessionGet(s.id).id).toBe(s.id);
27
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
28
+ expect(session.status).toBe("pending");
29
+ expect(session.revisions.length).toBe(1);
30
+ expect(core.sessionGet(session.id).id).toBe(session.id);
31
31
  expect(core.sessionList({ status: "pending" }).length).toBe(1);
32
32
  });
33
33
 
34
34
  test("annotate upserts by id; remove deletes", () => {
35
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
36
- core.sessionAnnotate(s.id, {
35
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
36
+ core.sessionAnnotate(session.id, {
37
37
  id: "a1",
38
38
  kind: "comment",
39
39
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
40
40
  body: "How carefully?",
41
41
  });
42
- core.sessionAnnotate(s.id, {
42
+ core.sessionAnnotate(session.id, {
43
43
  id: "a1",
44
44
  kind: "comment",
45
45
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
46
46
  body: "Define carefully.",
47
47
  });
48
- expect(core.sessionGet(s.id).annotations.length).toBe(1);
49
- expect(core.sessionGet(s.id).annotations[0]!.body).toBe("Define carefully.");
50
- core.sessionRemoveAnnotation(s.id, "a1");
51
- expect(core.sessionGet(s.id).annotations.length).toBe(0);
48
+ expect(core.sessionGet(session.id).annotations.length).toBe(1);
49
+ expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
50
+ core.sessionRemoveAnnotation(session.id, "a1");
51
+ expect(core.sessionGet(session.id).annotations.length).toBe(0);
52
52
  });
53
53
 
54
54
  test("resolve produces feedback.md and maps to the agent contract", () => {
55
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
56
- core.sessionAnnotate(s.id, {
55
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
56
+ core.sessionAnnotate(session.id, {
57
57
  id: "a1",
58
58
  kind: "comment",
59
59
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
60
60
  body: "Spell out the steps.",
61
61
  });
62
- const resolved = core.sessionResolve(s.id, "request_changes", "Needs detail.");
62
+ const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
63
63
  expect(resolved.verdict!.feedback).toContain("# Review: request changes");
64
64
  expect(resolved.verdict!.feedback).toContain("> carefully");
65
65
  expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
@@ -69,26 +69,40 @@ describe("session lifecycle", () => {
69
69
  });
70
70
 
71
71
  test("mutating a resolved session throws", () => {
72
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
- core.sessionResolve(s.id, "approve", "");
72
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
+ core.sessionResolve(session.id, "approve", "");
74
74
  expect(() =>
75
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
75
+ core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
76
76
  ).toThrow("already resolved");
77
77
  });
78
78
 
79
79
  test("working copy stores edits and clears on revert or no-op", () => {
80
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
81
- core.sessionSetWorkingCopy(s.id, PLAN.content.replace("carefully", "very carefully"));
82
- expect(core.sessionGet(s.id).workingCopy).toContain("very carefully");
83
- core.sessionSetWorkingCopy(s.id, PLAN.content);
84
- expect(core.sessionGet(s.id).workingCopy).toBeUndefined();
80
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
81
+ core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
82
+ expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
83
+ core.sessionSetWorkingCopy(session.id, PLAN.content);
84
+ expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
85
+ });
86
+
87
+ test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
88
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
+ core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
90
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
91
+ // merge-additive: a stale client sending only its own new mark loses nothing
92
+ core.sessionSetViewed(session.id, ["src/c.ts"]);
93
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
94
+ // a resumed review reads its progress back after a daemon restart
95
+ const reborn = new DaemonCore(home);
96
+ expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
97
+ core.sessionSetViewed(session.id, []);
98
+ expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
85
99
  });
86
100
 
87
101
  test("revision reopens the session and resets working copy + verdict", () => {
88
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
- core.sessionSetWorkingCopy(s.id, PLAN.content + "\nedit");
90
- core.sessionResolve(s.id, "request_changes", "redo");
91
- const revised = core.sessionSubmitRevision(s.id, "# Plan v2\n");
102
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
103
+ core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
104
+ core.sessionResolve(session.id, "request_changes", "redo");
105
+ const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
92
106
  expect(revised.status).toBe("pending");
93
107
  expect(revised.verdict).toBeNull();
94
108
  expect(revised.workingCopy).toBeUndefined();
@@ -99,37 +113,37 @@ describe("session lifecycle", () => {
99
113
 
100
114
  describe("the wait contract: verdicts outlive waits", () => {
101
115
  test("wait resolves when the verdict arrives", async () => {
102
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
103
- const wait = core.sessionWait(s.id, 5_000);
104
- core.sessionResolve(s.id, "approve", "");
116
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
117
+ const wait = core.sessionWait(session.id, 5_000);
118
+ core.sessionResolve(session.id, "approve", "");
105
119
  const resolved = await wait;
106
120
  expect(resolved!.verdict!.kind).toBe("approve");
107
121
  });
108
122
 
109
123
  test("wait times out to null; a later wait collects the stored verdict", async () => {
110
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
111
- const first = await core.sessionWait(s.id, 30);
124
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
125
+ const first = await core.sessionWait(session.id, 30);
112
126
  expect(first).toBeNull();
113
- core.sessionResolve(s.id, "request_changes", "later");
114
- const second = await core.sessionWait(s.id, 30);
127
+ core.sessionResolve(session.id, "request_changes", "later");
128
+ const second = await core.sessionWait(session.id, 30);
115
129
  expect(second!.verdict!.summary).toBe("later");
116
130
  });
117
131
 
118
132
  test("multiple waiters all resolve", async () => {
119
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
120
- const waits = [core.sessionWait(s.id, 5_000), core.sessionWait(s.id, 5_000)];
121
- core.sessionResolve(s.id, "approve", "");
133
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
134
+ const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
135
+ core.sessionResolve(session.id, "approve", "");
122
136
  const results = await Promise.all(waits);
123
- expect(results.every((r) => r?.status === "resolved")).toBe(true);
137
+ expect(results.every((result) => result?.status === "resolved")).toBe(true);
124
138
  });
125
139
  });
126
140
 
127
141
  describe("persistence and recovery", () => {
128
142
  test("sessions survive a daemon restart", () => {
129
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
130
- core.sessionResolve(s.id, "approve", "done");
143
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
144
+ core.sessionResolve(session.id, "approve", "done");
131
145
  const reborn = new DaemonCore(home);
132
- const recovered = reborn.sessionGet(s.id);
146
+ const recovered = reborn.sessionGet(session.id);
133
147
  expect(recovered.verdict!.summary).toBe("done");
134
148
  });
135
149
 
@@ -147,10 +161,10 @@ describe("persistence and recovery", () => {
147
161
  describe("events", () => {
148
162
  test("lifecycle events fire in order", () => {
149
163
  const seen: string[] = [];
150
- core.onEvent((e) => seen.push(e.event));
151
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
152
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
153
- core.sessionResolve(s.id, "approve", "");
164
+ core.onEvent((event) => seen.push(event.event));
165
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
166
+ core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
167
+ core.sessionResolve(session.id, "approve", "");
154
168
  expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
155
169
  });
156
170
  });
package/src/api.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The daemon's method surface (#14). Transport-independent: the socket
2
+ * The daemon's method surface. Transport-independent: the socket
3
3
  * server and the in-process test harness both call these handlers.
4
4
  * The wait contract: verdicts outlive waits - session.wait long-polls,
5
5
  * and a verdict resolved while nobody waited is delivered on next contact.
@@ -31,7 +31,7 @@ export interface DaemonEvent {
31
31
  sessionId: string;
32
32
  }
33
33
 
34
- type EventListener = (e: DaemonEvent) => void;
34
+ type EventListener = (event: DaemonEvent) => void;
35
35
 
36
36
  export class DaemonCore {
37
37
  readonly store: SessionStore;
@@ -50,12 +50,12 @@ export class DaemonCore {
50
50
  }
51
51
 
52
52
  private emit(event: EventName, sessionId: string): void {
53
- for (const l of this.listeners) l({ event, sessionId });
53
+ for (const listener of this.listeners) listener({ event, sessionId });
54
54
  }
55
55
 
56
56
  /** True when nothing awaits a verdict - drives idle-exit. */
57
57
  hasPendingSessions(): boolean {
58
- return this.store.list().some((s) => s.status === "pending");
58
+ return this.store.list().some((session) => session.status === "pending");
59
59
  }
60
60
 
61
61
  sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
@@ -78,16 +78,16 @@ export class DaemonCore {
78
78
  }
79
79
 
80
80
  sessionGet(id: string): ReviewSession {
81
- const s = this.store.get(id);
82
- if (!s) throw new DaemonError("not_found", `no session ${id}`);
83
- return s;
81
+ const session = this.store.get(id);
82
+ if (!session) throw new DaemonError("not_found", `no session ${id}`);
83
+ return session;
84
84
  }
85
85
 
86
86
  sessionList(filter?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
87
- return this.store.list().filter((s) => {
88
- if (filter?.status && s.status !== filter.status) return false;
89
- if (filter?.workspace?.repoRoot && s.workspace.repoRoot !== filter.workspace.repoRoot) return false;
90
- if (filter?.workspace?.branch && s.workspace.branch !== filter.workspace.branch) return false;
87
+ return this.store.list().filter((session) => {
88
+ if (filter?.status && session.status !== filter.status) return false;
89
+ if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot) return false;
90
+ if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch) return false;
91
91
  return true;
92
92
  });
93
93
  }
@@ -98,16 +98,16 @@ export class DaemonCore {
98
98
  * still pending - the caller re-polls later; the verdict is never lost).
99
99
  */
100
100
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
101
- const s = this.sessionGet(id);
102
- if (s.status === "resolved") return Promise.resolve(s);
101
+ const current = this.sessionGet(id);
102
+ if (current.status === "resolved") return Promise.resolve(current);
103
103
  return new Promise((resolve) => {
104
104
  const list = this.waiters.get(id) ?? [];
105
105
  let done = false;
106
106
  const timer = setTimeout(() => {
107
107
  if (done) return;
108
108
  done = true;
109
- const idx = list.indexOf(waiter);
110
- if (idx !== -1) list.splice(idx, 1);
109
+ const waiterIndex = list.indexOf(waiter);
110
+ if (waiterIndex !== -1) list.splice(waiterIndex, 1);
111
111
  resolve(null);
112
112
  }, timeoutMs);
113
113
  const waiter = (session: ReviewSession) => {
@@ -122,72 +122,86 @@ export class DaemonCore {
122
122
  }
123
123
 
124
124
  sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): ReviewSession {
125
- const s = this.mutable(id);
126
- const existing = s.annotations.findIndex((a) => a.id === annotation.id);
125
+ const session = this.mutable(id);
126
+ const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
127
127
  const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
128
- if (existing === -1) s.annotations.push(full);
129
- else s.annotations[existing] = { ...full, createdAt: s.annotations[existing]!.createdAt };
130
- this.store.upsert(s);
128
+ if (existing === -1) session.annotations.push(full);
129
+ else session.annotations[existing] = { ...full, createdAt: session.annotations[existing]!.createdAt };
130
+ this.store.upsert(session);
131
131
  this.emit("session.updated", id);
132
- return s;
132
+ return session;
133
133
  }
134
134
 
135
135
  sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
136
- const s = this.mutable(id);
137
- s.annotations = s.annotations.filter((a) => a.id !== annotationId);
138
- this.store.upsert(s);
136
+ const session = this.mutable(id);
137
+ session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
138
+ this.store.upsert(session);
139
139
  this.emit("session.updated", id);
140
- return s;
140
+ return session;
141
141
  }
142
142
 
143
143
  /** The reviewer's working copy; undefined clears it (revert all edits). */
144
144
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
145
- const s = this.mutable(id);
146
- if (workingCopy === undefined || workingCopy === s.artifact.content) delete s.workingCopy;
147
- else s.workingCopy = workingCopy;
148
- this.store.upsert(s);
145
+ const session = this.mutable(id);
146
+ if (workingCopy === undefined || workingCopy === session.artifact.content) delete session.workingCopy;
147
+ else session.workingCopy = workingCopy;
148
+ this.store.upsert(session);
149
149
  this.emit("session.updated", id);
150
- return s;
150
+ return session;
151
+ }
152
+
153
+ /**
154
+ * The guided walk's viewed marks. Merge-additive: walking only ever adds
155
+ * marks, so concurrent or stale clients converge instead of overwriting
156
+ * each other. An empty array is the explicit reset.
157
+ */
158
+ sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
159
+ const session = this.mutable(id);
160
+ if (viewedPaths.length === 0) delete session.viewedPaths;
161
+ else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
162
+ this.store.upsert(session);
163
+ this.emit("session.updated", id);
164
+ return session;
151
165
  }
152
166
 
153
167
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
- const s = this.mutable(id);
168
+ const session = this.mutable(id);
155
169
  const verdict: Verdict = {
156
170
  kind: verdictKind,
157
171
  summary,
158
- feedback: feedbackForSession(s, verdictKind, summary),
172
+ feedback: feedbackForSession(session, verdictKind, summary),
159
173
  resolvedAt: new Date().toISOString(),
160
174
  };
161
- s.verdict = verdict;
162
- s.status = "resolved";
163
- this.store.upsert(s);
175
+ session.verdict = verdict;
176
+ session.status = "resolved";
177
+ this.store.upsert(session);
164
178
  const parked = this.waiters.get(id) ?? [];
165
179
  this.waiters.delete(id);
166
- for (const w of parked) w(s);
180
+ for (const parkedWaiter of parked) parkedWaiter(session);
167
181
  this.emit("session.resolved", id);
168
182
  this.emit("inbox.changed", id);
169
- return s;
183
+ return session;
170
184
  }
171
185
 
172
186
  /** Agent resubmits: new revision becomes the artifact, session reopens. */
173
187
  sessionSubmitRevision(id: string, content: string): ReviewSession {
174
- const s = this.sessionGet(id);
188
+ const session = this.sessionGet(id);
175
189
  const now = new Date().toISOString();
176
- s.revisions.push({ revision: s.revisions.length + 1, content, submittedAt: now });
177
- s.artifact = { ...s.artifact, content };
178
- delete s.workingCopy;
179
- s.verdict = null;
180
- s.status = "pending";
181
- this.store.upsert(s);
190
+ session.revisions.push({ revision: session.revisions.length + 1, content, submittedAt: now });
191
+ session.artifact = { ...session.artifact, content };
192
+ delete session.workingCopy;
193
+ session.verdict = null;
194
+ session.status = "pending";
195
+ this.store.upsert(session);
182
196
  this.emit("session.revised", id);
183
197
  this.emit("inbox.changed", id);
184
- return s;
198
+ return session;
185
199
  }
186
200
 
187
201
  private mutable(id: string): ReviewSession {
188
- const s = this.sessionGet(id);
189
- if (s.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
190
- return s;
202
+ const session = this.sessionGet(id);
203
+ if (session.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
204
+ return session;
191
205
  }
192
206
  }
193
207
 
package/src/client.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Daemon client: the one library every consumer shares - CLI verbs, the TUI,
3
- * adapters, and tests. Also owns the lazy-launch story (#14): connect() with
3
+ * adapters, and tests. Also owns the lazy-launch story: connect() with
4
4
  * autostart spawns a detached daemon when the socket is dead, then attaches.
5
5
  */
6
6
 
7
7
  import { existsSync, rmSync } from "node:fs";
8
8
  import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
9
- import { LineBuffer, type EventFrame, type Response } from "./protocol";
9
+ import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
10
10
  import { cueloopHome, socketPath } from "./paths";
11
11
 
12
12
  export interface ConnectOptions {
@@ -15,24 +15,25 @@ export interface ConnectOptions {
15
15
  autostart?: boolean;
16
16
  }
17
17
 
18
- type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
18
+ type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
19
19
 
20
20
  export class DaemonClient {
21
21
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
22
- private pending = new Map<number, Pending>();
22
+ private writer: BackpressureWriter | null = null;
23
+ private pending = new Map<number, PendingRequest>();
23
24
  private nextId = 1;
24
- private eventListeners = new Set<(e: EventFrame) => void>();
25
+ private eventListeners = new Set<(event: EventFrame) => void>();
25
26
  private closed = false;
26
27
 
27
- static async connect(opts: ConnectOptions = {}): Promise<DaemonClient> {
28
- const home = opts.home ?? cueloopHome();
28
+ static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
29
+ const home = options.home ?? cueloopHome();
29
30
  const path = socketPath(home);
30
31
  const client = new DaemonClient();
31
32
  try {
32
33
  await client.dial(path);
33
34
  return client;
34
35
  } catch (err) {
35
- if (!opts.autostart) throw err;
36
+ if (!options.autostart) throw err;
36
37
  }
37
38
  // Socket dead or absent: clean a stale file and spawn the daemon detached.
38
39
  if (existsSync(path)) rmSync(path, { force: true });
@@ -40,17 +41,17 @@ export class DaemonClient {
40
41
  // Generous: a cold or loaded machine pays for a runtime start before the
41
42
  // socket exists, and giving up early looks to callers like a broken daemon.
42
43
  const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
43
- let lastErr: unknown;
44
+ let lastError: unknown;
44
45
  while (Date.now() < deadline) {
45
46
  try {
46
47
  await client.dial(path);
47
48
  return client;
48
49
  } catch (err) {
49
- lastErr = err;
50
+ lastError = err;
50
51
  await Bun.sleep(50);
51
52
  }
52
53
  }
53
- throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
54
+ throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
54
55
  }
55
56
 
56
57
  private async dial(path: string): Promise<void> {
@@ -60,27 +61,31 @@ export class DaemonClient {
60
61
  unix: path,
61
62
  socket: {
62
63
  data(_socket, data) {
63
- buffer.push(data.toString(), (line) => self.handleFrame(line));
64
+ buffer.push(data.toString(), (line) => self.routeInboundFrame(line));
65
+ },
66
+ drain() {
67
+ self.writer?.drain();
64
68
  },
65
69
  close() {
66
70
  self.closed = true;
67
- for (const p of self.pending.values()) p.reject(new Error("daemon connection closed"));
71
+ for (const pendingRequest of self.pending.values()) pendingRequest.reject(new Error("daemon connection closed"));
68
72
  self.pending.clear();
69
73
  },
70
74
  error() {},
71
75
  },
72
76
  });
77
+ this.writer = new BackpressureWriter(this.socket);
73
78
  // Verify liveness: a dead socket file accepts connects on some platforms
74
79
  // only to fail later, so a ping is the actual handshake.
75
80
  await this.request("daemon.ping", {}, 2_000);
76
81
  }
77
82
 
78
- onEvent(listener: (e: EventFrame) => void): () => void {
83
+ onEvent(listener: (event: EventFrame) => void): () => void {
79
84
  this.eventListeners.add(listener);
80
85
  return () => this.eventListeners.delete(listener);
81
86
  }
82
87
 
83
- private handleFrame(line: string): void {
88
+ private routeInboundFrame(line: string): void {
84
89
  let frame: Response | EventFrame;
85
90
  try {
86
91
  frame = JSON.parse(line) as Response | EventFrame;
@@ -88,14 +93,14 @@ export class DaemonClient {
88
93
  return;
89
94
  }
90
95
  if ("event" in frame) {
91
- for (const l of this.eventListeners) l(frame);
96
+ for (const listener of this.eventListeners) listener(frame);
92
97
  return;
93
98
  }
94
- const p = this.pending.get(frame.id);
95
- if (!p) return;
99
+ const pendingRequest = this.pending.get(frame.id);
100
+ if (!pendingRequest) return;
96
101
  this.pending.delete(frame.id);
97
- if (frame.error) p.reject(new DaemonClientError(frame.error.code, frame.error.message));
98
- else p.resolve(frame.result);
102
+ if (frame.error) pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
103
+ else pendingRequest.resolve(frame.result);
99
104
  }
100
105
 
101
106
  request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
@@ -107,16 +112,16 @@ export class DaemonClient {
107
112
  reject(new Error(`request ${method} timed out`));
108
113
  }, timeoutMs);
109
114
  this.pending.set(id, {
110
- resolve: (v) => {
115
+ resolve: (value) => {
111
116
  clearTimeout(timer);
112
- resolve(v as T);
117
+ resolve(value as T);
113
118
  },
114
- reject: (e) => {
119
+ reject: (error) => {
115
120
  clearTimeout(timer);
116
- reject(e);
121
+ reject(error);
117
122
  },
118
123
  });
119
- this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
124
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
120
125
  });
121
126
  }
122
127
 
@@ -154,6 +159,9 @@ export class DaemonClient {
154
159
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
155
160
  return this.request("session.setWorkingCopy", { id, workingCopy });
156
161
  }
162
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
163
+ return this.request("session.setViewed", { id, viewedPaths });
164
+ }
157
165
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
158
166
  return this.request("session.resolve", { id, verdictKind, summary });
159
167
  }
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
+ }