@cueloop/daemon 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.
@@ -1,31 +1,64 @@
1
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.
2
+ * Daemon roles: which primitives a connection may call. A connection is a
3
+ * collaborator until it proves ownership with the owner token in daemon.hello
4
+ * - never an owner by default. The owner (the local planner) may call every
5
+ * primitive; a collaborator or a review-side agent is capped to reading the
6
+ * session and adding comments, so a bring-your-own agent cannot resolve,
7
+ * rewrite, cut, share, or delete (ADR 0009). The table below is exhaustive
8
+ * over the primitive schemas: adding a primitive without naming its roles
9
+ * does not compile.
7
10
  */
8
11
 
9
12
  import * as v from "valibot";
13
+ import type { MethodName } from "./validate";
10
14
 
11
15
  export type DaemonRole = "owner" | "collaborator" | "agent";
12
16
 
13
- /** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
14
- const NON_OWNER_METHODS = new Set<string>([
15
- "daemon.ping",
16
- "daemon.hello",
17
- "events.subscribe",
18
- "session.get",
19
- "session.list",
20
- "session.wait",
21
- "session.annotate",
22
- ]);
23
-
24
- /** Whether `role` may call `method`. The owner may call anything; others are capped to read + annotate. */
25
- export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
26
- return role === "owner" || NON_OWNER_METHODS.has(method);
17
+ /** Who may call a primitive: everyone, or the owner only. */
18
+ type Audience = "any" | "owner";
19
+
20
+ const PRIMITIVE_ROLES = {
21
+ "daemon.ping": "any",
22
+ "daemon.hello": "any",
23
+ "daemon.shutdown": "owner",
24
+ "events.subscribe": "any",
25
+ "session.create": "owner",
26
+ "session.get": "any",
27
+ "session.list": "any",
28
+ "session.wait": "any",
29
+ "session.annotate": "any",
30
+ // any role may call it; a non-owner must act on behalf of an author and stays scoped to it
31
+ "session.removeAnnotation": "any",
32
+ "session.setParticipantName": "any",
33
+ "session.setWorkingCopy": "owner",
34
+ "session.cutBlock": "owner",
35
+ "session.restoreBlock": "owner",
36
+ "session.curate": "owner",
37
+ "session.setViewed": "owner",
38
+ // the tree is the owner's: collaborators comment on the branch a share follows
39
+ "session.navigate": "owner",
40
+ "session.branch": "owner",
41
+ "session.switch": "owner",
42
+ "session.label": "owner",
43
+ "session.fork": "owner",
44
+ "session.refreshDiff": "owner",
45
+ "session.setShareId": "owner",
46
+ "session.delete": "owner",
47
+ "session.mergeShared": "owner",
48
+ "session.resolve": "owner",
49
+ "session.submitRevision": "owner",
50
+ "herdr.getTab": "owner",
51
+ "herdr.setTab": "owner",
52
+ } as const satisfies Record<MethodName, Audience>;
53
+
54
+ /** Whether `role` may call `method`. */
55
+ export function roleAllowsMethod(role: DaemonRole, method: MethodName): boolean {
56
+ return role === "owner" || PRIMITIVE_ROLES[method] === "any";
27
57
  }
28
58
 
59
+ /** The role a connection starts with, before any proof. */
60
+ export const DEFAULT_ROLE: DaemonRole = "collaborator";
61
+
29
62
  export function asDaemonRole(value: Parameters<typeof v.safeParse>[1]): DaemonRole {
30
63
  const result = v.safeParse(v.picklist(["owner", "collaborator"]), value);
31
64
 
package/src/client.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Daemon client: the one library every consumer shares - CLI verbs, the TUI,
2
+ * Daemon client: the one library every consumer shares - CLI primitives, the TUI,
3
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
- import { existsSync, rmSync } from "node:fs";
7
+ import { existsSync, readFileSync, rmSync } from "node:fs";
8
8
  import * as v from "valibot";
9
9
  import type {
10
10
  Annotation,
11
11
  Artifact,
12
- Identity,
12
+ HunkRejection,
13
13
  ReviewSession,
14
14
  VerdictKind,
15
15
  WorkspaceKey,
@@ -24,8 +24,11 @@ import {
24
24
  } from "./protocol";
25
25
  import type { DaemonRole } from "./capabilities";
26
26
  import type { HerdrTabHandle } from "./herdr-tab-store";
27
- import { cueloopHome, socketPath } from "./paths";
28
- import { SessionRecordSchema } from "./validate";
27
+ import type { SharedMerge } from "./api";
28
+
29
+ export type { SharedMerge } from "./api";
30
+ import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
31
+ import { Params, SessionRecordSchema } from "./validate";
29
32
 
30
33
  export type { EventFrame } from "./protocol";
31
34
 
@@ -35,6 +38,8 @@ export interface ConnectOptions {
35
38
  autostart?: boolean;
36
39
  /** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
37
40
  role?: DaemonRole;
41
+ /** The author a non-owner connection acts as; its comments, removals, and name are bound to it. */
42
+ author?: string;
38
43
  }
39
44
 
40
45
  type PendingRequest = {
@@ -48,7 +53,7 @@ const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
48
53
  const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
49
54
 
50
55
  /**
51
- * The session verbs the review controller drives. DaemonClient is the local
56
+ * The session primitives the review controller drives. DaemonClient is the local
52
57
  * implementation (unix socket); the sharing gateway supplies an in-memory,
53
58
  * blob-backed one. Depending on this interface - not DaemonClient - is what
54
59
  * lets the same <App> render a local session or a decrypted share unchanged.
@@ -63,14 +68,32 @@ export interface SessionClient {
63
68
  annotation: Omit<Annotation, "createdAt">,
64
69
  authorName?: string,
65
70
  ): Promise<ReviewSession>;
71
+ /** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
66
72
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
67
73
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
74
+ /** Cut the `blockIndex`-th block of the working copy. */
75
+ sessionCutBlock(id: string, blockIndex: number): Promise<ReviewSession>;
76
+ /** Re-insert the `baseBlockIndex`-th block of the submitted revision before `line` (default: the end). */
77
+ sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<ReviewSession>;
78
+ /** Replace a diff review's reject decisions; the working copy follows. */
79
+ sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession>;
68
80
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
69
- sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
70
- sessionMergeShared(
81
+ /** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
82
+ sessionNavigate(
71
83
  id: string,
72
- incoming: { annotations: Annotation[]; participants?: Identity[] },
84
+ entryId: string,
85
+ summary?: string,
86
+ branch?: string,
73
87
  ): Promise<ReviewSession>;
88
+ /** Start a branch at the current tip and switch to it. */
89
+ sessionBranch(id: string, name: string): Promise<ReviewSession>;
90
+ sessionSwitch(id: string, branch: string): Promise<ReviewSession>;
91
+ /** Name the current tip as a checkpoint. */
92
+ sessionLabel(id: string, label: string): Promise<ReviewSession>;
93
+ /** Copy the current path into a new session; returns the fork. */
94
+ sessionFork(id: string): Promise<ReviewSession>;
95
+ sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
96
+ sessionMergeShared(id: string, incoming: SharedMerge): Promise<ReviewSession>;
74
97
  sessionDelete(id: string): Promise<void>;
75
98
  /** Record the caller's own identity name (collaborator self-naming on a share). */
76
99
  sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
@@ -78,6 +101,33 @@ export interface SessionClient {
78
101
  close(): void;
79
102
  }
80
103
 
104
+ /** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
105
+ interface HelloParams {
106
+ role: DaemonRole;
107
+ token?: string;
108
+ author?: string;
109
+ }
110
+
111
+ /** The token as the daemon writes it: 32 random bytes in hex. */
112
+ const OwnerTokenSchema = v.pipe(v.string(), v.trim(), v.regex(/^[0-9a-f]{64}$/));
113
+
114
+ /** The owner token in `home`, or undefined when the daemon there never wrote one. */
115
+ function readOwnerToken(home: string): string | undefined {
116
+ const path = ownerTokenPath(home);
117
+
118
+ if (!existsSync(path)) return undefined;
119
+ const parsed = v.safeParse(OwnerTokenSchema, readFileSync(path, "utf8"));
120
+
121
+ if (!parsed.success) {
122
+ throw new DaemonClientError(
123
+ "invalid_owner_token",
124
+ `${path} is not an owner token; restart the daemon to mint a fresh one`,
125
+ );
126
+ }
127
+
128
+ return parsed.output;
129
+ }
130
+
81
131
  export class DaemonClient implements SessionClient {
82
132
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
83
133
  private writer: BackpressureWriter | null = null;
@@ -86,6 +136,8 @@ export class DaemonClient implements SessionClient {
86
136
  private eventListeners = new Set<(event: EventFrame) => void>();
87
137
  private closed = false;
88
138
  private role: DaemonRole = "owner";
139
+ private author: string | undefined;
140
+ private home = cueloopHome();
89
141
 
90
142
  static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
91
143
  const home = options.home ?? cueloopHome();
@@ -93,12 +145,16 @@ export class DaemonClient implements SessionClient {
93
145
  const client = new DaemonClient();
94
146
 
95
147
  client.role = options.role ?? "owner";
148
+ client.author = options.author;
149
+ client.home = home;
96
150
  try {
97
151
  await client.dial(path);
98
152
 
99
153
  return client;
100
154
  } catch (err) {
101
- if (!options.autostart) throw err;
155
+ // a live daemon that refused the handshake is not a dead socket: the
156
+ // caller hears why instead of the client replacing a running daemon
157
+ if (!options.autostart || err instanceof DaemonClientError) throw err;
102
158
  }
103
159
  // Socket dead or absent: clean a stale file and spawn the daemon detached.
104
160
  if (existsSync(path)) rmSync(path, { force: true });
@@ -146,9 +202,22 @@ export class DaemonClient implements SessionClient {
146
202
  // Verify liveness: a dead socket file accepts connects on some platforms
147
203
  // only to fail later, so a ping is the actual handshake.
148
204
  await this.request("daemon.ping", {}, PingResultSchema, 2_000);
149
- // Cap this connection's role for the daemon's capability gate (owner is the default).
150
- if (this.role !== "owner")
151
- await this.request("daemon.hello", { role: this.role }, EmptyResultSchema, 2_000);
205
+ // Every connection starts as a collaborator; the owner proves itself with
206
+ // the token the daemon wrote into the home it serves, which only the home's
207
+ // user can read. A capped role just names itself.
208
+ await this.request("daemon.hello", this.helloParams(), EmptyResultSchema, 2_000);
209
+ }
210
+
211
+ private helloParams(): HelloParams {
212
+ if (this.role !== "owner") {
213
+ return this.author === undefined
214
+ ? { role: this.role }
215
+ : { role: this.role, author: this.author };
216
+ }
217
+ const token = readOwnerToken(this.home);
218
+
219
+ // a daemon from before owner tokens has no file; it still knows the bare hello
220
+ return token === undefined ? { role: "owner" } : { role: "owner", token };
152
221
  }
153
222
 
154
223
  onEvent(listener: (event: EventFrame) => void): () => void {
@@ -213,7 +282,7 @@ export class DaemonClient implements SessionClient {
213
282
  this.socket?.end();
214
283
  }
215
284
 
216
- // ── typed verbs ─────────────────────────────
285
+ // ── typed primitives ─────────────────────────────
217
286
  ping(): Promise<{ pid: number }> {
218
287
  return this.request("daemon.ping", {}, PingResultSchema);
219
288
  }
@@ -248,9 +317,51 @@ export class DaemonClient implements SessionClient {
248
317
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
249
318
  return this.request("session.removeAnnotation", { id, annotationId }, SessionRecordSchema);
250
319
  }
320
+ /** Register a display name for a participant - a collaborator's or an agent's own, on a share or locally. */
321
+ sessionSetParticipantName(id: string, author: string, name: string): Promise<ReviewSession> {
322
+ return this.request("session.setParticipantName", { id, author, name }, SessionRecordSchema);
323
+ }
251
324
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
252
325
  return this.request("session.setWorkingCopy", { id, workingCopy }, SessionRecordSchema);
253
326
  }
327
+ sessionCutBlock(id: string, blockIndex: number): Promise<ReviewSession> {
328
+ return this.request("session.cutBlock", { id, blockIndex }, SessionRecordSchema);
329
+ }
330
+ sessionNavigate(
331
+ id: string,
332
+ entryId: string,
333
+ summary?: string,
334
+ branch?: string,
335
+ ): Promise<ReviewSession> {
336
+ const params: v.InferInput<(typeof Params)["session.navigate"]> = { id, entryId };
337
+
338
+ if (summary !== undefined) params.summary = summary;
339
+ if (branch !== undefined) params.branch = branch;
340
+
341
+ return this.request("session.navigate", params, SessionRecordSchema);
342
+ }
343
+ sessionBranch(id: string, name: string): Promise<ReviewSession> {
344
+ return this.request("session.branch", { id, name }, SessionRecordSchema);
345
+ }
346
+ sessionSwitch(id: string, branch: string): Promise<ReviewSession> {
347
+ return this.request("session.switch", { id, branch }, SessionRecordSchema);
348
+ }
349
+ sessionLabel(id: string, label: string): Promise<ReviewSession> {
350
+ return this.request("session.label", { id, label }, SessionRecordSchema);
351
+ }
352
+ sessionFork(id: string): Promise<ReviewSession> {
353
+ return this.request("session.fork", { id }, SessionRecordSchema);
354
+ }
355
+ sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<ReviewSession> {
356
+ return this.request(
357
+ "session.restoreBlock",
358
+ line === undefined ? { id, baseBlockIndex } : { id, baseBlockIndex, line },
359
+ SessionRecordSchema,
360
+ );
361
+ }
362
+ sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession> {
363
+ return this.request("session.curate", { id, rejections }, SessionRecordSchema);
364
+ }
254
365
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
255
366
  return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
256
367
  }
@@ -261,10 +372,7 @@ export class DaemonClient implements SessionClient {
261
372
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
262
373
  return this.request("session.setShareId", { id, shareId }, SessionRecordSchema);
263
374
  }
264
- sessionMergeShared(
265
- id: string,
266
- incoming: { annotations: Annotation[]; participants?: Identity[] },
267
- ): Promise<ReviewSession> {
375
+ sessionMergeShared(id: string, incoming: SharedMerge): Promise<ReviewSession> {
268
376
  return this.request("session.mergeShared", { id, ...incoming }, SessionRecordSchema);
269
377
  }
270
378
  sessionDelete(id: string): Promise<void> {
package/src/curate.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The curated patch of a diff review: the submitted changes minus the hunks and
3
+ * change blocks the reviewer rejected. The daemon computes it from the
4
+ * session's reject decisions so the working copy every client sees is one and
5
+ * the same; the client only maps rows to decisions.
6
+ */
7
+
8
+ import { diffAcceptRejectHunk, parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs";
9
+ import {
10
+ unifiedDiffText,
11
+ type DiffFileContents,
12
+ type DiffFileStatus,
13
+ type HunkRejection,
14
+ } from "@cueloop/schema";
15
+
16
+ /**
17
+ * Context lines around each change. Matching git's default (3) makes the model
18
+ * split hunks like the rendered working-tree patch does; a row is mapped by
19
+ * absolute line number, never by counting hunks, so a whole-hunk reject can
20
+ * only ever span the model hunk it lands in.
21
+ */
22
+ export const DIFF_CONTEXT = 3;
23
+
24
+ /** Parse one file's full contents into a non-partial diff model. */
25
+ export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
26
+ return parseDiffFromFile(
27
+ { name: file.path, contents: file.oldContents },
28
+ { name: file.path, contents: file.newContents },
29
+ { context: DIFF_CONTEXT },
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Apply one file's rejections and return its curated patch, or null when the
35
+ * curated content equals the old content (every change rejected - no patch).
36
+ * Whole-hunk rejections supersede change-level ones for the same hunk. Because
37
+ * `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
38
+ * indices stay valid across calls; they are applied high-to-low regardless, so
39
+ * the order is deterministic.
40
+ */
41
+ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
42
+ let model = parseFileDiff(file);
43
+ const wholeHunks = new Set(
44
+ rejections
45
+ .filter((rejection) => rejection.changeIndex === undefined)
46
+ .map((rejection) => rejection.hunkIndex),
47
+ );
48
+
49
+ for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
50
+ model = diffAcceptRejectHunk(model, hunkIndex, "reject");
51
+ }
52
+ const changeRejections = rejections
53
+ .filter(
54
+ (rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
55
+ )
56
+ .sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
57
+
58
+ for (const rejection of changeRejections) {
59
+ model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
60
+ type: "reject",
61
+ changeIndex: rejection.changeIndex!,
62
+ });
63
+ }
64
+ const curatedNew = model.additionLines.join("");
65
+
66
+ if (curatedNew === file.oldContents) return null;
67
+ const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
68
+
69
+ if (patch === null) return null;
70
+
71
+ return withFileStateHeaders(patch, file.path, file.status, curatedNew);
72
+ }
73
+
74
+ /**
75
+ * Point the diff headers at /dev/null for a created or fully-deleted file so the
76
+ * curated patch applies. git's status - not empty contents - decides existence,
77
+ * so editing an existing empty file stays a modify; a deletion whose removals
78
+ * were partly restored also falls back to a modify.
79
+ */
80
+ function withFileStateHeaders(
81
+ patch: string,
82
+ path: string,
83
+ status: DiffFileStatus,
84
+ curatedNew: string,
85
+ ): string {
86
+ if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
87
+ if (status === "deleted" && curatedNew === "") {
88
+ return patch.replace(`+++ b/${path}`, "+++ /dev/null");
89
+ }
90
+
91
+ return patch;
92
+ }
93
+
94
+ /**
95
+ * The curated unified-diff patch of the accepted changes across every file.
96
+ * Files with no net change (unchanged, or with all changes rejected) drop out.
97
+ */
98
+ export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
99
+ const parts: string[] = [];
100
+
101
+ for (const file of files) {
102
+ const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
103
+ const patch = curateFilePatch(file, fileRejections);
104
+
105
+ if (patch) parts.push(patch);
106
+ }
107
+
108
+ return parts.join("\n");
109
+ }
@@ -100,6 +100,22 @@ describe("session.refreshDiff", () => {
100
100
  expect(events).toContain("session.updated");
101
101
  });
102
102
 
103
+ test("a tree move after a refresh keeps the fresh patch", async () => {
104
+ // Given a refreshed diff session
105
+ const session = await openDiffSession();
106
+
107
+ writeFileSync(join(repo, "a.ts"), "export const a = 4;\n");
108
+ await core.sessionRefreshDiff(session.id);
109
+
110
+ // When the owner branches off and switches back
111
+ core.sessionBranch(session.id, "alt");
112
+ const shown = core.sessionSwitch(session.id, "main");
113
+
114
+ // Then the artifact still shows the re-captured working tree, and the history has one revision
115
+ expect(shown.artifact.content).toContain("+export const a = 4;");
116
+ expect(shown.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(1);
117
+ });
118
+
103
119
  test("is a no-op for a non-diff (plan) session", async () => {
104
120
  // Given a plan session in the same workspace
105
121
  const workspace = await resolveWorkspace(repo);
@@ -196,7 +196,7 @@ describe("openHerdrPaneForReview", () => {
196
196
  });
197
197
 
198
198
  test("a recall failure (stale daemon) still opens a fresh tab", async () => {
199
- // Arrange - a daemon predating the herdr-tab verbs rejects the recall
199
+ // Arrange - a daemon predating the herdr-tab primitives rejects the recall
200
200
  const stub = makeStub("gated-recall-fail");
201
201
  const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
202
202
  const persistence: HerdrTabPersistence = {
package/src/paths.ts CHANGED
@@ -30,6 +30,11 @@ export function lockPath(home = cueloopHome()): string {
30
30
  return join(home, "cueloop.lock");
31
31
  }
32
32
 
33
+ /** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
34
+ export function ownerTokenPath(home = cueloopHome()): string {
35
+ return join(home, "owner.token");
36
+ }
37
+
33
38
  /** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
34
39
  export function herdrTabsPath(home = cueloopHome()): string {
35
40
  return join(home, "herdr-tabs.json");
package/src/protocol.ts CHANGED
@@ -21,6 +21,8 @@ export interface Response {
21
21
  export interface EventFrame {
22
22
  event: string;
23
23
  sessionId: string;
24
+ /** The history entry the change appended, when it appended one. */
25
+ entryId?: string;
24
26
  }
25
27
 
26
28
  export type Frame = Response | EventFrame;
@@ -38,6 +40,7 @@ const ResponseSchema = v.object({
38
40
  const EventFrameSchema = v.object({
39
41
  event: v.string(),
40
42
  sessionId: v.string(),
43
+ entryId: v.optional(v.string()),
41
44
  });
42
45
 
43
46
  export function parseRequestFrame(line: string): Request {
package/src/review.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  import { verdictResponse } from "./api";
19
19
  import type { DaemonClient } from "./client";
20
20
 
21
- // Adapters and CLI verbs reach the verdict mapping through this module too,
21
+ // Adapters and CLI primitives reach the verdict mapping through this module too,
22
22
  // so a session obtained outside a ReviewHandle maps the same way.
23
23
  export { verdictResponse };
24
24
 
@@ -35,12 +35,33 @@ async function git(args: string[], cwd: string): Promise<string | null> {
35
35
  }
36
36
  }
37
37
 
38
- /** Workspace key resolution: repo root + branch from the cwd. */
38
+ /** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
39
+ function earliestRootCommit(revList: string | null): string | undefined {
40
+ if (!revList) return undefined;
41
+ const roots = revList.split("\n").filter((line) => line.length > 0);
42
+
43
+ // --date-order lists newest first, so the last root is the earliest
44
+ return roots.at(-1);
45
+ }
46
+
47
+ /** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
39
48
  export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
40
49
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
41
50
  const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
42
-
43
- return { repoRoot, branch };
51
+ // a shallow clone's oldest commit is the graft boundary, not the true root, so
52
+ // it would key a different project than a full clone - leave it unset instead
53
+ const shallow = (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
54
+ const rootCommit = shallow
55
+ ? undefined
56
+ : earliestRootCommit(await git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd));
57
+ const remote = await git(["remote", "get-url", "origin"], cwd);
58
+
59
+ const workspace: WorkspaceKey = { repoRoot, branch };
60
+ // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
61
+ if (rootCommit) workspace.rootCommit = rootCommit;
62
+ if (remote) workspace.remote = remote;
63
+
64
+ return workspace;
44
65
  }
45
66
 
46
67
  function firstHeading(markdown: string): string | undefined {