cueloop 0.1.0-alpha.64 → 0.1.0-alpha.66

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",
3
- "version": "0.1.0-alpha.64",
3
+ "version": "0.1.0-alpha.66",
4
4
  "description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -30,10 +30,10 @@
30
30
  "build:binary": "bun build --compile --external cpu-features ./src/main.ts --outfile dist/cueloop"
31
31
  },
32
32
  "dependencies": {
33
- "@cueloop/adapters": "0.1.0-alpha.64",
34
- "@cueloop/client": "0.1.0-alpha.64",
35
- "@cueloop/daemon": "0.1.0-alpha.64",
36
- "@cueloop/schema": "0.1.0-alpha.64",
33
+ "@cueloop/adapters": "0.1.0-alpha.66",
34
+ "@cueloop/client": "0.1.0-alpha.66",
35
+ "@cueloop/daemon": "0.1.0-alpha.66",
36
+ "@cueloop/schema": "0.1.0-alpha.66",
37
37
  "valibot": "^1.4.2"
38
38
  },
39
39
  "engines": {
@@ -12,9 +12,9 @@ import { parseArgs, stringFlag } from "./args";
12
12
 
13
13
  export async function actionsCommand(argv: string[]): Promise<number> {
14
14
  const { positional, flags } = parseArgs(argv);
15
- const verb = positional[0] ?? "list";
15
+ const primitive = positional[0] ?? "list";
16
16
 
17
- if (verb !== "list") {
17
+ if (primitive !== "list") {
18
18
  console.error("usage: cueloop actions list [--session <id>]");
19
19
 
20
20
  return 2;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `cueloop dev`: open the TUI against an isolated dev home seeded once with a
3
+ * plan and a diff under this repo's project plus a standalone reply thread, so
4
+ * the Projects and Threads sidebar and the thread view are populated without a
5
+ * live agent. It never touches the real ~/.cueloop home.
6
+ */
7
+
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { DaemonClient } from "@cueloop/daemon/client";
11
+ import { resolveWorkspace } from "@cueloop/daemon/review";
12
+
13
+ const SEED_PLAN = `# Read the repository
14
+
15
+ A seeded plan so \`cueloop dev\` always has a thread to open.
16
+
17
+ ## Steps
18
+ - open the thread view
19
+ - move the cursor into the agent's text
20
+ - type to leave a comment
21
+ `;
22
+
23
+ const SEED_REPLY = `A standalone thought, not tied to any repository.
24
+
25
+ It lands under Threads in the sidebar rather than a project.
26
+ `;
27
+
28
+ const SEED_DIFF_PATH = "packages/client/src/theme.ts";
29
+ const SEED_DIFF_OLD = ' accent: "#f5a3a3",\n';
30
+ const SEED_DIFF_NEW = ' accent: "#cba6f7",\n';
31
+ const SEED_DIFF = `diff --git a/${SEED_DIFF_PATH} b/${SEED_DIFF_PATH}
32
+ --- a/${SEED_DIFF_PATH}
33
+ +++ b/${SEED_DIFF_PATH}
34
+ @@ -1 +1 @@
35
+ -${SEED_DIFF_OLD.trimEnd()}
36
+ +${SEED_DIFF_NEW.trimEnd()}
37
+ `;
38
+
39
+ async function seedDevSessions(client: DaemonClient): Promise<void> {
40
+ // the current repo carries a root commit, so its threads gather under a project
41
+ const project = await resolveWorkspace(process.cwd());
42
+ // a non-repo directory has no root commit, so its thread stays standalone
43
+ const standalone = await resolveWorkspace(tmpdir());
44
+
45
+ await client.sessionCreate(project, {
46
+ type: "plan",
47
+ content: SEED_PLAN,
48
+ meta: { title: "Read the repository" },
49
+ });
50
+ await client.sessionCreate(project, {
51
+ type: "diff",
52
+ content: SEED_DIFF,
53
+ meta: { title: "Review the accent change" },
54
+ files: [
55
+ {
56
+ path: SEED_DIFF_PATH,
57
+ oldContents: SEED_DIFF_OLD,
58
+ newContents: SEED_DIFF_NEW,
59
+ status: "modified",
60
+ },
61
+ ],
62
+ });
63
+ await client.sessionCreate(standalone, {
64
+ type: "reply",
65
+ content: SEED_REPLY,
66
+ meta: { title: "A standalone thought" },
67
+ });
68
+ }
69
+
70
+ /** `cueloop dev`: seed an isolated dev home once, then open the TUI on it. */
71
+ export async function devCommand(): Promise<number> {
72
+ process.env.CUELOOP_HOME ??= join(homedir(), ".cueloop-dev");
73
+
74
+ const client = await DaemonClient.connect({ autostart: true });
75
+ try {
76
+ const existing = await client.sessionList();
77
+ if (existing.length === 0) await seedDevSessions(client);
78
+ } finally {
79
+ client.close();
80
+ }
81
+
82
+ const { runClient } = await import("@cueloop/client");
83
+
84
+ return runClient({});
85
+ }
package/src/main.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * cueloop entry points: `cueloop` opens the TUI on the inbox, the verb-first
3
+ * cueloop entry points: `cueloop` opens the TUI on the inbox, the primitive-first
4
4
  * openers `cueloop plan|reply|diff|review` open the latest pending review of
5
5
  * that type (or address one by id/title), `cueloop diff`/`cueloop review <pr>` also
6
6
  * keep their create paths, `cueloop session *` mirrors the daemon API for
@@ -93,7 +93,7 @@ async function shareEntry(rest: string[]): Promise<number> {
93
93
 
94
94
  if (positional[0] === "pull") return sharePullCommand({ ...target, sessionId: positional[1] });
95
95
 
96
- return shareCommand({ ...target, sessionId: positional[0] });
96
+ return shareCommand({ ...target, sessionId: positional[0], fork: flags.fork === true });
97
97
  }
98
98
 
99
99
  type CommandHandler = (rest: string[]) => number | Promise<number>;
@@ -116,6 +116,7 @@ const commandHandlers: CommandHandlers = {
116
116
  refine: async (rest) => (await import("./refine-command")).refineCommand(rest),
117
117
  review: (rest) => reviewEntry(rest),
118
118
  "review-post": async (rest) => (await import("./pr")).reviewPostCommand(rest),
119
+ dev: async () => (await import("./dev-command")).devCommand(),
119
120
  };
120
121
 
121
122
  const versionAliases = new Set(["-v", "--version", "version"]);
@@ -143,7 +144,7 @@ async function main(): Promise<number> {
143
144
  }
144
145
 
145
146
  /**
146
- * The id-or-title selector for a verb-first opener: the bare positional, or a
147
+ * The id-or-title selector for a primitive-first opener: the bare positional, or a
147
148
  * value handed to `--open`/`--latest`. A bare `--latest`/`--open` flag carries
148
149
  * no value, so the selector stays undefined and the opener defaults to the
149
150
  * latest pending review.
@@ -155,7 +156,7 @@ function openSelector(parsed: ParsedArgs): string | undefined {
155
156
  }
156
157
 
157
158
  /**
158
- * Resolve one review of the verb's scope and open it in the TUI, or print the
159
+ * Resolve one review of the primitive's scope and open it in the TUI, or print the
159
160
  * miss and fail. `emptyMessage` overrides the default no-pending line for the
160
161
  * one caller that needs a scope-specific hint (a clean working tree).
161
162
  */
@@ -340,12 +341,13 @@ function printHelp(): void {
340
341
  " cueloop <plan|diff|review> --latest open the latest pending review of that type",
341
342
  "",
342
343
  "scripting:",
343
- " cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
344
+ " cueloop session <primitive> [flags] script the daemon (create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|resolve|submit-revision)",
344
345
  " cueloop actions list list the quick-action vocabulary (for annotate --action)",
345
346
  " cueloop refine mine past reviews into a markdown report + writeback proposals",
346
347
  " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
347
348
  " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
348
349
  " cueloop daemon run the daemon in the foreground",
350
+ " cueloop dev open the TUI on an isolated home seeded with example threads",
349
351
  "",
350
352
  " cueloop -v, --version print the installed version",
351
353
  " cueloop -h, --help print this help",
@@ -1,4 +1,4 @@
1
- /** Verb-first open resolution over a plain session list: latest-pending default, by-id, and fuzzy-by-title (exact wins, unique substring wins, else ambiguous). Pure over the array - no daemon, no TUI. */
1
+ /** Primitive-first open resolution over a plain session list: latest-pending default, by-id, and fuzzy-by-title (exact wins, unique substring wins, else ambiguous). Pure over the array - no daemon, no TUI. */
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
4
  import type { ArtifactType, ReviewSession } from "@cueloop/schema";
@@ -43,7 +43,7 @@ function session(overrides: {
43
43
 
44
44
  const isPlan = isPlanReview;
45
45
 
46
- describe("verb scope predicates are disjoint", () => {
46
+ describe("primitive scope predicates are disjoint", () => {
47
47
  test("isPlanReview matches only plan artifacts", () => {
48
48
  expect(isPlanReview(session({ id: "ses_p", type: "plan" }))).toBe(true);
49
49
  expect(isPlanReview(session({ id: "ses_d", type: "diff" }))).toBe(false);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Verb-first review-open resolution: turn a verb (plan/diff/review) plus an
2
+ * Primitive-first review-open resolution: turn a primitive (plan/diff/review) plus an
3
3
  * optional id-or-title selector into a single session to open, or a clear
4
4
  * miss. Pure over a session list so it unit-tests without a daemon or a TUI -
5
5
  * main.ts fetches the sessions and prints the miss messages.
@@ -12,15 +12,15 @@
12
12
  * - otherwise the selector matches session titles, case-insensitive: an
13
13
  * exact title wins, else a unique substring match wins, else the substring
14
14
  * candidates are reported as ambiguous.
15
- * Every lookup is scoped by the caller's `match` predicate, so a verb only
15
+ * Every lookup is scoped by the caller's `match` predicate, so a primitive only
16
16
  * ever resolves to its own artifact kind.
17
17
  */
18
18
 
19
19
  import type { ReviewSession } from "@cueloop/schema";
20
20
 
21
21
  /**
22
- * The verb scopes, kept disjoint on purpose so a single session never resolves
23
- * under two verbs. A PR review is a diff artifact that carries a `pr`
22
+ * The primitive scopes, kept disjoint on purpose so a single session never resolves
23
+ * under two primitives. A PR review is a diff artifact that carries a `pr`
24
24
  * reference (openReview maps a top-level `pr` into `meta.pr`), so the plain
25
25
  * diff scope must exclude those - otherwise `cueloop diff` and `cueloop
26
26
  * review` would both open the same pending PR review.
@@ -47,7 +47,7 @@ export function isSessionId(value: string): boolean {
47
47
  }
48
48
 
49
49
  export interface OpenTargetQuery {
50
- /** Only sessions this predicate accepts are eligible - the verb's artifact scope. */
50
+ /** Only sessions this predicate accepts are eligible - the primitive's artifact scope. */
51
51
  match: (session: ReviewSession) => boolean;
52
52
  /** Positional id-or-title selector; absent means "latest pending". */
53
53
  selector?: string;
@@ -107,7 +107,7 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
107
107
 
108
108
  /**
109
109
  * The reviewer-facing line for a miss, in the "working tree is clean - nothing
110
- * to review" tone. `label` is the human word for the verb's scope ("plan",
110
+ * to review" tone. `label` is the human word for the primitive's scope ("plan",
111
111
  * "diff", "PR"). The "session" outcome has no message - the caller opens it.
112
112
  */
113
113
  export function openTargetMessage(
@@ -4,7 +4,13 @@
4
4
  * JSON on stdout; exit code 0 unless the daemon returned an error.
5
5
  */
6
6
 
7
- import { ARTIFACT_TYPES, isArtifactType, newAnnotationId } from "@cueloop/schema";
7
+ import {
8
+ ARTIFACT_TYPES,
9
+ isArtifactType,
10
+ newAnnotationId,
11
+ type Anchor,
12
+ type Annotation,
13
+ } from "@cueloop/schema";
8
14
  import * as v from "valibot";
9
15
  import { DaemonClient } from "@cueloop/daemon/client";
10
16
  import { openHerdrPaneForReview } from "@cueloop/daemon/herdr-pane";
@@ -93,37 +99,242 @@ async function sessionWaitCommand({ client, positional, flags }: SessionContext)
93
99
  return 0;
94
100
  }
95
101
 
102
+ /**
103
+ * The anchor an annotate call means: a reply borrows its root comment's
104
+ * anchor, a prototype comment names an element by selector, and anything
105
+ * else quotes the passage.
106
+ */
107
+ async function annotateAnchor(
108
+ flags: Record<string, string | boolean>,
109
+ client: DaemonClient,
110
+ id: string,
111
+ ): Promise<{ anchor: Anchor; replyTo?: string }> {
112
+ const replyTo = stringFlag(flags, "reply-to");
113
+
114
+ if (replyTo !== undefined) {
115
+ const session = await client.sessionGet(id);
116
+ const root = session.annotations.find((annotation) => annotation.id === replyTo);
117
+
118
+ if (!root) throw new Error(`no comment ${replyTo} to reply to`);
119
+
120
+ // a reply to a reply still hangs off the discussion's root comment
121
+ return { anchor: root.anchor, replyTo: root.replyTo ?? root.id };
122
+ }
123
+ const selector = stringFlag(flags, "selector");
124
+ const quote =
125
+ selector === undefined
126
+ ? required(stringFlag(flags, "quote"), "--quote")
127
+ : (stringFlag(flags, "quote") ?? "");
128
+ const anchor: Anchor = {
129
+ quote,
130
+ prefix: stringFlag(flags, "prefix") ?? "",
131
+ suffix: stringFlag(flags, "suffix") ?? "",
132
+ };
133
+
134
+ if (selector !== undefined) anchor.selector = selector;
135
+
136
+ return { anchor };
137
+ }
138
+
96
139
  async function sessionAnnotateCommand({
97
140
  client,
98
141
  positional,
99
142
  flags,
100
143
  }: SessionContext): Promise<number> {
101
144
  const id = required(positional[1], "session id");
102
- const quote = required(stringFlag(flags, "quote"), "--quote");
103
145
  const author = stringFlag(flags, "author");
104
146
  const body = await annotateBody(flags, client, id);
147
+ const { anchor, replyTo } = await annotateAnchor(flags, client, id);
148
+ const annotation: Omit<Annotation, "createdAt"> = {
149
+ id: stringFlag(flags, "annotation-id") ?? newAnnotationId(),
150
+ kind: stringFlag(flags, "kind") ?? "comment",
151
+ anchor,
152
+ body,
153
+ author,
154
+ };
155
+
156
+ if (replyTo !== undefined) annotation.replyTo = replyTo;
157
+ out(await client.sessionAnnotate(id, annotation, stringFlag(flags, "author-name")));
158
+
159
+ return 0;
160
+ }
161
+
162
+ /** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
163
+ async function sessionRemoveCommand({ client, positional }: SessionContext): Promise<number> {
164
+ const id = required(positional[1], "session id");
165
+ const annotationId = required(positional[2], "annotation id");
166
+
167
+ out(await client.sessionRemoveAnnotation(id, annotationId));
168
+
169
+ return 0;
170
+ }
171
+
172
+ /** A 0-based block or line index as the command line carries it. */
173
+ const BlockIndexSchema = v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0));
174
+
175
+ /** The reject decisions of a diff review as `--rejections` carries them. */
176
+ const RejectionsSchema = v.array(
177
+ v.object({
178
+ path: v.pipe(v.string(), v.minLength(1)),
179
+ hunkIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
180
+ changeIndex: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
181
+ }),
182
+ );
183
+
184
+ /** Cut the given block of the working copy (a 0-based index into its blocks). */
185
+ async function sessionCutCommand({ client, positional }: SessionContext): Promise<number> {
186
+ const id = required(positional[1], "session id");
187
+ const blockIndex = v.parse(BlockIndexSchema, required(positional[2], "block index"));
188
+
189
+ out(await client.sessionCutBlock(id, blockIndex));
190
+
191
+ return 0;
192
+ }
193
+
194
+ /** Re-insert a cut block of the submitted revision, before --line (default: the end). */
195
+ async function sessionRestoreCommand({
196
+ client,
197
+ positional,
198
+ flags,
199
+ }: SessionContext): Promise<number> {
200
+ const id = required(positional[1], "session id");
201
+ const baseBlockIndex = v.parse(BlockIndexSchema, required(positional[2], "block index"));
202
+ const line = stringFlag(flags, "line");
203
+
204
+ out(
205
+ await client.sessionRestoreBlock(
206
+ id,
207
+ baseBlockIndex,
208
+ line === undefined ? undefined : v.parse(BlockIndexSchema, line),
209
+ ),
210
+ );
211
+
212
+ return 0;
213
+ }
214
+
215
+ /** Replace a diff review's reject decisions with the JSON list given. */
216
+ async function sessionCurateCommand({
217
+ client,
218
+ positional,
219
+ flags,
220
+ }: SessionContext): Promise<number> {
221
+ const id = required(positional[1], "session id");
222
+ const rejections = v.parse(
223
+ RejectionsSchema,
224
+ JSON.parse(required(stringFlag(flags, "rejections"), "--rejections")),
225
+ );
226
+
227
+ out(await client.sessionCurate(id, rejections));
228
+
229
+ return 0;
230
+ }
231
+
232
+ /** Move a branch's tip back to an entry on its path; --summary records what was left behind, --branch stands on that branch first. */
233
+ async function sessionNavigateCommand({
234
+ client,
235
+ positional,
236
+ flags,
237
+ }: SessionContext): Promise<number> {
238
+ const id = required(positional[1], "session id");
239
+ const entryId = required(positional[2], "entry id");
105
240
 
106
241
  out(
107
- await client.sessionAnnotate(
242
+ await client.sessionNavigate(
108
243
  id,
109
- {
110
- id: stringFlag(flags, "annotation-id") ?? newAnnotationId(),
111
- kind: stringFlag(flags, "kind") ?? "comment",
112
- anchor: {
113
- quote,
114
- prefix: stringFlag(flags, "prefix") ?? "",
115
- suffix: stringFlag(flags, "suffix") ?? "",
116
- },
117
- body,
118
- author,
119
- },
120
- stringFlag(flags, "author-name"),
244
+ entryId,
245
+ stringFlag(flags, "summary"),
246
+ stringFlag(flags, "branch"),
121
247
  ),
122
248
  );
123
249
 
124
250
  return 0;
125
251
  }
126
252
 
253
+ /** Start a branch at the current tip and switch to it. */
254
+ async function sessionBranchCommand({ client, positional }: SessionContext): Promise<number> {
255
+ const id = required(positional[1], "session id");
256
+
257
+ out(await client.sessionBranch(id, required(positional[2], "branch name")));
258
+
259
+ return 0;
260
+ }
261
+
262
+ /** Show another branch's path. */
263
+ async function sessionSwitchCommand({ client, positional }: SessionContext): Promise<number> {
264
+ const id = required(positional[1], "session id");
265
+
266
+ out(await client.sessionSwitch(id, required(positional[2], "branch name")));
267
+
268
+ return 0;
269
+ }
270
+
271
+ /** Name the current tip as a checkpoint. */
272
+ async function sessionLabelCommand({ client, positional }: SessionContext): Promise<number> {
273
+ const id = required(positional[1], "session id");
274
+
275
+ out(await client.sessionLabel(id, required(positional[2], "label")));
276
+
277
+ return 0;
278
+ }
279
+
280
+ /** Copy the current path into a new session and print the fork. */
281
+ async function sessionForkCommand({ client, positional }: SessionContext): Promise<number> {
282
+ out(await client.sessionFork(required(positional[1], "session id")));
283
+
284
+ return 0;
285
+ }
286
+
287
+ /** Mark files of a diff review as viewed - the guided walk's state, from a script. */
288
+ async function sessionSetViewedCommand({ client, positional }: SessionContext): Promise<number> {
289
+ const id = required(positional[1], "session id");
290
+
291
+ out(await client.sessionSetViewed(id, positional.slice(2)));
292
+
293
+ return 0;
294
+ }
295
+
296
+ /** Register the display name of a participant: how a collaborator or an agent names itself. */
297
+ async function sessionNameSelfCommand({
298
+ client,
299
+ positional,
300
+ flags,
301
+ }: SessionContext): Promise<number> {
302
+ const id = required(positional[1], "session id");
303
+ const name = required(positional[2], "name");
304
+ const author = required(stringFlag(flags, "author"), "--author");
305
+
306
+ out(await client.sessionSetParticipantName(id, author, name));
307
+
308
+ return 0;
309
+ }
310
+
311
+ /**
312
+ * Follow a session live: one JSON line per event, with the entry the change
313
+ * appended, until the process ends. `--once` prints the first event and exits.
314
+ * The subscription is in place before the session is read, so nothing that
315
+ * lands after the read can slip between the two.
316
+ */
317
+ async function sessionEventsCommand({
318
+ client,
319
+ positional,
320
+ flags,
321
+ }: SessionContext): Promise<number> {
322
+ const id = required(positional[1], "session id");
323
+ const once = flags.once === true;
324
+ const stream = new Promise<number>((resolve) => {
325
+ client.onEvent((event) => {
326
+ if (event.sessionId !== id) return;
327
+ out(event);
328
+ if (once) resolve(0);
329
+ });
330
+ });
331
+
332
+ await client.subscribe();
333
+ await client.sessionGet(id);
334
+
335
+ return stream;
336
+ }
337
+
127
338
  async function sessionResolveCommand({
128
339
  client,
129
340
  positional,
@@ -157,7 +368,7 @@ async function sessionSubmitRevisionCommand({
157
368
  }
158
369
 
159
370
  interface SessionVerbHandlers {
160
- [verb: string]: SessionVerbHandler;
371
+ [primitive: string]: SessionVerbHandler;
161
372
  }
162
373
 
163
374
  const sessionVerbHandlers: SessionVerbHandlers = {
@@ -166,24 +377,41 @@ const sessionVerbHandlers: SessionVerbHandlers = {
166
377
  list: sessionListCommand,
167
378
  wait: sessionWaitCommand,
168
379
  annotate: sessionAnnotateCommand,
380
+ remove: sessionRemoveCommand,
381
+ cut: sessionCutCommand,
382
+ restore: sessionRestoreCommand,
383
+ curate: sessionCurateCommand,
384
+ "set-viewed": sessionSetViewedCommand,
385
+ navigate: sessionNavigateCommand,
386
+ branch: sessionBranchCommand,
387
+ switch: sessionSwitchCommand,
388
+ label: sessionLabelCommand,
389
+ fork: sessionForkCommand,
390
+ "name-self": sessionNameSelfCommand,
391
+ events: sessionEventsCommand,
169
392
  resolve: sessionResolveCommand,
170
393
  "submit-revision": sessionSubmitRevisionCommand,
171
394
  };
172
395
 
173
396
  export async function sessionCommand(argv: string[]): Promise<number> {
174
397
  const { positional, flags } = parseArgs(argv);
175
- const verb = positional[0];
398
+ const primitive = positional[0];
176
399
  // --role agent (or collaborator) caps this connection to read + annotate; a
177
400
  // review-side agent passes it so the daemon rejects any escalation attempt.
401
+ // and --author binds the identity its comments, removals, and name carry
178
402
  const role = stringFlag(flags, "role") === "agent" ? "agent" : undefined;
179
- const client = await DaemonClient.connect({ autostart: true, role });
403
+ const client = await DaemonClient.connect({
404
+ autostart: true,
405
+ role,
406
+ author: role === undefined ? undefined : stringFlag(flags, "author"),
407
+ });
180
408
 
181
409
  try {
182
- const handler = verb !== undefined ? sessionVerbHandlers[verb] : undefined;
410
+ const handler = primitive !== undefined ? sessionVerbHandlers[primitive] : undefined;
183
411
 
184
412
  if (handler === undefined) {
185
413
  console.error(
186
- "usage: cueloop session <create|get|list|wait|annotate|resolve|submit-revision> [flags]",
414
+ "usage: cueloop session <create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|resolve|submit-revision> [flags]",
187
415
  );
188
416
 
189
417
  return 2;
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
2
+ import {
3
+ appendEntry,
4
+ historyFromLinear,
5
+ SCHEMA_VERSION,
6
+ type Annotation,
7
+ type ReviewSession,
8
+ } from "@cueloop/schema";
3
9
  import type { SessionClient } from "@cueloop/daemon/client";
4
10
  import { pullSession, shareSession, type PullDeps, type ShareDeps } from "./share-command";
5
11
 
@@ -33,7 +39,7 @@ function annotationFixture(id: string, author?: string): Annotation {
33
39
  const unimplemented = (member: string) => () =>
34
40
  Promise.reject(new Error(`fakeClient does not implement ${member}`));
35
41
 
36
- /** A SessionClient that answers get/list from a fixed list and records the share/merge verbs. */
42
+ /** A SessionClient that answers get/list from a fixed list and records the share/merge primitives. */
37
43
  function fakeClient(sessions: ReviewSession[]): SessionClient {
38
44
  return {
39
45
  onEvent: () => () => {},
@@ -43,6 +49,20 @@ function fakeClient(sessions: ReviewSession[]): SessionClient {
43
49
  sessionAnnotate: unimplemented("sessionAnnotate"),
44
50
  sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
45
51
  sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
52
+ sessionCutBlock: unimplemented("sessionCutBlock"),
53
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
54
+ sessionCurate: unimplemented("sessionCurate"),
55
+ sessionNavigate: unimplemented("sessionNavigate"),
56
+ sessionBranch: unimplemented("sessionBranch"),
57
+ sessionSwitch: unimplemented("sessionSwitch"),
58
+ sessionLabel: unimplemented("sessionLabel"),
59
+ sessionFork: mock(async (id: string) => {
60
+ const fork = { ...sessionFixture(`${id}_fork`), parentSessionId: id };
61
+
62
+ sessions.push(fork);
63
+
64
+ return fork;
65
+ }),
46
66
  sessionSetViewed: unimplemented("sessionSetViewed"),
47
67
  sessionSetShareId: mock(async (id: string, shareId: string) => {
48
68
  const session = sessions.find((candidate) => candidate.id === id)!;
@@ -51,15 +71,25 @@ function fakeClient(sessions: ReviewSession[]): SessionClient {
51
71
 
52
72
  return session;
53
73
  }),
54
- sessionMergeShared: mock(async (id: string, incoming: { annotations: Annotation[] }) => {
55
- const session = sessions.find((candidate) => candidate.id === id)!;
56
- const known = new Set(session.annotations.map((annotation) => annotation.id));
57
-
58
- for (const annotation of incoming.annotations)
59
- if (!known.has(annotation.id)) session.annotations.push(annotation);
60
-
61
- return session;
62
- }),
74
+ sessionMergeShared: mock(
75
+ async (
76
+ id: string,
77
+ incoming: { annotations: Annotation[]; removals?: Array<{ annotationId: string }> },
78
+ ) => {
79
+ const session = sessions.find((candidate) => candidate.id === id)!;
80
+ const known = new Set(session.annotations.map((annotation) => annotation.id));
81
+
82
+ for (const annotation of incoming.annotations)
83
+ if (!known.has(annotation.id)) session.annotations.push(annotation);
84
+ const shelved = new Set((incoming.removals ?? []).map((removal) => removal.annotationId));
85
+
86
+ session.annotations = session.annotations.filter(
87
+ (annotation) => !shelved.has(annotation.id),
88
+ );
89
+
90
+ return session;
91
+ },
92
+ ),
63
93
  sessionDelete: unimplemented("sessionDelete"),
64
94
  sessionSetSelfName: unimplemented("sessionSetSelfName"),
65
95
  sessionResolve: unimplemented("sessionResolve"),
@@ -156,6 +186,28 @@ describe(shareSession, () => {
156
186
  // Assert
157
187
  expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
158
188
  });
189
+
190
+ test("--fork shares a fork of the session and leaves the original unshared", async () => {
191
+ // Arrange
192
+ const session = sessionFixture("ses_1");
193
+ const client = fakeClient([session]);
194
+ const deps = depsSpy();
195
+
196
+ // Act
197
+ const code = await shareSession(client, { sessionId: "ses_1", fork: true }, deps);
198
+
199
+ // Assert
200
+ expect(code).toBe(0);
201
+ expect(client.sessionFork).toHaveBeenCalledWith("ses_1");
202
+ expect(deps.publish).toHaveBeenCalledTimes(1);
203
+ expect(deps.publish).toHaveBeenCalledWith(
204
+ expect.objectContaining({ id: "ses_1_fork", parentSessionId: "ses_1" }),
205
+ expect.anything(),
206
+ );
207
+ expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1_fork", "p_abc123xy");
208
+ expect(session.shareId).toBeUndefined();
209
+ expect(deps.lines[0]).toBe("forked ses_1 as ses_1_fork");
210
+ });
159
211
  });
160
212
 
161
213
  function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
@@ -221,6 +273,34 @@ describe(pullSession, () => {
221
273
  expect(deps.lines).toEqual(["no new annotations"]);
222
274
  });
223
275
 
276
+ test("counts only the new notes when a pull also carries a removal", async () => {
277
+ // Arrange: the owner holds one collaborator note; the share brings a new note and removes the old one
278
+ const local = sessionFixture("ses_1", {
279
+ shareId: "p_abc123xy",
280
+ annotations: [annotationFixture("gone", "SHA256:mate")],
281
+ });
282
+ const remote = sessionFixture("ses_1", {
283
+ annotations: [
284
+ annotationFixture("gone", "SHA256:mate"),
285
+ annotationFixture("fresh", "SHA256:mate"),
286
+ ],
287
+ });
288
+
289
+ remote.history = appendEntry(historyFromLinear(remote), {
290
+ id: "e_rm",
291
+ type: "comment-removed",
292
+ annotationId: "gone",
293
+ createdAt: "2026-02-02T00:00:00.000Z",
294
+ }).history;
295
+ const deps = pullDepsSpy(remote);
296
+
297
+ // Act
298
+ await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
299
+
300
+ // Assert: one note is genuinely new, so a plain length difference (0) would be wrong
301
+ expect(deps.lines).toEqual(["pulled 1 new annotation"]);
302
+ });
303
+
224
304
  test("refuses to pull a plan that was never shared", async () => {
225
305
  // Arrange
226
306
  const deps = pullDepsSpy(sessionFixture("ses_1"));
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
10
10
  import {
11
- collaboratorAnnotations,
11
+ mergeFromShare,
12
12
  publishShare,
13
13
  pullShare,
14
14
  shareIdFromLine,
@@ -19,6 +19,8 @@ import type { ReviewSession } from "@cueloop/schema";
19
19
 
20
20
  export interface ShareParams {
21
21
  sessionId?: string;
22
+ /** Fork the session first and share the fork: one artifact to two people, with separate discussions. */
23
+ fork?: boolean;
22
24
  host?: string;
23
25
  port?: number;
24
26
  home?: string;
@@ -71,17 +73,19 @@ export async function shareSession(
71
73
  params: ShareParams,
72
74
  deps: ShareDeps,
73
75
  ): Promise<number> {
74
- const session = await pickSession(client, params.sessionId);
76
+ const picked = await pickSession(client, params.sessionId);
75
77
 
76
- if (!session) {
78
+ if (!picked) {
77
79
  deps.out("no plan to share - open a review first");
78
80
 
79
81
  return 1;
80
82
  }
83
+ const session = params.fork ? await client.sessionFork(picked.id) : picked;
81
84
  const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
82
85
  const shareId = shareIdFromLine(line);
83
86
 
84
87
  if (shareId) await client.sessionSetShareId(session.id, shareId);
88
+ if (params.fork) deps.out(`forked ${picked.id} as ${session.id}`);
85
89
  deps.out(copied ? `share link copied - ${line}` : line);
86
90
 
87
91
  return 0;
@@ -105,12 +109,10 @@ export async function pullSession(
105
109
  return 1;
106
110
  }
107
111
  const remote = await deps.pull(session.shareId!, { host: params.host, port: params.port });
108
- const before = session.annotations.length;
109
- const merged = await client.sessionMergeShared(session.id, {
110
- annotations: collaboratorAnnotations(remote),
111
- participants: remote.participants,
112
- });
113
- const added = merged.annotations.length - before;
112
+ const before = new Set(session.annotations.map((annotation) => annotation.id));
113
+ const merged = await client.sessionMergeShared(session.id, mergeFromShare(remote));
114
+ // count notes that were not here before, so a pull carrying removals never miscounts
115
+ const added = merged.annotations.filter((annotation) => !before.has(annotation.id)).length;
114
116
 
115
117
  deps.out(
116
118
  added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations",