@cueloop/daemon 0.1.0-alpha.91 → 0.1.0-alpha.93

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.91",
3
+ "version": "0.1.0-alpha.93",
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": {
@@ -39,8 +39,8 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@cueloop/extension-api": "0.1.0-alpha.91",
43
- "@cueloop/schema": "0.1.0-alpha.91",
42
+ "@cueloop/extension-api": "0.1.0-alpha.93",
43
+ "@cueloop/schema": "0.1.0-alpha.93",
44
44
  "@pierre/diffs": "^1.4.1",
45
45
  "effect": "^4.0.0-rc.116",
46
46
  "smol-toml": "^1.8.0",
package/src/client.ts CHANGED
@@ -86,6 +86,7 @@ const HerdrThreadSurfaceResultSchema = v.nullable(
86
86
  tabId: v.string(),
87
87
  paneId: v.string(),
88
88
  mode: v.optional(v.picklist(["tab", "pane"])),
89
+ layout: v.optional(v.literal("pair")),
89
90
  }),
90
91
  );
91
92
  const GhosttyThreadSurfaceResultSchema = v.nullable(v.object({ terminalId: v.string() }));
@@ -163,6 +163,9 @@ export class DiffWatcher {
163
163
  if (isRefChange(filename)) this.scheduleRepoRefresh(repoRoot);
164
164
  });
165
165
  }
166
+
167
+ // Reconcile changes made while the platform activates its new watch.
168
+ this.scheduleRepoRefresh(repoRoot);
166
169
  }
167
170
 
