@cueloop/daemon 0.1.0-alpha.4 → 0.1.0-alpha.40

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/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.
@@ -8,15 +8,22 @@
8
8
  import {
9
9
  SCHEMA_VERSION,
10
10
  feedbackForSession,
11
+ isAddressed,
12
+ isAgentNote,
13
+ parseBlocks,
14
+ registerParticipant,
15
+ resolveAnchor,
11
16
  verdictAllows,
12
17
  type Annotation,
13
18
  type Artifact,
19
+ type Identity,
14
20
  type ReviewSession,
15
21
  type Verdict,
16
22
  type VerdictKind,
17
23
  type WorkspaceKey,
18
24
  } from "@cueloop/schema";
19
25
  import { SessionStore } from "./store";
26
+ import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
20
27
  import { DaemonError } from "./errors";
21
28
 
22
29
  export type EventName =
@@ -31,10 +38,11 @@ export interface DaemonEvent {
31
38
  sessionId: string;
32
39
  }
33
40
 
34
- type EventListener = (e: DaemonEvent) => void;
41
+ type EventListener = (event: DaemonEvent) => void;
35
42
 
36
43
  export class DaemonCore {
37
44
  readonly store: SessionStore;
45
+ readonly herdrTabs: HerdrTabStore;
38
46
  private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
39
47
  private listeners = new Set<EventListener>();
40
48
  private seq = 0;
@@ -42,6 +50,16 @@ export class DaemonCore {
42
50
  constructor(home: string) {
43
51
  this.store = new SessionStore(home);
44
52
  this.store.recover();
53
+ this.herdrTabs = new HerdrTabStore(home);
54
+ }
55
+
56
+ /** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
57
+ herdrGetTab(sessionId: string): HerdrTabHandle | null {
58
+ return this.herdrTabs.get(sessionId);
59
+ }
60
+
61
+ herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
62
+ this.herdrTabs.set(sessionId, handle);
45
63
  }
46
64
 
47
65
  onEvent(listener: EventListener): () => void {
@@ -50,12 +68,12 @@ export class DaemonCore {
50
68
  }
51
69
 
52
70
  private emit(event: EventName, sessionId: string): void {
53
- for (const l of this.listeners) l({ event, sessionId });
71
+ for (const listener of this.listeners) listener({ event, sessionId });
54
72
  }
55
73
 
56
74
  /** True when nothing awaits a verdict - drives idle-exit. */
57
75
  hasPendingSessions(): boolean {
58
- return this.store.list().some((s) => s.status === "pending");
76
+ return this.store.list().some((session) => session.status === "pending");
59
77
  }
60
78
 
61
79
  sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
@@ -78,16 +96,21 @@ export class DaemonCore {
78
96
  }
79
97
 
80
98
  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;
99
+ const session = this.store.get(id);
100
+ if (!session) throw new DaemonError("not_found", `no session ${id}`);
101
+ return session;
84
102
  }
85
103
 
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;
104
+ sessionList(filter?: {
105
+ status?: "pending" | "resolved";
106
+ workspace?: Partial<WorkspaceKey>;
107
+ }): ReviewSession[] {
108
+ return this.store.list().filter((session) => {
109
+ if (filter?.status && session.status !== filter.status) return false;
110
+ if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
111
+ return false;
112
+ if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
113
+ return false;
91
114
  return true;
92
115
  });
93
116
  }
@@ -98,16 +121,16 @@ export class DaemonCore {
98
121
  * still pending - the caller re-polls later; the verdict is never lost).
99
122
  */
100
123
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
101
- const s = this.sessionGet(id);
102
- if (s.status === "resolved") return Promise.resolve(s);
124
+ const current = this.sessionGet(id);
125
+ if (current.status === "resolved") return Promise.resolve(current);
103
126
  return new Promise((resolve) => {
104
127
  const list = this.waiters.get(id) ?? [];
105
128
  let done = false;
106
129
  const timer = setTimeout(() => {
107
130
  if (done) return;
108
131
  done = true;
109
- const idx = list.indexOf(waiter);
110
- if (idx !== -1) list.splice(idx, 1);
132
+ const waiterIndex = list.indexOf(waiter);
133
+ if (waiterIndex !== -1) list.splice(waiterIndex, 1);
111
134
  resolve(null);
112
135
  }, timeoutMs);
113
136
  const waiter = (session: ReviewSession) => {
@@ -121,73 +144,180 @@ export class DaemonCore {
121
144
  });
122
145
  }
123
146
 
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);
147
+ /**
148
+ * `authorName` registers the annotation's author in the participant registry;
149
+ * a bare author id (no name) keeps its short-handle fallback instead.
150
+ */
151
+ sessionAnnotate(
152
+ id: string,
153
+ annotation: Omit<Annotation, "createdAt">,
154
+ authorName?: string,
155
+ ): ReviewSession {
156
+ const session = this.mutable(id);
157
+ const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
127
158
  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);
159
+ if (existing === -1) session.annotations.push(full);
160
+ else
161
+ session.annotations[existing] = {
162
+ ...full,
163
+ createdAt: session.annotations[existing]!.createdAt,
164
+ };
165
+ if (annotation.author && authorName)
166
+ session.participants = registerParticipant(
167
+ session,
168
+ annotation.author,
169
+ authorName,
170
+ ).participants;
171
+ this.store.upsert(session);
131
172
  this.emit("session.updated", id);
132
- return s;
173
+ return session;
133
174
  }
