@cueloop/daemon 0.1.0-alpha.59 → 0.1.0-alpha.60

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/daemon",
3
- "version": "0.1.0-alpha.59",
3
+ "version": "0.1.0-alpha.60",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@cueloop/schema": "0.1.0-alpha.59",
36
+ "@cueloop/schema": "0.1.0-alpha.60",
37
37
  "valibot": "^1.4.2"
38
38
  },
39
39
  "engines": {
package/src/api.test.ts CHANGED
@@ -82,6 +82,7 @@ describe("session lifecycle", () => {
82
82
 
83
83
  // Assert
84
84
  const stored = core.sessionGet(session.id);
85
+
85
86
  expect(stored.annotations[0]!.author).toBe("SHA256:ana");
86
87
  expect(stored.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
87
88
  });
@@ -89,6 +90,7 @@ describe("session lifecycle", () => {
89
90
  test("resolve produces feedback.md and maps to the agent contract", () => {
90
91
  // Arrange
91
92
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
93
+
92
94
  core.sessionAnnotate(session.id, {
93
95
  id: "a1",
94
96
  kind: "comment",
@@ -120,6 +122,7 @@ describe("session lifecycle", () => {
120
122
  test("mutating a resolved session throws", () => {
121
123
  // Arrange
122
124
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
125
+
123
126
  core.sessionResolve(session.id, "approve", "");
124
127
 
125
128
  // Assert
@@ -168,6 +171,7 @@ describe("session lifecycle", () => {
168
171
  expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
169
172
  // a resumed review reads its progress back after a daemon restart
170
173
  const reborn = new DaemonCore(home);
174
+
171
175
  expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
172
176
 
173
177
  // Act
@@ -180,6 +184,7 @@ describe("session lifecycle", () => {
180
184
  test("revision reopens the session and resets working copy + verdict", () => {
181
185
  // Arrange
182
186
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
187
+
183
188
  core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
184
189
  core.sessionResolve(session.id, "request_changes", "redo");
185
190
 
@@ -207,6 +212,7 @@ describe("revision marks addressed annotations", () => {
207
212
  test("ids the agent reports are marked addressed by that revision", () => {
208
213
  // Arrange
209
214
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
215
+
210
216
  annotate(session.id, "a1", "carefully");
211
217
  annotate(session.id, "a2", "Context");
212
218
 
@@ -215,6 +221,7 @@ describe("revision marks addressed annotations", () => {
215
221
 
216
222
  // Assert
217
223
  const [first, second] = revised.annotations;
224
+
218
225
  expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
219
226
  expect(second!.resolution).toBeUndefined();
220
227
  });
@@ -222,6 +229,7 @@ describe("revision marks addressed annotations", () => {
222
229
  test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
223
230
  // Arrange
224
231
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
232
+
225
233
  annotate(session.id, "gone", "carefully");
226
234
  annotate(session.id, "kept", "Context");
227
235
 
@@ -242,6 +250,7 @@ describe("revision marks addressed annotations", () => {
242
250
  test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
243
251
  // Arrange
244
252
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
253
+
245
254
  annotate(session.id, "a1", "carefully");
246
255
  core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
247
256
 
@@ -257,6 +266,7 @@ describe("revision marks addressed annotations", () => {
257
266
  // Arrange
258
267
  const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
259
268
  const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
269
+
260
270
  annotate(session.id, "d1", "new line");
261
271
 
262
272
  // Act: the revised patch no longer contains the quoted text
@@ -269,6 +279,7 @@ describe("revision marks addressed annotations", () => {
269
279
  test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
270
280
  // Arrange
271
281
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
282
+
272
283
  annotate(session.id, "settled", "carefully");
273
284
  annotate(session.id, "open", "Context", "needs a diagram");
274
285
  core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
@@ -334,6 +345,7 @@ describe("persistence and recovery", () => {
334
345
  test("sessions survive a daemon restart", () => {
335
346
  // Arrange
336
347
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
348
+
337
349
  core.sessionResolve(session.id, "approve", "done");
338
350
 
339
351
  // Act
@@ -364,10 +376,12 @@ describe("events", () => {
364
376
  test("lifecycle events fire in order", () => {
365
377
  // Arrange
366
378
  const seen: string[] = [];
379
+
367
380
  core.onEvent((event) => seen.push(event.event));
368
381
 
369
382
  // Act
370
383
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
384
+
371
385
  core.sessionAnnotate(session.id, {
372
386
  id: "a1",
373
387
  kind: "comment",
package/src/api.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  feedbackForSession,
11
11
  isAddressed,
12
12
  isAgentNote,
13
+ isMarkdownArtifact,
13
14
  parseBlocks,
14
15
  registerParticipant,
15
16
  resolveAnchor,
@@ -84,6 +85,7 @@ export class DaemonCore {
84
85
 
85
86
  onEvent(listener: EventListener): () => void {
86
87
  this.listeners.add(listener);
88
+
87
89
  return () => this.listeners.delete(listener);
88
90
  }
89
91
 
@@ -109,16 +111,20 @@ export class DaemonCore {
109
111
  status: "pending",
110
112
  createdAt: now,
111
113
  };
114
+
112
115
  this.store.upsert(session);
113
116
  this.watchIfDiffSession(session);
114
117
  this.emit("session.created", session.id);
115
118
  this.emit("inbox.changed", session.id);
119
+
116
120
  return session;
117
121
  }
118
122
 
119
123
  sessionGet(id: string): ReviewSession {
120
124
  const session = this.store.get(id);
125
+
121
126
  if (!session) throw new DaemonError("not_found", `no session ${id}`);
127
+
122
128
  return session;
123
129
  }
124
130
 
@@ -132,6 +138,7 @@ export class DaemonCore {
132
138
  return false;
133
139
  if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
134
140
  return false;
141
+
135
142
  return true;
136
143
  });
137
144
  }
@@ -143,7 +150,9 @@ export class DaemonCore {
143
150
  */
144
151
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
145
152
  const current = this.sessionGet(id);
153
+
146
154
  if (current.status === "resolved") return Promise.resolve(current);
155
+
147
156
  return new Promise((resolve) => {
148
157
  const list = this.waiters.get(id) ?? [];
149
158
  let done = false;
@@ -151,6 +160,7 @@ export class DaemonCore {
151
160
  if (done) return;
152
161
  done = true;
153
162
  const waiterIndex = list.indexOf(waiter);
163
+
154
164
  if (waiterIndex !== -1) list.splice(waiterIndex, 1);
155
165
  resolve(null);
156
166
  }, timeoutMs);
@@ -160,6 +170,7 @@ export class DaemonCore {
160
170
  clearTimeout(timer);
161
171
  resolve(session);
162
172
  };
173
+
163
174
  list.push(waiter);
164
175
  this.waiters.set(id, list);
165
176
  });
@@ -177,6 +188,7 @@ export class DaemonCore {
177
188
  const session = this.mutable(id);
178
189
  const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
179
190
  const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
191
+
180
192
  if (existing === -1) session.annotations.push(full);
181
193
  else
182
194
  session.annotations[existing] = {
@@ -191,25 +203,30 @@ export class DaemonCore {
191
203
  ).participants;
192
204
  this.store.upsert(session);
193
205
  this.emit("session.updated", id);
206
+
194
207
  return session;
195
208
  }
196
209
 
197
210
  sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
198
211
  const session = this.mutable(id);
212
+
199
213
  session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
200
214
  this.store.upsert(session);
201
215
  this.emit("session.updated", id);
216
+
202
217
  return session;
203
218
  }
204
219
 
205
220
  /** The reviewer's working copy; undefined clears it (revert all edits). */
206
221
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
207
222
  const session = this.mutable(id);
223
+
208
224
  if (workingCopy === undefined || workingCopy === session.artifact.content)
209
225
  delete session.workingCopy;
210
226
  else session.workingCopy = workingCopy;
211
227
  this.store.upsert(session);
212
228
  this.emit("session.updated", id);
229
+
213
230
  return session;
214
231
  }
215
232
 
@@ -220,24 +237,29 @@ export class DaemonCore {
220
237
  */
221
238
  sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
222
239
  const session = this.mutable(id);
240
+
223
241
  if (viewedPaths.length === 0) delete session.viewedPaths;
224
242
  else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
225
243
  this.store.upsert(session);
226
244
  this.emit("session.updated", id);
245
+
227
246
  return session;
228
247
  }
229
248
 
230
249
  sessionSetShareId(id: string, shareId: string): ReviewSession {
231
250
  const session = this.mutable(id);
251
+
232
252
  session.shareId = shareId;
233
253
  this.store.upsert(session);
234
254
  this.emit("session.updated", id);
255
+
235
256
  return session;
236
257
  }
237
258
 
238
259
  /** Remove a session for good (inbox delete); resolved or pending, both go. */
239
260
  sessionDelete(id: string): void {
240
261
  const session = this.store.get(id);
262
+
241
263
  if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
242
264
  if (session) this.unwatchIfDiffSession(session);
243
265
  this.diffRefreshGenerations.delete(id);
@@ -258,17 +280,20 @@ export class DaemonCore {
258
280
  ): ReviewSession {
259
281
  const session = this.mutable(id);
260
282
  const known = new Set(session.annotations.map((annotation) => annotation.id));
283
+
261
284
  for (const annotation of incoming.annotations)
262
285
  if (!known.has(annotation.id)) session.annotations.push(annotation);
263
286
  if (incoming.participants?.length) {
264
287
  const registry = new Map(
265
288
  (session.participants ?? []).map((participant) => [participant.id, participant]),
266
289
  );
290
+
267
291
  for (const participant of incoming.participants) registry.set(participant.id, participant);
268
292
  session.participants = [...registry.values()];
269
293
  }
270
294
  this.store.upsert(session);
271
295
  this.emit("session.updated", id);
296
+
272
297
  return session;
273
298
  }
274
299
 
@@ -280,16 +305,19 @@ export class DaemonCore {
280
305
  feedback: feedbackForSession(session, verdictKind, summary),
281
306
  resolvedAt: new Date().toISOString(),
282
307
  };
308
+
283
309
  session.verdict = verdict;
284
310
  session.status = "resolved";
285
311
  this.store.upsert(session);
286
312
  // a resolved diff review is frozen; stop hot-reloading its working tree
287
313
  this.unwatchIfDiffSession(session);
288
314
  const parked = this.waiters.get(id) ?? [];
315
+
289
316
  this.waiters.delete(id);
290
317
  for (const parkedWaiter of parked) parkedWaiter(session);
291
318
  this.emit("session.resolved", id);
292
319
  this.emit("inbox.changed", id);
320
+
293
321
  return session;
294
322
  }
295
323
 
@@ -311,6 +339,7 @@ export class DaemonCore {
311
339
  const session = this.sessionGet(id);
312
340
  const now = new Date().toISOString();
313
341
  const revisionNumber = session.revisions.length + 1;
342
+
314
343
  session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
315
344
  session.artifact = { ...session.artifact, content };
316
345
  delete session.workingCopy;
@@ -318,9 +347,11 @@ export class DaemonCore {
318
347
  session.status = "pending";
319
348
 
320
349
  const reportedIds = new Set(addressedAnnotationIds);
321
- // drift assist applies to plans only: a diff revision is a whole new
322
- // patch, where a vanished quote says nothing about the feedback
323
- const revisedBlocks = session.artifact.type === "plan" ? parseBlocks(content) : null;
350
+ // drift assist applies to markdown artifacts (plan, reply) only: a diff
351
+ // revision is a whole new patch, where a vanished quote says nothing about
352
+ // the feedback
353
+ const revisedBlocks = isMarkdownArtifact(session.artifact.type) ? parseBlocks(content) : null;
354
+
324
355
  for (const annotation of session.annotations) {
325
356
  if (isAddressed(annotation) || isAgentNote(annotation)) continue;
326
357
  if (reportedIds.has(annotation.id)) {
@@ -336,28 +367,34 @@ export class DaemonCore {
336
367
  this.store.upsert(session);
337
368
  this.emit("session.revised", id);
338
369
  this.emit("inbox.changed", id);
370
+
339
371
  return session;
340
372
  }
341
373
 
342
374
  /** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
343
375
  async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
344
376
  const session = this.mutable(id);
377
+
345
378
  if (session.artifact.type !== "diff") return { changed: false };
346
379
  const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
380
+
347
381
  this.diffRefreshGenerations.set(id, generation);
348
382
  const diff = await workingTreeDiff(session.workspace.repoRoot);
383
+
349
384
  // The capture yields the event loop: a newer refresh may have started, or a
350
385
  // concurrent resolve/delete may have closed the session. Discard a stale
351
386
  // capture, and re-read so a resolved review is never mutated and a deleted
352
387
  // one is never revived by this upsert.
353
388
  if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
354
389
  const current = this.store.get(id);
390
+
355
391
  if (!current || current.status !== "pending" || current.artifact.type !== "diff")
356
392
  return { changed: false };
357
393
  if (diff.patch === current.artifact.content) return { changed: false };
358
394
  current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
359
395
  this.store.upsert(current);
360
396
  this.emit("session.updated", id);
397
+
361
398
  return { changed: true };
362
399
  }
363
400
 
@@ -366,6 +403,7 @@ export class DaemonCore {
366
403
  const live = this.store
367
404
  .list()
368
405
  .filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
406
+
369
407
  for (const session of live) {
370
408
  // a session resolved or deleted between the change and this tick just skips
371
409
  try {
@@ -388,8 +426,10 @@ export class DaemonCore {
388
426
 
389
427
  private mutable(id: string): ReviewSession {
390
428
  const session = this.sessionGet(id);
429
+
391
430
  if (session.status === "resolved")
392
431
  throw new DaemonError("resolved", `session ${id} is already resolved`);
432
+
393
433
  return session;
394
434
  }
395
435
  }
@@ -404,5 +444,6 @@ function isLiveDiffSession(session: ReviewSession): boolean {
404
444
  /** Convenience for adapters: map a resolved session to the agent contract. */
405
445
  export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
406
446
  if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
447
+
407
448
  return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
408
449
  }
package/src/client.ts CHANGED
@@ -74,9 +74,11 @@ export class DaemonClient implements SessionClient {
74
74
  const home = options.home ?? cueloopHome();
75
75
  const path = socketPath(home);
76
76
  const client = new DaemonClient();
77
+
77
78
  client.role = options.role ?? "owner";
78
79
  try {
79
80
  await client.dial(path);
81
+
80
82
  return client;
81
83
  } catch (err) {
82
84
  if (!options.autostart) throw err;
@@ -88,9 +90,11 @@ export class DaemonClient implements SessionClient {
88
90
  // socket exists, and giving up early looks to callers like a broken daemon.
89
91
  const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
90
92
  let lastError: unknown;
93
+
91
94
  while (Date.now() < deadline) {
92
95
  try {
93
96
  await client.dial(path);
97
+
94
98
  return client;
95
99
  } catch (err) {
96
100
  lastError = err;
@@ -102,6 +106,7 @@ export class DaemonClient implements SessionClient {
102
106
 
103
107
  private async dial(path: string): Promise<void> {
104
108
  const buffer = new LineBuffer();
109
+
105
110
  this.socket = await Bun.connect({
106
111
  unix: path,
107
112
  socket: {
@@ -130,11 +135,13 @@ export class DaemonClient implements SessionClient {
130
135
 
131
136
  onEvent(listener: (event: EventFrame) => void): () => void {
132
137
  this.eventListeners.add(listener);
138
+
133
139
  return () => this.eventListeners.delete(listener);
134
140
  }
135
141
 
136
142
  private routeInboundFrame(line: string): void {
137
143
  let frame: Response | EventFrame;
144
+
138
145
  try {
139
146
  frame = JSON.parse(line) as Response | EventFrame;
140
147
  } catch {
@@ -142,9 +149,11 @@ export class DaemonClient implements SessionClient {
142
149
  }
143
150
  if ("event" in frame) {
144
151
  for (const listener of this.eventListeners) listener(frame);
152
+
145
153
  return;
146
154
  }
147
155
  const pendingRequest = this.pending.get(frame.id);
156
+
148
157
  if (!pendingRequest) return;
149
158
  this.pending.delete(frame.id);
150
159
  if (frame.error)
@@ -155,11 +164,13 @@ export class DaemonClient implements SessionClient {
155
164
  request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
156
165
  if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
157
166
  const id = this.nextId++;
167
+
158
168
  return new Promise<T>((resolve, reject) => {
159
169
  const timer = setTimeout(() => {
160
170
  this.pending.delete(id);
161
171
  reject(new Error(`request ${method} timed out`));
162
172
  }, timeoutMs);
173
+
163
174
  this.pending.set(id, {
164
175
  resolve: (value) => {
165
176
  clearTimeout(timer);
@@ -268,6 +279,7 @@ export class DaemonClientError extends Error {
268
279
 
269
280
  function spawnDaemon(home: string): void {
270
281
  const entry = new URL("./main.ts", import.meta.url).pathname;
282
+
271
283
  Bun.spawn([process.execPath, "run", entry], {
272
284
  env: { ...process.env, CUELOOP_HOME: home },
273
285
  stdio: ["ignore", "ignore", "ignore"],
@@ -15,6 +15,7 @@ import type { Artifact } from "@cueloop/schema";
15
15
 
16
16
  function git(args: string[], cwd: string): void {
17
17
  const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
18
+
18
19
  if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
19
20
  }
20
21
 
@@ -45,6 +46,7 @@ async function openDiffSession() {
45
46
  const workspace = await resolveWorkspace(repo);
46
47
  const diff = await workingTreeDiff(repo);
47
48
  const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
49
+
48
50
  return core.sessionCreate({ workspace, artifact });
49
51
  }
50
52
 
@@ -52,6 +54,7 @@ describe("session.refreshDiff", () => {
52
54
  test("re-captures the working tree and updates the artifact when the patch changed", async () => {
53
55
  // Given an open diff session whose working tree then gains a new change
54
56
  const session = await openDiffSession();
57
+
55
58
  writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
56
59
 
57
60
  // When the diff is refreshed
@@ -60,8 +63,10 @@ describe("session.refreshDiff", () => {
60
63
  // Then the change is reported and the stored artifact carries the fresh patch and files
61
64
  expect(result.changed).toBe(true);
62
65
  const refreshed = core.sessionGet(session.id);
66
+
63
67
  expect(refreshed.artifact.content).toContain("+export const a = 2;");
64
68
  const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
69
+
65
70
  expect(modified.newContents).toBe("export const a = 2;\n");
66
71
  });
67
72
 
@@ -69,6 +74,7 @@ describe("session.refreshDiff", () => {
69
74
  // Given an open diff session and a listener for session events
70
75
  const session = await openDiffSession();
71
76
  const events: string[] = [];
77
+
72
78
  core.onEvent((event) => events.push(event.event));
73
79
 
74
80
  // When the diff is refreshed without any working-tree change
@@ -83,6 +89,7 @@ describe("session.refreshDiff", () => {
83
89
  // Given an open diff session, a listener, and a fresh working-tree change
84
90
  const session = await openDiffSession();
85
91
  const events: string[] = [];
92
+
86
93
  core.onEvent((event) => events.push(event.event));
87
94
  writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
88
95
 
@@ -110,8 +117,10 @@ describe("session.refreshDiff", () => {
110
117
  test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
111
118
  // Given a diff session that has been resolved
112
119
  const session = await openDiffSession();
120
+
113
121
  core.sessionResolve(session.id, "approve", "");
114
122
  const resolvedContent = core.sessionGet(session.id).artifact.content;
123
+
115
124
  writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
116
125
 
117
126
  // When a refresh is attempted on the resolved session
@@ -123,6 +132,7 @@ describe("session.refreshDiff", () => {
123
132
  test("overlapping refreshes settle to one coherent artifact without error", async () => {
124
133
  // Given a live diff session with a fresh working-tree change
125
134
  const session = await openDiffSession();
135
+
126
136
  writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
127
137
 
128
138
  // When two refreshes run concurrently over the same session
@@ -150,6 +160,7 @@ describe("the fs watcher drives hot-reload", () => {
150
160
  // Watchers can be slow under CI contention, so poll generously.
151
161
  const deadline = Date.now() + 8_000;
152
162
  let content = core.sessionGet(session.id).artifact.content;
163
+
153
164
  while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
154
165
  await Bun.sleep(100);
155
166
  content = core.sessionGet(session.id).artifact.content;
@@ -20,6 +20,7 @@ const DIFF_REFRESH_DEBOUNCE_MS = 300;
20
20
  */
21
21
  function isIgnoredWatchPath(relativePath: string): boolean {
22
22
  const segments = relativePath.split(sep);
23
+
23
24
  return segments.includes(".git") || segments.includes("node_modules");
24
25
  }
25
26
 
@@ -44,11 +45,14 @@ export class DiffWatcher {
44
45
  /** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
45
46
  trackDiffRepo(repoRoot: string, sessionId: string): void {
46
47
  const existing = this.repoWatches.get(repoRoot);
48
+
47
49
  if (existing) {
48
50
  existing.sessionIds.add(sessionId);
51
+
49
52
  return;
50
53
  }
51
54
  let handle: FSWatcher;
55
+
52
56
  try {
53
57
  handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
54
58
  if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
@@ -70,6 +74,7 @@ export class DiffWatcher {
70
74
  /** Drop one diff session; close the repo watch once no diff session tracks it. */
71
75
  untrackDiffRepo(repoRoot: string, sessionId: string): void {
72
76
  const repoWatch = this.repoWatches.get(repoRoot);
77
+
73
78
  if (!repoWatch) return;
74
79
  repoWatch.sessionIds.delete(sessionId);
75
80
  if (repoWatch.sessionIds.size > 0) return;
@@ -80,6 +85,7 @@ export class DiffWatcher {
80
85
 
81
86
  private scheduleRepoRefresh(repoRoot: string): void {
82
87
  const repoWatch = this.repoWatches.get(repoRoot);
88
+
83
89
  if (!repoWatch) return;
84
90
  if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
85
91
  repoWatch.debounce = setTimeout(() => {
@@ -20,6 +20,7 @@ function makeStub(name: string, paneAlive = false): { binPath: string; logPath:
20
20
  const logPath = join(dir, `${name}.log`);
21
21
  const binPath = join(dir, `${name}.sh`);
22
22
  const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
23
+
23
24
  writeFileSync(
24
25
  binPath,
25
26
  `#!/bin/sh
@@ -32,11 +33,13 @@ exit 0
32
33
  `,
33
34
  );
34
35
  chmodSync(binPath, 0o755);
36
+
35
37
  return { binPath, logPath };
36
38
  }
37
39
 
38
40
  function readLines(logPath: string): string[] {
39
41
  if (!existsSync(logPath)) return [];
42
+
40
43
  return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
41
44
  }
42
45
 
@@ -46,6 +49,7 @@ function fakePersistence(initial: HerdrTabHandle | null = null): {
46
49
  saved: () => HerdrTabHandle | null;
47
50
  } {
48
51
  let stored = initial;
52
+
49
53
  return {
50
54
  persistence: {
51
55
  herdrGetTab: async () => stored,
@@ -109,6 +113,7 @@ describe("openHerdrPane", () => {
109
113
  // Arrange
110
114
  const logPath = join(dir, "nopane.log");
111
115
  const binPath = join(dir, "nopane.sh");
116
+
112
117
  writeFileSync(
113
118
  binPath,
114
119
  `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
@@ -170,6 +175,7 @@ describe("openHerdrPaneForReview", () => {
170
175
 
171
176
  // Assert
172
177
  const lines = readLines(stub.logPath);
178
+
173
179
  expect(lines).toContain("pane get w9:p9");
174
180
  expect(lines).toContain("tab focus w9:t9");
175
181
  expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
@@ -225,6 +231,7 @@ describe("openHerdrPaneForReview", () => {
225
231
 
226
232
  // Assert
227
233
  const lines = readLines(stub.logPath);
234
+
228
235
  expect(lines).toContain("pane get old:p");
229
236
  expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
230
237
  expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });