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 +6 -6
- package/src/daemon-control.test.ts +26 -1
- package/src/daemon-control.ts +1 -5
- package/src/dev-command.ts +1 -1
- package/src/main.ts +5 -5
- package/src/open-target.test.ts +2 -2
- package/src/open-target.ts +1 -1
- package/src/pr.ts +31 -53
- package/src/refine-command.test.ts +52 -23
- package/src/refine-command.ts +22 -242
- package/src/share-command.test.ts +5 -5
- package/src/share-command.ts +6 -9
- package/src/{session-commands.ts → thread-commands.ts} +48 -11
- package/src/wake-command.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a
|
|
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.
|
|
34
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
35
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
36
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
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);
|
package/src/daemon-control.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* talking to the old-version daemon (which refuses the handshake).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync
|
|
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();
|
package/src/dev-command.ts
CHANGED
|
@@ -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 "./
|
|
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|
|
|
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
|
|
348
|
-
" cueloop review-post <id> <pr> post a resolved session's
|
|
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",
|
package/src/open-target.test.ts
CHANGED
|
@@ -32,9 +32,9 @@ function session(overrides: {
|
|
|
32
32
|
},
|
|
33
33
|
revisions: [],
|
|
34
34
|
annotations: [],
|
|
35
|
-
|
|
35
|
+
message:
|
|
36
36
|
overrides.status === "resolved"
|
|
37
|
-
? {
|
|
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",
|
package/src/open-target.ts
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
console.error(
|
|
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:
|
|
70
|
-
title:
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
130
|
-
async function
|
|
131
|
-
const
|
|
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
|
-
|
|
142
|
-
|
|
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 ${
|
|
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,
|
|
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
|
|
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
|
|
27
|
-
return {
|
|
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
|
-
|
|
51
|
+
message: null,
|
|
44
52
|
status: "pending",
|
|
45
|
-
createdAt:
|
|
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", {
|
|
62
|
+
session("ses_a", { message: message("approved"), status: "resolved" }),
|
|
55
63
|
session("ses_b", {
|
|
56
64
|
type: "diff",
|
|
57
|
-
|
|
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("-
|
|
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", {
|
|
100
|
+
store.upsert(session("ses_a", { message: message("approved"), status: "resolved" }));
|
|
93
101
|
store.upsert(
|
|
94
102
|
session("ses_b", {
|
|
95
|
-
|
|
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
|
-
|
|
118
|
+
try {
|
|
119
|
+
const firstCode = await refineCommand(["--home", home]);
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
// Assert
|
|
122
|
+
expect(firstCode).toBe(0);
|
|
123
|
+
expect(readFileSync(reportPath, "utf8")).toContain("3 sessions analyzed (4 total).");
|
|
112
124
|
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
// Act
|
|
126
|
+
await refineCommand(["--home", home]);
|
|
115
127
|
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
package/src/refine-command.ts
CHANGED
|
@@ -1,67 +1,37 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
28
|
-
const
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
62
|
-
timestamped: timestampedPath,
|
|
63
|
-
analyzed: analyzed
|
|
64
|
-
total:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
43
|
-
function fakeClient(sessions: Thread[]):
|
|
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
|
-
|
|
99
|
+
sessionSendMessage: unimplemented("sessionSendMessage"),
|
|
100
100
|
close: () => {},
|
|
101
101
|
};
|
|
102
102
|
}
|
package/src/share-command.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* testable without a live gateway.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { DaemonClient, type
|
|
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
|
|
71
|
+
/** The share orchestration, over any ThreadClient - the seam the tests drive. */
|
|
72
72
|
export async function shareSession(
|
|
73
|
-
client:
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
|
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,
|
|
96
|
+
const { allow, message } = messageResponse(session);
|
|
97
97
|
|
|
98
|
-
out({ status: "resolved", allow,
|
|
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
|
|
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
|
|
346
|
-
|
|
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(
|
|
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
|
-
|
|
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|
|
|
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;
|
package/src/wake-command.ts
CHANGED
|
@@ -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
|
|
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
|