cueloop 0.1.0-alpha.79 → 0.1.0-alpha.80

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,7 +1,7 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.79",
4
- "description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
3
+ "version": "0.1.0-alpha.80",
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": {
7
7
  "url": "https://github.com/mmurakaru/cueloop/issues"
@@ -30,10 +30,10 @@
30
30
  "build:binary": "bun build --compile --external cpu-features ./src/main.ts --outfile dist/cueloop"
31
31
  },
32
32
  "dependencies": {
33
- "@cueloop/adapters": "0.1.0-alpha.79",
34
- "@cueloop/client": "0.1.0-alpha.79",
35
- "@cueloop/daemon": "0.1.0-alpha.79",
36
- "@cueloop/schema": "0.1.0-alpha.79",
33
+ "@cueloop/adapters": "0.1.0-alpha.80",
34
+ "@cueloop/client": "0.1.0-alpha.80",
35
+ "@cueloop/daemon": "0.1.0-alpha.80",
36
+ "@cueloop/schema": "0.1.0-alpha.80",
37
37
  "valibot": "^1.4.2"
38
38
  },
39
39
  "engines": {
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { DaemonClient } from "@cueloop/daemon/client";
12
12
  import { pidPath, socketPath } from "@cueloop/daemon/paths";
13
- import { stopDaemon } from "./daemon-control";
13
+ import { restartCommand, stopDaemon } from "./daemon-control";
14
14
 
15
15
  let home: string;
16
16
 
@@ -41,3 +41,28 @@ test("stopDaemon stops a running daemon and clears its socket and pid", async ()
41
41
  test("stopDaemon reports nothing to stop when no daemon is running", async () => {
42
42
  expect(await stopDaemon(home)).toBe(false);
43
43
  });
44
+
45
+ test("restartCommand replaces the daemon and reconnects", async () => {
46
+ const previousHome = process.env.CUELOOP_HOME;
47
+ const first = await DaemonClient.connect({ home, autostart: true });
48
+ const firstPid = (await first.ping()).pid;
49
+
50
+ first.close();
51
+ process.env.CUELOOP_HOME = home;
52
+
53
+ try {
54
+ expect(await restartCommand()).toBe(0);
55
+
56
+ const restarted = await DaemonClient.connect({ home, autostart: false });
57
+
58
+ try {
59
+ expect((await restarted.ping()).pid).not.toBe(firstPid);
60
+ expect(existsSync(socketPath(home))).toBe(true);
61
+ } finally {
62
+ restarted.close();
63
+ }
64
+ } finally {
65
+ if (previousHome === undefined) delete process.env.CUELOOP_HOME;
66
+ else process.env.CUELOOP_HOME = previousHome;
67
+ }
68
+ }, 60_000);
@@ -4,7 +4,7 @@
4
4
  * talking to the old-version daemon (which refuses the handshake).
5
5
  */
6
6
 
7
- import { existsSync, readFileSync, rmSync } from "node:fs";
7
+ import { existsSync, readFileSync } from "node:fs";
8
8
  import * as v from "valibot";
9
9
  import { DaemonClient } from "@cueloop/daemon/client";
10
10
  import { cueloopHome, pidPath, socketPath } from "@cueloop/daemon/paths";
@@ -95,10 +95,6 @@ export async function restartCommand(): Promise<number> {
95
95
  const home = cueloopHome();
96
96
 
97
97
  await stopDaemon(home);
98
- // a wedged daemon that never released its socket would otherwise be reused, not replaced
99
- const socket = socketPath(home);
100
-
101
- if (existsSync(socket)) rmSync(socket, { force: true });
102
98
  const client = await DaemonClient.connect({ home, autostart: true });
103
99
 
104
100
  client.close();
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
  import { DaemonClient } from "@cueloop/daemon/client";
12
- import { resolveWorkspace } from "@cueloop/daemon/review";
12
+ import { resolveWorkspace } from "@cueloop/daemon/thread-review";
13
13
 
14
14
  /** Bump whenever the seed content below changes, so a long-lived dev home refreshes instead of keeping
15
15
  * stale threads (an old seed's diff had no files, which showed as "No changes"). */
package/src/main.ts CHANGED
@@ -20,11 +20,11 @@ import {
20
20
  openTargetMessage,
21
21
  resolveOpenTarget,
22
22
  } from "./open-target";
23
- import { sessionCommand } from "./session-commands";
23
+ import { sessionCommand } from "./thread-commands";
24
24
  import { CLI_VERSION } from "./version";
25
25
  import { DaemonClient } from "@cueloop/daemon/client";
26
26
  import type { Thread } from "@cueloop/schema";
27
- import { openReview } from "@cueloop/daemon/review";
27
+ import { openReview } from "@cueloop/daemon/thread-review";
28
28
 
29
29
  const argv = process.argv.slice(2);
30
30
  const cmd = argv[0];
@@ -340,12 +340,12 @@ function printHelp(): void {
340
340
  " cueloop <plan|diff|review> --latest open the latest pending review of that type",
341
341
  "",
342
342
  "scripting:",
343
- " cueloop session <primitive> [flags] script the daemon (create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|resolve|submit-revision)",
343
+ " cueloop session <primitive> [flags] script the daemon (create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|send-message|bind-harness|pending-deliveries|acknowledge-delivery|submit-revision)",
344
344
  " cueloop actions list list the quick-action vocabulary (for annotate --action)",
345
345
  " cueloop refine mine past reviews into a markdown report + writeback proposals",
346
346
  " cueloop update [--dry-run] update the installed cueloop binary (--dry-run reports the target only)",
347
- " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
348
- " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
347
+ " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the message (spawn detached)",
348
+ " cueloop review-post <id> <pr> post a resolved session's message back to the PR",
349
349
  " cueloop daemon run the daemon in the foreground",
350
350
  " cueloop stop stop the local daemon",
351
351
  " cueloop restart stop the local daemon and start a fresh one",
@@ -32,9 +32,9 @@ function session(overrides: {
32
32
  },
33
33
  revisions: [],
34
34
  annotations: [],
35
- verdict:
35
+ message:
36
36
  overrides.status === "resolved"
37
- ? { kind: "approve", summary: "", feedback: "", resolvedAt: "" }
37
+ ? { id: "msg_1", outcome: "approved", summary: "", body: "", sentAt: "" }
38
38
  : null,
39
39
  status: overrides.status ?? "pending",
40
40
  createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Rules, in order:
8
8
  * - no selector opens the latest pending session in scope (most recent
9
- * createdAt, verdict still open) - the `--latest` default;
9
+ * createdAt, message still open) - the `--latest` default;
10
10
  * - a selector that exactly equals a session id opens that session, pending
11
11
  * or resolved;
12
12
  * - otherwise the selector matches session titles, case-insensitive: an
package/src/pr.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * PR review entry: `cueloop review <pr>` fetches the PR diff through the
3
3
  * user's `gh` CLI (auth fully delegated), opens a diff session, and posts the
4
- * verdict back to the PR as a real review when the session resolves.
4
+ * message back to the PR as a real review when the session resolves.
5
5
  * `cueloop review-post <session-id> <pr>` is the non-interactive post-back
6
6
  * half for agents and scripts.
7
7
  */
8
8
 
9
- import type { Thread, VerdictKind } from "@cueloop/schema";
9
+ import type { Thread } from "@cueloop/schema";
10
+ import { join } from "node:path";
11
+ import { GitHubForgeReviewPort } from "@cueloop/adapters/forge-review";
12
+ import { DeliveredMessageStore } from "@cueloop/adapters/delivered-message-store";
10
13
  import { DaemonClient } from "@cueloop/daemon/client";
11
- import { openReview } from "@cueloop/daemon/review";
14
+ import { cueloopHome } from "@cueloop/daemon/paths";
15
+ import { openReview } from "@cueloop/daemon/thread-review";
12
16
  import { parseArgs } from "./args";
13
17
 
14
18
  /** The gh binary is injectable so tests can stub it. */
@@ -16,30 +20,13 @@ function ghBin(): string {
16
20
  return process.env.CUELOOP_GH || "gh";
17
21
  }
18
22
 
19
- interface GhResult {
20
- code: number;
21
- stdout: string;
22
- stderr: string;
23
+ function forge(): GitHubForgeReviewPort {
24
+ return new GitHubForgeReviewPort(
25
+ new DeliveredMessageStore(join(cueloopHome(), "forge-delivered-messages.json")),
26
+ ghBin(),
27
+ );
23
28
  }
24
29
 
25
- async function gh(args: string[]): Promise<GhResult> {
26
- const proc = Bun.spawn([ghBin(), ...args], { stdout: "pipe", stderr: "pipe" });
27
- const [stdout, stderr, code] = await Promise.all([
28
- new Response(proc.stdout).text(),
29
- new Response(proc.stderr).text(),
30
- proc.exited,
31
- ]);
32
-
33
- return { code, stdout, stderr };
34
- }
35
-
36
- /** Verdict kinds map 1:1 onto gh review flags. */
37
- const VERDICT_FLAG: Record<VerdictKind, string> = {
38
- approve: "--approve",
39
- request_changes: "--request-changes",
40
- comment: "--comment",
41
- };
42
-
43
30
  export async function reviewCommand(argv: string[]): Promise<number> {
44
31
  const { positional, flags } = parseArgs(argv);
45
32
  const pr = positional[0];
@@ -49,15 +36,12 @@ export async function reviewCommand(argv: string[]): Promise<number> {
49
36
 
50
37
  return 2;
51
38
  }
52
- const diff = await gh(["pr", "diff", pr]);
53
-
54
- if (diff.code !== 0) {
55
- console.error(diff.stderr.trim() || `gh pr diff ${pr} failed (exit ${diff.code})`);
39
+ let imported: { content: string; title: string };
56
40
 
57
- return 1;
58
- }
59
- if (!diff.stdout.trim()) {
60
- console.error(`PR ${pr} has an empty diff - nothing to review`);
41
+ try {
42
+ imported = await forge().importPullRequest(pr);
43
+ } catch (error) {
44
+ console.error(error instanceof Error ? error.message : String(error));
61
45
 
62
46
  return 1;
63
47
  }
@@ -66,8 +50,8 @@ export async function reviewCommand(argv: string[]): Promise<number> {
66
50
  // hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
67
51
  const review = await openReview(client, {
68
52
  type: "diff",
69
- content: diff.stdout,
70
- title: `PR ${pr}`,
53
+ content: imported.content,
54
+ title: imported.title,
71
55
  pr,
72
56
  });
73
57
  const session = review.session;
@@ -86,13 +70,13 @@ export async function reviewCommand(argv: string[]): Promise<number> {
86
70
 
87
71
  const after = await getSession(session.id);
88
72
 
89
- if (after.status !== "resolved" || !after.verdict) {
73
+ if (after.status !== "resolved" || !after.message) {
90
74
  console.log(`session ${session.id} is unresolved - nothing was posted to PR ${pr}`);
91
75
 
92
76
  return 0;
93
77
  }
94
78
 
95
- return postVerdict(after, pr);
79
+ return postMessage(after, pr);
96
80
  }
97
81
 
98
82
  export async function reviewPostCommand(argv: string[]): Promise<number> {
@@ -107,13 +91,13 @@ export async function reviewPostCommand(argv: string[]): Promise<number> {
107
91
  }
108
92
  const session = await getSession(sessionId);
109
93
 
110
- if (session.status !== "resolved" || !session.verdict) {
94
+ if (session.status !== "resolved" || !session.message) {
111
95
  console.error(`session ${sessionId} is unresolved - nothing was posted to PR ${pr}`);
112
96
 
113
97
  return 1;
114
98
  }
115
99
 
116
- return postVerdict(session, pr);
100
+ return postMessage(session, pr);
117
101
  }
118
102
 
119
103
  async function getSession(id: string): Promise<Thread> {
@@ -126,24 +110,18 @@ async function getSession(id: string): Promise<Thread> {
126
110
  }
127
111
  }
128
112
 
129
- /** Post the resolved session's verdict to the PR: feedback.md is the review body. */
130
- async function postVerdict(session: Thread, pr: string): Promise<number> {
131
- const verdict = session.verdict!;
132
- const result = await gh([
133
- "pr",
134
- "review",
135
- pr,
136
- VERDICT_FLAG[verdict.kind],
137
- "--body",
138
- verdict.feedback,
139
- ]);
113
+ /** Post the resolved session's message to the PR: feedback.md is the review body. */
114
+ async function postMessage(session: Thread, pr: string): Promise<number> {
115
+ const message = session.message!;
140
116
 
141
- if (result.code !== 0) {
142
- console.error(result.stderr.trim() || `gh pr review ${pr} failed (exit ${result.code})`);
117
+ try {
118
+ await forge().postPullRequestMessage(pr, message);
119
+ } catch (error) {
120
+ console.error(error instanceof Error ? error.message : String(error));
143
121
 
144
122
  return 1;
145
123
  }
146
- console.log(`posted ${verdict.kind} review to PR ${pr} (session ${session.id})`);
124
+ console.log(`posted ${message.outcome} review to PR ${pr} (session ${session.id})`);
147
125
 
148
126
  return 0;
149
127
  }
@@ -4,10 +4,12 @@ import { describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, readFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import type { Annotation, Thread, Verdict, VerdictKind } from "@cueloop/schema";
7
+ import type { Annotation, Thread, Message, MessageOutcome } from "@cueloop/schema";
8
8
  import { ThreadStore } from "@cueloop/daemon/store";
9
+ import { DaemonServer } from "@cueloop/daemon";
9
10
  import { reportsDir } from "@cueloop/daemon/paths";
10
- import { buildRefineReport, refineCommand } from "./refine-command";
11
+ import { buildRefineReport } from "@cueloop/adapters/refine-corpus";
12
+ import { refineCommand } from "./refine-command";
11
13
 
12
14
  function tempHome(): string {
13
15
  return mkdtempSync(join(tmpdir(), "cueloop-refine-home-"));
@@ -23,8 +25,14 @@ function annotation(quote: string, body: string): Annotation {
23
25
  };
24
26
  }
25
27
 
26
- function verdict(kind: VerdictKind): Verdict {
27
- return { kind, summary: "", feedback: "", resolvedAt: "2026-08-20T11:00:00Z" };
28
+ function message(outcome: MessageOutcome): Message {
29
+ return {
30
+ id: `msg_${outcome}`,
31
+ outcome,
32
+ summary: "",
33
+ body: "",
34
+ sentAt: "2026-08-20T11:00:00Z",
35
+ };
28
36
  }
29
37
 
30
38
  function session(
@@ -40,9 +48,9 @@ function session(
40
48
  artifact: { type: type ?? "plan", content: "", meta: {} },
41
49
  revisions: [],
42
50
  annotations: [],
43
- verdict: null,
51
+ message: null,
44
52
  status: "pending",
45
- createdAt: "2026-08-20T10:00:00Z",
53
+ createdAt: new Date().toISOString(),
46
54
  ...rest,
47
55
  };
48
56
  }
@@ -51,10 +59,10 @@ describe("buildRefineReport", () => {
51
59
  test("reports the analyzed and total counts, distributions, and annotations", () => {
52
60
  // Arrange
53
61
  const analyzed = [
54
- session("ses_a", { verdict: verdict("approve"), status: "resolved" }),
62
+ session("ses_a", { message: message("approved"), status: "resolved" }),
55
63
  session("ses_b", {
56
64
  type: "diff",
57
- verdict: verdict("request_changes"),
65
+ message: message("changes_requested"),
58
66
  status: "resolved",
59
67
  annotations: [annotation("skip tests", "please add a regression test")],
60
68
  }),
@@ -67,7 +75,7 @@ describe("buildRefineReport", () => {
67
75
  expect(markdown).toContain("2 sessions analyzed (3 total).");
68
76
  expect(markdown).toContain("- plan: 1");
69
77
  expect(markdown).toContain("- diff: 1");
70
- expect(markdown).toContain("- approve: 1");
78
+ expect(markdown).toContain("- approved: 1");
71
79
  expect(markdown).toContain("- request changes: 1");
72
80
  expect(markdown).toContain("skip tests");
73
81
  expect(markdown).toContain("please add a regression test");
@@ -89,10 +97,10 @@ describe("refineCommand", () => {
89
97
  const home = tempHome();
90
98
  const store = new ThreadStore(home);
91
99
 
92
- store.upsert(session("ses_a", { verdict: verdict("approve"), status: "resolved" }));
100
+ store.upsert(session("ses_a", { message: message("approved"), status: "resolved" }));
93
101
  store.upsert(
94
102
  session("ses_b", {
95
- verdict: verdict("request_changes"),
103
+ message: message("changes_requested"),
96
104
  status: "resolved",
97
105
  annotations: [annotation("wrong file", "this belongs in store.ts")],
98
106
  }),
@@ -102,19 +110,26 @@ describe("refineCommand", () => {
102
110
  );
103
111
  store.upsert(session("ses_empty"));
104
112
  const reportPath = join(reportsDir(home), "report.md");
113
+ const server = new DaemonServer({ home, idleExitMs: 0 });
114
+
115
+ server.start();
105
116
 
106
117
  // Act
107
- const firstCode = await refineCommand(["--home", home]);
118
+ try {
119
+ const firstCode = await refineCommand(["--home", home]);
108
120
 
109
- // Assert
110
- expect(firstCode).toBe(0);
111
- expect(readFileSync(reportPath, "utf8")).toContain("3 sessions analyzed (4 total).");
121
+ // Assert
122
+ expect(firstCode).toBe(0);
123
+ expect(readFileSync(reportPath, "utf8")).toContain("3 sessions analyzed (4 total).");
112
124
 
113
- // Act
114
- await refineCommand(["--home", home]);
125
+ // Act
126
+ await refineCommand(["--home", home]);
115
127
 
116
- // Assert
117
- expect(readFileSync(reportPath, "utf8")).toContain("1 sessions analyzed (4 total).");
128
+ // Assert
129
+ expect(readFileSync(reportPath, "utf8")).toContain("1 sessions analyzed (4 total).");
130
+ } finally {
131
+ server.stop();
132
+ }
118
133
  });
119
134
 
120
135
  test("re-analyzes a resolved session that is reopened and resolved again with new feedback", async () => {
@@ -124,15 +139,22 @@ describe("refineCommand", () => {
124
139
 
125
140
  store.upsert(
126
141
  session("ses_x", {
127
- verdict: verdict("approve"),
142
+ message: message("approved"),
128
143
  status: "resolved",
129
144
  annotations: [annotation("first", "one")],
130
145
  }),
131
146
  );
132
147
  const reportPath = join(reportsDir(home), "report.md");
148
+ const firstServer = new DaemonServer({ home, idleExitMs: 0 });
149
+
150
+ firstServer.start();
133
151
 
134
152
  // Act
135
- await refineCommand(["--home", home]);
153
+ try {
154
+ await refineCommand(["--home", home]);
155
+ } finally {
156
+ firstServer.stop();
157
+ }
136
158
 
137
159
  // Assert
138
160
  expect(readFileSync(reportPath, "utf8")).toContain("1 sessions analyzed (1 total).");
@@ -140,14 +162,21 @@ describe("refineCommand", () => {
140
162
  // Arrange
141
163
  store.upsert(
142
164
  session("ses_x", {
143
- verdict: { ...verdict("request_changes"), resolvedAt: "2026-08-21T09:00:00Z" },
165
+ message: { ...message("changes_requested"), sentAt: "2026-08-21T09:00:00Z" },
144
166
  status: "resolved",
145
167
  annotations: [annotation("first", "one"), annotation("second", "two")],
146
168
  }),
147
169
  );
170
+ const secondServer = new DaemonServer({ home, idleExitMs: 0 });
171
+
172
+ secondServer.start();
148
173
 
149
174
  // Act
150
- await refineCommand(["--home", home]);
175
+ try {
176
+ await refineCommand(["--home", home]);
177
+ } finally {
178
+ secondServer.stop();
179
+ }
151
180
 
152
181
  // Assert
153
182
  const secondReport = readFileSync(reportPath, "utf8");
@@ -1,67 +1,37 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { cueloopHome, reportsDir } from "@cueloop/daemon/paths";
4
- import { ThreadStore } from "@cueloop/daemon/store";
5
- import {
6
- LATEST_REPORT_FILENAME,
7
- parseRefineState,
8
- pruneExpiredReports,
9
- resolveCleanupPeriodDays,
10
- timestampedReportFilename,
11
- } from "@cueloop/daemon/retention";
12
- import { isAgentNote, type Annotation, type Thread } from "@cueloop/schema";
13
- import { parseArgs, stringFlag } from "./args";
14
-
15
- const DEFAULT_SESSION_LIMIT = 200;
16
- const QUOTE_LIMIT = 60;
17
- const BODY_LIMIT = 100;
1
+ /** CLI wrapper around the shared corpus analysis used by harness refine workflows. */
18
2
 
19
- interface AnnotatedEntry {
20
- annotation: Annotation;
21
- session: Thread;
22
- }
3
+ import { cueloopHome } from "@cueloop/daemon/paths";
4
+ import { LocalRefineCorpusPort } from "@cueloop/adapters/refine-corpus";
5
+ import { DaemonClient } from "@cueloop/daemon/client";
6
+ import { parseArgs, stringFlag } from "./args";
23
7
 
8
+ /** Generate the same refine report the shared harness controller consumes. */
24
9
  export async function refineCommand(argv: string[]): Promise<number> {
25
10
  const { flags } = parseArgs(argv);
26
11
  const home = stringFlag(flags, "home") ?? cueloopHome();
27
- const limit = parseLimit(stringFlag(flags, "limit"));
28
- const nowMs = Date.now();
29
-
30
- const store = new ThreadStore(home);
31
-
32
- store.recover();
33
- const all = store.list();
34
-
35
- const analyzedState = readState(home);
36
- const fresh = all.filter(
37
- (session) => analyzedState.get(session.id) !== analysisFingerprint(session),
38
- );
39
- const analyzed = fresh.filter(hasReviewSignal).slice(0, limit);
40
-
41
- const markdown = buildRefineReport(analyzed, all.length, new Date(nowMs).toISOString());
42
-
43
- const directory = reportsDir(home);
12
+ const rawLimit = stringFlag(flags, "limit");
13
+ const client = await DaemonClient.connect({ home, autostart: true });
14
+ let result: Awaited<ReturnType<LocalRefineCorpusPort["analyzeRefineCorpus"]>>;
44
15
 
45
- mkdirSync(directory, { recursive: true });
46
- pruneExpiredReports(directory, resolveCleanupPeriodDays(), nowMs);
47
- const latestPath = join(directory, LATEST_REPORT_FILENAME);
48
- const timestampedPath = join(directory, timestampedReportFilename(nowMs));
49
-
50
- writeFileSync(latestPath, markdown);
51
- writeFileSync(timestampedPath, markdown);
16
+ try {
17
+ const corpus = new LocalRefineCorpusPort(
18
+ client,
19
+ home,
20
+ rawLimit === undefined ? undefined : Number(rawLimit),
21
+ );
52
22
 
53
- for (const session of analyzed) {
54
- if (session.status === "resolved") analyzedState.set(session.id, analysisFingerprint(session));
23
+ result = await corpus.analyzeRefineCorpus();
24
+ } finally {
25
+ client.close();
55
26
  }
56
- writeState(home, analyzedState);
57
27
 
58
28
  console.log(
59
29
  JSON.stringify(
60
30
  {
61
- report: latestPath,
62
- timestamped: timestampedPath,
63
- analyzed: analyzed.length,
64
- total: all.length,
31
+ report: result.reportPath,
32
+ timestamped: result.timestampedPath,
33
+ analyzed: result.analyzed,
34
+ total: result.total,
65
35
  },
66
36
  null,
67
37
  2,
@@ -70,193 +40,3 @@ export async function refineCommand(argv: string[]): Promise<number> {
70
40
 
71
41
  return 0;
72
42
  }
73
-
74
- export function buildRefineReport(
75
- analyzed: Thread[],
76
- totalCount: number,
77
- generatedAt: string,
78
- ): string {
79
- const entries = flattenReviewAnnotations(analyzed);
80
- const lines: string[] = [
81
- "# refine report",
82
- "",
83
- `Generated ${generatedAt}.`,
84
- "",
85
- `${analyzed.length} sessions analyzed (${totalCount} total).`,
86
- "",
87
- "## Corpus",
88
- "",
89
- "By primitive:",
90
- ...byCount(analyzed, primitiveLabel).map(([label, count]) => `- ${label}: ${count}`),
91
- "",
92
- "By verdict:",
93
- ...byCount(analyzed, verdictLabel).map(([label, count]) => `- ${label}: ${count}`),
94
- "",
95
- "## Annotations by kind",
96
- "",
97
- ];
98
-
99
- if (entries.length === 0) {
100
- lines.push("No reviewer annotations in the analyzed sessions.", "");
101
- } else {
102
- for (const [kind, group] of groupByKind(entries)) {
103
- lines.push(`### ${kind} (${group.length})`, "");
104
- for (const { annotation, session } of group) {
105
- const meta = [
106
- session.id,
107
- primitiveLabel(session),
108
- verdictLabel(session),
109
- isoWeek(annotation.createdAt),
110
- ].join(" · ");
111
-
112
- lines.push(
113
- `- "${truncate(annotation.anchor.quote, QUOTE_LIMIT)}": ${truncate(annotation.body, BODY_LIMIT)} · ${meta}`,
114
- );
115
- }
116
- lines.push("");
117
- }
118
- }
119
-
120
- lines.push("## Weekly volume", "");
121
- for (const [week, count] of byKey(
122
- entries.map((entry) => entry.annotation),
123
- (annotation) => isoWeek(annotation.createdAt),
124
- )) {
125
- lines.push(`- ${week}: ${count}`);
126
- }
127
- lines.push(
128
- "",
129
- "## Next",
130
- "",
131
- "Group the annotations above into named patterns of three or more members.",
132
- "Rank each pattern by how often its members sit on a request-changes or comment verdict.",
133
- "Draft one writeback per pattern, routed to a skill, AGENTS.md, CLAUDE.md, or memory.",
134
- "",
135
- );
136
-
137
- return lines.join("\n");
138
- }
139
-
140
- function hasReviewSignal(session: Thread): boolean {
141
- return (
142
- session.verdict !== null || session.annotations.some((annotation) => !isAgentNote(annotation))
143
- );
144
- }
145
-
146
- function flattenReviewAnnotations(sessions: Thread[]): AnnotatedEntry[] {
147
- const entries: AnnotatedEntry[] = [];
148
-
149
- for (const session of sessions) {
150
- for (const annotation of session.annotations) {
151
- if (!isAgentNote(annotation)) entries.push({ annotation, session });
152
- }
153
- }
154
-
155
- return entries;
156
- }
157
-
158
- function groupByKind(entries: AnnotatedEntry[]): [string, AnnotatedEntry[]][] {
159
- const groups = new Map<string, AnnotatedEntry[]>();
160
-
161
- for (const entry of entries) {
162
- const group = groups.get(entry.annotation.kind) ?? [];
163
-
164
- group.push(entry);
165
- groups.set(entry.annotation.kind, group);
166
- }
167
-
168
- return [...groups.entries()].toSorted(
169
- (left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0]),
170
- );
171
- }
172
-
173
- function byCount<T>(items: T[], keyOf: (item: T) => string): [string, number][] {
174
- return [...tally(items, keyOf).entries()].toSorted(
175
- (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
176
- );
177
- }
178
-
179
- function byKey<T>(items: T[], keyOf: (item: T) => string): [string, number][] {
180
- return [...tally(items, keyOf).entries()].toSorted((left, right) =>
181
- left[0].localeCompare(right[0]),
182
- );
183
- }
184
-
185
- function tally<T>(items: T[], keyOf: (item: T) => string): Map<string, number> {
186
- const counts = new Map<string, number>();
187
-
188
- for (const item of items) {
189
- const key = keyOf(item);
190
-
191
- counts.set(key, (counts.get(key) ?? 0) + 1);
192
- }
193
-
194
- return counts;
195
- }
196
-
197
- function primitiveLabel(session: Thread): string {
198
- if (session.artifact.type === "diff" && session.artifact.meta.pr) return "pull request";
199
-
200
- return session.artifact.type;
201
- }
202
-
203
- function verdictLabel(session: Thread): string {
204
- switch (session.verdict?.kind) {
205
- case "approve":
206
- return "approve";
207
- case "request_changes":
208
- return "request changes";
209
- case "comment":
210
- return "comment";
211
- default:
212
- return "none";
213
- }
214
- }
215
-
216
- function isoWeek(iso: string): string {
217
- const date = new Date(iso);
218
-
219
- if (Number.isNaN(date.getTime())) return "undated";
220
- const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
221
- const weekday = utc.getUTCDay() || 7;
222
-
223
- utc.setUTCDate(utc.getUTCDate() + 4 - weekday);
224
- const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
225
- const week = Math.ceil(((utc.getTime() - yearStart.getTime()) / (24 * 60 * 60 * 1000) + 1) / 7);
226
-
227
- return `${utc.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
228
- }
229
-
230
- function truncate(text: string, max: number): string {
231
- const collapsed = text.replace(/\s+/g, " ").trim();
232
-
233
- return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed;
234
- }
235
-
236
- function parseLimit(raw: string | undefined): number {
237
- const value = Number(raw);
238
-
239
- return Number.isInteger(value) && value > 0 ? value : DEFAULT_SESSION_LIMIT;
240
- }
241
-
242
- function analysisFingerprint(session: Thread): string {
243
- return `${session.revisions.length}:${session.annotations.length}:${session.verdict?.resolvedAt ?? "pending"}`;
244
- }
245
-
246
- function statePath(home: string): string {
247
- return join(home, "refine-state.json");
248
- }
249
-
250
- function readState(home: string): Map<string, string> {
251
- try {
252
- return parseRefineState(JSON.parse(readFileSync(statePath(home), "utf8")));
253
- } catch {
254
- return new Map();
255
- }
256
- }
257
-
258
- function writeState(home: string, analyzed: Map<string, string>): void {
259
- const state = { analyzed: Object.fromEntries(analyzed) };
260
-
261
- writeFileSync(statePath(home), JSON.stringify(state, null, 2));
262
- }
@@ -6,7 +6,7 @@ import {
6
6
  type Annotation,
7
7
  type Thread,
8
8
  } from "@cueloop/schema";
9
- import type { SessionClient } from "@cueloop/daemon/client";
9
+ import type { ThreadClient } from "@cueloop/daemon/client";
10
10
  import { pullSession, shareSession, type PullDeps, type ShareDeps } from "./share-command";
11
11
 
12
12
  function sessionFixture(id: string, overrides: Partial<Thread> = {}): Thread {
@@ -17,7 +17,7 @@ function sessionFixture(id: string, overrides: Partial<Thread> = {}): Thread {
17
17
  artifact: { type: "plan", content: "# Plan\n", meta: {} },
18
18
  revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
19
19
  annotations: [],
20
- verdict: null,
20
+ message: null,
21
21
  status: "pending",
22
22
  createdAt: "2026-01-01T00:00:00.000Z",
23
23
  ...overrides,
@@ -39,8 +39,8 @@ function annotationFixture(id: string, author?: string): Annotation {
39
39
  const unimplemented = (member: string) => () =>
40
40
  Promise.reject(new Error(`fakeClient does not implement ${member}`));
41
41
 
42
- /** A SessionClient that answers get/list from a fixed list and records the share/merge primitives. */
43
- function fakeClient(sessions: Thread[]): SessionClient {
42
+ /** A ThreadClient that answers get/list from a fixed list and records the share/merge primitives. */
43
+ function fakeClient(sessions: Thread[]): ThreadClient {
44
44
  return {
45
45
  onEvent: () => () => {},
46
46
  subscribe: async () => {},
@@ -96,7 +96,7 @@ function fakeClient(sessions: Thread[]): SessionClient {
96
96
  ),
97
97
  sessionDelete: unimplemented("sessionDelete"),
98
98
  sessionSetSelfName: unimplemented("sessionSetSelfName"),
99
- sessionResolve: unimplemented("sessionResolve"),
99
+ sessionSendMessage: unimplemented("sessionSendMessage"),
100
100
  close: () => {},
101
101
  };
102
102
  }
@@ -6,7 +6,7 @@
6
6
  * testable without a live gateway.
7
7
  */
8
8
 
9
- import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
9
+ import { DaemonClient, type ThreadClient } from "@cueloop/daemon/client";
10
10
  import {
11
11
  mergeFromShare,
12
12
  publishShare,
@@ -68,9 +68,9 @@ export async function sharePullCommand(
68
68
  }
69
69
  }
70
70
 
71
- /** The share orchestration, over any SessionClient - the seam the tests drive. */
71
+ /** The share orchestration, over any ThreadClient - the seam the tests drive. */
72
72
  export async function shareSession(
73
- client: SessionClient,
73
+ client: ThreadClient,
74
74
  params: ShareParams,
75
75
  deps: ShareDeps,
76
76
  ): Promise<number> {
@@ -102,7 +102,7 @@ export async function shareSession(
102
102
  * ones land. The gateway lets only the fingerprint that shared it pull.
103
103
  */
104
104
  export async function pullSession(
105
- client: SessionClient,
105
+ client: ThreadClient,
106
106
  params: ShareParams,
107
107
  deps: PullDeps,
108
108
  ): Promise<number> {
@@ -127,7 +127,7 @@ export async function pullSession(
127
127
  }
128
128
 
129
129
  /** The named session, or the most recent one when no id is given. */
130
- async function pickSession(client: SessionClient, sessionId?: string): Promise<Thread | null> {
130
+ async function pickSession(client: ThreadClient, sessionId?: string): Promise<Thread | null> {
131
131
  if (sessionId) return client.sessionGet(sessionId);
132
132
  const sessions = await client.sessionList();
133
133
 
@@ -135,10 +135,7 @@ async function pickSession(client: SessionClient, sessionId?: string): Promise<T
135
135
  }
136
136
 
137
137
  /** The named session (if shared), or the most recent shared one. */
138
- async function pickSharedSession(
139
- client: SessionClient,
140
- sessionId?: string,
141
- ): Promise<Thread | null> {
138
+ async function pickSharedSession(client: ThreadClient, sessionId?: string): Promise<Thread | null> {
142
139
  if (sessionId) {
143
140
  const session = await client.sessionGet(sessionId);
144
141
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `cueloop session *` - mirrors the daemon socket API 1:1. One surface,
2
+ * `cueloop session *` - mirrors the daemon socket API 1:1. One Thread surface,
3
3
  * three consumers: agent adapters, the dev loop, and integrations. Output is
4
4
  * JSON on stdout; exit code 0 unless the daemon returned an error.
5
5
  */
@@ -14,7 +14,7 @@ import {
14
14
  import * as v from "valibot";
15
15
  import { DaemonClient } from "@cueloop/daemon/client";
16
16
  import { openHerdrPaneForReview } from "@cueloop/daemon/herdr-pane";
17
- import { openReview, verdictResponse } from "@cueloop/daemon/review";
17
+ import { openReview, messageResponse } from "@cueloop/daemon/thread-review";
18
18
  import { loadConfig, quickActionBody, resolveQuickAction } from "@cueloop/client/config";
19
19
  import { slashItemsFrom } from "@cueloop/client/slash-palette";
20
20
  import { parseArgs, stringFlag } from "./args";
@@ -29,7 +29,7 @@ function out(value: Parameters<typeof JSON.stringify>[0]): void {
29
29
 
30
30
  const ReviewNotesSchema = v.array(v.object({ path: v.string(), body: v.string() }));
31
31
  const SessionStatusSchema = v.picklist(["pending", "resolved"]);
32
- const VerdictKindSchema = v.picklist(["comment", "approve", "request_changes"]);
32
+ const MessageOutcomeSchema = v.picklist(["approved", "changes_requested"]);
33
33
 
34
34
  type SessionFlags = Record<string, string | boolean>;
35
35
  type SessionContext = { client: DaemonClient; positional: string[]; flags: SessionFlags };
@@ -93,9 +93,9 @@ async function sessionWaitCommand({ client, positional, flags }: SessionContext)
93
93
 
94
94
  return 0;
95
95
  }
96
- const { allow, feedback } = verdictResponse(session);
96
+ const { allow, message } = messageResponse(session);
97
97
 
98
- out({ status: "resolved", allow, verdict: session.verdict!.kind, feedback });
98
+ out({ status: "resolved", allow, message });
99
99
 
100
100
  return 0;
101
101
  }
@@ -336,14 +336,17 @@ async function sessionEventsCommand({
336
336
  return stream;
337
337
  }
338
338
 
339
- async function sessionResolveCommand({
339
+ async function sessionSendMessageCommand({
340
340
  client,
341
341
  positional,
342
342
  flags,
343
343
  }: SessionContext): Promise<number> {
344
344
  const id = required(positional[1], "session id");
345
- const kind = v.parse(VerdictKindSchema, required(stringFlag(flags, "verdict"), "--verdict"));
346
- // expand /name quick-action references in TUI-authored comments, so a CLI resolve sends the agent
345
+ const outcome = v.parse(
346
+ MessageOutcomeSchema,
347
+ required(stringFlag(flags, "outcome"), "--outcome"),
348
+ );
349
+ // Expand /name quick-action references in TUI-authored comments, so send-message sends the agent
347
350
  // the action bodies too, not the bare references
348
351
  const session = await client.sessionGet(id);
349
352
  const actions = loadConfig({ repoRoot: session.workspace.repoRoot }).actions;
@@ -351,7 +354,38 @@ async function sessionResolveCommand({
351
354
  slashItemsFrom(actions).map((item) => [item.name, item.body]),
352
355
  );
353
356
 
354
- out(await client.sessionResolve(id, kind, stringFlag(flags, "summary") ?? "", actionBodies));
357
+ out(
358
+ await client.sessionSendMessage(id, outcome, stringFlag(flags, "summary") ?? "", actionBodies),
359
+ );
360
+
361
+ return 0;
362
+ }
363
+
364
+ async function harnessBindCommand({ client, positional, flags }: SessionContext): Promise<number> {
365
+ const threadId = required(positional[1], "thread id");
366
+ const harness = required(stringFlag(flags, "harness"), "--harness");
367
+ const harnessSessionId = required(
368
+ stringFlag(flags, "harness-session-id"),
369
+ "--harness-session-id",
370
+ );
371
+
372
+ out(await client.harnessBind(threadId, harness, harnessSessionId));
373
+
374
+ return 0;
375
+ }
376
+
377
+ async function deliveryPendingCommand({ client, positional }: SessionContext): Promise<number> {
378
+ const bindingId = required(positional[1], "binding id");
379
+
380
+ out(await client.deliveryPending(bindingId));
381
+
382
+ return 0;
383
+ }
384
+
385
+ async function deliveryAcknowledgeCommand({ client, positional }: SessionContext): Promise<number> {
386
+ const deliveryId = required(positional[1], "delivery id");
387
+
388
+ out(await client.deliveryAcknowledge(deliveryId));
355
389
 
356
390
  return 0;
357
391
  }
@@ -397,7 +431,10 @@ const sessionVerbHandlers: SessionVerbHandlers = {
397
431
  fork: sessionForkCommand,
398
432
  "name-self": sessionNameSelfCommand,
399
433
  events: sessionEventsCommand,
400
- resolve: sessionResolveCommand,
434
+ "send-message": sessionSendMessageCommand,
435
+ "bind-harness": harnessBindCommand,
436
+ "pending-deliveries": deliveryPendingCommand,
437
+ "acknowledge-delivery": deliveryAcknowledgeCommand,
401
438
  "submit-revision": sessionSubmitRevisionCommand,
402
439
  };
403
440
 
@@ -419,7 +456,7 @@ export async function sessionCommand(argv: string[]): Promise<number> {
419
456
 
420
457
  if (handler === undefined) {
421
458
  console.error(
422
- "usage: cueloop session <create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|resolve|submit-revision> [flags]",
459
+ "usage: cueloop session <create|get|list|wait|annotate|remove|cut|restore|curate|set-viewed|navigate|branch|switch|label|fork|name-self|events|send-message|bind-harness|pending-deliveries|acknowledge-delivery|submit-revision> [flags]",
423
460
  );
424
461
 
425
462
  return 2;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `cueloop wake <session-id>` - the production caller for the non-blocking wake.
3
- * A detached child of a coding-agent session parks on the review's verdict and
3
+ * A detached child of a coding-agent session parks on the review's message and
4
4
  * injects it back into that same live session when the human decides, so the
5
5
  * agent resumes itself without a blocked, pinned tool call. The plan skill (and
6
6
  * the Claude Code hook's non-blocking path) spawn this right after a