@cueloop/daemon 0.1.0-alpha.25 → 0.1.0-alpha.27

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.25",
3
+ "version": "0.1.0-alpha.27",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,7 @@
11
11
  "./share-blob": "./src/share-blob.ts"
12
12
  },
13
13
  "dependencies": {
14
- "@cueloop/schema": "0.1.0-alpha.25",
14
+ "@cueloop/schema": "0.1.0-alpha.27",
15
15
  "valibot": "^1.4.2"
16
16
  },
17
17
  "publishConfig": {
package/src/api.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  verdictAllows,
16
16
  type Annotation,
17
17
  type Artifact,
18
+ type Identity,
18
19
  type ReviewSession,
19
20
  type Verdict,
20
21
  type VerdictKind,
@@ -176,11 +177,28 @@ export class DaemonCore {
176
177
  return session;
177
178
  }
178
179
 
179
- /** Union incoming annotations in by id; existing ids (the planner's) win. */
180
- sessionMergeAnnotations(id: string, incoming: Annotation[]): ReviewSession {
180
+ /** Remove a session for good (inbox delete); resolved or pending, both go. */
181
+ sessionDelete(id: string): void {
182
+ if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
183
+ this.emit("inbox.changed", id);
184
+ }
185
+
186
+ /**
187
+ * Merge a share's collaborator state back into the local session: annotations
188
+ * union by id with existing ones (the planner's) winning, and the participant
189
+ * registry union by id with the incoming identity winning (a collaborator is
190
+ * the authority on their own name). This is how a teammate's name reaches the
191
+ * planner after a pull, alongside their notes.
192
+ */
193
+ sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): ReviewSession {
181
194
  const session = this.mutable(id);
182
195
  const known = new Set(session.annotations.map((annotation) => annotation.id));
183
- for (const annotation of incoming) if (!known.has(annotation.id)) session.annotations.push(annotation);
196
+ for (const annotation of incoming.annotations) if (!known.has(annotation.id)) session.annotations.push(annotation);
197
+ if (incoming.participants?.length) {
198
+ const registry = new Map((session.participants ?? []).map((participant) => [participant.id, participant]));
199
+ for (const participant of incoming.participants) registry.set(participant.id, participant);
200
+ session.participants = [...registry.values()];
201
+ }
184
202
  this.store.upsert(session);
185
203
  this.emit("session.updated", id);
186
204
  return session;
package/src/client.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, rmSync } from "node:fs";
8
- import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
8
+ import type { Annotation, Artifact, Identity, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
9
9
  import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
10
10
  import { cueloopHome, socketPath } from "./paths";
11
11
 
@@ -35,7 +35,10 @@ export interface SessionClient {
35
35
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
36
36
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
37
37
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
38
- sessionMergeAnnotations(id: string, annotations: Annotation[]): Promise<ReviewSession>;
38
+ sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession>;
39
+ sessionDelete(id: string): Promise<void>;
40
+ /** Record the caller's own identity name (collaborator self-naming on a share). */
41
+ sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
39
42
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
40
43
  close(): void;
41
44
  }
@@ -188,8 +191,15 @@ export class DaemonClient implements SessionClient {
188
191
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
189
192
  return this.request("session.setShareId", { id, shareId });
190
193
  }
191
- sessionMergeAnnotations(id: string, annotations: Annotation[]): Promise<ReviewSession> {
192
- return this.request("session.mergeAnnotations", { id, annotations });
194
+ sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession> {
195
+ return this.request("session.mergeShared", { id, ...incoming });
196
+ }
197
+ sessionDelete(id: string): Promise<void> {
198
+ return this.request("session.delete", { id });
199
+ }
200
+ /** Local sessions have no collaborator self-name; the share client owns this. */
201
+ sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
202
+ return this.sessionGet(id);
193
203
  }
194
204
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
195
205
  return this.request("session.resolve", { id, verdictKind, summary });
@@ -121,41 +121,61 @@ describe("socket round-trip", () => {
121
121
  expect(got.artifact.meta.agent).toBe("claude-code");
122
122
  });
123
123
 
124
- test("share-sync verbs round-trip: setShareId persists, mergeAnnotations unions by id", async () => {
125
- // Arrange
124
+ test("share-sync verbs round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
125
+ // Given a shared session with one local note
126
126
  const session = await client.sessionCreate(WS, PLAN);
127
127
  const anchor = { quote: "Body text", prefix: "", suffix: "." };
128
-
129
- // Act - stamp the share id, then a local note
130
128
  await client.sessionSetShareId(session.id, "p_abc123xy");
131
129
  await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
132
-
133
- // Assert - the share id came back over the wire
134
130
  expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
135
131
 
136
- // Act - merge an update to the known id plus a new collaborator note
137
- const merged = await client.sessionMergeAnnotations(session.id, [
138
- { id: "a1", kind: "comment", anchor, body: "should not overwrite", createdAt: "2026-01-01T00:00:00.000Z" },
139
- { id: "a2", kind: "comment", anchor, body: "theirs", author: "SHA256:mate", createdAt: "2026-01-01T00:00:00.000Z" },
140
- ]);
132
+ // When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
133
+ const merged = await client.sessionMergeShared(session.id, {
134
+ annotations: [
135
+ { id: "a1", kind: "comment", anchor, body: "should not overwrite", createdAt: "2026-01-01T00:00:00.000Z" },
136
+ { id: "a2", kind: "comment", anchor, body: "theirs", author: "SHA256:mate", createdAt: "2026-01-01T00:00:00.000Z" },
137
+ ],
138
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
139
+ });
141
140
 
142
- // Assert - union by id: existing a1 kept as-is, a2 added with its author
141
+ // Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
143
142
  expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
144
143
  expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
145
144
  expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe("SHA256:mate");
145
+ expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
146
146
  });
147
147
 
148
- test("mergeAnnotations validates each annotation at the socket boundary", async () => {
148
+ test("mergeShared validates each annotation at the socket boundary", async () => {
149
+ // Given a session
149
150
  const session = await client.sessionCreate(WS, PLAN);
150
151
  try {
152
+ // When a malformed annotation is merged
151
153
  // @ts-expect-error intentionally malformed annotation (empty id, missing fields)
152
- await client.sessionMergeAnnotations(session.id, [{ id: "" }]);
154
+ await client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
153
155
  throw new Error("should have thrown");
154
156
  } catch (error) {
157
+ // Then it is rejected at the wire boundary
155
158
  expect((error as DaemonClientError).code).toBe("invalid_params");
156
159
  }
157
160
  });
158
161
 
162
+ test("session.delete removes a session over the wire", async () => {
163
+ // Given a created session
164
+ const session = await client.sessionCreate(WS, PLAN);
165
+
166
+ // When it is deleted
167
+ await client.sessionDelete(session.id);
168
+
169
+ // Then it is gone, and deleting an unknown id is a not_found error
170
+ expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
171
+ try {
172
+ await client.sessionDelete("ses_missing");
173
+ throw new Error("should have thrown");
174
+ } catch (error) {
175
+ expect((error as DaemonClientError).code).toBe("not_found");
176
+ }
177
+ });
178
+
159
179
  test("two clients see the same state (thin-renderer model)", async () => {
160
180
  // Arrange
161
181
  const second = await DaemonClient.connect({ home });
package/src/server.ts CHANGED
@@ -249,9 +249,13 @@ export class DaemonServer {
249
249
  const params = parseParams("session.setShareId", request.params);
250
250
  return core.sessionSetShareId(params.id, params.shareId);
251
251
  }
252
- case "session.mergeAnnotations": {
253
- const params = parseParams("session.mergeAnnotations", request.params);
254
- return core.sessionMergeAnnotations(params.id, params.annotations);
252
+ case "session.delete": {
253
+ core.sessionDelete(parseParams("session.delete", request.params).id);
254
+ return {};
255
+ }
256
+ case "session.mergeShared": {
257
+ const params = parseParams("session.mergeShared", request.params);
258
+ return core.sessionMergeShared(params.id, { annotations: params.annotations, participants: params.participants });
255
259
  }
256
260
  case "session.resolve": {
257
261
  const params = parseParams("session.resolve", request.params);
@@ -9,6 +9,7 @@ import {
9
9
  AnnotationSchema,
10
10
  ArtifactMetaSchema,
11
11
  ArtifactSchema,
12
+ IdentitySchema,
12
13
  RevisionSchema,
13
14
  SessionRecordSchema,
14
15
  VerdictSchema,
@@ -23,6 +24,7 @@ import {
23
24
  type Annotation,
24
25
  type Artifact,
25
26
  type ArtifactMeta,
27
+ type Identity,
26
28
  type ReviewSession,
27
29
  type Revision,
28
30
  type Verdict,
@@ -179,6 +181,7 @@ describe("wire pins", () => {
179
181
  const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
180
182
  const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
181
183
  const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
184
+ const fullIdentity: Required<Identity> = { id: "SHA256:abc", provider: "ssh", name: "Al", handle: "abc" };
182
185
  const fullSession: Required<ReviewSession> = {
183
186
  schemaVersion: SCHEMA_VERSION,
184
187
  id: "ses_1",
@@ -193,6 +196,7 @@ describe("wire pins", () => {
193
196
  createdAt: "now",
194
197
  shareId: "p_abc123xy",
195
198
  owner: "SHA256:owner",
199
+ participants: [fullIdentity],
196
200
  };
197
201
 
198
202
  test("schema key sets match the schema types", () => {
@@ -205,6 +209,7 @@ describe("wire pins", () => {
205
209
  expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
206
210
  expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
207
211
  expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
212
+ expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
208
213
  expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
209
214
  // persisted annotations carry the stamped createdAt
210
215
  const stored = SessionRecordSchema.entries.annotations.item;
package/src/validate.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type Annotation,
17
17
  type Artifact,
18
18
  type ArtifactMeta,
19
+ type Identity,
19
20
  type ReviewSession,
20
21
  type Revision,
21
22
  type Verdict,
@@ -86,6 +87,13 @@ const SessionId = NonEmpty;
86
87
  /** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
87
88
  export const FullAnnotationSchema = v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>);
88
89
 
90
+ export const IdentitySchema = v.object({
91
+ id: NonEmpty,
92
+ provider: v.literal("ssh"),
93
+ name: v.optional(v.string()),
94
+ handle: v.optional(v.string()),
95
+ } satisfies EntriesOf<Identity>);
96
+
89
97
  export const Params = {
90
98
  "session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
91
99
  "session.get": v.object({ id: SessionId }),
@@ -107,7 +115,12 @@ export const Params = {
107
115
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
108
116
  "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
109
117
  "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
110
- "session.mergeAnnotations": v.object({ id: SessionId, annotations: v.array(FullAnnotationSchema) }),
118
+ "session.delete": v.object({ id: SessionId }),
119
+ "session.mergeShared": v.object({
120
+ id: SessionId,
121
+ annotations: v.array(FullAnnotationSchema),
122
+ participants: v.optional(v.array(IdentitySchema)),
123
+ }),
111
124
  "session.resolve": v.object({
112
125
  id: SessionId,
113
126
  verdictKind: v.picklist(["comment", "approve", "request_changes"]),
@@ -169,6 +182,7 @@ export const SessionRecordSchema = v.object({
169
182
  createdAt: v.string(),
170
183
  shareId: v.optional(v.string()),
171
184
  owner: v.optional(v.string()),
185
+ participants: v.optional(v.array(IdentitySchema)),
172
186
  } satisfies EntriesOf<ReviewSession>);
173
187
 
174
188
  export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {