cueloop 0.1.0-alpha.33 → 0.1.0-alpha.35
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 +24 -23
- package/src/args.ts +4 -1
- package/src/main.ts +22 -6
- package/src/open-target.test.ts +21 -9
- package/src/open-target.ts +15 -4
- package/src/pr.ts +10 -1
- package/src/session-commands.ts +3 -1
- package/src/share-command.test.ts +53 -12
- package/src/share-command.ts +37 -8
- package/src/wake-command.test.ts +14 -0
- package/src/wake-command.ts +37 -0
- package/src/working-tree.ts +83 -10
package/package.json
CHANGED
|
@@ -1,37 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.35",
|
|
4
|
+
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
5
|
-
"
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/cli"
|
|
14
|
+
},
|
|
6
15
|
"bin": {
|
|
7
16
|
"cueloop": "./src/main.ts"
|
|
8
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
9
23
|
"exports": {
|
|
10
24
|
".": "./src/main.ts"
|
|
11
25
|
},
|
|
12
|
-
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.33",
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.33",
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.33"
|
|
16
|
-
},
|
|
17
26
|
"publishConfig": {
|
|
18
27
|
"access": "public"
|
|
19
28
|
},
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@cueloop/adapters": "0.1.0-alpha.35",
|
|
31
|
+
"@cueloop/client": "0.1.0-alpha.35",
|
|
32
|
+
"@cueloop/daemon": "0.1.0-alpha.35",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.35"
|
|
34
|
+
},
|
|
24
35
|
"engines": {
|
|
25
36
|
"bun": ">=1.3.0"
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
30
|
-
"directory": "packages/cli"
|
|
31
|
-
},
|
|
32
|
-
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
33
|
-
"bugs": {
|
|
34
|
-
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
35
|
-
},
|
|
36
|
-
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back"
|
|
37
|
-
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -31,7 +31,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
31
31
|
return { positional, flags };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function stringFlag(
|
|
34
|
+
export function stringFlag(
|
|
35
|
+
flags: Record<string, string | boolean>,
|
|
36
|
+
key: string,
|
|
37
|
+
): string | undefined {
|
|
35
38
|
const value = flags[key];
|
|
36
39
|
return typeof value === "string" ? value : undefined;
|
|
37
40
|
}
|
package/src/main.ts
CHANGED
|
@@ -77,10 +77,18 @@ async function main(): Promise<number> {
|
|
|
77
77
|
const { positional, flags } = parseArgs(argv.slice(1));
|
|
78
78
|
const { shareCommand, sharePullCommand } = await import("./share-command");
|
|
79
79
|
const port = stringFlag(flags, "port");
|
|
80
|
-
const target = {
|
|
81
|
-
|
|
80
|
+
const target = {
|
|
81
|
+
host: stringFlag(flags, "host"),
|
|
82
|
+
port: port !== undefined ? Number(port) : undefined,
|
|
83
|
+
};
|
|
84
|
+
if (positional[0] === "pull")
|
|
85
|
+
return sharePullCommand({ ...target, sessionId: positional[1] });
|
|
82
86
|
return shareCommand({ ...target, sessionId: positional[0] });
|
|
83
87
|
}
|
|
88
|
+
case "wake": {
|
|
89
|
+
const { wakeCommand } = await import("./wake-command");
|
|
90
|
+
return wakeCommand(argv.slice(1));
|
|
91
|
+
}
|
|
84
92
|
case "review":
|
|
85
93
|
return reviewEntry(argv.slice(1));
|
|
86
94
|
case "review-post": {
|
|
@@ -115,7 +123,9 @@ async function main(): Promise<number> {
|
|
|
115
123
|
* latest pending review.
|
|
116
124
|
*/
|
|
117
125
|
function openSelector(parsed: ParsedArgs): string | undefined {
|
|
118
|
-
return
|
|
126
|
+
return (
|
|
127
|
+
parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest")
|
|
128
|
+
);
|
|
119
129
|
}
|
|
120
130
|
|
|
121
131
|
/**
|
|
@@ -167,7 +177,7 @@ async function diffCommand(argv: string[]): Promise<number> {
|
|
|
167
177
|
|
|
168
178
|
const workspace = await resolveWorkspace();
|
|
169
179
|
const diff = await workingTreeDiff();
|
|
170
|
-
if (!diff.trim()) {
|
|
180
|
+
if (!diff.patch.trim()) {
|
|
171
181
|
return openReviewOfKind(
|
|
172
182
|
isDiffReview,
|
|
173
183
|
"diff",
|
|
@@ -178,7 +188,8 @@ async function diffCommand(argv: string[]): Promise<number> {
|
|
|
178
188
|
const client = await DaemonClient.connect({ autostart: true });
|
|
179
189
|
const review = await openReview(client, {
|
|
180
190
|
type: "diff",
|
|
181
|
-
content: diff,
|
|
191
|
+
content: diff.patch,
|
|
192
|
+
files: diff.files,
|
|
182
193
|
workspace,
|
|
183
194
|
title: `working tree @ ${workspace.branch}`,
|
|
184
195
|
});
|
|
@@ -202,7 +213,11 @@ async function reviewEntry(argv: string[]): Promise<number> {
|
|
|
202
213
|
const { reviewCommand } = await import("./pr");
|
|
203
214
|
return reviewCommand(argv);
|
|
204
215
|
}
|
|
205
|
-
return openReviewOfKind(
|
|
216
|
+
return openReviewOfKind(
|
|
217
|
+
isPrReview,
|
|
218
|
+
"PR",
|
|
219
|
+
explicitOpen || looksLikeSessionId ? selector : undefined,
|
|
220
|
+
);
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
async function runTui(sessionId?: string): Promise<number> {
|
|
@@ -239,6 +254,7 @@ function printHelp(): void {
|
|
|
239
254
|
"",
|
|
240
255
|
"scripting:",
|
|
241
256
|
" cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
|
|
257
|
+
" cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
|
|
242
258
|
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
243
259
|
" cueloop daemon run the daemon in the foreground",
|
|
244
260
|
"",
|
package/src/open-target.test.ts
CHANGED
|
@@ -30,7 +30,10 @@ function session(overrides: {
|
|
|
30
30
|
},
|
|
31
31
|
revisions: [],
|
|
32
32
|
annotations: [],
|
|
33
|
-
verdict:
|
|
33
|
+
verdict:
|
|
34
|
+
overrides.status === "resolved"
|
|
35
|
+
? { kind: "approve", summary: "", feedback: "", resolvedAt: "" }
|
|
36
|
+
: null,
|
|
34
37
|
status: overrides.status ?? "pending",
|
|
35
38
|
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
|
36
39
|
};
|
|
@@ -84,7 +87,10 @@ describe("resolveOpenTarget: latest pending default", () => {
|
|
|
84
87
|
];
|
|
85
88
|
|
|
86
89
|
// Assert
|
|
87
|
-
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
90
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
91
|
+
kind: "session",
|
|
92
|
+
sessionId: "ses_new",
|
|
93
|
+
});
|
|
88
94
|
});
|
|
89
95
|
|
|
90
96
|
test("ignores resolved sessions and other artifact types", () => {
|
|
@@ -96,7 +102,10 @@ describe("resolveOpenTarget: latest pending default", () => {
|
|
|
96
102
|
];
|
|
97
103
|
|
|
98
104
|
// Assert
|
|
99
|
-
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
105
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
106
|
+
kind: "session",
|
|
107
|
+
sessionId: "ses_plan",
|
|
108
|
+
});
|
|
100
109
|
});
|
|
101
110
|
|
|
102
111
|
test("no pending session in scope reports no-pending", () => {
|
|
@@ -111,10 +120,7 @@ describe("resolveOpenTarget: latest pending default", () => {
|
|
|
111
120
|
describe("resolveOpenTarget: by id", () => {
|
|
112
121
|
test("an exact session id in scope opens that session, pending or not", () => {
|
|
113
122
|
// Arrange
|
|
114
|
-
const sessions = [
|
|
115
|
-
session({ id: "ses_a" }),
|
|
116
|
-
session({ id: "ses_b", status: "resolved" }),
|
|
117
|
-
];
|
|
123
|
+
const sessions = [session({ id: "ses_a" }), session({ id: "ses_b", status: "resolved" })];
|
|
118
124
|
|
|
119
125
|
// Assert
|
|
120
126
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
|
|
@@ -193,7 +199,9 @@ describe("resolveOpenTarget: fuzzy by title", () => {
|
|
|
193
199
|
|
|
194
200
|
describe("openTargetMessage", () => {
|
|
195
201
|
test("no-pending mirrors the clean-tree tone", () => {
|
|
196
|
-
expect(openTargetMessage("plan", { kind: "no-pending" })).toBe(
|
|
202
|
+
expect(openTargetMessage("plan", { kind: "no-pending" })).toBe(
|
|
203
|
+
"no pending plan review - nothing to open",
|
|
204
|
+
);
|
|
197
205
|
});
|
|
198
206
|
|
|
199
207
|
test("no-match names the selector", () => {
|
|
@@ -204,7 +212,11 @@ describe("openTargetMessage", () => {
|
|
|
204
212
|
|
|
205
213
|
test("ambiguous lists the candidate titles", () => {
|
|
206
214
|
// Act
|
|
207
|
-
const message = openTargetMessage("plan", {
|
|
215
|
+
const message = openTargetMessage("plan", {
|
|
216
|
+
kind: "ambiguous",
|
|
217
|
+
selector: "rollout",
|
|
218
|
+
titles: ["A", "B"],
|
|
219
|
+
});
|
|
208
220
|
|
|
209
221
|
// Assert
|
|
210
222
|
expect(message).toContain('"rollout" matches several plan reviews');
|
package/src/open-target.ts
CHANGED
|
@@ -62,8 +62,12 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
|
|
|
62
62
|
const scoped = sessions.filter(query.match);
|
|
63
63
|
|
|
64
64
|
if (query.selector === undefined) {
|
|
65
|
-
const latestPending = scoped
|
|
66
|
-
|
|
65
|
+
const latestPending = scoped
|
|
66
|
+
.filter((candidate) => candidate.status === "pending")
|
|
67
|
+
.sort(newestFirst)[0];
|
|
68
|
+
return latestPending
|
|
69
|
+
? { kind: "session", sessionId: latestPending.id }
|
|
70
|
+
: { kind: "no-pending" };
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
const selector = query.selector;
|
|
@@ -83,7 +87,11 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
|
|
|
83
87
|
.sort(newestFirst);
|
|
84
88
|
if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
|
|
85
89
|
if (substringMatches.length === 0) return { kind: "no-match", selector };
|
|
86
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
kind: "ambiguous",
|
|
92
|
+
selector,
|
|
93
|
+
titles: substringMatches.map((candidate) => candidate.artifact.meta.title!),
|
|
94
|
+
};
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/**
|
|
@@ -91,7 +99,10 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
|
|
|
91
99
|
* to review" tone. `label` is the human word for the verb's scope ("plan",
|
|
92
100
|
* "diff", "PR"). The "session" outcome has no message - the caller opens it.
|
|
93
101
|
*/
|
|
94
|
-
export function openTargetMessage(
|
|
102
|
+
export function openTargetMessage(
|
|
103
|
+
label: string,
|
|
104
|
+
target: Exclude<OpenTarget, { kind: "session" }>,
|
|
105
|
+
): string {
|
|
95
106
|
switch (target.kind) {
|
|
96
107
|
case "no-pending":
|
|
97
108
|
return `no pending ${label} review - nothing to open`;
|
package/src/pr.ts
CHANGED
|
@@ -56,6 +56,8 @@ export async function reviewCommand(argv: string[]): Promise<number> {
|
|
|
56
56
|
return 1;
|
|
57
57
|
}
|
|
58
58
|
const client = await DaemonClient.connect({ autostart: true });
|
|
59
|
+
// A PR diff is a partial patch with no full file contents, so no `files` here;
|
|
60
|
+
// hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
|
|
59
61
|
const review = await openReview(client, {
|
|
60
62
|
type: "diff",
|
|
61
63
|
content: diff.stdout,
|
|
@@ -109,7 +111,14 @@ async function getSession(id: string): Promise<ReviewSession> {
|
|
|
109
111
|
/** Post the resolved session's verdict to the PR: feedback.md is the review body. */
|
|
110
112
|
async function postVerdict(session: ReviewSession, pr: string): Promise<number> {
|
|
111
113
|
const verdict = session.verdict!;
|
|
112
|
-
const result = await gh([
|
|
114
|
+
const result = await gh([
|
|
115
|
+
"pr",
|
|
116
|
+
"review",
|
|
117
|
+
pr,
|
|
118
|
+
VERDICT_FLAG[verdict.kind],
|
|
119
|
+
"--body",
|
|
120
|
+
verdict.feedback,
|
|
121
|
+
]);
|
|
113
122
|
if (result.code !== 0) {
|
|
114
123
|
console.error(result.stderr.trim() || `gh pr review ${pr} failed (exit ${result.code})`);
|
|
115
124
|
return 1;
|
package/src/session-commands.ts
CHANGED
|
@@ -29,7 +29,9 @@ export async function sessionCommand(argv: string[]): Promise<number> {
|
|
|
29
29
|
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
30
30
|
// per-file agent notes for diff sessions: a JSON array of { path, body }
|
|
31
31
|
const notesFile = stringFlag(flags, "notes-file");
|
|
32
|
-
const notes = notesFile
|
|
32
|
+
const notes = notesFile
|
|
33
|
+
? (JSON.parse(await Bun.file(notesFile).text()) as ReviewNote[])
|
|
34
|
+
: undefined;
|
|
33
35
|
const review = await openReview(client, {
|
|
34
36
|
type: (stringFlag(flags, "type") ?? "plan") as ArtifactType,
|
|
35
37
|
content,
|
|
@@ -19,7 +19,13 @@ function sessionFixture(id: string, overrides: Partial<ReviewSession> = {}): Rev
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function annotationFixture(id: string, author?: string): Annotation {
|
|
22
|
-
const base: Annotation = {
|
|
22
|
+
const base: Annotation = {
|
|
23
|
+
id,
|
|
24
|
+
kind: "comment",
|
|
25
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
26
|
+
body: "note",
|
|
27
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
28
|
+
};
|
|
23
29
|
return author ? { ...base, author } : base;
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -36,7 +42,8 @@ function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
|
36
42
|
sessionMergeShared: mock(async (id: string, incoming: { annotations: Annotation[] }) => {
|
|
37
43
|
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
38
44
|
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
39
|
-
for (const annotation of incoming.annotations)
|
|
45
|
+
for (const annotation of incoming.annotations)
|
|
46
|
+
if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
40
47
|
return session;
|
|
41
48
|
}),
|
|
42
49
|
} as unknown as SessionClient;
|
|
@@ -55,11 +62,20 @@ function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: strin
|
|
|
55
62
|
describe(shareSession, () => {
|
|
56
63
|
test("publishes the named session and reports the copied ssh line", async () => {
|
|
57
64
|
// Arrange
|
|
58
|
-
const publish = mock(
|
|
65
|
+
const publish = mock(
|
|
66
|
+
async (session: ReviewSession) => (
|
|
67
|
+
expect(session.id).toBe("ses_2"),
|
|
68
|
+
{ line: "ssh p_abc123xy@cueloop.dev", copied: true }
|
|
69
|
+
),
|
|
70
|
+
);
|
|
59
71
|
const deps = depsSpy({ publish });
|
|
60
72
|
|
|
61
73
|
// Act
|
|
62
|
-
const code = await shareSession(
|
|
74
|
+
const code = await shareSession(
|
|
75
|
+
fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]),
|
|
76
|
+
{ sessionId: "ses_2" },
|
|
77
|
+
deps,
|
|
78
|
+
);
|
|
63
79
|
|
|
64
80
|
// Assert
|
|
65
81
|
expect(code).toBe(0);
|
|
@@ -68,7 +84,12 @@ describe(shareSession, () => {
|
|
|
68
84
|
|
|
69
85
|
test("without an id, shares the most recent session", async () => {
|
|
70
86
|
// Arrange
|
|
71
|
-
const publish = mock(
|
|
87
|
+
const publish = mock(
|
|
88
|
+
async (session: ReviewSession) => (
|
|
89
|
+
expect(session.id).toBe("ses_2"),
|
|
90
|
+
{ line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }
|
|
91
|
+
),
|
|
92
|
+
);
|
|
72
93
|
const deps = depsSpy({ publish });
|
|
73
94
|
|
|
74
95
|
// Act
|
|
@@ -80,7 +101,9 @@ describe(shareSession, () => {
|
|
|
80
101
|
|
|
81
102
|
test("prints the bare line when the clipboard is unavailable", async () => {
|
|
82
103
|
// Arrange
|
|
83
|
-
const deps = depsSpy({
|
|
104
|
+
const deps = depsSpy({
|
|
105
|
+
publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })),
|
|
106
|
+
});
|
|
84
107
|
|
|
85
108
|
// Act
|
|
86
109
|
await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
|
|
@@ -118,15 +141,24 @@ describe(shareSession, () => {
|
|
|
118
141
|
|
|
119
142
|
function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
|
|
120
143
|
const lines: string[] = [];
|
|
121
|
-
return {
|
|
144
|
+
return {
|
|
145
|
+
pull: mock(async () => remote),
|
|
146
|
+
out: (message: string) => void lines.push(message),
|
|
147
|
+
lines,
|
|
148
|
+
};
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
describe(pullSession, () => {
|
|
125
152
|
test("unions collaborator notes in, ignoring the planner's own unauthored notes", async () => {
|
|
126
153
|
// Arrange - the planner's own note carries no author; the collaborator's does
|
|
127
|
-
const local = sessionFixture("ses_1", {
|
|
154
|
+
const local = sessionFixture("ses_1", {
|
|
155
|
+
shareId: "p_abc123xy",
|
|
156
|
+
annotations: [annotationFixture("mine")],
|
|
157
|
+
});
|
|
128
158
|
const client = fakeClient([local]);
|
|
129
|
-
const remote = sessionFixture("ses_1", {
|
|
159
|
+
const remote = sessionFixture("ses_1", {
|
|
160
|
+
annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")],
|
|
161
|
+
});
|
|
130
162
|
const deps = pullDepsSpy(remote);
|
|
131
163
|
|
|
132
164
|
// Act
|
|
@@ -154,8 +186,13 @@ describe(pullSession, () => {
|
|
|
154
186
|
|
|
155
187
|
test("reports when nothing new came back", async () => {
|
|
156
188
|
// Arrange
|
|
157
|
-
const local = sessionFixture("ses_1", {
|
|
158
|
-
|
|
189
|
+
const local = sessionFixture("ses_1", {
|
|
190
|
+
shareId: "p_abc123xy",
|
|
191
|
+
annotations: [annotationFixture("theirs", "SHA256:mate")],
|
|
192
|
+
});
|
|
193
|
+
const deps = pullDepsSpy(
|
|
194
|
+
sessionFixture("ses_1", { annotations: [annotationFixture("theirs", "SHA256:mate")] }),
|
|
195
|
+
);
|
|
159
196
|
|
|
160
197
|
// Act
|
|
161
198
|
await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
|
|
@@ -169,7 +206,11 @@ describe(pullSession, () => {
|
|
|
169
206
|
const deps = pullDepsSpy(sessionFixture("ses_1"));
|
|
170
207
|
|
|
171
208
|
// Act
|
|
172
|
-
const code = await pullSession(
|
|
209
|
+
const code = await pullSession(
|
|
210
|
+
fakeClient([sessionFixture("ses_1")]),
|
|
211
|
+
{ sessionId: "ses_1" },
|
|
212
|
+
deps,
|
|
213
|
+
);
|
|
173
214
|
|
|
174
215
|
// Assert
|
|
175
216
|
expect(code).toBe(1);
|
package/src/share-command.ts
CHANGED
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
collaboratorAnnotations,
|
|
12
|
+
publishShare,
|
|
13
|
+
pullShare,
|
|
14
|
+
shareIdFromLine,
|
|
15
|
+
type ShareResult,
|
|
16
|
+
type ShareTarget,
|
|
17
|
+
} from "@cueloop/client";
|
|
11
18
|
import type { ReviewSession } from "@cueloop/schema";
|
|
12
19
|
|
|
13
20
|
export interface ShareParams {
|
|
@@ -31,7 +38,10 @@ const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => consol
|
|
|
31
38
|
const defaultPullDeps: PullDeps = { pull: pullShare, out: (message) => console.log(message) };
|
|
32
39
|
|
|
33
40
|
/** Connect to the local daemon, then share the chosen session. */
|
|
34
|
-
export async function shareCommand(
|
|
41
|
+
export async function shareCommand(
|
|
42
|
+
params: ShareParams,
|
|
43
|
+
deps: ShareDeps = defaultDeps,
|
|
44
|
+
): Promise<number> {
|
|
35
45
|
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
36
46
|
try {
|
|
37
47
|
return await shareSession(client, params, deps);
|
|
@@ -41,7 +51,10 @@ export async function shareCommand(params: ShareParams, deps: ShareDeps = defaul
|
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
/** Connect to the local daemon, then pull collaborator notes for the plan. */
|
|
44
|
-
export async function sharePullCommand(
|
|
54
|
+
export async function sharePullCommand(
|
|
55
|
+
params: ShareParams,
|
|
56
|
+
deps: PullDeps = defaultPullDeps,
|
|
57
|
+
): Promise<number> {
|
|
45
58
|
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
46
59
|
try {
|
|
47
60
|
return await pullSession(client, params, deps);
|
|
@@ -51,7 +64,11 @@ export async function sharePullCommand(params: ShareParams, deps: PullDeps = def
|
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/** The share orchestration, over any SessionClient - the seam the tests drive. */
|
|
54
|
-
export async function shareSession(
|
|
67
|
+
export async function shareSession(
|
|
68
|
+
client: SessionClient,
|
|
69
|
+
params: ShareParams,
|
|
70
|
+
deps: ShareDeps,
|
|
71
|
+
): Promise<number> {
|
|
55
72
|
const session = await pickSession(client, params.sessionId);
|
|
56
73
|
if (!session) {
|
|
57
74
|
deps.out("no plan to share - open a review first");
|
|
@@ -69,7 +86,11 @@ export async function shareSession(client: SessionClient, params: ShareParams, d
|
|
|
69
86
|
* plan. Union-by-id keeps the planner's own notes; only collaborators' new
|
|
70
87
|
* ones land. The gateway lets only the fingerprint that shared it pull.
|
|
71
88
|
*/
|
|
72
|
-
export async function pullSession(
|
|
89
|
+
export async function pullSession(
|
|
90
|
+
client: SessionClient,
|
|
91
|
+
params: ShareParams,
|
|
92
|
+
deps: PullDeps,
|
|
93
|
+
): Promise<number> {
|
|
73
94
|
const session = await pickSharedSession(client, params.sessionId);
|
|
74
95
|
if (!session) {
|
|
75
96
|
deps.out("no shared plan to pull - run `cueloop share` first");
|
|
@@ -82,19 +103,27 @@ export async function pullSession(client: SessionClient, params: ShareParams, de
|
|
|
82
103
|
participants: remote.participants,
|
|
83
104
|
});
|
|
84
105
|
const added = merged.annotations.length - before;
|
|
85
|
-
deps.out(
|
|
106
|
+
deps.out(
|
|
107
|
+
added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations",
|
|
108
|
+
);
|
|
86
109
|
return 0;
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
/** The named session, or the most recent one when no id is given. */
|
|
90
|
-
async function pickSession(
|
|
113
|
+
async function pickSession(
|
|
114
|
+
client: SessionClient,
|
|
115
|
+
sessionId?: string,
|
|
116
|
+
): Promise<ReviewSession | null> {
|
|
91
117
|
if (sessionId) return client.sessionGet(sessionId);
|
|
92
118
|
const sessions = await client.sessionList();
|
|
93
119
|
return sessions.at(-1) ?? null;
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
/** The named session (if shared), or the most recent shared one. */
|
|
97
|
-
async function pickSharedSession(
|
|
123
|
+
async function pickSharedSession(
|
|
124
|
+
client: SessionClient,
|
|
125
|
+
sessionId?: string,
|
|
126
|
+
): Promise<ReviewSession | null> {
|
|
98
127
|
if (sessionId) {
|
|
99
128
|
const session = await client.sessionGet(sessionId);
|
|
100
129
|
return session.shareId ? session : null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** cueloop wake argument guards: a missing session id and a codex wake without a thread id both fail with a usage code before any daemon or harness work. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { wakeCommand } from "./wake-command";
|
|
5
|
+
|
|
6
|
+
describe("wakeCommand argument guards", () => {
|
|
7
|
+
test("a missing session id is a usage error", async () => {
|
|
8
|
+
expect(await wakeCommand([])).toBe(2);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a codex wake without a thread id is a usage error", async () => {
|
|
12
|
+
expect(await wakeCommand(["ses_1", "--harness", "codex"])).toBe(2);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* injects it back into that same live session when the human decides, so the
|
|
5
|
+
* agent resumes itself without a blocked, pinned tool call. The plan skill (and
|
|
6
|
+
* the Claude Code hook's non-blocking path) spawn this right after a
|
|
7
|
+
* non-blocking review: `cueloop wake <session-id> &`.
|
|
8
|
+
*
|
|
9
|
+
* Claude Code is the default and reads its inbox socket from the environment.
|
|
10
|
+
* Codex needs its running thread id (`--harness codex --thread <thread-id>`).
|
|
11
|
+
* pi wakes in-process from its extension and does not use this command.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseArgs, stringFlag } from "./args";
|
|
15
|
+
|
|
16
|
+
export async function wakeCommand(argv: string[]): Promise<number> {
|
|
17
|
+
const { positional, flags } = parseArgs(argv);
|
|
18
|
+
const sessionId = positional[0];
|
|
19
|
+
if (sessionId === undefined) {
|
|
20
|
+
console.error(
|
|
21
|
+
"usage: cueloop wake <session-id> [--harness claude-code|codex] [--thread <codex-thread-id>]",
|
|
22
|
+
);
|
|
23
|
+
return 2;
|
|
24
|
+
}
|
|
25
|
+
const harness = stringFlag(flags, "harness") ?? "claude-code";
|
|
26
|
+
if (harness === "codex") {
|
|
27
|
+
const threadId = stringFlag(flags, "thread");
|
|
28
|
+
if (threadId === undefined) {
|
|
29
|
+
console.error("cueloop wake --harness codex needs --thread <codex-thread-id>");
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
const { runCodexWake } = await import("@cueloop/adapters/codex/wake");
|
|
33
|
+
return (await runCodexWake(sessionId, threadId)) ? 0 : 1;
|
|
34
|
+
}
|
|
35
|
+
const { runInboxWake } = await import("@cueloop/adapters/claude-code/wake");
|
|
36
|
+
return (await runInboxWake(sessionId)) ? 0 : 1;
|
|
37
|
+
}
|
package/src/working-tree.ts
CHANGED
|
@@ -1,27 +1,100 @@
|
|
|
1
1
|
/** Working-tree diff capture for `cueloop diff`, untracked files included. */
|
|
2
2
|
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
5
|
+
|
|
3
6
|
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
if ((await
|
|
7
|
-
return
|
|
7
|
+
const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
8
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
9
|
+
if ((await gitProcess.exited) !== 0) return null;
|
|
10
|
+
return stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
|
|
14
|
+
async function headContents(path: string, cwd: string): Promise<string> {
|
|
15
|
+
const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
|
|
16
|
+
cwd,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "ignore",
|
|
19
|
+
});
|
|
20
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
21
|
+
return (await gitProcess.exited) === 0 ? stdout : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The working-tree file, or "" when it is gone (deleted). */
|
|
25
|
+
async function workingContents(path: string, cwd: string): Promise<string> {
|
|
26
|
+
try {
|
|
27
|
+
return await Bun.file(join(cwd, path)).text();
|
|
28
|
+
} catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A NUL byte marks content git would treat as binary - not text-curatable. */
|
|
34
|
+
function looksBinary(text: string): boolean {
|
|
35
|
+
for (let index = 0; index < text.length; index++) {
|
|
36
|
+
if (text.charCodeAt(index) === 0) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Full old/new contents for every tracked change against HEAD, classified by
|
|
43
|
+
* git's own status letter. Renames/copies and binary files are omitted: a
|
|
44
|
+
* path+text pair cannot express a rename header or a binary blob, so curation
|
|
45
|
+
* would emit a patch that drops the old path or corrupts the file - leaving them
|
|
46
|
+
* out keeps those changes in the patch but simply not curatable. The
|
|
47
|
+
* NUL-delimited stream survives paths with spaces or unicode.
|
|
48
|
+
*/
|
|
49
|
+
async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
50
|
+
const nameStatus = (await git(["diff", "HEAD", "--name-status", "-z"], cwd)) ?? "";
|
|
51
|
+
const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
|
|
52
|
+
const files: DiffFileContents[] = [];
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
while (cursor < tokens.length) {
|
|
55
|
+
const code = tokens[cursor++]![0];
|
|
56
|
+
if (code === "R" || code === "C") {
|
|
57
|
+
cursor += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const path = tokens[cursor++]!;
|
|
61
|
+
const oldContents = code === "A" ? "" : await headContents(path, cwd);
|
|
62
|
+
const newContents = code === "D" ? "" : await workingContents(path, cwd);
|
|
63
|
+
if (looksBinary(oldContents) || looksBinary(newContents)) continue;
|
|
64
|
+
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
|
|
65
|
+
files.push({ path, oldContents, newContents, status });
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface WorkingTreeDiff {
|
|
71
|
+
patch: string;
|
|
72
|
+
/** Full file contents per curatable changed file, keeping curation applyable. */
|
|
73
|
+
files: DiffFileContents[];
|
|
8
74
|
}
|
|
9
75
|
|
|
10
|
-
export async function workingTreeDiff(cwd = process.cwd()): Promise<
|
|
76
|
+
export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeDiff> {
|
|
11
77
|
const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
|
|
78
|
+
const files = await trackedFileContents(cwd);
|
|
12
79
|
// untracked files included by default
|
|
13
80
|
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
14
81
|
let untracked = "";
|
|
15
82
|
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
|
16
83
|
// `git diff --no-index` exits 1 when the files differ - that is success here
|
|
17
|
-
const
|
|
84
|
+
const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
|
|
18
85
|
cwd,
|
|
19
86
|
stdout: "pipe",
|
|
20
87
|
stderr: "ignore",
|
|
21
88
|
});
|
|
22
|
-
const
|
|
23
|
-
const code = await
|
|
24
|
-
if ((code === 0 || code === 1) &&
|
|
89
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
90
|
+
const code = await gitProcess.exited;
|
|
91
|
+
if ((code === 0 || code === 1) && stdout.trim()) {
|
|
92
|
+
untracked += stdout.trim() + "\n";
|
|
93
|
+
const newContents = await workingContents(file, cwd);
|
|
94
|
+
if (!looksBinary(newContents)) {
|
|
95
|
+
files.push({ path: file, oldContents: "", newContents, status: "added" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
25
98
|
}
|
|
26
|
-
return [tracked, untracked].filter(Boolean).join("\n");
|
|
99
|
+
return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
|
|
27
100
|
}
|