134
175
 
135
176
  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);
177
+ const session = this.mutable(id);
178
+ session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
179
+ this.store.upsert(session);
139
180
  this.emit("session.updated", id);
140
- return s;
181
+ return session;
141
182
  }
142
183
 
143
184
  /** The reviewer's working copy; undefined clears it (revert all edits). */
144
185
  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);
186
+ const session = this.mutable(id);
187
+ if (workingCopy === undefined || workingCopy === session.artifact.content)
188
+ delete session.workingCopy;
189
+ else session.workingCopy = workingCopy;
190
+ this.store.upsert(session);
149
191
  this.emit("session.updated", id);
150
- return s;
192
+ return session;
193
+ }
194
+
195
+ /**
196
+ * The guided walk's viewed marks. Merge-additive: walking only ever adds
197
+ * marks, so concurrent or stale clients converge instead of overwriting
198
+ * each other. An empty array is the explicit reset.
199
+ */
200
+ sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
201
+ const session = this.mutable(id);
202
+ if (viewedPaths.length === 0) delete session.viewedPaths;
203
+ else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
204
+ this.store.upsert(session);
205
+ this.emit("session.updated", id);
206
+ return session;
207
+ }
208
+
209
+ sessionSetShareId(id: string, shareId: string): ReviewSession {
210
+ const session = this.mutable(id);
211
+ session.shareId = shareId;
212
+ this.store.upsert(session);
213
+ this.emit("session.updated", id);
214
+ return session;
215
+ }
216
+
217
+ /** Remove a session for good (inbox delete); resolved or pending, both go. */
218
+ sessionDelete(id: string): void {
219
+ if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
220
+ this.herdrTabs.delete(id);
221
+ this.emit("inbox.changed", id);
222
+ }
223
+
224
+ /**
225
+ * Merge a share's collaborator state back into the local session: annotations
226
+ * union by id with existing ones (the planner's) winning, and the participant
227
+ * registry union by id with the incoming identity winning (a collaborator is
228
+ * the authority on their own name). This is how a teammate's name reaches the
229
+ * planner after a pull, alongside their notes.
230
+ */
231
+ sessionMergeShared(
232
+ id: string,
233
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
234
+ ): ReviewSession {
235
+ const session = this.mutable(id);
236
+ const known = new Set(session.annotations.map((annotation) => annotation.id));
237
+ for (const annotation of incoming.annotations)
238
+ if (!known.has(annotation.id)) session.annotations.push(annotation);
239
+ if (incoming.participants?.length) {
240
+ const registry = new Map(
241
+ (session.participants ?? []).map((participant) => [participant.id, participant]),
242
+ );
243
+ for (const participant of incoming.participants) registry.set(participant.id, participant);
244
+ session.participants = [...registry.values()];
245
+ }
246
+ this.store.upsert(session);
247
+ this.emit("session.updated", id);
248
+ return session;
151
249
  }
