@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.71

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.
Files changed (42) hide show
  1. package/package.json +33 -22
  2. package/src/api.test.ts +891 -50
  3. package/src/api.ts +849 -72
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +77 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +459 -69
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +186 -0
  11. package/src/diff-watcher.ts +102 -0
  12. package/src/herdr-pane.test.ts +240 -0
  13. package/src/herdr-pane.ts +167 -0
  14. package/src/herdr-split.test.ts +106 -0
  15. package/src/herdr-split.ts +92 -0
  16. package/src/herdr-tab-store.test.ts +96 -0
  17. package/src/herdr-tab-store.ts +66 -0
  18. package/src/index.ts +8 -9
  19. package/src/interruptible-wait.test.ts +127 -0
  20. package/src/interruptible-wait.ts +66 -0
  21. package/src/main.ts +4 -1
  22. package/src/paths.ts +39 -0
  23. package/src/project-files.test.ts +124 -0
  24. package/src/project-files.ts +56 -0
  25. package/src/protocol.ts +79 -7
  26. package/src/retention.test.ts +182 -0
  27. package/src/retention.ts +122 -0
  28. package/src/review.test.ts +255 -28
  29. package/src/review.ts +164 -62
  30. package/src/server.test.ts +381 -39
  31. package/src/server.ts +322 -84
  32. package/src/share-blob.test.ts +112 -0
  33. package/src/share-blob.ts +55 -0
  34. package/src/single-instance.test.ts +38 -11
  35. package/src/store.conformance.test.ts +41 -0
  36. package/src/store.jsonl.test.ts +159 -0
  37. package/src/store.ts +261 -34
  38. package/src/testing/store-conformance.ts +194 -0
  39. package/src/validate.test.ts +174 -28
  40. package/src/validate.ts +226 -16
  41. package/src/version.ts +6 -0
  42. package/src/working-tree.ts +149 -0
package/src/review.ts CHANGED
@@ -2,16 +2,24 @@
2
2
  * The shared review core: one open/wait/verdict path for every consumer -
3
3
  * CLI commands and agent adapters. openReview resolves the workspace, shapes
4
4
  * the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
5
- * wait contract (#14) onto the agent contract through verdictResponse. An
5
+ * wait contract onto the agent contract through verdictResponse. An
6
6
  * adapter keeps only two bespoke parts: parsing its host's event shape and
7
7
  * serializing the decision in its host's contract.
8
8
  */
9
9
 
10
- import type { ArtifactType, ReviewSession, WorkspaceKey } from "@cueloop/schema";
10
+ import {
11
+ isMarkdownArtifact,
12
+ newAnnotationId,
13
+ type ArtifactType,
14
+ type DiffFileContents,
15
+ type Thread,
16
+ type WorkspaceKey,
17
+ } from "@cueloop/schema";
11
18
  import { verdictResponse } from "./api";
12
19
  import type { DaemonClient } from "./client";
20
+ import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
13
21
 
14
- // Adapters and CLI verbs reach the verdict mapping through this module too,
22
+ // Adapters and CLI primitives reach the verdict mapping through this module too,
15
23
  // so a session obtained outside a ReviewHandle maps the same way.
16
24
  export { verdictResponse };
17
25
 
@@ -19,23 +27,48 @@ async function git(args: string[], cwd: string): Promise<string | null> {
19
27
  try {
20
28
  const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
21
29
  const out = await new Response(proc.stdout).text();
30
+
22
31
  if ((await proc.exited) !== 0) return null;
32
+
23
33
  return out.trim();
24
34
  } catch {
25
35
  return null;
26
36
  }
27
37
  }
28
38
 
29
- /** Workspace key resolution (#9): repo root + branch from the cwd. */
39
+ /** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
40
+ function earliestRootCommit(revList: string | null): string | undefined {
41
+ if (!revList) return undefined;
42
+ const roots = revList.split("\n").filter((line) => line.length > 0);
43
+
44
+ // --date-order lists newest first, so the last root is the earliest
45
+ return roots.at(-1);
46
+ }
47
+
48
+ /** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
30
49
  export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
31
50
  const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
32
51
  const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
33
- return { repoRoot, branch };
52
+ // a shallow clone's oldest commit is the graft boundary, not the true root, so
53
+ // it would key a different project than a full clone - leave it unset instead
54
+ const shallow = (await git(["rev-parse", "--is-shallow-repository"], cwd)) === "true";
55
+ const rootCommit = shallow
56
+ ? undefined
57
+ : earliestRootCommit(await git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd));
58
+ const remote = await git(["remote", "get-url", "origin"], cwd);
59
+
60
+ const workspace: WorkspaceKey = { repoRoot, branch };
61
+ // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
62
+ if (rootCommit) workspace.rootCommit = rootCommit;
63
+ if (remote) workspace.remote = remote;
64
+
65
+ return workspace;
34
66
  }
35
67
 
36
- function firstHeading(md: string): string | undefined {
37
- const m = md.match(/^#\s+(.+)$/m);
38
- return m?.[1]?.trim();
68
+ function firstHeading(markdown: string): string | undefined {
69
+ const headingMatch = markdown.match(/^#\s+(.+)$/m);
70
+
71
+ return headingMatch?.[1]?.trim();
39
72
  }
40
73
 
41
74
  export interface OpenReviewOptions {
@@ -49,10 +82,45 @@ export interface OpenReviewOptions {
49
82
  /** When set, a resubmit from the same agent session becomes a revision, not a new session. */
50
83
  agentSessionId?: string;
51
84
  planPath?: string;
85
+ prototypePath?: string;
52
86
  pr?: string;
53
87
  herdrPane?: string;
54
- /** Defaults to the plan's first markdown heading; diffs get no derived title. */
88
+ /**
89
+ * Full file contents per changed file for a working-tree diff, carried onto
90
+ * the artifact so hunk curation produces an exactly applyable patch.
91
+ */
92
+ files?: DiffFileContents[];
93
+ /** Defaults to a markdown artifact's first heading (plan, reply); diffs get no derived title. */
55
94
  title?: string;
95
+ /**
96
+ * Per-file agent notes for diff sessions: the submitting agent's own
97
+ * explanation of each file's change, in dead prose. Stored as annotations
98
+ * with kind "note" anchored at the file path, so they render as regular
99
+ * rail cards and feed the guided walk's agent-note block.
100
+ */
101
+ notes?: ReviewNote[];
102
+ }
103
+
104
+ /** The note contract: one note per changed file, anchored by the file path. */
105
+ export interface ReviewNote {
106
+ path: string;
107
+ body: string;
108
+ }
109
+
110
+ /** Anchor a note at its file: quote = the path, no context selectors. */
111
+ async function attachNotes(
112
+ client: DaemonClient,
113
+ sessionId: string,
114
+ notes: ReviewNote[],
115
+ ): Promise<void> {
116
+ for (const note of notes) {
117
+ await client.sessionAnnotate(sessionId, {
118
+ id: newAnnotationId(),
119
+ kind: "note",
120
+ anchor: { quote: note.path, prefix: "", suffix: "" },
121
+ body: note.body,
122
+ });
123
+ }
56
124
  }
57
125
 
58
126
  export interface AwaitVerdictOptions {
@@ -61,7 +129,7 @@ export interface AwaitVerdictOptions {
61
129
  /** Chunk length for the poll loop; between chunks the session is re-read for progress. */
62
130
  pollMs?: number;
63
131
  /** Called with the fresh session after each chunk that is still pending. */
64
- onProgress?: (session: ReviewSession) => void;
132
+ onProgress?: (session: Thread) => void;
65
133
  signal?: AbortSignal;
66
134
  }
67
135
 
@@ -69,43 +137,13 @@ export interface AwaitVerdictOptions {
69
137
  export interface VerdictOutcome {
70
138
  allow: boolean;
71
139
  feedback: string;
72
- session: ReviewSession;
73
- }
74
-
75
- /** Sentinel distinguishing an abort from any daemon response. */
76
- const ABORTED = Symbol("aborted");
77
-
78
- function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof ABORTED> {
79
- if (!signal) return promise;
80
- if (signal.aborted) {
81
- promise.catch(() => {});
82
- return Promise.resolve(ABORTED);
83
- }
84
- return new Promise<T | typeof ABORTED>((resolve, reject) => {
85
- const onAbort = () => {
86
- // The daemon request keeps running until the client closes; swallow its
87
- // eventual rejection so the abort path never leaks an unhandled error.
88
- promise.catch(() => {});
89
- resolve(ABORTED);
90
- };
91
- signal.addEventListener("abort", onAbort, { once: true });
92
- promise.then(
93
- (v) => {
94
- signal.removeEventListener("abort", onAbort);
95
- resolve(v);
96
- },
97
- (e) => {
98
- signal.removeEventListener("abort", onAbort);
99
- reject(e);
100
- },
101
- );
102
- });
140
+ session: Thread;
103
141
  }
104
142
 
105
143
  export class ReviewHandle {
106
144
  constructor(
107
145
  private readonly client: DaemonClient,
108
- readonly session: ReviewSession,
146
+ readonly session: Thread,
109
147
  ) {}
110
148
 
111
149
  get id(): string {
@@ -115,60 +153,124 @@ export class ReviewHandle {
115
153
  /**
116
154
  * Block on the verdict. "pending" means the budget ran out or the signal
117
155
  * aborted - the session stays open and the verdict is collectable later
118
- * (verdicts outlive waits, #14). With only timeoutMs this is one long-poll;
156
+ * (verdicts outlive waits). With only timeoutMs this is one long-poll;
119
157
  * pollMs/onProgress/signal switch to the chunked loop.
120
158
  */
121
- async awaitVerdict(opts: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
122
- const { timeoutMs, pollMs, onProgress, signal } = opts;
159
+ async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
160
+ const { timeoutMs, pollMs, onProgress, signal } = options;
161
+
123
162
  if (pollMs === undefined && onProgress === undefined && signal === undefined) {
124
163
  const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
164
+
125
165
  return resolved === null ? "pending" : outcome(resolved);
126
166
  }
127
167
  const chunkMs = pollMs ?? 10_000;
128
168
  const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
169
+
129
170
  for (;;) {
130
171
  const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
172
+
131
173
  if (budget <= 0 || signal?.aborted) return "pending";
132
174
  const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
175
+
133
176
  if (resolved === ABORTED) return "pending";
134
177
  if (resolved !== null) return outcome(resolved);
135
178
  // Still pending after this chunk: re-read to surface reviewer progress.
136
179
  const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
180
+
137
181
  if (current === ABORTED) return "pending";
138
182
  onProgress?.(current);
139
183
  }
140
184
  }
141
185
  }
142
186
 
143
- function outcome(session: ReviewSession): VerdictOutcome {
187
+ function outcome(session: Thread): VerdictOutcome {
144
188
  return { ...verdictResponse(session), session };
145
189
  }
146
190
 
191
+ export interface AwaitResolveOptions {
192
+ /** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
193
+ pollMs?: number;
194
+ /** Abort the wait (the harness session shut down); resolves to null. */
195
+ signal?: AbortSignal;
196
+ }
197
+
198
+ /**
199
+ * Park until a review session resolves, then return the verdict outcome; null
200
+ * when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
201
+ * that opened the review, this needs only a session id - so a background waiter
202
+ * that woke on a session it did not open (a detached Claude Code / Codex waiter,
203
+ * or pi's session_start listener) can collect the same verdict. This is the
204
+ * wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
205
+ * a verdict that lands between chunks is never missed and a session already
206
+ * resolved returns on the first chunk. The held connection also keeps the daemon
207
+ * off its idle-exit path for the whole wait.
208
+ */
209
+ export async function awaitResolve(
210
+ client: DaemonClient,
211
+ sessionId: string,
212
+ options: AwaitResolveOptions = {},
213
+ ): Promise<VerdictOutcome | null> {
214
+ const chunkMs = options.pollMs ?? 30_000;
215
+ const resolved = await pollUntilResolved(
216
+ () => client.sessionWait(sessionId, chunkMs),
217
+ options.signal,
218
+ );
219
+
220
+ return resolved === null ? null : outcome(resolved);
221
+ }
222
+
147
223
  /** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
148
- export async function openReview(client: DaemonClient, opts: OpenReviewOptions): Promise<ReviewHandle> {
149
- const cwd = opts.cwd ?? process.cwd();
150
- const workspace = opts.workspace ?? (await resolveWorkspace(cwd));
151
- // Resubmits from the same agent session become revisions, not new sessions.
152
- if (opts.agentSessionId !== undefined) {
224
+ export async function openReview(
225
+ client: DaemonClient,
226
+ options: OpenReviewOptions,
227
+ ): Promise<ReviewHandle> {
228
+ const cwd = options.cwd ?? process.cwd();
229
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
230
+
231
+ // Resubmits from the same agent session become revisions, not new sessions -
232
+ // but only within the same primitive: a different artifact type is a new
233
+ // review, never a silent type mismatch on the old session.
234
+ if (options.agentSessionId !== undefined) {
153
235
  const existing = (await client.sessionList()).find(
154
- (s) => s.artifact.meta.agentSessionId === opts.agentSessionId,
236
+ (candidate) =>
237
+ candidate.artifact.meta.agentSessionId === options.agentSessionId &&
238
+ candidate.artifact.type === options.type,
155
239
  );
240
+
156
241
  if (existing !== undefined) {
157
- return new ReviewHandle(client, await client.sessionSubmitRevision(existing.id, opts.content));
242
+ let revised = await client.sessionSubmitRevision(existing.id, options.content);
243
+
244
+ if (options.notes?.length) {
245
+ await attachNotes(client, revised.id, options.notes);
246
+ revised = await client.sessionGet(revised.id);
247
+ }
248
+
249
+ return new ReviewHandle(client, revised);
158
250
  }
159
251
  }
160
- const session = await client.sessionCreate(workspace, {
161
- type: opts.type,
162
- content: opts.content,
252
+ let session = await client.sessionCreate(workspace, {
253
+ type: options.type,
254
+ content: options.content,
255
+ files: options.files,
163
256
  meta: {
164
- agent: opts.agent,
165
- agentSessionId: opts.agentSessionId,
166
- planPath: opts.planPath,
167
- pr: opts.pr,
168
- herdrPane: opts.herdrPane,
169
- title: opts.title ?? (opts.type === "plan" ? firstHeading(opts.content) : undefined),
257
+ agent: options.agent,
258
+ agentSessionId: options.agentSessionId,
259
+ planPath: options.planPath,
260
+ prototypePath: options.prototypePath,
261
+ pr: options.pr,
262
+ herdrPane: options.herdrPane,
263
+ title:
264
+ options.title ??
265
+ (isMarkdownArtifact(options.type) ? firstHeading(options.content) : undefined),
170
266
  cwd,
171
267
  },
172
268
  });
269
+
270
+ if (options.notes?.length) {
271
+ await attachNotes(client, session.id, options.notes);
272
+ session = await client.sessionGet(session.id);
273
+ }
274
+
173
275
  return new ReviewHandle(client, session);
174
276
  }