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

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 +63 -0
  4. package/src/client-reliability.test.ts +2 -0
  5. package/src/client.ts +24 -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 +2 -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 +8 -0
  39. package/src/thread-subscription.test.ts +3 -0
  40. package/src/thread-subscription.ts +9 -0
  41. package/src/validate.test.ts +4 -0
  42. package/src/validate.ts +3 -0
  43. package/src/vcs-source.ts +6 -0
  44. package/src/working-tree.ts +4 -0
@@ -0,0 +1,27 @@
1
+ import type { Thread, ThreadAgentState } from "@cueloop/schema";
2
+
3
+ /** Handoff transfers continuation context, never provider credentials or private session files. */
4
+ export function harnessHandoffPrompt(thread: Thread, state: ThreadAgentState): string {
5
+ return `Prepare a continuation document for another agent taking over this Thread.
6
+ Do not execute tools or change files. Return only the handoff text.
7
+ Include the current goal, decisions, completed work, outstanding work, constraints, and suggested skills.
8
+ Reference existing artifacts by path or URL instead of duplicating them.
9
+ Redact credentials, passwords, and personal information.
10
+ Thread: ${thread.id}
11
+ Workspace: ${thread.workspace.repoRoot}
12
+ Reviewed artifact:
13
+ ${thread.artifact.content.slice(0, 32768)}
14
+ Pending continuation context:
15
+ ${state.continuation ?? ""}
16
+ Recent conversation:
17
+ ${state.messages
18
+ .filter((message) => message.complete)
19
+ .map((message) => `${message.role}: ${message.text}`)
20
+ .join("\n")
21
+ .slice(-65536)}`;
22
+ }
23
+
24
+ /** The receiving agent treats the handoff as context rather than a fresh user instruction. */
25
+ export function harnessContinuationPrompt(handoff: string, prompt: string): string {
26
+ return `Continuation context from the previous harness:\n${handoff}\n\nCurrent request:\n${prompt}`;
27
+ }
@@ -32,6 +32,7 @@ export class HarnessStateStore {
32
32
  );
33
33
 
34
34
  if (existing) return existing;
35
+
35
36
  const binding: HarnessBinding = {
36
37
  ...input,
37
38
  id: newRoutingId("bind"),
@@ -77,6 +78,7 @@ export class HarnessStateStore {
77
78
 
78
79
  private deleteBindingsAndDeliveries(bindingIds: ReadonlySet<string>): boolean {
79
80
  if (bindingIds.size === 0) return false;
81
+
80
82
  for (const bindingId of bindingIds) this.bindings.delete(bindingId);
81
83
  for (const delivery of this.deliveries.values()) {
82
84
  if (bindingIds.has(delivery.bindingId)) this.deliveries.delete(delivery.id);
@@ -111,6 +113,7 @@ export class HarnessStateStore {
111
113
  );
112
114
 
113
115
  if (existing) return existing;
116
+
114
117
  const delivery: Delivery = {
115
118
  ...input,
116
119
  id: newRoutingId("del"),
@@ -136,6 +139,7 @@ export class HarnessStateStore {
136
139
 
137
140
  for (const delivery of this.deliveries.values()) {
138
141
  if (delivery.status !== "pending") continue;
142
+
139
143
  const binding = this.bindings.get(delivery.bindingId);
140
144
 
141
145
  if (binding) ids.add(binding.threadId);
@@ -163,6 +167,7 @@ export class HarnessStateStore {
163
167
  if (!delivery) throw new Error(`no delivery ${deliveryId}`);
164
168
 
165
169
  if (delivery.status === "acknowledged") return delivery;
170
+
166
171
  const acknowledged: Delivery = {
167
172
  ...delivery,
168
173
  status: "acknowledged",
@@ -34,6 +34,7 @@ export function launchHarnessInSplit(
34
34
  const herdr = detectHerdr(env);
35
35
 
36
36
  if (!herdr) return false;
37
+
37
38
  try {
38
39
  const split = Bun.spawnSync(
39
40
  [
@@ -52,14 +53,18 @@ export function launchHarnessInSplit(
52
53
  );
53
54
 
54
55
  if (split.exitCode !== 0) return false;
56
+
55
57
  const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
56
58
 
57
59
  if (!parsed.success) return false;
60
+
58
61
  const paneId = parsed.output.result?.pane?.pane_id;
59
62
 
60
63
  if (!paneId) return false;
64
+
61
65
  sendText(herdr.binPath, paneId, options.command);
62
66
  sendKeys(herdr.binPath, paneId, "enter");
67
+
63
68
  if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
64
69
 
65
70
  return true;
@@ -92,9 +92,11 @@ export function openHerdrThreadTab(
92
92
  );
93
93
 
94
94
  if (created.exitCode !== 0) return null;
95
+
95
96
  const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
96
97
 
97
98
  if (!parsed.success) return null;
99
+
98
100
  const paneId = parsed.output.result?.root_pane?.pane_id;
99
101
  const tabId = parsed.output.result?.root_pane?.tab_id;
100
102
 
@@ -147,10 +149,12 @@ export function openHerdrThreadPane(
147
149
  );
148
150
 
149
151
  if (created.exitCode !== 0) return null;
152
+
150
153
  const parsed = v.safeParse(CreatedPaneSchema, JSON.parse(created.stdout.toString()));
151
154
  const paneId = parsed.success ? parsed.output.result?.pane?.pane_id : undefined;
152
155
 
153
156
  if (!paneId) return null;
157
+
154
158
  if (!sendCueloopThreadCommand(binPath, paneId, sessionId, layout)) {
155
159
  closeHerdrThreadSurface(binPath, "pane", paneId);
156
160
 
@@ -189,6 +193,7 @@ function sendCueloopThreadCommand(
189
193
  });
190
194
 
191
195
  if (typed.exitCode !== 0) return false;
196
+
192
197
  const entered = runHerdrCommand([binPath, "pane", "send-keys", paneId, "enter"], {
193
198
  stdout: "ignore",
194
199
  stderr: "ignore",
@@ -208,6 +213,7 @@ function herdrPaneAlive(binPath: string, paneId: string): boolean {
208
213
  });
209
214
 
210
215
  if (got.exitCode !== 0) return false;
216
+
211
217
  const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
212
218
 
213
219
  return parsed.success && parsed.output.result?.pane != null;
@@ -249,10 +255,13 @@ function focusHerdrThreadSurface(binPath: string, handle: HerdrThreadSurfaceHand
249
255
  const neighbor = herdrPaneNeighbor(binPath, handle.paneId, direction);
250
256
 
251
257
  if (neighbor === undefined) return false;
258
+
252
259
  if (neighbor === null) continue;
260
+
253
261
  hasNeighbor = true;
254
262
 
255
263
  if (herdrPaneNeighbor(binPath, neighbor, opposite) !== handle.paneId) continue;
264
+
256
265
  const focused = runHerdrCommand(
257
266
  [binPath, "pane", "focus", "--pane", neighbor, "--direction", opposite],
258
267
  { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
@@ -286,6 +295,7 @@ function herdrPaneNeighbor(
286
295
  );
287
296
 
288
297
  if (neighbor.exitCode !== 0) return undefined;
298
+
289
299
  const parsed = v.safeParse(PaneNeighborSchema, JSON.parse(neighbor.stdout.toString()));
290
300
 
291
301
  return parsed.success ? (parsed.output.result.neighbor.neighbor_pane_id ?? null) : undefined;
@@ -307,6 +317,7 @@ export async function openHerdrThreadSurface(
307
317
  if (!herdr) return "unavailable";
308
318
 
309
319
  if (mode === "none") return "disabled";
320
+
310
321
  const recorded = await recallHerdrThreadSurface(persistence, session.id);
311
322
 
312
323
  if (
@@ -317,6 +328,7 @@ export async function openHerdrThreadSurface(
317
328
  ) {
318
329
  return focusHerdrThreadSurface(herdr.binPath, recorded) ? "focused" : "failed";
319
330
  }
331
+
320
332
  const cwd = session.artifact.meta.cwd ?? session.workspace.repoRoot;
321
333
  const opened =
322
334
  mode === "pane"
@@ -340,6 +352,7 @@ export async function openHerdrThreadSurface(
340
352
  });
341
353
 
342
354
  if (!opened) return "failed";
355
+
343
356
  if (recorded && recorded.paneId !== opened.paneId) {
344
357
  closeHerdrThreadSurface(
345
358
  herdr.binPath,
@@ -347,6 +360,7 @@ export async function openHerdrThreadSurface(
347
360
  recorded.mode === "pane" ? recorded.paneId : recorded.tabId,
348
361
  );
349
362
  }
363
+
350
364
  await rememberHerdrThreadSurface(persistence, session.id, opened);
351
365
 
352
366
  return "opened";
@@ -9,6 +9,7 @@ const whenSignalAborts = (signal: AbortSignal): Effect.Effect<void> =>
9
9
 
10
10
  return;
11
11
  }
12
+
12
13
  const onAbort = () => resume(Effect.void);
13
14
 
14
15
  signal.addEventListener("abort", onAbort, { once: true });
@@ -37,6 +38,7 @@ export function raceAbort<T>(
37
38
  signal: AbortSignal | undefined,
38
39
  ): Promise<T | typeof ABORTED> {
39
40
  if (!signal) return promise;
41
+
40
42
  if (signal.aborted) {
41
43
  promise.catch(() => {});
42
44
 
@@ -56,6 +58,7 @@ export function pollUntilResolved<T>(
56
58
  signal: AbortSignal | undefined,
57
59
  ): Promise<T | null> {
58
60
  if (signal?.aborted) return Promise.resolve(null);
61
+
59
62
  const loop = Effect.repeat(fromSettledPromise(attempt), {
60
63
  until: (result): result is T => result !== null,
61
64
  });
@@ -26,6 +26,7 @@ function jjSummary(text: string): { path: string; status: DiffFileStatus; curata
26
26
  const path = tokens[index + 1];
27
27
 
28
28
  if (!code || !path) continue;
29
+
29
30
  changes.push({
30
31
  path,
31
32
  status: code === "A" ? "added" : code === "D" ? "deleted" : "modified",
@@ -62,12 +63,15 @@ async function captureJjRevision(repoRoot: string, revset: string): Promise<VcsD
62
63
  );
63
64
 
64
65
  if (!identity) throw new Error(`Jujutsu diff capture failed: could not resolve ${revset}`);
66
+
65
67
  const lines = identity.trim().split("\n");
66
68
 
67
69
  if (lines.length !== 3) throw new Error(`Jujutsu change is ambiguous: ${revset}`);
70
+
68
71
  const [changeId, revisionId, parents] = lines;
69
72
 
70
73
  if (!changeId || !revisionId) throw new Error("Jujutsu diff capture failed: invalid identity");
74
+
71
75
  const [patch, summary] = await Promise.all([
72
76
  jj(["--ignore-working-copy", "diff", "--git", "-r", revisionId], repoRoot),
73
77
  jj(["--ignore-working-copy", "diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", revisionId], repoRoot),
@@ -75,19 +79,23 @@ async function captureJjRevision(repoRoot: string, revset: string): Promise<VcsD
75
79
 
76
80
  if (patch === null || summary === null)
77
81
  throw new Error("Jujutsu diff capture failed: could not read pinned revision");
82
+
78
83
  const files: DiffFileContents[] = [];
79
84
  const parent = parents?.split(",")[0];
80
85
 
81
86
  if (parent && !parents?.includes(",")) {
82
87
  for (const change of jjSummary(summary)) {
83
88
  if (!change.curatable) continue;
89
+
84
90
  const oldContents =
85
91
  change.status === "added" ? "" : await jjFileContents(repoRoot, parent, change.path);
86
92
  const newContents =
87
93
  change.status === "deleted" ? "" : await jjFileContents(repoRoot, revisionId, change.path);
88
94
 
89
95
  if (oldContents === null || newContents === null) continue;
96
+
90
97
  if (isBinary(oldContents) || isBinary(newContents)) continue;
98
+
91
99
  files.push({ path: change.path, oldContents, newContents, status: change.status });
92
100
  }
93
101
  }
@@ -109,6 +117,7 @@ export const jjVcsAdapter: VcsAdapter = {
109
117
  },
110
118
  async captureChange(repoRoot, changeId) {
111
119
  if (!/^[a-z]{32}$/.test(changeId)) throw new Error(`Jujutsu change ID is invalid: ${changeId}`);
120
+
112
121
  if ((await jj(["status"], repoRoot)) === null)
113
122
  throw new Error("Jujutsu diff capture failed: could not snapshot the working copy");
114
123
 
package/src/main.ts CHANGED
@@ -13,6 +13,7 @@ if (path === null) {
13
13
  console.log("cueloop daemon already running for this home");
14
14
  process.exit(0);
15
15
  }
16
+
16
17
  console.log(`cueloop daemon listening on ${path}`);
17
18
 
18
19
  process.on("SIGINT", () => {
@@ -16,6 +16,7 @@ export function findOperationReceipt<Result>(
16
16
  fingerprint: string,
17
17
  ): OperationReceipt<Result> | undefined {
18
18
  if (!operationId) return;
19
+
19
20
  const receipt = receipts?.find((entry) => entry.operationId === operationId);
20
21
 
21
22
  if (receipt && receipt.fingerprint !== fingerprint)
@@ -23,6 +24,7 @@ export function findOperationReceipt<Result>(
23
24
  "operation_conflict",
24
25
  "Operation payload conflict: use a new operation ID for changed input",
25
26
  );
27
+
26
28
  if (!receipt && (receipts?.length ?? 0) >= 128)
27
29
  throw new DaemonError(
28
30
  "operation_capacity",
@@ -39,12 +41,14 @@ export function recordPromptOperation(
39
41
  input: AgentPromptRequest,
40
42
  ): void {
41
43
  if (!input.operationId) return;
44
+
42
45
  const previous = new Set(before.submissions?.map((entry) => entry.id));
43
46
  const accepted = input.retry
44
47
  ? [input.retry]
45
48
  : (state.submissions ?? []).filter((entry) => !previous.has(entry.id)).map((entry) => entry.id);
46
49
 
47
50
  if (!accepted.length) throw new DaemonError("invalid_params", "Agent operation requires input");
51
+
48
52
  const fingerprint = promptOperationFingerprint(input);
49
53
 
50
54
  (state.promptOperations ??= []).push({
@@ -58,6 +62,7 @@ export function recordPromptOperation(
58
62
  export function settlePromptOperations(state: ThreadAgentState): void {
59
63
  for (const receipt of state.promptOperations ?? []) {
60
64
  if (receipt.outcome || !receipt.result.length) continue;
65
+
61
66
  const submissions = receipt.result.map((id) =>
62
67
  state.submissions?.find((entry) => entry.id === id),
63
68
  );
@@ -66,6 +71,7 @@ export function settlePromptOperations(state: ThreadAgentState): void {
66
71
  submissions.some((entry) => !entry || entry.status === "queued" || entry.status === "running")
67
72
  )
68
73
  continue;
74
+
69
75
  receipt.outcome = submissions.some((entry) => entry?.cancelled)
70
76
  ? "cancelled"
71
77
  : submissions.some((entry) => entry?.status === "failed")
package/src/pr-poller.ts CHANGED
@@ -27,6 +27,7 @@ export class PrReviewPoller {
27
27
  /** Start polling a PR review. Idempotent per session; the persisted reviewed head is the baseline. */
28
28
  trackPr(sessionId: string, pr: string, reviewedRefs: PullRequestRefs | null = null): void {
29
29
  if (this.polls.has(sessionId)) return;
30
+
30
31
  const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
31
32
 
32
33
  // the poll timer must not by itself keep the daemon alive against idle-exit
@@ -40,20 +41,25 @@ export class PrReviewPoller {
40
41
  const poll = this.polls.get(sessionId);
41
42
 
42
43
  if (!poll) return;
44
+
43
45
  const refs = await this.refs(poll.pr).catch(() => null);
44
46
 
45
47
  if (refs === null) return;
48
+
46
49
  // the await yields; re-read so an untrack during the call is honoured
47
50
  const current = this.polls.get(sessionId);
48
51
 
49
52
  if (!current) return;
53
+
50
54
  if (current.lastRefs === null) {
51
55
  current.lastRefs = refs;
52
56
 
53
57
  return;
54
58
  }
59
+
55
60
  if (refs.baseSha === current.lastRefs.baseSha && refs.headSha === current.lastRefs.headSha)
56
61
  return;
62
+
57
63
  current.lastRefs = refs;
58
64
  this.onPrAdvance(sessionId, refs);
59
65
  }
@@ -62,6 +68,7 @@ export class PrReviewPoller {
62
68
  const poll = this.polls.get(sessionId);
63
69
 
64
70
  if (!poll) return;
71
+
65
72
  clearInterval(poll.timer);
66
73
  this.polls.delete(sessionId);
67
74
  }
@@ -21,6 +21,7 @@ async function gitFileList(repoRoot: string, args: string[]): Promise<string[] |
21
21
  /** Existing tracked paths, sorted ascending and capped; [] on failure or a non-repo. */
22
22
  export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
23
23
  if (repoRoot === undefined || repoRoot.length === 0) return [];
24
+
24
25
  try {
25
26
  const [tracked, deleted] = await Promise.all([
26
27
  gitFileList(repoRoot, ["--cached"]),
@@ -28,6 +29,7 @@ export async function listProjectFiles(repoRoot: string | undefined): Promise<st
28
29
  ]);
29
30
 
30
31
  if (tracked === null || deleted === null) return [];
32
+
31
33
  const missing = new Set(deleted);
32
34
 
33
35
  return tracked
@@ -52,10 +54,12 @@ export async function readProjectFile(
52
54
  path: string,
53
55
  ): Promise<string | null> {
54
56
  if (repoRoot === undefined || repoRoot.length === 0) return null;
57
+
55
58
  const root = resolve(repoRoot);
56
59
  const target = resolve(root, path);
57
60
 
58
61
  if (escapesRoot(root, target)) return null;
62
+
59
63
  try {
60
64
  const stats = await stat(target);
61
65
 
package/src/protocol.ts CHANGED
@@ -60,9 +60,11 @@ export class LineBuffer {
60
60
  const newlineIndex = this.buffered.indexOf("\n");
61
61
 
62
62
  if (newlineIndex === -1) return;
63
+
63
64
  const line = this.buffered.slice(0, newlineIndex).trim();
64
65
 
65
66
  this.buffered = this.buffered.slice(newlineIndex + 1);
67
+
66
68
  if (line) onLine(line);
67
69
  }
68
70
  }
@@ -93,6 +95,7 @@ export class BackpressureWriter {
93
95
 
94
96
  return;
95
97
  }
98
+
96
99
  const written = Math.max(0, this.socket.write(bytes));
97
100
 
98
101
  if (written < bytes.length) this.pending = bytes.subarray(written);
@@ -101,6 +104,7 @@ export class BackpressureWriter {
101
104
  /** Call from the socket's drain handler. */
102
105
  drain(): void {
103
106
  if (!this.pending) return;
107
+
104
108
  const tail = this.pending;
105
109
 
106
110
  this.pending = null;
package/src/retention.ts CHANGED
@@ -32,6 +32,7 @@ function userConfigPath(env: NodeJS.ProcessEnv): string {
32
32
  const explicit = env.CUELOOP_CONFIG;
33
33
 
34
34
  if (explicit && explicit.trim()) return explicit;
35
+
35
36
  const xdgConfigHome = env.XDG_CONFIG_HOME;
36
37
  const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : join(homedir(), ".config");
37
38
 
@@ -49,6 +50,7 @@ function readConfiguredPeriodDays(path: string): number | undefined {
49
50
  const parsed = v.safeParse(CleanupConfigSchema, raw);
50
51
 
51
52
  if (!parsed.success) return undefined;
53
+
52
54
  const value = parsed.output.cleanup?.period_days;
53
55
 
54
56
  return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined;
@@ -60,6 +62,7 @@ export function resolveCleanupPeriodDays(env: NodeJS.ProcessEnv = process.env):
60
62
 
61
63
  export function isExpired(createdAt: string, periodDays: number, nowMs: number): boolean {
62
64
  if (periodDays <= 0) return false;
65
+
63
66
  const createdMs = Date.parse(createdAt);
64
67
 
65
68
  if (Number.isNaN(createdMs)) return false;
@@ -74,10 +77,12 @@ export function pruneExpiredSessions(
74
77
  protectedThreadIds: ReadonlySet<string> = new Set(),
75
78
  ): string[] {
76
79
  if (periodDays <= 0) return [];
80
+
77
81
  const pruned: string[] = [];
78
82
 
79
83
  for (const session of store.list()) {
80
84
  if (session.status !== "resolved" || protectedThreadIds.has(session.id)) continue;
85
+
81
86
  if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
82
87
  pruned.push(session.id);
83
88
  }
@@ -98,6 +103,7 @@ export function pruneExpiredReports(
98
103
  nowMs: number,
99
104
  ): string[] {
100
105
  if (periodDays <= 0) return [];
106
+
101
107
  let entries: string[];
102
108
 
103
109
  try {
@@ -110,7 +116,9 @@ export function pruneExpiredReports(
110
116
 
111
117
  for (const entry of entries) {
112
118
  if (entry === LATEST_REPORT_FILENAME) continue;
119
+
113
120
  if (!entry.startsWith(TIMESTAMPED_REPORT_PREFIX)) continue;
121
+
114
122
  const path = join(reportsDirectory, entry);
115
123
 
116
124
  if (statSync(path).mtimeMs < cutoffMs) {
package/src/server.ts CHANGED
@@ -45,7 +45,11 @@ interface Connection {
45
45
  type MethodHandler = (connection: Connection, request: Request) => Response["result"];
46
46
 
47
47
  export interface DaemonOptions {
48
- threadAgent?: Pick<ThreadAgentOptions, "enabled" | "enabledForThread" | "adapter" | "adapters">;
48
+ agentHarnessDefault?: () => string;
49
+ threadAgent?: Pick<
50
+ ThreadAgentOptions,
51
+ "enabled" | "enabledForThread" | "adapter" | "adapters" | "defaultHarnessForThread"
52
+ >;
49
53
  home?: string;
50
54
  onEvent?: (event: import("./client").EventFrame) => void;
51
55
  idleExitMs?: number;
@@ -76,7 +80,7 @@ export class DaemonServer {
76
80
  private readonly version: string;
77
81
  private readonly onEvent?: (event: import("./client").EventFrame) => void;
78
82
 
79
- constructor(options: DaemonOptions = {}) {
83
+ constructor(private readonly options: DaemonOptions = {}) {
80
84
  this.onEvent = options.onEvent;
81
85
  this.home = options.home ?? cueloopHome();
82
86
  this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
@@ -154,6 +158,7 @@ export class DaemonServer {
154
158
  */
155
159
  private acquireLock(): boolean {
156
160
  if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
161
+
157
162
  const path = lockPath(this.home);
158
163
 
159
164
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -171,6 +176,7 @@ export class DaemonServer {
171
176
  return true;
172
177
  } catch (error) {
173
178
  closeSync(fd);
179
+
174
180
  if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
175
181
 
176
182
  let ownerPid = 0;
@@ -184,12 +190,15 @@ export class DaemonServer {
184
190
  Atomics.wait(LOCK_WAIT, 0, 0, 20);
185
191
  ownerText = readFileSync(path, "utf8").trim();
186
192
  }
193
+
187
194
  if (!ownerText && Date.now() - statSync(path).mtimeMs < 5_000) return false;
195
+
188
196
  ownerPid = Number(ownerText);
189
197
  } catch {
190
198
  // the owner vanished between open and read; retry
191
199
  continue;
192
200
  }
201
+
193
202
  if (ownerPid && ownerPid !== process.pid) {
194
203
  try {
195
204
  process.kill(ownerPid, 0); // throws when the pid is gone
@@ -199,6 +208,7 @@ export class DaemonServer {
199
208
  // stale lock from a crashed daemon
200
209
  }
201
210
  }
211
+
202
212
  // ownerPid === our pid but HELD_HOMES says we do not own it: a stale
203
213
  // file from an earlier instance in this process - reclaim it.
204
214
  try {
@@ -216,6 +226,7 @@ export class DaemonServer {
216
226
 
217
227
  private releaseLock(): void {
218
228
  HELD_HOMES.delete(this.home);
229
+
219
230
  if (this.lockFd !== null) {
220
231
  try {
221
232
  if (statSync(lockPath(this.home)).ino === fstatSync(this.lockFd).ino)
@@ -238,11 +249,13 @@ export class DaemonServer {
238
249
  */
239
250
  start(): string | null {
240
251
  if (!this.acquireLock()) return null;
252
+
241
253
  const path = socketPath(this.home);
242
254
 
243
255
  // safe now: holding the lock means no live daemon owns this home, so any
244
256
  // socket file left behind is stale
245
257
  if (existsSync(path)) rmSync(path, { force: true });
258
+
246
259
  // the token exists before the socket does: the first client to connect
247
260
  // must be able to prove ownership
248
261
  this.ownerToken = randomBytes(32).toString("hex");
@@ -294,12 +307,10 @@ export class DaemonServer {
294
307
  resumeAgents(): void {
295
308
  for (const thread of this.core.sessionList({ status: "pending" })) {
296
309
  if (!this.threadAgent.isEnabled(thread.id)) continue;
310
+
297
311
  const state = this.threadAgent.get(thread.id);
298
312
 
299
- if (
300
- state.harness?.id === "pi" &&
301
- state.submissions?.some((submission) => submission.status === "queued")
302
- )
313
+ if (state.handoff || state.submissions?.some((submission) => submission.status === "queued"))
303
314
  void this.threadAgent
304
315
  .configure({ id: thread.id })
305
316
  .catch((error) => console.error("[agent recovery]", error));
@@ -314,13 +325,17 @@ export class DaemonServer {
314
325
  stop(): void {
315
326
  void this.threadAgent.dispose().catch((error) => console.error("[agent shutdown]", error));
316
327
  this.core.dispose();
328
+
317
329
  if (this.lockFd === null) return;
330
+
318
331
  this.server?.stop(true);
319
332
  this.server = null;
320
333
  rmSync(socketPath(this.home), { force: true });
321
334
  rmSync(pidPath(this.home), { force: true });
322
335
  rmSync(ownerTokenPath(this.home), { force: true });
336
+
323
337
  if (this.idleTimer) clearTimeout(this.idleTimer);
338
+
324
339
  this.releaseLock();
325
340
  }
326
341
 
@@ -335,7 +350,9 @@ export class DaemonServer {
335
350
  /** Idle = no pending session and no attached client. */
336
351
  private scheduleIdleCheck(): void {
337
352
  if (this.idleExitMs <= 0) return;
353
+
338
354
  if (this.idleTimer) clearTimeout(this.idleTimer);
355
+
339
356
  this.idleTimer = setTimeout(() => {
340
357
  if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
341
358
  this.stop();
@@ -376,16 +393,19 @@ export class DaemonServer {
376
393
  const origin = this.core.sessionGet(threadId);
377
394
 
378
395
  assertAgentToolScope(origin, args);
396
+
379
397
  if (args.kind === "api" && args.method === "session.list") {
380
398
  const { filter } = parseParams("session.list", args.params);
381
399
 
382
400
  return JSON.stringify(!filter?.status || origin.status === filter.status ? [origin] : []);
383
401
  }
402
+
384
403
  if (args.kind === "reply") {
385
404
  const thread = origin;
386
405
  const comment = thread.annotations.find((entry) => entry.id === args.commentId);
387
406
 
388
407
  if (!comment) return JSON.stringify(this.replyAgentComment(args));
408
+
389
409
  const root =
390
410
  thread.annotations.find((entry) => entry.id === (comment.replyTo ?? comment.id)) ?? comment;
391
411
 
@@ -405,6 +425,7 @@ export class DaemonServer {
405
425
  ),
406
426
  );
407
427
  }
428
+
408
429
  const method = args.method;
409
430
 
410
431
  if (!isKnownMethod(method)) throw new Error("Thread agent tool API method is unavailable");
@@ -475,7 +496,11 @@ export class DaemonServer {
475
496
 
476
497
  return this.threadAgent.permission(params);
477
498
  },
478
- "daemon.ping": () => ({ pid: process.pid, version: this.version }),
499
+ "daemon.ping": () => ({
500
+ pid: process.pid,
501
+ version: this.version,
502
+ agentHarness: this.options.agentHarnessDefault?.() ?? this.options.threadAgent?.adapter?.id,
503
+ }),
479
504
  "daemon.hello": (connection, request) => {
480
505
  const params = parseParams("daemon.hello", request.params);
481
506
 
@@ -484,6 +509,7 @@ export class DaemonServer {
484
509
  if (params.role === "owner" && params.token !== this.ownerToken) {
485
510
  throw new DaemonError("forbidden", "owner token required");
486
511
  }
512
+
487
513
  if (params.clientVersion !== this.version) {
488
514
  throw new DaemonError(
489
515
  "version_mismatch",
@@ -492,6 +518,7 @@ export class DaemonServer {
492
518
  }
493
519
 
494
520
  connection.role = params.role;
521
+
495
522
  // identity is bound once, here; a non-owner never names it per call
496
523
  if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
497
524
 
@@ -779,11 +806,13 @@ export class DaemonServer {
779
806
  if (!isKnownMethod(request.method)) {
780
807
  throw new DaemonError("unknown_method", `unknown method ${request.method}`);
781
808
  }
809
+
782
810
  // Capability gate: a capped role (a review-side agent) cannot escalate past
783
811
  // read + annotate, whatever primitive it sends.
784
812
  if (!roleAllowsMethod(connection.role, request.method)) {
785
813
  throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
786
814
  }
815
+
787
816
  if (!Object.hasOwn(this.handlers, request.method)) {
788
817
  throw new DaemonError("unknown_method", `unknown method ${request.method}`);
789
818
  }
@@ -71,8 +71,10 @@ describe("the payload cap against a long review", () => {
71
71
  body: "Fail fast with a readable error; the review never opens on a store that cannot write.",
72
72
  replyTo: `a_${round}`,
73
73
  });
74
+
74
75
  if (round % 10 === 0)
75
76
  core.sessionSetWorkingCopy(session.id, `${content}\n\nEdit ${round}.\n`);
77
+
76
78
  if (round % 20 === 0) core.sessionRemoveAnnotation(session.id, `r_${round}`);
77
79
  }
78
80
  const shared = viewFollowing(core.sessionGet(session.id));