152
250
 
153
251
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
- const s = this.mutable(id);
252
+ const session = this.mutable(id);
155
253
  const verdict: Verdict = {
156
254
  kind: verdictKind,
157
255
  summary,
158
- feedback: feedbackForSession(s, verdictKind, summary),
256
+ feedback: feedbackForSession(session, verdictKind, summary),
159
257
  resolvedAt: new Date().toISOString(),
160
258
  };
161
- s.verdict = verdict;
162
- s.status = "resolved";
163
- this.store.upsert(s);
259
+ session.verdict = verdict;
260
+ session.status = "resolved";
261
+ this.store.upsert(session);
164
262
  const parked = this.waiters.get(id) ?? [];
165
263
  this.waiters.delete(id);
166
- for (const w of parked) w(s);
264
+ for (const parkedWaiter of parked) parkedWaiter(session);
167
265
  this.emit("session.resolved", id);
168
266
  this.emit("inbox.changed", id);
169
- return s;
267
+ return session;
170
268
  }
171
269
 
172
- /** Agent resubmits: new revision becomes the artifact, session reopens. */
173
- sessionSubmitRevision(id: string, content: string): ReviewSession {
174
- const s = this.sessionGet(id);
270
+ /**
271
+ * Agent resubmits: new revision becomes the artifact, session reopens.
272
+ * Annotations the agent reports as acted on (by id) are marked addressed,
273
+ * and for plan revisions any still-open annotation whose quoted text no
274
+ * longer resolves is marked addressed too ("drift" - the line it pointed at
275
+ * was rewritten). Addressed is a marker, never a delete: the reviewer's
276
+ * rail hides them behind a count, and the next feedback document omits them.
277
+ * Unknown ids are ignored, so a stale id in the agent's list never fails
278
+ * the resubmit.
279
+ */
280
+ sessionSubmitRevision(
281
+ id: string,
282
+ content: string,
283
+ addressedAnnotationIds: string[] = [],
284
+ ): ReviewSession {
285
+ const session = this.sessionGet(id);
175
286
  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);
287
+ const revisionNumber = session.revisions.length + 1;
288
+ session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
289
+ session.artifact = { ...session.artifact, content };
290
+ delete session.workingCopy;
291
+ session.verdict = null;
292
+ session.status = "pending";
293
+
294
+ const reportedIds = new Set(addressedAnnotationIds);
295
+ // drift assist applies to plans only: a diff revision is a whole new
296
+ // patch, where a vanished quote says nothing about the feedback
297
+ const revisedBlocks = session.artifact.type === "plan" ? parseBlocks(content) : null;
298
+ for (const annotation of session.annotations) {
299
+ if (isAddressed(annotation) || isAgentNote(annotation)) continue;
300
+ if (reportedIds.has(annotation.id)) {
301
+ annotation.resolution = { revision: revisionNumber, source: "agent" };
302
+ } else if (
303
+ revisedBlocks !== null &&
304
+ resolveAnchor(annotation.anchor, revisedBlocks) === null
305
+ ) {
306
+ annotation.resolution = { revision: revisionNumber, source: "drift" };
307
+ }
308
+ }
309
+
310
+ this.store.upsert(session);
182
311
  this.emit("session.revised", id);
183
312
  this.emit("inbox.changed", id);
184
- return s;
313
+ return session;
185
314
  }
186
315
 
187
316
  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;
317
+ const session = this.sessionGet(id);
318
+ if (session.status === "resolved")
319
+ throw new DaemonError("resolved", `session ${id} is already resolved`);
320
+ return session;
191
321
  }
192
322
  }
