@cueloop/daemon 0.1.0-alpha.97 → 0.1.0-alpha.99

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.
@@ -74,6 +74,7 @@ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKe
74
74
  const remote = nativeJjRoot ? null : gitRemote;
75
75
 
76
76
  const workspace: WorkspaceKey = { repoRoot, branch };
77
+
77
78
  // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
78
79
  if (rootCommit) workspace.rootCommit = rootCommit;
79
80
  if (remote) workspace.remote = remote;
@@ -0,0 +1,174 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DaemonServer } from "./server";
6
+ import { DaemonClient } from "./client";
7
+ import { subscribeThreadState, connectThreadObserver } from "./thread-subscription";
8
+
9
+ import { createTestSignal } from "../../../test/helpers/test-signal";
10
+
11
+ test("subscription refresh discards stale reads and disposal suppresses late callbacks", async () => {
12
+ const home = mkdtempSync(join(tmpdir(), "cueloop-subscription-"));
13
+ const server = new DaemonServer({ home, idleExitMs: 0 });
14
+
15
+ server.start();
16
+ const client = await DaemonClient.connect({ home });
17
+ const firstRead = createTestSignal<void>();
18
+ const releaseFirst = createTestSignal<void>();
19
+ const latest = createTestSignal<void>();
20
+ const values: string[] = [];
21
+ let reads = 0;
22
+ let dispose = () => {};
23
+
24
+ try {
25
+ const thread = await client.sessionCreate(
26
+ { repoRoot: home, branch: "main" },
27
+ { type: "plan", content: "Initial", meta: {} },
28
+ );
29
+
30
+ dispose = subscribeThreadState({
31
+ connect: connectThreadObserver({ home }),
32
+ matches: (event) => event.sessionId === thread.id,
33
+ read: async (api) => {
34
+ const state = await api.sessionGet(thread.id);
35
+
36
+ if (++reads === 1) {
37
+ firstRead.resolve();
38
+ await releaseFirst.promise;
39
+ }
40
+
41
+ return state;
42
+ },
43
+ onValue: (state) => {
44
+ values.push(state.artifact.meta.title ?? "Initial");
45
+ latest.resolve();
46
+ },
47
+ onError: (error) => latest.reject(error),
48
+ });
49
+ await firstRead.promise;
50
+ await client.sessionSetTitle(thread.id, "New");
51
+ await latest.promise;
52
+ releaseFirst.resolve();
53
+ await Promise.resolve();
54
+ expect(values).toEqual(["New"]);
55
+ dispose();
56
+ await client.sessionSetTitle(thread.id, "After disposal");
57
+ expect(values).toEqual(["New"]);
58
+ } finally {
59
+ dispose();
60
+ client.close();
61
+ server.stop();
62
+ rmSync(home, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test("reconnect subscribes again and refreshes authoritative state even without a notification", async () => {
67
+ const home = mkdtempSync(join(tmpdir(), "cueloop-reconnect-"));
68
+ let server = new DaemonServer({ home, idleExitMs: 0 });
69
+
70
+ server.start();
71
+ const client = await DaemonClient.connect({ home });
72
+ const initial = createTestSignal<void>();
73
+ const recovered = createTestSignal<void>();
74
+ let connections = 0;
75
+ const values: string[] = [];
76
+ let dispose = () => {};
77
+
78
+ try {
79
+ const thread = await client.sessionCreate(
80
+ { repoRoot: home, branch: "main" },
81
+ { type: "plan", content: "Initial", meta: {} },
82
+ );
83
+
84
+ dispose = subscribeThreadState({
85
+ connect: connectThreadObserver({ home }),
86
+ reconnectMs: 1,
87
+ matches: (event) => event.sessionId === thread.id,
88
+ read: (api) => api.sessionGet(thread.id),
89
+ onConnect: () => {
90
+ connections++;
91
+ },
92
+ onValue: (state) => {
93
+ values.push(state.artifact.meta.title ?? "Initial");
94
+ if (connections === 1) initial.resolve();
95
+ else recovered.resolve();
96
+ },
97
+ onError() {},
98
+ });
99
+ await initial.promise;
100
+ server.stop();
101
+ server = new DaemonServer({ home, idleExitMs: 0 });
102
+ server.core.sessionSetTitle(thread.id, "Recovered");
103
+ server.start();
104
+ await recovered.promise;
105
+ expect(connections).toBe(2);
106
+ expect(values).toEqual(["Initial", "Recovered"]);
107
+ } finally {
108
+ dispose();
109
+ client.close();
110
+ server.stop();
111
+ rmSync(home, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("a malformed protocol frame stops the observer instead of reconnecting", async () => {
116
+ const { LineBuffer, parseRequestFrame } = await import("./protocol");
117
+ const { socketPath } = await import("./paths");
118
+ const { DAEMON_VERSION } = await import("./version");
119
+ const home = mkdtempSync(join(tmpdir(), "cueloop-subscription-protocol-"));
120
+ const failed = createTestSignal<void>();
121
+ const reads = new Map<object, InstanceType<typeof LineBuffer>>();
122
+ let connections = 0;
123
+ const server = Bun.listen({
124
+ unix: socketPath(home),
125
+ socket: {
126
+ open(socket) {
127
+ reads.set(socket, new LineBuffer());
128
+ connections++;
129
+ if (connections > 1) failed.reject(new Error("Unexpected reconnect"));
130
+ },
131
+ data(socket, bytes) {
132
+ reads.get(socket)!.push(bytes.toString(), (line) => {
133
+ const request = parseRequestFrame(line);
134
+
135
+ socket.write(
136
+ JSON.stringify({
137
+ id: request.id,
138
+ result:
139
+ request.method === "daemon.ping"
140
+ ? { pid: process.pid, version: DAEMON_VERSION }
141
+ : {},
142
+ }) + "\n",
143
+ );
144
+ if (request.method === "events.subscribe") socket.write("{broken\n");
145
+ });
146
+ },
147
+ close(socket) {
148
+ reads.delete(socket);
149
+ },
150
+ },
151
+ });
152
+ const dispose = subscribeThreadState({
153
+ connect: connectThreadObserver({ home }),
154
+ reconnectMs: 1,
155
+ matches: () => true,
156
+ read: (client) => client.ping(),
157
+ onValue() {},
158
+ onError: (error) => {
159
+ expect(error).toMatchObject({ kind: "protocol" });
160
+ failed.resolve();
161
+ },
162
+ });
163
+
164
+ try {
165
+ await failed.promise;
166
+ // A timer boundary proves no reconnect remains scheduled after the protocol closure.
167
+ await new Promise((resolve) => setTimeout(resolve, 20));
168
+ expect(connections).toBe(1);
169
+ } finally {
170
+ dispose();
171
+ server.stop(true);
172
+ rmSync(home, { recursive: true, force: true });
173
+ }
174
+ });
@@ -0,0 +1,127 @@
1
+ import { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
2
+ import { DaemonTransportError } from "./client-errors";
3
+ import type { EventFrame } from "./protocol";
4
+
5
+ /** Notifications are refresh hints; each connection starts with an authoritative read. */
6
+ export interface ThreadSubscriptionOptions<Value> {
7
+ connect: () => Promise<DaemonClient>;
8
+ matches: (event: EventFrame) => boolean;
9
+ read: (client: DaemonClient, signal: AbortSignal) => Promise<Value>;
10
+ onValue: (value: Value, client: DaemonClient) => void;
11
+ // eslint-disable-next-line type-evidence/no-unknown-parameters -- Socket and read failures arrive from an untyped Promise rejection boundary.
12
+ onError: (error: unknown) => void;
13
+ onConnect?: (client: DaemonClient) => void;
14
+ reconnectMs?: number;
15
+ }
16
+
17
+ /** Dispose removes listeners, aborts local reads, and closes only the observation connection. */
18
+ export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<Value>): () => void {
19
+ let stopped = false;
20
+ let generation = 0;
21
+ let connection: DaemonClient | undefined;
22
+ let detach: (() => void) | undefined;
23
+ let timer: ReturnType<typeof setTimeout> | undefined;
24
+ const reads = new Set<AbortController>();
25
+ const schedule = () => {
26
+ if (!stopped) timer = setTimeout(() => void connect(), options.reconnectMs ?? 250);
27
+ };
28
+ const connect = async () => {
29
+ const epoch = ++generation;
30
+ let request = 0;
31
+ let api: DaemonClient | undefined;
32
+
33
+ try {
34
+ api = await options.connect();
35
+ if (stopped || epoch !== generation) {
36
+ api.close();
37
+
38
+ return;
39
+ }
40
+ connection = api;
41
+ const current = api;
42
+ const refresh = async () => {
43
+ const version = ++request;
44
+
45
+ for (const read of reads) read.abort();
46
+ const controller = new AbortController();
47
+
48
+ reads.add(controller);
49
+ try {
50
+ const value = await options.read(current, controller.signal);
51
+
52
+ if (!stopped && epoch === generation && version === request)
53
+ options.onValue(value, current);
54
+ } catch (error) {
55
+ if (!stopped && epoch === generation && version === request) {
56
+ if (error instanceof Error && isFatalSubscriptionError(error)) {
57
+ stopped = true;
58
+ ++generation;
59
+ clearTimeout(timer);
60
+ detach?.();
61
+ current.close();
62
+ }
63
+ options.onError(error);
64
+ }
65
+ } finally {
66
+ reads.delete(controller);
67
+ }
68
+ };
69
+ const offEvent = api.onEvent((event) => {
70
+ if (options.matches(event)) void refresh();
71
+ });
72
+ const offDisconnect = api.onDisconnect((reason) => {
73
+ if (stopped || epoch !== generation) return;
74
+ ++generation;
75
+ detach?.();
76
+ for (const read of reads) read.abort();
77
+ connection = undefined;
78
+ current.close();
79
+ if (isFatalSubscriptionError(reason)) {
80
+ stopped = true;
81
+ options.onError(reason);
82
+ } else schedule();
83
+ });
84
+
85
+ detach = () => {
86
+ offEvent();
87
+ offDisconnect();
88
+ };
89
+ await api.subscribe();
90
+ if (stopped || epoch !== generation) return;
91
+ options.onConnect?.(api);
92
+ await refresh();
93
+ } catch (error) {
94
+ if (stopped || epoch !== generation) return;
95
+ ++generation;
96
+ detach?.();
97
+ api?.close();
98
+ connection = undefined;
99
+ options.onError(error);
100
+ if (!(error instanceof Error && isFatalSubscriptionError(error))) schedule();
101
+ }
102
+ };
103
+
104
+ void connect();
105
+
106
+ return () => {
107
+ stopped = true;
108
+ ++generation;
109
+ clearTimeout(timer);
110
+ detach?.();
111
+ for (const read of reads) read.abort();
112
+ reads.clear();
113
+ connection?.close();
114
+ };
115
+ }
116
+
117
+ /** Reconnect never replays writes, only subscribes and reads the latest Thread state. */
118
+ export function connectThreadObserver(options: ConnectOptions): () => Promise<DaemonClient> {
119
+ return () => DaemonClient.connect(options);
120
+ }
121
+
122
+ function isFatalSubscriptionError(error: Error): boolean {
123
+ return (
124
+ error instanceof DaemonClientError ||
125
+ (error instanceof DaemonTransportError && error.kind === "protocol")
126
+ );
127
+ }
@@ -330,6 +330,7 @@ describe("wire pins", () => {
330
330
  workingCopy: "# ",
331
331
  textCuts: [{ start: 2, end: 3, quote: "P" }],
332
332
  viewedPaths: ["src/a.ts"],
333
+ messageOperations: [{ operationId: "operation", fingerprint: "hash", result: fullMessage }],
333
334
  message: fullMessage,
334
335
  status: "pending",
335
336
  createdAt: "now",
package/src/validate.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  } from "@cueloop/schema";
42
42
  import { DaemonError } from "./errors";
43
43
  import type { Request } from "./protocol";
44
+ import { AgentCommentSchema } from "./thread-agent-validation";
44
45
 
45
46
  /**
46
47
  * Drift guard for every hand-mirrored shape below. v.object strips keys it
@@ -211,6 +212,27 @@ export const DeliverySchema = v.object({
211
212
  } satisfies EntriesOf<Delivery>);
212
213
 
213
214
  export const Params = {
215
+ "agent.configure": v.object({
216
+ id: SessionId,
217
+ configId: v.optional(NonEmpty),
218
+ value: v.optional(NonEmpty),
219
+ }),
220
+ "agent.get": v.object({ id: SessionId }),
221
+ "agent.prompt": v.object({
222
+ id: SessionId,
223
+ text: v.pipe(v.string(), v.maxLength(65_536)),
224
+ context: v.optional(v.pipe(v.string(), v.maxLength(32_768))),
225
+ retry: v.optional(NonEmpty),
226
+ operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
227
+ }),
228
+ "agent.reply": v.object({
229
+ id: SessionId,
230
+ commentId: NonEmpty,
231
+ body: v.pipe(NonEmpty, v.maxLength(32_768)),
232
+ }),
233
+ "agent.cancel": v.object({ id: SessionId }),
234
+ "agent.comment": v.object({ id: SessionId, comment: AgentCommentSchema }),
235
+ "agent.permission": v.object({ id: SessionId, requestId: NonEmpty, optionId: NonEmpty }),
214
236
  "session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
215
237
  "session.get": v.object({ id: SessionId }),
216
238
  "session.list": v.object({
@@ -306,6 +328,7 @@ export const Params = {
306
328
  outcome: v.picklist(MESSAGE_OUTCOMES),
307
329
  summary: v.optional(v.string(), ""),
308
330
  actionBodies: v.optional(v.record(v.string(), v.string())),
331
+ operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
309
332
  }),
310
333
  "harness.bind": v.object({
311
334
  threadId: SessionId,
@@ -481,6 +504,9 @@ export const ThreadRecordSchema = v.pipe(
481
504
  workingCopy: v.optional(v.string()),
482
505
  textCuts: v.optional(TextCutsSchema),
483
506
  viewedPaths: v.optional(v.array(v.string())),
507
+ messageOperations: v.optional(
508
+ v.array(v.object({ operationId: NonEmpty, fingerprint: NonEmpty, result: MessageSchema })),
509
+ ),
484
510
  message: v.nullable(MessageSchema),
485
511
  status: v.picklist(["pending", "resolved"]),
486
512
  createdAt: v.string(),
package/src/vcs-source.ts CHANGED
@@ -114,6 +114,7 @@ export class VcsSourceManager {
114
114
 
115
115
  for (const path of paths) {
116
116
  const absolute = isAbsolute(path) ? path : resolve(dirname(this.userConfigPath), path);
117
+
117
118
  await this.loadAdapterExtension(absolute);
118
119
  }
119
120
  const discovery = discoverInstalledExtensionPackages(this.installRoot);
@@ -15,6 +15,7 @@ async function git(args: string[], cwd: string): Promise<string | null> {
15
15
  /** Map a `git diff --name-status` letter to a diff status; renames/copies read as modified. */
16
16
  function statusFromCode(code: string): DiffFileStatus {
17
17
  const letter = code.charAt(0);
18
+
18
19
  if (letter === "D") return "deleted";
19
20
  if (letter === "A") return "added";
20
21
 
@@ -35,6 +36,7 @@ export async function workingChangeList(
35
36
  for (const line of tracked.split("\n")) {
36
37
  if (line.trim().length === 0) continue;
37
38
  const parts = line.split("\t");
39
+
38
40
  // a rename is "R100<tab>old<tab>new"; the new path (last field) is what the reviewer opens
39
41
  changes.push({ path: parts[parts.length - 1]!, status: statusFromCode(parts[0]!) });
40
42
  }