@cueloop/daemon 0.1.0-alpha.102 → 0.1.0-alpha.104

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 (44) hide show
  1. package/package.json +3 -3
  2. package/src/agent-tools.ts +6 -0
  3. package/src/api.ts +65 -8
  4. package/src/client-reliability.test.ts +2 -0
  5. package/src/client.ts +25 -2
  6. package/src/curate.ts +2 -0
  7. package/src/daemon-sdk.ts +8 -0
  8. package/src/diff-hot-reload.test.ts +4 -0
  9. package/src/diff-watcher.test.ts +2 -0
  10. package/src/diff-watcher.ts +33 -0
  11. package/src/examples/daemon-sdk.ts +2 -0
  12. package/src/gh.ts +4 -0
  13. package/src/ghostty-thread-surface-store.ts +3 -0
  14. package/src/ghostty-thread-surface.test.ts +2 -0
  15. package/src/ghostty-thread-surface.ts +6 -0
  16. package/src/harness-diagnostics.ts +1 -0
  17. package/src/harness-handoff.test.ts +725 -0
  18. package/src/harness-handoff.ts +27 -0
  19. package/src/harness-state-store.ts +5 -0
  20. package/src/herdr-split.ts +5 -0
  21. package/src/herdr-thread-surface.ts +14 -0
  22. package/src/interruptible-wait.ts +3 -0
  23. package/src/jj-working-tree.ts +9 -0
  24. package/src/main.ts +1 -0
  25. package/src/operation-receipts.ts +6 -0
  26. package/src/pr-poller.ts +7 -0
  27. package/src/project-files.ts +4 -0
  28. package/src/protocol.ts +4 -0
  29. package/src/retention.ts +8 -0
  30. package/src/server.ts +36 -7
  31. package/src/share-blob.test.ts +23 -0
  32. package/src/single-instance.test.ts +3 -0
  33. package/src/store.ts +11 -0
  34. package/src/thread-agent-validation.ts +20 -0
  35. package/src/thread-agent.test.ts +14 -1
  36. package/src/thread-agent.ts +353 -24
  37. package/src/thread-review.test.ts +4 -0
  38. package/src/thread-review.ts +16 -4
  39. package/src/thread-subscription.test.ts +3 -0
  40. package/src/thread-subscription.ts +9 -0
  41. package/src/validate.test.ts +11 -0
  42. package/src/validate.ts +5 -0
  43. package/src/vcs-source.ts +6 -0
  44. package/src/working-tree.ts +4 -0