193
323
 
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { asDaemonRole, roleAllowsMethod } from "./capabilities";
3
+
4
+ describe("roleAllowsMethod", () => {
5
+ test("the owner may call every verb", () => {
6
+ // Assert
7
+ for (const method of ["session.resolve", "session.submitRevision", "session.delete"])
8
+ expect(roleAllowsMethod("owner", method)).toBe(true);
9
+ });
10
+
11
+ test("a capped role may read and annotate but not escalate", () => {
12
+ // Assert - allowed
13
+ for (const method of ["session.get", "session.list", "session.wait", "session.annotate"])
14
+ expect(roleAllowsMethod("agent", method)).toBe(true);
15
+ // Assert - denied
16
+ for (const method of [
17
+ "session.resolve",
18
+ "session.submitRevision",
19
+ "session.setWorkingCopy",
20
+ "session.delete",
21
+ "session.setShareId",
22
+ ])
23
+ expect(roleAllowsMethod("agent", method)).toBe(false);
24
+ expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("asDaemonRole", () => {
29
+ test("passes owner and collaborator through and caps anything unknown to agent", () => {
30
+ // Assert
31
+ expect(asDaemonRole("owner")).toBe("owner");
32
+ expect(asDaemonRole("collaborator")).toBe("collaborator");
33
+ expect(asDaemonRole("agent")).toBe("agent");
34
+ expect(asDaemonRole("root")).toBe("agent");
35
+ expect(asDaemonRole(undefined)).toBe("agent");
36
+ });
37
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Daemon role capabilities: which socket methods a connection may call. The
3
+ * owner (the local planner) may call everything; a collaborator or a review-side
4
+ * agent is capped to reading the session and adding annotations, so a bring-your-
5
+ * own agent literally cannot resolve, rewrite, cut, share, or delete (ADR 0009).
6
+ * This is the single source of truth the server dispatch enforces.
7
+ */
8
+
9
+ export type DaemonRole = "owner" | "collaborator" | "agent";
10
+
11
+ /** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
12
+ const NON_OWNER_METHODS = new Set<string>([
13
+ "daemon.ping",
14
+ "daemon.hello",
15
+ "events.subscribe",
16
+ "session.get",
17
+ "session.list",
18
+ "session.wait",
19
+ "session.annotate",
20
+ ]);
21
+
22
+ /** Whether `role` may call `method`. The owner may call anything; others are capped to read + annotate. */
23
+ export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
24
+ return role === "owner" || NON_OWNER_METHODS.has(method);
25
+ }
26
+
27
+ /** Parse an untrusted role string, defaulting unknown values to the capped "agent". */
28
+ export function asDaemonRole(value: unknown): DaemonRole {
29
+ return value === "owner" || value === "collaborator" ? value : "agent";
30
+ }
package/src/client.ts CHANGED
@@ -1,38 +1,85 @@
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
- import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
9
- import { LineBuffer, type EventFrame, type Response } from "./protocol";
8
+ import type {
9
+ Annotation,
10
+ Artifact,
11
+ Identity,
12
+ ReviewSession,
13
+ VerdictKind,
14
+ WorkspaceKey,
15
+ } from "@cueloop/schema";
16
+ import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
17
+ import type { DaemonRole } from "./capabilities";
18
+ import type { HerdrTabHandle } from "./herdr-tab-store";
10
19
  import { cueloopHome, socketPath } from "./paths";
11
20
 
21
+ export type { EventFrame } from "./protocol";
22
+
12
23
  export interface ConnectOptions {
13
24
  home?: string;
14
25
  /** Spawn the daemon when the socket is not alive. */
15
26
  autostart?: boolean;
27
+ /** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
28
+ role?: DaemonRole;
16
29
  }
17
30
 
18
- type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
31
+ type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
19
32
 
20
- export class DaemonClient {
33
+ /**
34
+ * The session verbs the review controller drives. DaemonClient is the local
35
+ * implementation (unix socket); the sharing gateway supplies an in-memory,
36
+ * blob-backed one. Depending on this interface - not DaemonClient - is what
37
+ * lets the same <App> render a local session or a decrypted share unchanged.
38
+ */
39
+ export interface SessionClient {
40
+ onEvent(listener: (event: EventFrame) => void): () => void;
41
+ subscribe(): Promise<void>;
42
+ sessionGet(id: string): Promise<ReviewSession>;
43
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]>;
44
+ sessionAnnotate(
45
+ id: string,
46
+ annotation: Omit<Annotation, "createdAt">,
47
+ authorName?: string,
48
+ ): Promise<ReviewSession>;
49
+ sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
50
+ sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
51
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
52
+ sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
53
+ sessionMergeShared(
54
+ id: string,
55
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
56
+ ): Promise<ReviewSession>;
57
+ sessionDelete(id: string): Promise<void>;
58
+ /** Record the caller's own identity name (collaborator self-naming on a share). */
59
+ sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
60
+ sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
61
+ close(): void;
62
+ }
63
+
64
+ export class DaemonClient implements SessionClient {
21
65
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
22
- private pending = new Map<number, Pending>();
66
+ private writer: BackpressureWriter | null = null;
67
+ private pending = new Map<number, PendingRequest>();
23
68
  private nextId = 1;
24
- private eventListeners = new Set<(e: EventFrame) => void>();
69
+ private eventListeners = new Set<(event: EventFrame) => void>();
25
70
  private closed = false;
71
+ private role: DaemonRole = "owner";
26
72
 
27
- static async connect(opts: ConnectOptions = {}): Promise<DaemonClient> {
28
- const home = opts.home ?? cueloopHome();
73
+ static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
74
+ const home = options.home ?? cueloopHome();
29
75
  const path = socketPath(home);
30
76
  const client = new DaemonClient();
77
+ client.role = options.role ?? "owner";
31
78
  try {
32
79
  await client.dial(path);
33
80
  return client;
34
81
  } catch (err) {
35
- if (!opts.autostart) throw err;
82
+ if (!options.autostart) throw err;
36
83
  }
37
84
  // Socket dead or absent: clean a stale file and spawn the daemon detached.
38
85
  if (existsSync(path)) rmSync(path, { force: true });
@@ -40,47 +87,53 @@ export class DaemonClient {
40
87
  // Generous: a cold or loaded machine pays for a runtime start before the
41
88
  // socket exists, and giving up early looks to callers like a broken daemon.
42
89
  const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
43
- let lastErr: unknown;
90
+ let lastError: unknown;
44
91
  while (Date.now() < deadline) {
45
92
  try {
46
93
  await client.dial(path);
47
94
  return client;
48
95
  } catch (err) {
49
- lastErr = err;
96
+ lastError = err;
50
97
  await Bun.sleep(50);
51
98
  }
52
99
  }
53
- throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
100
+ throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
54
101
  }
55
102
 
56
103
  private async dial(path: string): Promise<void> {
57
104
  const buffer = new LineBuffer();
58
- const self = this;
59
105
  this.socket = await Bun.connect({
60
106
  unix: path,
61
107
  socket: {
62
- data(_socket, data) {
63
- buffer.push(data.toString(), (line) => self.handleFrame(line));
108
+ data: (_socket, data) => {
109
+ buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
110
+ },
111
+ drain: () => {
112
+ this.writer?.drain();
64
113
  },
65
- close() {
66
- self.closed = true;
67
- for (const p of self.pending.values()) p.reject(new Error("daemon connection closed"));
68
- self.pending.clear();
114
+ close: () => {
115
+ this.closed = true;
116
+ for (const pendingRequest of this.pending.values())
117
+ pendingRequest.reject(new Error("daemon connection closed"));
118
+ this.pending.clear();
69
119
  },
70
120
  error() {},
71
121
  },
72
122
  });
123
+ this.writer = new BackpressureWriter(this.socket);
73
124
  // Verify liveness: a dead socket file accepts connects on some platforms
74
125
  // only to fail later, so a ping is the actual handshake.
75
126
  await this.request("daemon.ping", {}, 2_000);
127
+ // Cap this connection's role for the daemon's capability gate (owner is the default).
128
+ if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
76
129
  }
77
130
 
78
- onEvent(listener: (e: EventFrame) => void): () => void {
131
+ onEvent(listener: (event: EventFrame) => void): () => void {
79
132
  this.eventListeners.add(listener);
80
133
  return () => this.eventListeners.delete(listener);
81
134
  }
82
135
 
83
- private handleFrame(line: string): void {
136
+ private routeInboundFrame(line: string): void {
84
137
  let frame: Response | EventFrame;
85
138
  try {
86
139
  frame = JSON.parse(line) as Response | EventFrame;
@@ -88,14 +141,15 @@ export class DaemonClient {
88
141
  return;
89
142
  }
90
143
  if ("event" in frame) {
91
- for (const l of this.eventListeners) l(frame);
144
+ for (const listener of this.eventListeners) listener(frame);
92
145
  return;
93
146
  }
94
- const p = this.pending.get(frame.id);
95
- if (!p) return;
147
+ const pendingRequest = this.pending.get(frame.id);
148
+ if (!pendingRequest) return;
96
149
  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);
150
+ if (frame.error)
151
+ pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
152
+ else pendingRequest.resolve(frame.result);
99
153
  }
100
154
 
101
155
  request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
@@ -107,16 +161,16 @@ export class DaemonClient {
107
161
  reject(new Error(`request ${method} timed out`));
108
162
  }, timeoutMs);
109
163
  this.pending.set(id, {
110
- resolve: (v) => {
164
+ resolve: (value) => {
111
165
  clearTimeout(timer);
112
- resolve(v as T);
166
+ resolve(value as T);
113
167
  },
114
- reject: (e) => {
168
+ reject: (error) => {
115
169
  clearTimeout(timer);
116
- reject(e);
170
+ reject(error);
117
171
  },
118
172
  });
119
- this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
173
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
120
174
  });
121
175
  }
122
176
 
@@ -145,8 +199,12 @@ export class DaemonClient {
145
199
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
146
200
  return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
147
201
  }
148
- sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession> {
149
- return this.request("session.annotate", { id, annotation });
202
+ sessionAnnotate(
203
+ id: string,
204
+ annotation: Omit<Annotation, "createdAt">,
205
+ authorName?: string,
206
+ ): Promise<ReviewSession> {
207
+ return this.request("session.annotate", { id, annotation, authorName });
150
208
  }
151
209
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
152
210
  return this.request("session.removeAnnotation", { id, annotationId });
@@ -154,11 +212,41 @@ export class DaemonClient {
154
212
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
155
213
  return this.request("session.setWorkingCopy", { id, workingCopy });
156
214
  }
215
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
216
+ return this.request("session.setViewed", { id, viewedPaths });
217
+ }
218
+ sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
219
+ return this.request("session.setShareId", { id, shareId });
220
+ }
221
+ sessionMergeShared(
222
+ id: string,
223
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
224
+ ): Promise<ReviewSession> {
225
+ return this.request("session.mergeShared", { id, ...incoming });
226
+ }
227
+ sessionDelete(id: string): Promise<void> {
228
+ return this.request("session.delete", { id });
229
+ }
230
+ /** Local sessions have no collaborator self-name; the share client owns this. */
231
+ sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
232
+ return this.sessionGet(id);
233
+ }
157
234
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
158
235
  return this.request("session.resolve", { id, verdictKind, summary });
159
236
  }
160
- sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
161
- return this.request("session.submitRevision", { id, content });
237
+ sessionSubmitRevision(
238
+ id: string,
239
+ content: string,
240
+ addressedAnnotationIds: string[] = [],
241
+ ): Promise<ReviewSession> {
242
+ return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
243
+ }
244
+ /** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
245
+ herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
246
+ return this.request("herdr.getTab", { id });
247
+ }
248
+ async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
249
+ await this.request("herdr.setTab", { id, ...handle });
162
250
  }
163
251
  shutdown(): Promise<void> {
164
252
  return this.request("daemon.shutdown", {});