cueloop 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.102",
3
+ "version": "0.1.0-alpha.103",
4
4
  "description": "Terminal review surface for coding agents: annotate plans and diffs, return a message, send structured feedback back",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -30,11 +30,11 @@
30
30
  "build:binary": "bun build --compile --bytecode --format=esm --minify --external cpu-features ./src/main.ts --outfile dist/cueloop"
31
31
  },
32
32
  "dependencies": {
33
- "@cueloop/adapters": "0.1.0-alpha.102",
34
- "@cueloop/client": "0.1.0-alpha.102",
35
- "@cueloop/daemon": "0.1.0-alpha.102",
36
- "@cueloop/extension-api": "0.1.0-alpha.102",
37
- "@cueloop/schema": "0.1.0-alpha.102",
33
+ "@cueloop/adapters": "0.1.0-alpha.103",
34
+ "@cueloop/client": "0.1.0-alpha.103",
35
+ "@cueloop/daemon": "0.1.0-alpha.103",
36
+ "@cueloop/extension-api": "0.1.0-alpha.103",
37
+ "@cueloop/schema": "0.1.0-alpha.103",
38
38
  "@modelcontextprotocol/server": "2.1.0",
39
39
  "@valibot/to-json-schema": "1.8.0",
40
40
  "valibot": "^1.5.0"
@@ -19,6 +19,7 @@ export async function actionsCommand(argv: string[]): Promise<number> {
19
19
 
20
20
  return 2;
21
21
  }
22
+
22
23
  const role = stringFlag(flags, "role") === "agent" ? "agent" : undefined;
23
24
  const repoRoot = await repoRootForSession(stringFlag(flags, "session"), role);
24
25
  const actions = loadConfig({ repoRoot }).actions;
@@ -39,6 +40,7 @@ async function repoRootForSession(
39
40
  role: "agent" | undefined,
40
41
  ): Promise<string> {
41
42
  if (sessionId === undefined) return process.cwd();
43
+
42
44
  const client = await DaemonClient.connect({ autostart: true, role });
43
45
 
44
46
  try {
@@ -1,18 +1,17 @@
1
1
  import { createPiHarness } from "@cueloop/adapters/pi/durable-harness";
2
2
  import { cueloopHome } from "@cueloop/daemon";
3
+ import { createThreadHarness } from "@cueloop/adapters/thread-harness";
3
4
  import { createFxHarness } from "@cueloop/adapters/fx/harness";
4
5
  import { loadConfig } from "@cueloop/client/config";
5
6
  import type { AgentHarnessAdapter } from "@cueloop/schema";
6
7
 
7
- /** Select a locally installed harness; unsupported providers fail before a daemon starts. */
8
- export function configuredAgentHarness(): AgentHarnessAdapter | undefined {
9
- const id = process.env.CUELOOP_AGENT_HARNESS ?? "pi";
8
+ /** Explicit startup selection overrides the owner and workspace preferences. */
9
+ export function configuredAgentHarnessId(repoRoot?: string): "pi" | "fx" {
10
+ const id = process.env.CUELOOP_AGENT_HARNESS ?? loadConfig({ repoRoot }).thread.harness;
10
11
 
11
- if (id === "pi") return createPiHarness({ home: cueloopHome() });
12
+ if (id !== "pi" && id !== "fx") throw new Error(`Thread agent harness is not installed: ${id}`);
12
13
 
13
- if (id !== "fx") throw new Error(`Thread agent harness is not installed: ${id}`);
14
-
15
- return fxHarness();
14
+ return id;
16
15
  }
17
16
 
18
17
  /** Resolve the experimental Thread agent flag from user and workspace TOML. */
@@ -28,5 +27,8 @@ export function installedAgentHarnesses() {
28
27
  }
29
28
 
30
29
  function fxHarness(): AgentHarnessAdapter {
31
- return createFxHarness({ command: [process.env.CUELOOP_FX_EXECUTABLE ?? "fx", "acp"] });
30
+ return createThreadHarness({
31
+ home: cueloopHome(),
32
+ backend: createFxHarness({ command: [process.env.CUELOOP_FX_EXECUTABLE ?? "fx", "acp"] }),
33
+ });
32
34
  }
package/src/args.ts CHANGED
@@ -18,6 +18,7 @@ export function parseArgs(argv: string[], booleanFlags: readonly string[] = []):
18
18
  positional.push(argument);
19
19
  continue;
20
20
  }
21
+
21
22
  const equalsIndex = argument.indexOf("=");
22
23
 
23
24
  if (equalsIndex !== -1) {
@@ -38,6 +38,7 @@ test("a Codex reply is delivered without an MCP transport", async () => {
38
38
  );
39
39
 
40
40
  if (opened.operation !== "open") throw new Error("expected open Thread");
41
+
41
42
  const previousCodexBin = process.env.CUELOOP_CODEX_BIN;
42
43
 
43
44
  // A reused PID belonging to this test process must not impersonate the worker.
@@ -49,8 +50,10 @@ test("a Codex reply is delivered without an MCP transport", async () => {
49
50
  import.meta.dir + "/main.ts",
50
51
  "codex-delivery-worker",
51
52
  ]);
53
+
52
54
  if (previousCodexBin === undefined) delete process.env.CUELOOP_CODEX_BIN;
53
55
  else process.env.CUELOOP_CODEX_BIN = previousCodexBin;
56
+
54
57
  expect(worker).toBeDefined();
55
58
  const deadline = Date.now() + 5000;
56
59
 
@@ -17,6 +17,7 @@ function claimWorker(path: string): boolean {
17
17
  return true;
18
18
  } catch (error) {
19
19
  if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
20
+
20
21
  const before = statSync(path);
21
22
  const owner = Number(readFileSync(path, "utf8"));
22
23
 
@@ -39,7 +40,9 @@ function claimWorker(path: string): boolean {
39
40
  } else if (Date.now() - before.mtimeMs < 5_000) {
40
41
  return false;
41
42
  }
43
+
42
44
  if (statSync(path).ino !== before.ino) return false;
45
+
43
46
  unlinkSync(path);
44
47
  }
45
48
  }
@@ -53,6 +56,7 @@ export async function codexDeliveryWorkerCommand(): Promise<number> {
53
56
  const lockPath = join(home, "codex-delivery-worker.pid");
54
57
 
55
58
  if (!claimWorker(lockPath)) return 0;
59
+
56
60
  const sessions = createCodexSessionRegistry(home);
57
61
  const delivery = createCodexDeliveryService({
58
62
  home,
@@ -70,6 +74,7 @@ export async function codexDeliveryWorkerCommand(): Promise<number> {
70
74
  }
71
75
  } finally {
72
76
  delivery.stop();
77
+
73
78
  if (readFileSync(lockPath, "utf8") === String(process.pid)) unlinkSync(lockPath);
74
79
  }
75
80
 
@@ -41,6 +41,7 @@ export function createCodexMcpServer(home = cueloopHome()): McpServer {
41
41
  "cueloop Codex session is not authorized; trust the plugin hooks and restart Codex",
42
42
  );
43
43
  }
44
+
44
45
  const { hookToken: _hookToken, ...threadInput } = input;
45
46
  const result = await runHarnessBridge(
46
47
  { operation: "open", harness: "codex", ...threadInput },
@@ -8,20 +8,26 @@ export async function daemonCommand(argv: string[]): Promise<number> {
8
8
  let idleExitMs: number | undefined = 0;
9
9
 
10
10
  if (argv.includes("--autostart")) idleExitMs = envIdle ? Number(envIdle) : undefined;
11
- const { configuredAgentHarness, installedAgentHarnesses, threadAgentEnabled } =
11
+
12
+ const { configuredAgentHarnessId, installedAgentHarnesses, threadAgentEnabled } =
12
13
  await import("./agent-harness");
14
+ const adapters = installedAgentHarnesses();
13
15
  let relay: OwnerAgentRelay | undefined;
14
16
  const server = new DaemonServer({
15
17
  onEvent: (event) => {
16
18
  relay?.notify(event);
19
+
17
20
  if (event.event !== "agent.updated") relay?.update(server.core.sessionList());
18
21
  },
19
22
  idleExitMs,
23
+ agentHarnessDefault: () => configuredAgentHarnessId(),
20
24
  threadAgent: {
21
25
  enabled: true,
22
26
  enabledForThread: (thread) => threadAgentEnabled(thread.workspace.repoRoot),
23
- adapter: configuredAgentHarness(),
24
- adapters: installedAgentHarnesses(),
27
+ adapter: adapters[configuredAgentHarnessId()],
28
+ adapters,
29
+ defaultHarnessForThread: (thread) =>
30
+ adapters[configuredAgentHarnessId(thread.workspace.repoRoot)],
25
31
  },
26
32
  });
27
33
  const path = server.start();
@@ -31,6 +37,7 @@ export async function daemonCommand(argv: string[]): Promise<number> {
31
37
 
32
38
  return 1;
33
39
  }
40
+
34
41
  server.resumeAgents();
35
42
  relay = new OwnerAgentRelay({
36
43
  home: server.home,
@@ -21,6 +21,7 @@ function signalDaemonByProcessId(home: string): boolean {
21
21
  const path = pidPath(home);
22
22
 
23
23
  if (!existsSync(path)) return false;
24
+
24
25
  let raw: string;
25
26
 
26
27
  try {
@@ -31,6 +32,7 @@ function signalDaemonByProcessId(home: string): boolean {
31
32
  const parsed = v.safeParse(ProcessIdSchema, raw);
32
33
 
33
34
  if (!parsed.success) return false;
35
+
34
36
  try {
35
37
  process.kill(parsed.output);
36
38
 
@@ -47,6 +49,7 @@ async function waitForSocketGone(home: string): Promise<boolean> {
47
49
 
48
50
  while (Date.now() < deadline) {
49
51
  if (!existsSync(path)) return true;
52
+
50
53
  await Bun.sleep(50);
51
54
  }
52
55
 
@@ -62,6 +65,7 @@ async function waitForSocketGone(home: string): Promise<boolean> {
62
65
  */
63
66
  export async function stopDaemon(home = cueloopHome()): Promise<boolean> {
64
67
  if (!existsSync(socketPath(home))) return false;
68
+
65
69
  try {
66
70
  const client = await DaemonClient.connect({ home, autostart: false });
67
71
 
@@ -83,6 +87,7 @@ export async function stopCommand(): Promise<number> {
83
87
 
84
88
  return 0;
85
89
  }
90
+
86
91
  const stopped = await stopDaemon(home);
87
92
 
88
93
  console.log(stopped ? "cueloop daemon stopped" : "cueloop daemon did not stop in time");
@@ -97,17 +97,22 @@ async function refreshDevSeed(client: DaemonClient, home: string): Promise<void>
97
97
  writeFileSync(versionFile, String(SEED_VERSION));
98
98
  }
99
99
 
100
- /** `cueloop dev`: seed an isolated dev home (refreshing a stale seed), then open the TUI on it. */
101
- export async function devCommand(): Promise<number> {
102
- const home = (process.env.CUELOOP_HOME ??= threadAgentEnabled(process.cwd())
100
+ export function configureDevHome(): string {
101
+ return (process.env.CUELOOP_HOME ??= threadAgentEnabled(process.cwd())
103
102
  ? join(process.cwd(), ".cueloop-dev")
104
103
  : join(homedir(), ".cueloop-dev"));
104
+ }
105
+
106
+ /** `cueloop dev`: seed an isolated dev home (refreshing a stale seed), then open the TUI on it. */
107
+ export async function devCommand(): Promise<number> {
108
+ const home = configureDevHome();
105
109
 
106
110
  let sessionId: string | undefined;
107
111
  const client = await DaemonClient.connect({ autostart: true });
108
112
 
109
113
  try {
110
114
  await refreshDevSeed(client, home);
115
+
111
116
  if (threadAgentEnabled(process.cwd()))
112
117
  sessionId = (await client.sessionList()).find(
113
118
  (thread) => thread.artifact.meta.title === "Read the repository",
@@ -0,0 +1,22 @@
1
+ import { expect, test } from "bun:test";
2
+ import { parseHarnessSelection } from "./harness-selection";
3
+
4
+ test("harness selection supports launcher and restart without stealing session flags", () => {
5
+ expect(parseHarnessSelection(["--harness", "fx"])).toEqual({ args: [], harness: "fx" });
6
+ expect(parseHarnessSelection(["restart", "--harness=pi"])).toEqual({
7
+ args: ["restart"],
8
+ harness: "pi",
9
+ });
10
+ expect(parseHarnessSelection(["--harness", "fx", "plan", "thread-id"])).toEqual({
11
+ args: ["plan", "thread-id"],
12
+ harness: "fx",
13
+ });
14
+ expect(parseHarnessSelection(["session", "bind-harness", "--harness", "codex"])).toEqual({
15
+ args: ["session", "bind-harness", "--harness", "codex"],
16
+ });
17
+ expect(() => parseHarnessSelection(["restart", "--harness"])).toThrow(
18
+ "--harness pi or --harness fx",
19
+ );
20
+ expect(() => parseHarnessSelection(["--harness=bad"])).toThrow("--harness pi or --harness fx");
21
+ expect(() => parseHarnessSelection(["--harness=pi", "--harness=fx"])).toThrow("only one");
22
+ });
@@ -0,0 +1,45 @@
1
+ import { DaemonClient } from "@cueloop/daemon/client";
2
+
3
+ /** Global harness flags never consume a session primitive's unrelated --harness argument. */
4
+ interface HarnessSelection {
5
+ args: string[];
6
+ harness?: "pi" | "fx";
7
+ }
8
+
9
+ export function parseHarnessSelection(args: readonly string[]): HarnessSelection {
10
+ const remaining = [...args];
11
+ const global = args[0]?.startsWith("--harness") || args[0] === "restart" || args[0] === "daemon";
12
+
13
+ if (!global) return { args: remaining };
14
+
15
+ const index = remaining.findIndex((arg) => arg === "--harness" || arg.startsWith("--harness="));
16
+
17
+ if (index < 0) return { args: remaining };
18
+
19
+ const flag = remaining[index]!;
20
+ const value = flag === "--harness" ? remaining[index + 1] : flag.slice("--harness=".length);
21
+
22
+ if (value !== "pi" && value !== "fx")
23
+ throw new Error("Thread harness selection requires --harness pi or --harness fx");
24
+
25
+ remaining.splice(index, flag === "--harness" ? 2 : 1);
26
+
27
+ if (remaining.some((arg) => arg === "--harness" || arg.startsWith("--harness=")))
28
+ throw new Error("Thread harness selection accepts only one --harness flag");
29
+
30
+ return { args: remaining, harness: value };
31
+ }
32
+
33
+ /** A running daemon must confirm the requested default before a launcher opens its UI. */
34
+ export async function assertDaemonHarness(harness: "pi" | "fx"): Promise<void> {
35
+ const client = await DaemonClient.connect({ autostart: true });
36
+
37
+ try {
38
+ const current = await client.ping();
39
+
40
+ if (current.agentHarness !== harness)
41
+ throw new Error(`Thread harness default mismatch. Run cueloop restart --harness ${harness}.`);
42
+ } finally {
43
+ client.close();
44
+ }
45
+ }
@@ -74,6 +74,7 @@ describe("cueloop install", () => {
74
74
  installRoot: root,
75
75
  runPackageCommand: async (args) => {
76
76
  commands.push(args);
77
+
77
78
  if (args[0] === "add") {
78
79
  const packageRoot = join(root, "node_modules", "example");
79
80
 
@@ -112,6 +113,7 @@ describe("cueloop install", () => {
112
113
  installRoot: root,
113
114
  runPackageCommand: async (args) => {
114
115
  commands.push(args);
116
+
115
117
  if (args.at(-1) === "example@2.0.0") {
116
118
  writeFileSync(
117
119
  join(root, "package.json"),
@@ -34,6 +34,7 @@ function previousPackageSpec(installRoot: string, name: string): string | null {
34
34
  const path = join(installRoot, "node_modules", name, "package.json");
35
35
 
36
36
  if (!existsSync(path)) return null;
37
+
37
38
  try {
38
39
  const parsed = v.safeParse(
39
40
  v.object({ version: v.pipe(v.string(), v.minLength(1)) }),
@@ -59,6 +60,7 @@ export async function installExtensionCommand(
59
60
 
60
61
  return 2;
61
62
  }
63
+
62
64
  const name = match[1]!;
63
65
  const spec = source!.slice("npm:".length);
64
66
  const installRoot = options.installRoot ?? extensionInstallRoot();
@@ -74,6 +76,7 @@ export async function installExtensionCommand(
74
76
  JSON.stringify({ name: "cueloop-extensions", private: true }, null, 2),
75
77
  );
76
78
  }
79
+
77
80
  const { exitCode, stderr } = await runCommand([
78
81
  "add",
79
82
  "--ignore-scripts",
@@ -87,6 +90,7 @@ export async function installExtensionCommand(
87
90
 
88
91
  return 1;
89
92
  }
93
+
90
94
  const discovered = discoverInstalledExtensionPackages(installRoot);
91
95
  const installed = discovered.packages.find((entry) => entry.name === name);
92
96
 
@@ -107,6 +111,7 @@ export async function installExtensionCommand(
107
111
 
108
112
  return 1;
109
113
  }
114
+
110
115
  console.log(
111
116
  `Installed ${name}. Restart cueloop and run 'cueloop restart' to load daemon capabilities.`,
112
117
  );
package/src/main.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * observers), `cueloop daemon` runs the daemon in the foreground.
9
9
  */
10
10
 
11
+ import { parseHarnessSelection, assertDaemonHarness } from "./harness-selection";
11
12
  import { basename, resolve } from "node:path";
12
13
  import { parseArgs, stringFlag, type ParsedArgs } from "./args";
13
14
  import {
@@ -29,7 +30,11 @@ import { openReview } from "@cueloop/daemon/thread-review";
29
30
  // The CLI owns adapter composition; the daemon package still starts independently.
30
31
  process.env.CUELOOP_DAEMON_ENTRY = import.meta.path;
31
32
 
32
- const argv = process.argv.slice(2);
33
+ const harnessSelection = parseHarnessSelection(process.argv.slice(2));
34
+ const argv = harnessSelection.args;
35
+
36
+ if (harnessSelection.harness) process.env.CUELOOP_AGENT_HARNESS = harnessSelection.harness;
37
+
33
38
  const cmd = argv[0];
34
39
 
35
40
  async function serveEntry(rest: string[]): Promise<number> {
@@ -116,20 +121,25 @@ const helpAliases = new Set(["-h", "--help", "help"]);
116
121
 
117
122
  async function main(): Promise<number> {
118
123
  if (cmd === undefined) return runTui();
124
+
119
125
  const handler = commandHandlers[cmd];
120
126
 
121
127
  if (handler !== undefined) return handler(argv.slice(1));
128
+
122
129
  if (versionAliases.has(cmd)) {
123
130
  console.log(CLI_VERSION);
124
131
 
125
132
  return 0;
126
133
  }
134
+
127
135
  if (helpAliases.has(cmd)) {
128
136
  printHelp();
129
137
 
130
138
  return 0;
131
139
  }
140
+
132
141
  if (cmd.startsWith("ses_")) return runTui(cmd);
142
+
133
143
  printHelp();
134
144
 
135
145
  return 2;
@@ -170,11 +180,13 @@ async function openReviewOfKind(
170
180
  const target = resolveOpenTarget(sessions, { match, selector });
171
181
 
172
182
  if (target.kind === "session") return runTui(target.sessionId, layout);
183
+
173
184
  if (target.kind === "no-pending" && emptyMessage !== undefined) {
174
185
  console.error(emptyMessage);
175
186
 
176
187
  return 1;
177
188
  }
189
+
178
190
  console.error(openTargetMessage(label, target));
179
191
 
180
192
  return 1;
@@ -223,6 +235,7 @@ async function prototypeCommand(argv: string[]): Promise<number> {
223
235
 
224
236
  return 1;
225
237
  }
238
+
226
239
  const client = await DaemonClient.connect({ autostart: true });
227
240
  const review = await openReview(client, {
228
241
  type: "prototype",
@@ -262,6 +275,7 @@ async function diffCommand(argv: string[]): Promise<number> {
262
275
  initialReview = await client.workbenchReview(cwd);
263
276
  } catch (error) {
264
277
  if (!(error instanceof DaemonClientError) || error.code !== "unknown_method") throw error;
278
+
265
279
  const session = await client.sessionWorkbench(cwd);
266
280
  const diff = await client.repoDiff(cwd);
267
281
 
@@ -286,12 +300,14 @@ async function pairCommand(argv: string[]): Promise<number> {
286
300
 
287
301
  if (sessionId !== undefined) {
288
302
  thread = await client.sessionGet(sessionId);
303
+
289
304
  if (thread.artifact.meta.workbench !== true || thread.status !== "pending") {
290
305
  throw new Error(`Thread ${sessionId} is not an open workbench`);
291
306
  }
292
307
  } else {
293
308
  thread = await client.sessionWorkbench(process.cwd());
294
309
  }
310
+
295
311
  if (parsed.flags["open-tab"] === true) {
296
312
  const { openHerdrThreadSurface } = await import("@cueloop/daemon/herdr-thread-surface");
297
313
 
@@ -300,6 +316,7 @@ async function pairCommand(argv: string[]): Promise<number> {
300
316
  } finally {
301
317
  client.close();
302
318
  }
319
+
303
320
  if (parsed.flags["no-tui"] === true || parsed.flags["open-tab"] === true) {
304
321
  console.log(
305
322
  JSON.stringify({ threadId: thread.id, startedAt, ...(openStatus && { openStatus }) }),
@@ -405,13 +422,27 @@ function printHelp(): void {
405
422
  " cueloop review-comment <id> add or update an agent PR finding (see --help)",
406
423
  " cueloop daemon run the daemon in the foreground",
407
424
  " cueloop stop stop the local daemon",
408
- " cueloop restart stop the local daemon and start a fresh one",
425
+ " cueloop restart [--harness pi|fx] restart with the preferred Thread harness",
409
426
  " cueloop dev open the TUI on an isolated home seeded with example threads",
410
427
  "",
428
+ " cueloop --harness pi|fx select the default for new Threads",
411
429
  " cueloop -v, --version print the installed version",
412
430
  " cueloop -h, --help print this help",
413
431
  ].join("\n"),
414
432
  );
415
433
  }
416
434
 
435
+ if (cmd === "dev") (await import("./dev-command")).configureDevHome();
436
+
437
+ if (
438
+ harnessSelection.harness &&
439
+ cmd !== "restart" &&
440
+ cmd !== "daemon" &&
441
+ cmd !== "--help" &&
442
+ cmd !== "-h" &&
443
+ cmd !== "--version" &&
444
+ cmd !== "-v"
445
+ )
446
+ await assertDaemonHarness(harnessSelection.harness);
447
+
417
448
  process.exit(await main());
@@ -94,6 +94,7 @@ export function resolveOpenTarget(sessions: Thread[], query: OpenTargetQuery): O
94
94
  .sort(newestFirst);
95
95
 
96
96
  if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
97
+
97
98
  if (substringMatches.length === 0) return { kind: "no-match", selector };
98
99
 
99
100
  return {
package/src/pr.ts CHANGED
@@ -65,7 +65,9 @@ function isReviewSeverity(value: string): value is ReviewSeverity {
65
65
 
66
66
  function reviewEvent(value: string): GitHubReviewEvent | undefined {
67
67
  if (value === "comment") return "COMMENT";
68
+
68
69
  if (value === "approve") return "APPROVE";
70
+
69
71
  if (value === "request-changes") return "REQUEST_CHANGES";
70
72
 
71
73
  return undefined;
@@ -75,6 +77,7 @@ function reviewSummary(event: GitHubReviewEvent, body?: string): string | undefi
75
77
  const trimmed = body?.trim();
76
78
 
77
79
  if (trimmed) return trimmed;
80
+
78
81
  if (event === "APPROVE") return undefined;
79
82
 
80
83
  return "Review submitted with cueloop.";
@@ -115,6 +118,7 @@ async function reanchorReviewComments(
115
118
  `finding ${annotation.id} is outdated or ambiguous - refresh the finding before posting`,
116
119
  );
117
120
  }
121
+
118
122
  const line = finding.side === "RIGHT" ? matched.newLine! : matched.oldLine!;
119
123
  let startLine: number | undefined;
120
124
 
@@ -124,6 +128,7 @@ async function reanchorReviewComments(
124
128
  `finding ${annotation.id} has no start anchor - refresh the finding before posting`,
125
129
  );
126
130
  }
131
+
127
132
  const matchedStart = resolveLine(finding.startAnchor);
128
133
 
129
134
  if (!matchedStart) {
@@ -131,6 +136,7 @@ async function reanchorReviewComments(
131
136
  `finding ${annotation.id} start is outdated or ambiguous - refresh the finding before posting`,
132
137
  );
133
138
  }
139
+
134
140
  startLine = finding.side === "RIGHT" ? matchedStart.newLine! : matchedStart.oldLine!;
135
141
 
136
142
  if (startLine >= line) {
@@ -174,11 +180,16 @@ function reviewFindingInput(flags: Record<string, string | boolean>): ReviewFind
174
180
  const suggestion = textFlag(flags, "suggestion", "suggestion-file");
175
181
 
176
182
  if (!body) throw new Error("--body or --body-file is required");
183
+
177
184
  if (side !== "LEFT" && side !== "RIGHT") throw new Error("--side must be LEFT or RIGHT");
185
+
178
186
  if (!isReviewSeverity(severity)) throw new Error("--severity must be p0, p1, or p2");
187
+
179
188
  if (!Number.isInteger(line) || line < 1) throw new Error("--line must be a positive integer");
189
+
180
190
  if (startLine !== undefined && (!Number.isInteger(startLine) || startLine < 1))
181
191
  throw new Error("--start-line must be a positive integer");
192
+
182
193
  if (suggestion?.trim() && side !== "RIGHT")
183
194
  throw new Error("suggestions must target the RIGHT side of a diff");
184
195
 
@@ -216,12 +227,14 @@ async function reviewFindingAnnotation(
216
227
  throw new Error(
217
228
  `${input.path}:${input.line} is not present on the ${input.side} side of this diff`,
218
229
  );
230
+
219
231
  const fileRange = fileRowRange(rows, input.path)!;
220
232
  const fileRows = rows.slice(fileRange.start, fileRange.end);
221
233
  let startAnchor: Annotation["anchor"] | undefined;
222
234
 
223
235
  if (input.startLine !== undefined) {
224
236
  if (input.startLine >= input.line) throw new Error("--start-line must be before --line");
237
+
225
238
  const startRowIndex = rows.findIndex(
226
239
  (candidate) =>
227
240
  candidate.file === input.path &&
@@ -234,8 +247,10 @@ async function reviewFindingAnnotation(
234
247
  throw new Error(
235
248
  `${input.path}:${input.startLine} is not present on the ${input.side} side of this diff`,
236
249
  );
250
+
237
251
  startAnchor = diffRowAnchor(fileRows, startRowIndex - fileRange.start);
238
252
  }
253
+
239
254
  const id = input.id ?? nextFindingId(session.annotations);
240
255
  const existing = session.annotations.find((annotation) => annotation.id === id);
241
256
 
@@ -275,6 +290,7 @@ export async function reviewCommentCommand(argv: string[]): Promise<number> {
275
290
 
276
291
  return 2;
277
292
  }
293
+
278
294
  try {
279
295
  const client = await DaemonClient.connect({ autostart: true, role: "agent", author: "agent" });
280
296
 
@@ -317,6 +333,7 @@ export async function reviewCommand(argv: string[]): Promise<number> {
317
333
 
318
334
  return 2;
319
335
  }
336
+
320
337
  let imported: Awaited<ReturnType<ReturnType<typeof forge>["importPullRequest"]>>;
321
338
 
322
339
  try {
@@ -335,6 +352,7 @@ export async function reviewCommand(argv: string[]): Promise<number> {
335
352
 
336
353
  return 1;
337
354
  }
355
+
338
356
  const client = await DaemonClient.connect({ autostart: true });
339
357
  // A PR diff is a partial patch with no full file contents, so no `files` here;
340
358
  // hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
@@ -384,6 +402,7 @@ export async function reviewOpenCommand(argv: string[]): Promise<number> {
384
402
 
385
403
  return 2;
386
404
  }
405
+
387
406
  const client = await DaemonClient.connect({ autostart: true });
388
407
 
389
408
  try {
@@ -417,6 +436,7 @@ export async function reviewPostCommand(argv: string[]): Promise<number> {
417
436
 
418
437
  return 2;
419
438
  }
439
+
420
440
  const session = await getSession(sessionId);
421
441
  const pr = session.artifact.meta.pr;
422
442
 
@@ -430,11 +450,13 @@ export async function reviewPostCommand(argv: string[]): Promise<number> {
430
450
 
431
451
  return 1;
432
452
  }
453
+
433
454
  if (session.status !== "resolved" || !session.message) {
434
455
  console.error(`session ${sessionId} is unresolved - approve or request changes before posting`);
435
456
 
436
457
  return 1;
437
458
  }
459
+
438
460
  const selectedIds = new Set(
439
461
  (stringFlag(flags, "comments") ?? "")
440
462
  .split(",")
@@ -453,6 +475,7 @@ export async function reviewPostCommand(argv: string[]): Promise<number> {
453
475
 
454
476
  return 1;
455
477
  }
478
+
456
479
  const eventFlag = stringFlag(flags, "event") ?? "comment";
457
480
  const event = reviewEvent(eventFlag);
458
481
 
@@ -461,6 +484,7 @@ export async function reviewPostCommand(argv: string[]): Promise<number> {
461
484
 
462
485
  return 2;
463
486
  }
487
+
464
488
  try {
465
489
  const body = reviewSummary(event, textFlag(flags, "body", "body-file"));
466
490
  const forgePort = forge();
@@ -475,10 +499,12 @@ export async function reviewPostCommand(argv: string[]): Promise<number> {
475
499
  ) {
476
500
  throw new Error("pull request changed since this review - refresh before posting");
477
501
  }
502
+
478
503
  const comments = await reanchorReviewComments(session, selectedComments);
479
504
  const number = Number(new URL(session.artifact.meta.prUrl).pathname.split("/").at(-1));
480
505
 
481
506
  if (!Number.isInteger(number)) throw new Error("pull request URL has no numeric PR number");
507
+
482
508
  const posted = await forgePort.postPullRequestComments({
483
509
  publicationId: reviewPublicationId(
484
510
  session,
@@ -80,6 +80,7 @@ export async function shareSession(
80
80
 
81
81
  return 1;
82
82
  }
83
+
83
84
  const session = params.fork ? await client.sessionFork(picked.id) : picked;
84
85
  // a workbench thread renders live locally; freeze its diff so the remote reviewer gets a stable snapshot
85
86
  const shared = client.repoDiff
@@ -98,7 +99,9 @@ export async function shareSession(
98
99
  shareBranch: session.history?.branch ?? "main",
99
100
  },
100
101
  ]);
102
+
101
103
  if (params.fork) deps.out(`forked ${picked.id} as ${session.id}`);
104
+
102
105
  deps.out(copied ? `share link copied - ${line}` : line);
103
106
 
104
107
  return 0;
@@ -121,6 +124,7 @@ export async function pullSession(
121
124
 
122
125
  return 1;
123
126
  }
127
+
124
128
  const remote = await deps.pull(session.shares!.at(-1)!.id, {
125
129
  host: params.host,
126
130
  port: params.port,
@@ -143,6 +147,7 @@ export async function pullSession(
143
147
  /** The named session, or the most recent one when no id is given. */
144
148
  async function pickSession(client: ThreadClient, sessionId?: string): Promise<Thread | null> {
145
149
  if (sessionId) return client.sessionGet(sessionId);
150
+
146
151
  const sessions = await client.sessionList();
147
152
 
148
153
  return sessions.at(-1) ?? null;
@@ -155,6 +160,7 @@ async function pickSharedSession(client: ThreadClient, sessionId?: string): Prom
155
160
 
156
161
  return session.shares?.length ? session : null;
157
162
  }
163
+
158
164
  const sessions = await client.sessionList();
159
165
 
160
166
  return sessions.filter((session) => session.shares?.length).at(-1) ?? null;
@@ -46,6 +46,7 @@ async function sessionCreate({ client, flags }: SessionContext): Promise<number>
46
46
 
47
47
  return 2;
48
48
  }
49
+
49
50
  const contentFile = stringFlag(flags, "content-file");
50
51
  const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
51
52
  // per-file agent notes for diff sessions: a JSON array of { path, body }
@@ -76,6 +77,7 @@ async function sessionCreate({ client, flags }: SessionContext): Promise<number>
76
77
  if (openResult !== "opened" && openResult !== "focused") {
77
78
  console.error(manualThreadOpenCommand(review.id));
78
79
  }
80
+
79
81
  out(review.session);
80
82
 
81
83
  return 0;
@@ -113,6 +115,7 @@ async function sessionWaitCommand({ client, positional, flags }: SessionContext)
113
115
 
114
116
  return 0;
115
117
  }
118
+
116
119
  const { allow, message } = messageResponse(session);
117
120
 
118
121
  out({ status: "resolved", allow, message });
@@ -143,6 +146,7 @@ async function annotateAnchor(
143
146
  ? { anchor: root.anchor, replyTo: root.replyTo ?? root.id }
144
147
  : { anchor: root.anchor, replyTo: root.replyTo ?? root.id, target: root.target };
145
148
  }
149
+
146
150
  const selector = stringFlag(flags, "selector");
147
151
  const quote =
148
152
  selector === undefined
@@ -177,6 +181,7 @@ async function sessionAnnotateCommand({
177
181
  };
178
182
 
179
183
  if (replyTo !== undefined) base.replyTo = replyTo;
184
+
180
185
  const annotation: Omit<Annotation, "createdAt"> =
181
186
  target === undefined ? base : { ...base, target };
182
187
 
@@ -351,7 +356,9 @@ async function sessionEventsCommand({
351
356
  const stream = new Promise<number>((resolve) => {
352
357
  client.onEvent((event) => {
353
358
  if (event.sessionId !== id) return;
359
+
354
360
  out(event);
361
+
355
362
  if (once) resolve(0);
356
363
  });
357
364
  });
@@ -359,6 +366,7 @@ async function sessionEventsCommand({
359
366
  const disconnect = new Promise<number>((resolve) => {
360
367
  client.onDisconnect(() => {
361
368
  if (finished) return;
369
+
362
370
  console.error("daemon connection closed - restart cueloop session events to resume");
363
371
  resolve(1);
364
372
  });
@@ -366,6 +374,7 @@ async function sessionEventsCommand({
366
374
 
367
375
  await client.subscribe();
368
376
  await client.sessionGet(id);
377
+
369
378
  if (ready) console.log(JSON.stringify({ event: "events.ready", sessionId: id }));
370
379
 
371
380
  const code = await Promise.race([stream, disconnect]);
@@ -546,9 +555,11 @@ async function annotateBody(
546
555
  const explicit = stringFlag(flags, "body");
547
556
 
548
557
  if (explicit !== undefined) return explicit;
558
+
549
559
  const actionRef = stringFlag(flags, "action");
550
560
 
551
561
  if (actionRef === undefined) return "";
562
+
552
563
  const session = await client.sessionGet(id);
553
564
  const actions = loadConfig({ repoRoot: session.workspace.repoRoot }).actions;
554
565
  const action = resolveQuickAction(actions, actionRef);
@@ -52,6 +52,7 @@ async function fetchLatestVersion(): Promise<string | undefined> {
52
52
  const response = await fetch(RELEASES_URL, { headers: { "user-agent": "cueloop-update" } });
53
53
 
54
54
  if (!response.ok) return undefined;
55
+
55
56
  const parsed = v.safeParse(ReleasesSchema, await response.json());
56
57
 
57
58
  if (!parsed.success) return undefined;
@@ -87,6 +88,7 @@ async function runInstaller(targetInstallDir: string): Promise<number> {
87
88
 
88
89
  return 1;
89
90
  }
91
+
90
92
  const parsed = v.safeParse(InstallerScriptSchema, await response.text());
91
93
 
92
94
  if (!parsed.success) {
@@ -94,6 +96,7 @@ async function runInstaller(targetInstallDir: string): Promise<number> {
94
96
 
95
97
  return 1;
96
98
  }
99
+
97
100
  const child = Bun.spawn(["sh"], {
98
101
  stdin: "pipe",
99
102
  stdout: "inherit",
@@ -143,11 +146,13 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
143
146
 
144
147
  return 1;
145
148
  }
149
+
146
150
  if (!targetInstallDir.startsWith("/")) {
147
151
  deps.error("cueloop update: CUELOOP_INSTALL_DIR must be an absolute path");
148
152
 
149
153
  return 1;
150
154
  }
155
+
151
156
  deps.out(`Current version: ${deps.currentVersion}`);
152
157
 
153
158
  if (dryRun) {
@@ -155,6 +160,7 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
155
160
 
156
161
  return 0;
157
162
  }
163
+
158
164
  deps.out("Checking for updates to latest version...");
159
165
  const latest = await deps.fetchLatestVersion();
160
166
 
@@ -163,6 +169,7 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
163
169
 
164
170
  return 0;
165
171
  }
172
+
166
173
  deps.out(`updating cueloop in ${targetInstallDir}...`);
167
174
  const exitCode = await deps.runInstaller(targetInstallDir);
168
175