@@ -308,7 +308,9 @@ describe("awaitMessage: one long-poll (the hook shape)", () => {
308
308
 
309
309
  // Assert
310
310
  expect(message).not.toBe("pending");
311
+
311
312
  if (message === "pending") throw new Error("unreachable");
313
+
312
314
  expect(message.allow).toBe(true);
313
315
  expect(message.message.body).toContain("Fine.");
314
316
  expect(message.session.message!.outcome).toBe("approved");
@@ -325,6 +327,7 @@ describe("awaitMessage: one long-poll (the hook shape)", () => {
325
327
 
326
328
  // Assert
327
329
  if (message === "pending") throw new Error("expected a message");
330
+
328
331
  expect(message.allow).toBe(false);
329
332
  expect(message.message.body).toContain("# Review: changes requested");
330
333
  expect(message.message.body).toContain("One stage only.");
@@ -362,6 +365,7 @@ describe("awaitMessage: chunked loop (the pi shape)", () => {
362
365
 
363
366
  // Assert
364
367
  if (message === "pending") throw new Error("expected a message");
368
+
365
369
  expect(message.allow).toBe(false);
366
370
  expect(message.session.annotations.length).toBe(1);
367
371
  }, 15_000);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  isMarkdownArtifact,
3
+ messageAllows,
3
4
  newAnnotationId,
4
5
  type Annotation,
5
6
  type Artifact,
@@ -12,13 +13,16 @@ import {
12
13
  } from "@cueloop/schema";
13
14
  import { execFile } from "node:child_process";
14
15
  import { promisify } from "node:util";
15
- import { messageResponse } from "./api";
16
+ import { DaemonError } from "./errors";
16
17
  import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
17
18
  import { VcsSourceManager } from "./vcs-source";
18
19
 
19
- // Adapters and CLI primitives reach the message mapping through this module too,
20
- // so a session obtained outside a ReviewHandle maps the same way.
21
- export { messageResponse };
20
+ /** Map a resolved session to the agent message contract. */
21
+ export function messageResponse(session: Thread) {
22
+ if (!session.message) throw new DaemonError("pending", "session has no message");
23
+
24
+ return { allow: messageAllows(session.message.outcome), message: session.message };
25
+ }
22
26
 
23
27
  async function git(args: string[], cwd: string): Promise<string | null> {
24
28
  try {
@@ -43,6 +47,7 @@ async function jjRoot(cwd: string): Promise<string | null> {
43
47
  /** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
44
48
  function earliestRootCommit(revList: string | null): string | undefined {
45
49
  if (!revList) return undefined;
50
+
46
51
  const roots = revList.split("\n").filter((line) => line.length > 0);
47
52
 
48
53
  // --date-order lists newest first, so the last root is the earliest
@@ -77,6 +82,7 @@ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKe
77
82
 
78
83
  // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
79
84
  if (rootCommit) workspace.rootCommit = rootCommit;
85
+
80
86
  if (remote) workspace.remote = remote;
81
87
 
82
88
  return workspace;
@@ -191,6 +197,7 @@ export class ReviewHandle {
191
197
 
192
198
  return resolved === null ? "pending" : outcome(resolved);
193
199
  }
200
+
194
201
  const chunkMs = pollMs ?? 10_000;
195
202
  const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
196
203
 
@@ -198,11 +205,13 @@ export class ReviewHandle {
198
205
  const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
199
206
 
200
207
  if (budget <= 0 || signal?.aborted) return "pending";
208
+
201
209
  const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
202
210
 
203
211
  if (resolved === ABORTED) return "pending";
204
212
 
205
213
  if (resolved !== null) return outcome(resolved);
214
+
206
215
  // Still pending after this chunk: re-read to surface reviewer progress.
207
216
  const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
208
217
 
@@ -278,6 +287,7 @@ export async function findExistingReview(
278
287
  return candidates.find(
279
288
  (candidate) => candidate.artifact.meta.vcsChangeId === options.vcsChangeId,
280
289
  );
290
+
281
291
  if (isJjDiff) {
282
292
  const jjCandidates = candidates.filter(hasJjProvenance);
283
293
 
@@ -295,6 +305,7 @@ async function matchingDiffCapture(
295
305
  cwd: string,
296
306
  ): Promise<{ source?: DiffSource; files?: DiffFileContents[] }> {
297
307
  if (options.type !== "diff" || options.pr || !options.content.trim()) return {};
308
+
298
309
  try {
299
310
  const captured = await new VcsSourceManager().capture(cwd, options.vcs);
300
311
 
@@ -347,6 +358,7 @@ export async function openReview(
347
358
 
348
359
  return new ReviewHandle(client, revised);
349
360
  }
361
+
350
362
  let session = await client.sessionCreate(workspace, {
351
363
  type: options.type,
352
364
  content: options.content,
@@ -91,6 +91,7 @@ test("reconnect subscribes again and refreshes authoritative state even without
91
91
  },
92
92
  onValue: (state) => {
93
93
  values.push(state.artifact.meta.title ?? "Initial");
94
+
94
95
  if (connections === 1) initial.resolve();
95
96
  else recovered.resolve();
96
97
  },
@@ -126,6 +127,7 @@ test("a malformed protocol frame stops the observer instead of reconnecting", as
126
127
  open(socket) {
127
128
  reads.set(socket, new LineBuffer());
128
129
  connections++;
130
+
129
131
  if (connections > 1) failed.reject(new Error("Unexpected reconnect"));
130
132
  },
131
133
  data(socket, bytes) {
@@ -141,6 +143,7 @@ test("a malformed protocol frame stops the observer instead of reconnecting", as
141
143
  : {},
142
144
  }) + "\n",
143
145
  );
146
+
144
147
  if (request.method === "events.subscribe") socket.write("{broken\n");
145
148
  });
146
149
  },
@@ -34,11 +34,13 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
34
34
 
35
35
  try {
36
36
  api = await options.connect();
37
+
37
38
  if (stopped || epoch !== generation) {
38
39
  api.close();
39
40
 
40
41
  return;
41
42
  }
43
+
42
44
  connection = api;
43
45
  const current = api;
44
46
  const refresh = async () => {
@@ -62,6 +64,7 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
62
64
  detach?.();
63
65
  current.close();
64
66
  }
67
+
65
68
  options.onError(error);
66
69
  }
67
70
  } finally {
@@ -73,11 +76,13 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
73
76
  });
74
77
  const offDisconnect = api.onDisconnect((reason) => {
75
78
  if (stopped || epoch !== generation) return;
79
+
76
80
  ++generation;
77
81
  detach?.();
78
82
  for (const read of reads) read.abort();
79
83
  connection = undefined;
80
84
  current.close();
85
+
81
86
  if (isFatalSubscriptionError(reason)) {
82
87
  stopped = true;
83
88
  options.onError(reason);
@@ -89,16 +94,20 @@ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<V
89
94
  offDisconnect();
90
95
  };
91
96
  await api.subscribe();
97
+
92
98
  if (stopped || epoch !== generation) return;
99
+
93
100
  options.onConnect?.(api);
94
101
  await refresh();
95
102
  } catch (error) {
96
103
  if (stopped || epoch !== generation) return;
104
+
97
105
  ++generation;
98
106
  detach?.();
99
107
  api?.close();
100
108
  connection = undefined;
101
109
  options.onError(error);
110
+
102
111
  if (!(error instanceof Error && isFatalSubscriptionError(error))) schedule();
103
112
  }
104
113
  };
@@ -58,6 +58,7 @@ describe("parseParams", () => {
58
58
  throw new Error("should have thrown");
59
59
  } catch (err) {
60
60
  expect(err).toBeInstanceOf(DaemonError);
61
+
61
62
  if (err instanceof DaemonError) {
62
63
  expect(err.code).toBe("invalid_params");
63
64
  expect(err.message).toContain("session.create: workspace");
@@ -180,6 +181,7 @@ describe("validateThreadRecord", () => {
180
181
 
181
182
  // Assert
182
183
  expect(result.ok).toBe(false);
184
+
183
185
  if (!result.ok) expect(result.error).toContain("schemaVersion");
184
186
  });
185
187
 
@@ -291,6 +293,13 @@ describe("wire pins", () => {
291
293
  handle: "abc",
292
294
  };
293
295
  const fullSession: Required<Thread> = {
296
+ agentSnapshot: {
297
+ threadId: "ses_1",
298
+ phase: { kind: "idle" },
299
+ messages: [],
300
+ comments: [],
301
+ tools: [],
302
+ },
294
303
  schemaVersion: SCHEMA_VERSION,
295
304
  id: "ses_1",
296
305
  workspace: fullWorkspace,
@@ -366,6 +375,7 @@ describe("wire pins", () => {
366
375
 
367
376
  // Assert
368
377
  expect(parsed.ok).toBe(false);
378
+
369
379
  if (!parsed.ok) expect(parsed.error).toContain("history:");
370
380
  });
371
381
 
@@ -376,6 +386,7 @@ describe("wire pins", () => {
376
386
  });
377
387
 
378
388
  expect(result.ok).toBe(false);
389
+
379
390
  if (!result.ok) {
380
391
  expect(result.error).toContain(
381
392
  "text Cuts do not match the submitted artifact and working copy",
package/src/validate.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import * as v from "valibot";
13
+ import { ThreadAgentSchema } from "./thread-agent-validation";
13
14
  import {
14
15
  ARTIFACT_TYPES,
15
16
  MESSAGE_OUTCOMES,
@@ -478,6 +479,7 @@ export const SessionHistorySchema = v.pipe(
478
479
  // the shape is not enough: the tree itself must hold, or a walk hangs or rewires the path
479
480
  v.rawCheck(({ dataset, addIssue }) => {
480
481
  if (!dataset.typed) return;
482
+
481
483
  const problem = validateHistory(dataset.value);
482
484
 
483
485
  if (problem !== null) addIssue({ message: `history: ${problem}` });
@@ -516,9 +518,11 @@ export const ThreadRecordSchema = v.pipe(
516
518
  parentSessionId: v.optional(v.string()),
517
519
  shares: v.optional(v.array(ShareLinkSchema)),
518
520
  participants: v.optional(v.array(IdentitySchema)),
521
+ agentSnapshot: v.optional(ThreadAgentSchema),
519
522
  } satisfies EntriesOf<Thread>),
520
523
  v.rawCheck(({ dataset, addIssue }) => {
521
524
  if (!dataset.typed || !dataset.value.textCuts?.length) return;
525
+
522
526
  const { artifact, textCuts, workingCopy } = dataset.value;
523
527
  const matchesSource = textCuts.every(
524
528
  (cut) => artifact.content.slice(cut.start, cut.end) === cut.quote,
@@ -536,6 +540,7 @@ export function validateThreadRecord(
536
540
  const result = v.safeParse(ThreadRecordSchema, raw);
537
541
 
538
542
  if (result.success) return { ok: true, value: result.output };
543
+
539
544
  const issue = result.issues[0]!;
540
545
  const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
541
546
 
package/src/vcs-source.ts CHANGED
@@ -71,6 +71,7 @@ const ExtensionModuleSchema = v.object({ default: v.function() });
71
71
 
72
72
  function readVcsToml(path: string): Partial<VcsConfig> {
73
73
  if (!existsSync(path)) return {};
74
+
74
75
  try {
75
76
  const parsed = v.safeParse(VcsConfigSchema, parseToml(readFileSync(path, "utf8")));
76
77
 
@@ -100,6 +101,7 @@ export class VcsSourceManager {
100
101
 
101
102
  private async adapters(): Promise<VcsAdapter[]> {
102
103
  if (!this.loaded) this.loaded = this.loadExtensions();
104
+
103
105
  await this.loaded;
104
106
 
105
107
  return [
@@ -131,6 +133,7 @@ export class VcsSourceManager {
131
133
  const parsed = v.safeParse(ExtensionModuleSchema, module);
132
134
 
133
135
  if (!parsed.success) throw new Error("VCS extension must export a default factory");
136
+
134
137
  const factory: ExtensionFactory = async (api) => {
135
138
  await parsed.output.default(api);
136
139
  };
@@ -177,6 +180,7 @@ export class VcsSourceManager {
177
180
 
178
181
  if (!selected && requested === "git" && detected.length === 0)
179
182
  return { adapter: gitVcsAdapter, repoRoot: cwd };
183
+
180
184
  if (!selected)
181
185
  throw new Error(
182
186
  `VCS adapter unavailable: ${requested}${this.extensionErrors.length > 0 ? ` (${this.extensionErrors.join("; ")})` : ""}`,
@@ -184,6 +188,7 @@ export class VcsSourceManager {
184
188
 
185
189
  return selected;
186
190
  }
191
+
187
192
  detected.sort((a, b) => {
188
193
  const rootDistance = b.repoRoot.length - a.repoRoot.length;
189
194
 
@@ -219,6 +224,7 @@ export class VcsSourceManager {
219
224
 
220
225
  if (!selected.adapter.captureChange)
221
226
  throw new Error(`VCS adapter ${provider} cannot follow a change ID`);
227
+
222
228
  const snapshot = v.parse(
223
229
  VcsDiffSnapshotSchema,
224
230
  await selected.adapter.captureChange(selected.repoRoot, changeId),
@@ -17,6 +17,7 @@ function statusFromCode(code: string): DiffFileStatus {
17
17
  const letter = code.charAt(0);
18
18
 
19
19
  if (letter === "D") return "deleted";
20
+
20
21
  if (letter === "A") return "added";
21
22
 
22
23
  return "modified";
@@ -35,6 +36,7 @@ export async function workingChangeList(
35
36
 
36
37
  for (const line of tracked.split("\n")) {
37
38
  if (line.trim().length === 0) continue;
39
+
38
40
  const parts = line.split("\t");
39
41
 
40
42
  // a rename is "R100<tab>old<tab>new"; the new path (last field) is what the reviewer opens
@@ -101,11 +103,13 @@ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
101
103
  cursor += 2;
102
104
  continue;
103
105
  }
106
+
104
107
  const path = tokens[cursor++]!;
105
108
  const oldContents = code === "A" ? "" : await headContents(path, cwd);
106
109
  const newContents = code === "D" ? "" : await workingContents(path, cwd);
107
110
 
108
111
  if (looksBinary(oldContents) || looksBinary(newContents)) continue;
112
+
109
113
  const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
110
114
 
111
115
  files.push({ path, oldContents, newContents, status });