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 CHANGED
@@ -1,37 +1,38 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.33",
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
- "type": "module",
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
- "files": [
21
- "src",
22
- "README.md"
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
- "repository": {
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(flags: Record<string, string | boolean>, key: string): string | undefined {
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 = { host: stringFlag(flags, "host"), port: port !== undefined ? Number(port) : undefined };
81
- if (positional[0] === "pull") return sharePullCommand({ ...target, sessionId: positional[1] });
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 parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest");
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(isPrReview, "PR", explicitOpen || looksLikeSessionId ? selector : undefined);
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
  "",
@@ -30,7 +30,10 @@ function session(overrides: {
30
30
  },
31
31
  revisions: [],
32
32
  annotations: [],
33
- verdict: overrides.status === "resolved" ? { kind: "approve", summary: "", feedback: "", resolvedAt: "" } : null,
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({ kind: "session", sessionId: "ses_new" });
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({ kind: "session", sessionId: "ses_plan" });
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("no pending plan review - nothing to open");
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", { kind: "ambiguous", selector: "rollout", titles: ["A", "B"] });
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');
@@ -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.filter((candidate) => candidate.status === "pending").sort(newestFirst)[0];
66
- return latestPending ? { kind: "session", sessionId: latestPending.id } : { kind: "no-pending" };
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 { kind: "ambiguous", selector, titles: substringMatches.map((candidate) => candidate.artifact.meta.title!) };
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(label: string, target: Exclude<OpenTarget, { kind: "session" }>): string {
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(["pr", "review", pr, VERDICT_FLAG[verdict.kind], "--body", verdict.feedback]);
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;
@@ -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 ? (JSON.parse(await Bun.file(notesFile).text()) as ReviewNote[]) : undefined;
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 = { id, kind: "comment", anchor: { quote: "Plan", prefix: "", suffix: "" }, body: "note", createdAt: "2026-01-01T00:00:00.000Z" };
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) if (!known.has(annotation.id)) session.annotations.push(annotation);
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(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_abc123xy@cueloop.dev", copied: true }));
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(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), { sessionId: "ses_2" }, deps);
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(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }));
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({ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })) });
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 { pull: mock(async () => remote), out: (message: string) => void lines.push(message), lines };
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", { shareId: "p_abc123xy", annotations: [annotationFixture("mine")] });
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", { annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")] });
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", { shareId: "p_abc123xy", annotations: [annotationFixture("theirs", "SHA256:mate")] });
158
- const deps = pullDepsSpy(sessionFixture("ses_1", { annotations: [annotationFixture("theirs", "SHA256:mate")] }));
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(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
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);
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
10
- import { collaboratorAnnotations, publishShare, pullShare, shareIdFromLine, type ShareResult, type ShareTarget } from "@cueloop/client";
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(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
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(params: ShareParams, deps: PullDeps = defaultPullDeps): Promise<number> {
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(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
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(client: SessionClient, params: ShareParams, deps: PullDeps): Promise<number> {
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(added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations");
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(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
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(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
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
+ }
@@ -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 proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
5
- const out = await new Response(proc.stdout).text();
6
- if ((await proc.exited) !== 0) return null;
7
- return out.trim();
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<string> {
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 proc = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
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 out = await new Response(proc.stdout).text();
23
- const code = await proc.exited;
24
- if ((code === 0 || code === 1) && out.trim()) untracked += out.trim() + "\n";
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
  }