168
171
  private startJjPoll(repoWatch: RepoWatch, repoRoot: string, sessionId: string): void {
@@ -5,6 +5,7 @@ import { herdrThreadSurfacesPath } from "./paths";
5
5
  /** Herdr-native handle, stored outside the canonical Thread. */
6
6
  export interface HerdrThreadSurfaceHandle {
7
7
  mode?: "tab" | "pane";
8
+ layout?: "pair";
8
9
  tabId: string;
9
10
  paneId: string;
10
11
  }
@@ -13,6 +14,7 @@ const HerdrThreadSurfaceHandleSchema = v.object({
13
14
  tabId: v.string(),
14
15
  paneId: v.string(),
15
16
  mode: v.optional(v.picklist(["tab", "pane"])),
17
+ layout: v.optional(v.literal("pair")),
16
18
  });
17
19
  const HerdrThreadSurfaceMapSchema = v.record(v.string(), v.unknown());
18
20
 
@@ -358,6 +358,60 @@ describe("openHerdrThreadSurface", () => {
358
358
  expect(store.saved()).toEqual({ tabId: "w9:t9", paneId: "w9:p9" });
359
359
  });
360
360
 
361
+ test("opens a tab when the recorded live surface is a pane", async () => {
362
+ const stub = makeStub("pane-to-tab", true);
363
+ const herdrEnvironment = {
364
+ HERDR_ENV: "1",
365
+ HERDR_PANE_ID: "w1:p1",
366
+ HERDR_BIN_PATH: stub.binPath,
367
+ };
368
+ const store = fakePersistence({ mode: "pane", tabId: "w1:t1", paneId: "w1:p3" });
369
+
370
+ expect(
371
+ await openHerdrThreadSurface(
372
+ newSession(),
373
+ store.persistence,
374
+ herdrEnvironment,
375
+ "tab",
376
+ "pair",
377
+ ),
378
+ ).toBe("opened");
379
+ expect(readLines(stub.logPath)).toEqual([
380
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
381
+ "pane send-text w1:p2 cueloop pair ses_new1",
382
+ "pane send-keys w1:p2 enter",
383
+ "pane close w1:p3",
384
+ ]);
385
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2", layout: "pair" });
386
+ });
387
+
388
+ test("replaces a generic review tab with the pairing layout", async () => {
389
+ const stub = makeStub("review-to-pair", true);
390
+ const herdrEnvironment = {
391
+ HERDR_ENV: "1",
392
+ HERDR_PANE_ID: "w1:p1",
393
+ HERDR_BIN_PATH: stub.binPath,
394
+ };
395
+ const store = fakePersistence({ tabId: "w9:t9", paneId: "w9:p9" });
396
+
397
+ expect(
398
+ await openHerdrThreadSurface(
399
+ newSession(),
400
+ store.persistence,
401
+ herdrEnvironment,
402
+ "tab",
403
+ "pair",
404
+ ),
405
+ ).toBe("opened");
406
+ expect(readLines(stub.logPath)).toEqual([
407
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
408
+ "pane send-text w1:p2 cueloop pair ses_new1",
409
+ "pane send-keys w1:p2 enter",
410
+ "tab close w9:t9",
411
+ ]);
412
+ expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2", layout: "pair" });
413
+ });
414
+
361
415
  test("a persistence failure never escapes - review creation stays best-effort", async () => {
362
416
  const stub = makeStub("gated-persist-fail");
363
417
  const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
@@ -60,6 +60,7 @@ export interface OpenHerdrThreadTabOptions {
60
60
  binPath: string;
61
61
  label: string;
62
62
  workspaceId?: string;
63
+ layout?: "pair";
63
64
  }
64
65
 
65
66
  /** Daemon-owned native handles, separate from canonical Thread records. */
@@ -72,7 +73,7 @@ export interface HerdrThreadSurfacePersistence {
72
73
  export function openHerdrThreadTab(
73
74
  options: OpenHerdrThreadTabOptions,
74
75
  ): HerdrThreadSurfaceHandle | null {
75
- const { sessionId, cwd, binPath, label, workspaceId } = options;
76
+ const { sessionId, cwd, binPath, label, workspaceId, layout } = options;
76
77
 
77
78
  try {
78
79
  const created = runHerdrCommand(
@@ -99,13 +100,13 @@ export function openHerdrThreadTab(
99
100
 
100
101
  if (!paneId || !tabId) return null;
101
102
 
102
- if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
103
- closeUnlaunchedHerdrSurface(binPath, "tab", tabId);
103
+ if (!sendCueloopThreadCommand(binPath, paneId, sessionId, layout)) {
104
+ closeHerdrThreadSurface(binPath, "tab", tabId);
104
105
 
105
106
  return null;
106
107
  }
107
108
 
108
- return { tabId, paneId };
109
+ return { tabId, paneId, ...(layout && { layout }) };
109
110
  } catch {
110
111
  return null;
111
112
  }
@@ -118,13 +119,14 @@ export interface OpenHerdrThreadPaneOptions {
118
119
  binPath: string;
119
120
  sourcePaneId: string;
120
121
  tabId: string;
122
+ layout?: "pair";
121
123
  }
122
124
 
123
125
  /** Split the calling Herdr pane right at 50 percent and launch cueloop in it. */
124
126
  export function openHerdrThreadPane(
125
127
  options: OpenHerdrThreadPaneOptions,
126
128
  ): HerdrThreadSurfaceHandle | null {
127
- const { sessionId, cwd, binPath, sourcePaneId, tabId } = options;
129
+ const { sessionId, cwd, binPath, sourcePaneId, tabId, layout } = options;
128
130
 
129
131
  try {
130
132
  const created = runHerdrCommand(
@@ -149,19 +151,19 @@ export function openHerdrThreadPane(
149
151
  const paneId = parsed.success ? parsed.output.result?.pane?.pane_id : undefined;
150
152
 
151
153
  if (!paneId) return null;
152
- if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
153
- closeUnlaunchedHerdrSurface(binPath, "pane", paneId);
154
+ if (!sendCueloopThreadCommand(binPath, paneId, sessionId, layout)) {
155
+ closeHerdrThreadSurface(binPath, "pane", paneId);
154
156
 
155
157
  return null;
156
158
  }
157
159
 
158
- return { mode: "pane", tabId, paneId };
160
+ return { mode: "pane", tabId, paneId, ...(layout && { layout }) };
159
161
  } catch {
160
162
  return null;
161
163
  }
162
164
  }
163
165
 
164
- function closeUnlaunchedHerdrSurface(binPath: string, kind: "tab" | "pane", id: string): void {
166
+ function closeHerdrThreadSurface(binPath: string, kind: "tab" | "pane", id: string): void {
165
167
  try {
166
168
  runHerdrCommand([binPath, kind, "close", id], {
167
169
  stdout: "ignore",
@@ -173,8 +175,14 @@ function closeUnlaunchedHerdrSurface(binPath: string, kind: "tab" | "pane", id:
173
175
  }
174
176
  }
175
177
 
176
- function sendCueloopThreadCommand(binPath: string, paneId: string, sessionId: string): boolean {
177
- const typed = runHerdrCommand([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
178
+ function sendCueloopThreadCommand(
179
+ binPath: string,
180
+ paneId: string,
181
+ sessionId: string,
182
+ layout?: "pair",
183
+ ): boolean {
184
+ const command = layout === "pair" ? `cueloop pair ${sessionId}` : `cueloop ${sessionId}`;
185
+ const typed = runHerdrCommand([binPath, "pane", "send-text", paneId, command], {
178
186
  stdout: "ignore",
179
187
  stderr: "ignore",
180
188
  timeout: HERDR_SPAWN_TIMEOUT_MS,
@@ -292,6 +300,7 @@ export async function openHerdrThreadSurface(
292
300
  persistence: HerdrThreadSurfacePersistence,
293
301
  env: HerdrEnv = process.env,
294
302
  mode: HerdrThreadSurface = loadHerdrThreadSurface(),
303
+ layout?: "pair",
295
304
  ): Promise<ThreadSurfaceOpenStatus> {
296
305
  const herdr = detectHerdr(env);
297
306
 
@@ -300,7 +309,12 @@ export async function openHerdrThreadSurface(
300
309
  if (mode === "none") return "disabled";
301
310
  const recorded = await recallHerdrThreadSurface(persistence, session.id);
302
311
 
303
- if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
312
+ if (
313
+ recorded &&
314
+ (recorded.mode ?? "tab") === mode &&
315
+ recorded.layout === layout &&
316
+ herdrPaneAlive(herdr.binPath, recorded.paneId)
317
+ ) {
304
318
  return focusHerdrThreadSurface(herdr.binPath, recorded) ? "focused" : "failed";
305
319
  }
306
320
  const cwd = session.artifact.meta.cwd ?? session.workspace.repoRoot;
@@ -313,6 +327,7 @@ export async function openHerdrThreadSurface(
313
327
  binPath: herdr.binPath,
314
328
  sourcePaneId: herdr.paneId,
315
329
  tabId: env.HERDR_TAB_ID,
330
+ layout,
316
331
  })
317
332
  : null
318
333
  : openHerdrThreadTab({
@@ -321,9 +336,17 @@ export async function openHerdrThreadSurface(
321
336
  binPath: herdr.binPath,
322
337
  label: session.artifact.meta.title ?? session.id,
323
338
  workspaceId: env.HERDR_WORKSPACE_ID,
339
+ layout,
324
340
  });
325
341
 
326
342
  if (!opened) return "failed";
343
+ if (recorded && recorded.paneId !== opened.paneId) {
344
+ closeHerdrThreadSurface(
345
+ herdr.binPath,
346
+ recorded.mode === "pane" ? "pane" : "tab",
347
+ recorded.mode === "pane" ? recorded.paneId : recorded.tabId,
348
+ );
349
+ }
327
350
  await rememberHerdrThreadSurface(persistence, session.id, opened);
328
351
 
329
352
  return "opened";
@@ -41,6 +41,16 @@ describe("listProjectFiles", () => {
41
41
  expect(files).toEqual(["a.ts", "m.ts", "z.ts"]);
42
42
  });
43
43
 
44
+ test("omits a tracked file deleted from the working tree", async () => {
45
+ writeFileSync(join(repo, "keep.ts"), "export const keep = true;\n");
46
+ writeFileSync(join(repo, "gone.ts"), "export const gone = true;\n");
47
+ git(["add", "."], repo);
48
+ git(["commit", "-qm", "init"], repo);
49
+ rmSync(join(repo, "gone.ts"));
50
+
51
+ expect(await listProjectFiles(repo)).toEqual(["keep.ts"]);
52
+ });
53
+
44
54
  test("returns [] for a directory that is not a git repo", async () => {
45
55
  // Given a plain directory with a file but no git repo
46
56
  const plain = mkdtempSync(join(tmpdir(), "cueloop-project-files-plain-"));
@@ -8,20 +8,32 @@ const MAX_FILES = 5000;
8
8
  /** Ceiling on a served file; the panel renders source, not multi-megabyte blobs. */
9
9
  const MAX_FILE_BYTES = 1024 * 1024;
10
10
 
11
- /** Tracked, repo-relative paths from `git ls-files`, sorted ascending and capped; [] on any failure or non-repo. */
11
+ async function gitFileList(repoRoot: string, args: string[]): Promise<string[] | null> {
12
+ const gitProcess = Bun.spawn(["git", "-C", repoRoot, "ls-files", "-z", ...args], {
13
+ stdout: "pipe",
14
+ stderr: "ignore",
15
+ });
16
+ const stdout = await new Response(gitProcess.stdout).text();
17
+
18
+ return (await gitProcess.exited) === 0 ? stdout.split("\0").filter(Boolean) : null;
19
+ }
20
+
21
+ /** Existing tracked paths, sorted ascending and capped; [] on failure or a non-repo. */
12
22
  export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
13
23
  if (repoRoot === undefined || repoRoot.length === 0) return [];
14
24
  try {
15
- const gitProcess = Bun.spawn(["git", "-C", repoRoot, "ls-files", "-z"], {
16
- stdout: "pipe",
17
- stderr: "ignore",
18
- });
19
- const stdout = await new Response(gitProcess.stdout).text();
25
+ const [tracked, deleted] = await Promise.all([
26
+ gitFileList(repoRoot, ["--cached"]),
27
+ gitFileList(repoRoot, ["--deleted"]),
28
+ ]);
20
29
 
21
- if ((await gitProcess.exited) !== 0) return [];
22
- const paths = stdout.split("\0").filter((path) => path.length > 0);
30
+ if (tracked === null || deleted === null) return [];
31
+ const missing = new Set(deleted);
23
32
 
24
- return paths.toSorted().slice(0, MAX_FILES);
33
+ return tracked
34
+ .filter((path) => !missing.has(path))
35
+ .toSorted()
36
+ .slice(0, MAX_FILES);
25
37
  } catch {
26
38
  return [];
27
39
  }
package/src/server.ts CHANGED
@@ -550,6 +550,7 @@ export class DaemonServer {
550
550
  tabId: params.tabId,
551
551
  paneId: params.paneId,
552
552
  mode: params.mode,
553
+ layout: params.layout,
553
554
  });
554
555
 
555
556
  return {};
package/src/validate.ts CHANGED
@@ -353,6 +353,7 @@ export const Params = {
353
353
  tabId: NonEmpty,
354
354
  paneId: NonEmpty,
355
355
  mode: v.optional(v.picklist(["tab", "pane"])),
356
+ layout: v.optional(v.literal("pair")),
356
357
  }),
357
358
  "ghostty.getThreadSurface": v.object({ id: SessionId }),
358
359
  "ghostty.setThreadSurface": v.object({ id: SessionId, terminalId: NonEmpty }),