@cueloop/daemon 0.1.0-alpha.10 → 0.1.0-alpha.101
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/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +44 -22
- package/src/agent-tools.ts +98 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.test.ts +1081 -65
- package/src/api.ts +1425 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +95 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.test.ts +37 -0
- package/src/client.ts +820 -94
- package/src/curate.ts +109 -0
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.test.ts +117 -0
- package/src/diff-watcher.ts +326 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +269 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +30 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +85 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +66 -0
- package/src/herdr-thread-surface.test.ts +465 -0
- package/src/herdr-thread-surface.ts +378 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +98 -0
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +134 -0
- package/src/project-files.ts +68 -0
- package/src/protocol.ts +78 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.test.ts +502 -42
- package/src/server.ts +631 -88
- package/src/share-blob.test.ts +119 -0
- package/src/share-blob.ts +57 -0
- package/src/shared-agent-protocol.ts +30 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +173 -0
- package/src/store.ts +267 -34
- package/src/testing/store-conformance.ts +192 -0
- package/src/thread-agent-validation.ts +121 -0
- package/src/thread-agent.test.ts +719 -0
- package/src/thread-agent.ts +835 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +382 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +129 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +279 -37
- package/src/validate.ts +422 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +229 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +150 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
package/src/client.ts
CHANGED
|
@@ -1,171 +1,846 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon client: the one library every consumer shares - CLI
|
|
3
|
-
* adapters, and tests. Also owns the lazy-launch story
|
|
2
|
+
* Daemon client: the one library every consumer shares - CLI primitives, the TUI,
|
|
3
|
+
* adapters, and tests. Also owns the lazy-launch story: connect() with
|
|
4
4
|
* autostart spawns a detached daemon when the socket is dead, then attaches.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
appendFileSync,
|
|
11
|
+
closeSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { createConnection } from "node:net";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import * as v from "valibot";
|
|
21
|
+
import type {
|
|
22
|
+
Annotation,
|
|
23
|
+
Artifact,
|
|
24
|
+
DiffFileStatus,
|
|
25
|
+
DiffFileContents,
|
|
26
|
+
HunkRejection,
|
|
27
|
+
ShareLink,
|
|
28
|
+
Thread,
|
|
29
|
+
HarnessBinding,
|
|
30
|
+
Delivery,
|
|
31
|
+
PendingDelivery,
|
|
32
|
+
MessageOutcome,
|
|
33
|
+
WorkspaceKey,
|
|
34
|
+
TextCut,
|
|
35
|
+
} from "@cueloop/schema";
|
|
36
|
+
import {
|
|
37
|
+
BackpressureWriter,
|
|
38
|
+
LineBuffer,
|
|
39
|
+
parseInboundFrame,
|
|
40
|
+
type EventFrame,
|
|
41
|
+
type Request,
|
|
42
|
+
type Response,
|
|
43
|
+
} from "./protocol";
|
|
44
|
+
import type { DaemonRole } from "./capabilities";
|
|
45
|
+
import type { HerdrThreadSurfaceHandle } from "./herdr-thread-surface-store";
|
|
46
|
+
import type { GhosttyThreadSurfaceHandle } from "./ghostty-thread-surface-store";
|
|
47
|
+
import type { SharedMerge } from "./api";
|
|
48
|
+
|
|
49
|
+
export type { SharedMerge } from "./api";
|
|
50
|
+
import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
|
|
51
|
+
import {
|
|
52
|
+
Params,
|
|
53
|
+
ThreadRecordSchema,
|
|
54
|
+
DiffFileContentsSchema,
|
|
55
|
+
HarnessBindingSchema,
|
|
56
|
+
DeliverySchema,
|
|
57
|
+
PendingDeliverySchema,
|
|
58
|
+
} from "./validate";
|
|
59
|
+
import type { WorkingTreeDiff } from "./working-tree";
|
|
60
|
+
import { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
|
|
61
|
+
export { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
|
|
62
|
+
import { DAEMON_VERSION } from "./version";
|
|
63
|
+
import { ThreadAgentSchema } from "./thread-agent-validation";
|
|
64
|
+
import type { AgentComment, ThreadAgentState, AgentPromptRequest } from "@cueloop/schema";
|
|
65
|
+
|
|
66
|
+
export type { EventFrame } from "./protocol";
|
|
11
67
|
|
|
12
68
|
export interface ConnectOptions {
|
|
13
69
|
home?: string;
|
|
14
|
-
/** Spawn the daemon when the socket is not alive. */
|
|
15
70
|
autostart?: boolean;
|
|
71
|
+
role?: DaemonRole;
|
|
72
|
+
author?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type PendingRequest = {
|
|
76
|
+
resolve: (value: Response["result"]) => void;
|
|
77
|
+
reject: (error: Error) => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const EmptyResultSchema = v.object({});
|
|
81
|
+
// version is optional: a daemon from before the handshake carried one reads as
|
|
82
|
+
// undefined, which never equals this build.
|
|
83
|
+
const PingResultSchema = v.object({ pid: v.number(), version: v.optional(v.string()) });
|
|
84
|
+
const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
85
|
+
const HerdrThreadSurfaceResultSchema = v.nullable(
|
|
86
|
+
v.object({
|
|
87
|
+
tabId: v.string(),
|
|
88
|
+
paneId: v.string(),
|
|
89
|
+
mode: v.optional(v.picklist(["tab", "pane"])),
|
|
90
|
+
layout: v.optional(v.literal("pair")),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const GhosttyThreadSurfaceResultSchema = v.nullable(v.object({ terminalId: v.string() }));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The session primitives the review controller drives. DaemonClient is the local
|
|
97
|
+
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
98
|
+
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
99
|
+
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
100
|
+
*/
|
|
101
|
+
export interface ThreadClient {
|
|
102
|
+
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
103
|
+
subscribe(): Promise<void>;
|
|
104
|
+
sessionGet(id: string): Promise<Thread>;
|
|
105
|
+
sessionCreate?(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread>;
|
|
106
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
|
|
107
|
+
sessionComment(
|
|
108
|
+
id: string,
|
|
109
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
110
|
+
authorName?: string,
|
|
111
|
+
): Promise<Thread>;
|
|
112
|
+
sessionAnnotate(
|
|
113
|
+
id: string,
|
|
114
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
115
|
+
authorName?: string,
|
|
116
|
+
): Promise<Thread>;
|
|
117
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread>;
|
|
118
|
+
sessionSetWorkingCopy(
|
|
119
|
+
id: string,
|
|
120
|
+
workingCopy: string | undefined,
|
|
121
|
+
textCuts?: TextCut[],
|
|
122
|
+
): Promise<Thread>;
|
|
123
|
+
sessionCutBlock(id: string, blockIndex: number): Promise<Thread>;
|
|
124
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<Thread>;
|
|
125
|
+
sessionCurate(id: string, rejections: HunkRejection[]): Promise<Thread>;
|
|
126
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<Thread>;
|
|
127
|
+
sessionSetTitle(id: string, title: string): Promise<Thread>;
|
|
128
|
+
projectFiles?(sessionId: string): Promise<string[]>;
|
|
129
|
+
fileContents?(sessionId: string, path: string): Promise<string | null>;
|
|
130
|
+
repoFiles?(cwd: string): Promise<string[]>;
|
|
131
|
+
repoFileContents?(cwd: string, path: string): Promise<string | null>;
|
|
132
|
+
repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
|
|
133
|
+
repoDiff?(cwd: string, vcs?: string): Promise<WorkingTreeDiff>;
|
|
134
|
+
sessionRefreshDiff?(id: string): Promise<{ changed: boolean }>;
|
|
135
|
+
sessionWorkbench?(cwd: string): Promise<Thread>;
|
|
136
|
+
sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Promise<Thread>;
|
|
137
|
+
sessionBranch(id: string, name: string): Promise<Thread>;
|
|
138
|
+
sessionSwitch(id: string, branch: string): Promise<Thread>;
|
|
139
|
+
sessionLabel(id: string, label: string): Promise<Thread>;
|
|
140
|
+
sessionFork(id: string): Promise<Thread>;
|
|
141
|
+
sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread>;
|
|
142
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread>;
|
|
143
|
+
sessionDelete(id: string): Promise<void>;
|
|
144
|
+
sessionSetSelfName(id: string, name: string): Promise<Thread>;
|
|
145
|
+
sessionSendMessage(
|
|
146
|
+
id: string,
|
|
147
|
+
outcome: MessageOutcome,
|
|
148
|
+
summary: string,
|
|
149
|
+
actionBodies?: Record<string, string>,
|
|
150
|
+
): Promise<Thread>;
|
|
151
|
+
harnessBind?(
|
|
152
|
+
threadId: string,
|
|
153
|
+
harness: string,
|
|
154
|
+
harnessSessionId: string,
|
|
155
|
+
): Promise<HarnessBinding>;
|
|
156
|
+
harnessGetBinding?(bindingId: string): Promise<HarnessBinding>;
|
|
157
|
+
harnessBindingsForSession?(harness: string, harnessSessionId: string): Promise<HarnessBinding[]>;
|
|
158
|
+
harnessConsumeApprovedRetry?(
|
|
159
|
+
bindingId: string,
|
|
160
|
+
messageId: string,
|
|
161
|
+
content: string,
|
|
162
|
+
): Promise<boolean>;
|
|
163
|
+
deliveryPending?(bindingId: string): Promise<PendingDelivery[]>;
|
|
164
|
+
deliveryAcknowledge?(deliveryId: string): Promise<Delivery>;
|
|
165
|
+
close(): void;
|
|
16
166
|
}
|
|
17
167
|
|
|
18
|
-
|
|
168
|
+
/** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
|
|
169
|
+
interface HelloParams {
|
|
170
|
+
role: DaemonRole;
|
|
171
|
+
clientVersion: string;
|
|
172
|
+
token?: string;
|
|
173
|
+
author?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The token as the daemon writes it: 32 random bytes in hex. */
|
|
177
|
+
const OwnerTokenSchema = v.pipe(v.string(), v.trim(), v.regex(/^[0-9a-f]{64}$/));
|
|
178
|
+
|
|
179
|
+
/** The owner token in `home`, or undefined when the daemon there never wrote one. */
|
|
180
|
+
function readOwnerToken(home: string): string | undefined {
|
|
181
|
+
const path = ownerTokenPath(home);
|
|
182
|
+
|
|
183
|
+
if (!existsSync(path)) return undefined;
|
|
184
|
+
const parsed = v.safeParse(OwnerTokenSchema, readFileSync(path, "utf8"));
|
|
185
|
+
|
|
186
|
+
if (!parsed.success) {
|
|
187
|
+
throw new DaemonClientError(
|
|
188
|
+
"invalid_owner_token",
|
|
189
|
+
`${path} is not an owner token; restart the daemon to mint a fresh one`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return parsed.output;
|
|
194
|
+
}
|
|
19
195
|
|
|
20
|
-
export class DaemonClient {
|
|
21
|
-
private socket:
|
|
22
|
-
private
|
|
196
|
+
export class DaemonClient implements ThreadClient {
|
|
197
|
+
private socket: { end(): void } | null = null;
|
|
198
|
+
private writer: BackpressureWriter | null = null;
|
|
199
|
+
private connectionEpoch = 0;
|
|
200
|
+
private pending = new Map<number, PendingRequest>();
|
|
201
|
+
/** The connected daemon's build version, learned from the ping handshake. */
|
|
202
|
+
private daemonVersion: string | undefined;
|
|
23
203
|
private nextId = 1;
|
|
24
|
-
private eventListeners = new Set<(
|
|
204
|
+
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
205
|
+
private disconnectListeners = new Set<(reason: DaemonTransportError) => void>();
|
|
206
|
+
private disconnectReason: DaemonTransportError | undefined;
|
|
25
207
|
private closed = false;
|
|
208
|
+
private role: DaemonRole = "owner";
|
|
209
|
+
private author: string | undefined;
|
|
210
|
+
private home = cueloopHome();
|
|
26
211
|
|
|
27
|
-
static async connect(
|
|
28
|
-
const home =
|
|
212
|
+
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
213
|
+
const home = options.home ?? cueloopHome();
|
|
29
214
|
const path = socketPath(home);
|
|
30
215
|
const client = new DaemonClient();
|
|
216
|
+
|
|
217
|
+
client.role = options.role ?? "owner";
|
|
218
|
+
client.author = options.author;
|
|
219
|
+
client.home = home;
|
|
31
220
|
try {
|
|
32
221
|
await client.dial(path);
|
|
33
|
-
return client;
|
|
222
|
+
if (client.daemonVersion === DAEMON_VERSION) return client;
|
|
223
|
+
const daemonVersion = client.daemonVersion ?? "unknown";
|
|
224
|
+
|
|
225
|
+
client.close();
|
|
226
|
+
throw new DaemonClientError(
|
|
227
|
+
"version_mismatch",
|
|
228
|
+
`daemon is version ${daemonVersion}, but this client is ${DAEMON_VERSION}; align cueloop versions before restarting the daemon`,
|
|
229
|
+
);
|
|
34
230
|
} catch (err) {
|
|
35
|
-
|
|
231
|
+
// Autostart repairs an unavailable socket, never a live incompatible daemon.
|
|
232
|
+
if (!options.autostart || err instanceof DaemonClientError) {
|
|
233
|
+
client.close();
|
|
234
|
+
|
|
235
|
+
throw err instanceof DaemonClientError || err instanceof DaemonTransportError
|
|
236
|
+
? err
|
|
237
|
+
: new DaemonTransportError(
|
|
238
|
+
"connection",
|
|
239
|
+
"Daemon connection failed",
|
|
240
|
+
"not_sent",
|
|
241
|
+
undefined,
|
|
242
|
+
{ cause: err },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
36
245
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
246
|
+
|
|
247
|
+
// Socket dead or absent: let the new daemon own stale socket cleanup.
|
|
248
|
+
return client.attachFreshDaemon(home, path);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Reset per-connection state so a fresh dial() can reuse this client instance. */
|
|
252
|
+
private resetConnection(): void {
|
|
253
|
+
this.connectionEpoch += 1;
|
|
254
|
+
this.socket = null;
|
|
255
|
+
this.writer = null;
|
|
256
|
+
this.closed = false;
|
|
257
|
+
for (const pendingRequest of this.pending.values())
|
|
258
|
+
pendingRequest.reject(
|
|
259
|
+
new DaemonTransportError("connection", "daemon connection replaced", "unknown"),
|
|
260
|
+
);
|
|
261
|
+
this.pending.clear();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Spawn a detached daemon and dial the lock owner until it answers. */
|
|
265
|
+
private async attachFreshDaemon(home: string, path: string): Promise<DaemonClient> {
|
|
266
|
+
const startupLogPath = spawnDaemon(home);
|
|
40
267
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
41
268
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
42
269
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
43
|
-
let
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
270
|
+
let lastError: unknown;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
while (Date.now() < deadline) {
|
|
274
|
+
try {
|
|
275
|
+
await this.dial(path);
|
|
276
|
+
if (this.daemonVersion !== DAEMON_VERSION) {
|
|
277
|
+
this.close();
|
|
278
|
+
if (typeof Bun === "undefined") {
|
|
279
|
+
throw new DaemonClientError(
|
|
280
|
+
"version_mismatch",
|
|
281
|
+
`installed cueloop is ${this.daemonVersion ?? "unknown"}, but this adapter is ${DAEMON_VERSION}; run cueloop update`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
throw new Error("daemon autostarted an older build");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return this;
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err instanceof DaemonClientError) throw err;
|
|
291
|
+
this.socket?.end();
|
|
292
|
+
this.resetConnection();
|
|
293
|
+
lastError = err;
|
|
294
|
+
await sleep(50);
|
|
295
|
+
}
|
|
51
296
|
}
|
|
297
|
+
const output = readFileSync(startupLogPath, "utf8").trim();
|
|
298
|
+
const detail = output ? `\ndaemon startup output:\n${output.slice(-4_096)}` : "";
|
|
299
|
+
|
|
300
|
+
throw new DaemonTransportError(
|
|
301
|
+
"connection",
|
|
302
|
+
`Daemon startup failed at ${path}: ${String(lastError)}${detail}`,
|
|
303
|
+
"not_sent",
|
|
304
|
+
);
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(startupLogPath, { force: true });
|
|
52
307
|
}
|
|
53
|
-
throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
|
|
54
308
|
}
|
|
55
309
|
|
|
56
310
|
private async dial(path: string): Promise<void> {
|
|
57
311
|
const buffer = new LineBuffer();
|
|
58
|
-
|
|
59
|
-
this.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
312
|
+
|
|
313
|
+
this.disconnectReason = undefined;
|
|
314
|
+
const epoch = ++this.connectionEpoch;
|
|
315
|
+
|
|
316
|
+
const close = () => {
|
|
317
|
+
if (this.connectionEpoch !== epoch) return;
|
|
318
|
+
this.closed = true;
|
|
319
|
+
for (const pendingRequest of this.pending.values())
|
|
320
|
+
pendingRequest.reject(
|
|
321
|
+
new DaemonTransportError("connection", "daemon connection closed", "unknown"),
|
|
322
|
+
);
|
|
323
|
+
this.pending.clear();
|
|
324
|
+
const reason =
|
|
325
|
+
this.disconnectReason ??
|
|
326
|
+
new DaemonTransportError("connection", "daemon connection closed", "unknown");
|
|
327
|
+
|
|
328
|
+
for (const listener of this.disconnectListeners) listener(reason);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
if (typeof Bun !== "undefined") {
|
|
332
|
+
const socket = await Bun.connect({
|
|
333
|
+
unix: path,
|
|
334
|
+
socket: {
|
|
335
|
+
data: (_socket, data) => {
|
|
336
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
337
|
+
},
|
|
338
|
+
drain: () => this.writer?.drain(),
|
|
339
|
+
close,
|
|
340
|
+
error() {},
|
|
64
341
|
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
this.socket = socket;
|
|
345
|
+
this.writer = new BackpressureWriter(socket);
|
|
346
|
+
} else {
|
|
347
|
+
const socket = createConnection(path);
|
|
348
|
+
|
|
349
|
+
await new Promise<void>((resolve, reject) => {
|
|
350
|
+
socket.once("connect", resolve);
|
|
351
|
+
socket.once("error", reject);
|
|
352
|
+
});
|
|
353
|
+
socket.on("data", (data) => {
|
|
354
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
355
|
+
});
|
|
356
|
+
socket.on("close", close);
|
|
357
|
+
socket.on("error", () => {});
|
|
358
|
+
this.socket = socket;
|
|
359
|
+
// Node queues the complete Buffer internally when write returns false.
|
|
360
|
+
this.writer = new BackpressureWriter({
|
|
361
|
+
write(data) {
|
|
362
|
+
socket.write(data);
|
|
363
|
+
|
|
364
|
+
return data.length;
|
|
69
365
|
},
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
73
368
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
74
|
-
// only to fail later, so a ping is the actual handshake.
|
|
75
|
-
|
|
369
|
+
// only to fail later, so a ping is the actual handshake. It also carries the
|
|
370
|
+
// daemon's build version, so connect() can reject incompatible clients.
|
|
371
|
+
const pong = await this.request("daemon.ping", {}, PingResultSchema, 2_000);
|
|
372
|
+
|
|
373
|
+
this.daemonVersion = pong.version;
|
|
374
|
+
// Every connection starts as a collaborator; the owner proves itself with
|
|
375
|
+
// the token the daemon wrote into the home it serves, which only the home's
|
|
376
|
+
// user can read. A capped role just names itself.
|
|
377
|
+
await this.request("daemon.hello", this.helloParams(), EmptyResultSchema, 2_000);
|
|
76
378
|
}
|
|
77
379
|
|
|
78
|
-
|
|
380
|
+
private helloParams(): HelloParams {
|
|
381
|
+
if (this.role !== "owner") {
|
|
382
|
+
return this.author === undefined
|
|
383
|
+
? { role: this.role, clientVersion: DAEMON_VERSION }
|
|
384
|
+
: { role: this.role, clientVersion: DAEMON_VERSION, author: this.author };
|
|
385
|
+
}
|
|
386
|
+
const token = readOwnerToken(this.home);
|
|
387
|
+
|
|
388
|
+
// a daemon from before owner tokens has no file; it still knows the bare hello
|
|
389
|
+
return token === undefined
|
|
390
|
+
? { role: "owner", clientVersion: DAEMON_VERSION }
|
|
391
|
+
: { role: "owner", clientVersion: DAEMON_VERSION, token };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
79
395
|
this.eventListeners.add(listener);
|
|
396
|
+
|
|
80
397
|
return () => this.eventListeners.delete(listener);
|
|
81
398
|
}
|
|
82
399
|
|
|
83
|
-
|
|
84
|
-
|
|
400
|
+
/** Notify an adapter when its daemon socket closes so it can reconnect and replay Messages. */
|
|
401
|
+
onDisconnect(listener: (reason: DaemonTransportError) => void): () => void {
|
|
402
|
+
this.disconnectListeners.add(listener);
|
|
403
|
+
|
|
404
|
+
return () => this.disconnectListeners.delete(listener);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private routeInboundFrame(line: string): void {
|
|
408
|
+
let frame;
|
|
409
|
+
|
|
85
410
|
try {
|
|
86
|
-
frame =
|
|
87
|
-
} catch {
|
|
411
|
+
frame = parseInboundFrame(line);
|
|
412
|
+
} catch (cause) {
|
|
413
|
+
this.disconnectReason = new DaemonTransportError(
|
|
414
|
+
"protocol",
|
|
415
|
+
"daemon sent a malformed frame",
|
|
416
|
+
"unknown",
|
|
417
|
+
undefined,
|
|
418
|
+
{ cause },
|
|
419
|
+
);
|
|
420
|
+
for (const pendingRequest of this.pending.values())
|
|
421
|
+
pendingRequest.reject(
|
|
422
|
+
new DaemonTransportError(
|
|
423
|
+
"protocol",
|
|
424
|
+
"daemon sent a malformed frame",
|
|
425
|
+
"unknown",
|
|
426
|
+
undefined,
|
|
427
|
+
{ cause },
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
this.pending.clear();
|
|
431
|
+
this.close();
|
|
432
|
+
|
|
88
433
|
return;
|
|
89
434
|
}
|
|
90
435
|
if ("event" in frame) {
|
|
91
|
-
for (const
|
|
436
|
+
for (const listener of this.eventListeners) listener(frame);
|
|
437
|
+
|
|
92
438
|
return;
|
|
93
439
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
440
|
+
const pendingRequest = this.pending.get(frame.id);
|
|
441
|
+
|
|
442
|
+
if (!pendingRequest) return;
|
|
96
443
|
this.pending.delete(frame.id);
|
|
97
|
-
if (frame.error)
|
|
98
|
-
|
|
444
|
+
if (frame.error)
|
|
445
|
+
pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
446
|
+
else pendingRequest.resolve(frame.result);
|
|
99
447
|
}
|
|
100
448
|
|
|
101
|
-
request<
|
|
102
|
-
|
|
449
|
+
request<TOutput>(
|
|
450
|
+
method: string,
|
|
451
|
+
params: Request["params"],
|
|
452
|
+
resultSchema: v.GenericSchema<unknown, TOutput>,
|
|
453
|
+
options: number | DaemonRequestOptions = 30_000,
|
|
454
|
+
): Promise<TOutput> {
|
|
455
|
+
const { timeoutMs = 30_000, signal } = v.is(v.number(), options)
|
|
456
|
+
? { timeoutMs: options }
|
|
457
|
+
: options;
|
|
458
|
+
|
|
459
|
+
if (signal?.aborted)
|
|
460
|
+
return Promise.reject(
|
|
461
|
+
new DaemonTransportError("cancelled", `request ${method} cancelled`, "not_sent", method),
|
|
462
|
+
);
|
|
463
|
+
if (this.closed || !this.socket)
|
|
464
|
+
return Promise.reject(
|
|
465
|
+
new DaemonTransportError("connection", "not connected", "not_sent", method),
|
|
466
|
+
);
|
|
103
467
|
const id = this.nextId++;
|
|
104
|
-
|
|
105
|
-
|
|
468
|
+
|
|
469
|
+
return new Promise<TOutput>((resolve, reject) => {
|
|
470
|
+
const cleanup = () => {
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
signal?.removeEventListener("abort", abort);
|
|
106
473
|
this.pending.delete(id);
|
|
107
|
-
|
|
474
|
+
};
|
|
475
|
+
const fail = (error: Error) => {
|
|
476
|
+
cleanup();
|
|
477
|
+
reject(error);
|
|
478
|
+
};
|
|
479
|
+
const abort = () =>
|
|
480
|
+
fail(
|
|
481
|
+
new DaemonTransportError("cancelled", `request ${method} cancelled`, "unknown", method),
|
|
482
|
+
);
|
|
483
|
+
const timer = setTimeout(() => {
|
|
484
|
+
fail(new DaemonTransportError("timeout", `request ${method} timed out`, "unknown", method));
|
|
108
485
|
}, timeoutMs);
|
|
486
|
+
|
|
109
487
|
this.pending.set(id, {
|
|
110
|
-
resolve: (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
488
|
+
resolve: (value) => {
|
|
489
|
+
cleanup();
|
|
490
|
+
try {
|
|
491
|
+
const parsed = v.safeParse(resultSchema, value);
|
|
492
|
+
|
|
493
|
+
if (!parsed.success)
|
|
494
|
+
throw new DaemonTransportError(
|
|
495
|
+
"protocol",
|
|
496
|
+
`request ${method} returned an invalid result`,
|
|
497
|
+
"unknown",
|
|
498
|
+
method,
|
|
499
|
+
{ cause: parsed.issues },
|
|
500
|
+
);
|
|
501
|
+
resolve(parsed.output);
|
|
502
|
+
} catch (cause) {
|
|
503
|
+
reject(
|
|
504
|
+
cause instanceof DaemonTransportError
|
|
505
|
+
? cause
|
|
506
|
+
: new DaemonTransportError(
|
|
507
|
+
"protocol",
|
|
508
|
+
`request ${method} returned an invalid result`,
|
|
509
|
+
"unknown",
|
|
510
|
+
method,
|
|
511
|
+
{ cause },
|
|
512
|
+
),
|
|
513
|
+
);
|
|
514
|
+
}
|
|
117
515
|
},
|
|
516
|
+
reject: fail,
|
|
118
517
|
});
|
|
119
|
-
|
|
518
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
519
|
+
try {
|
|
520
|
+
this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
|
|
521
|
+
} catch (cause) {
|
|
522
|
+
fail(
|
|
523
|
+
new DaemonTransportError(
|
|
524
|
+
"connection",
|
|
525
|
+
`request ${method} could not be written`,
|
|
526
|
+
"unknown",
|
|
527
|
+
method,
|
|
528
|
+
{ cause },
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
120
532
|
});
|
|
121
533
|
}
|
|
122
534
|
|
|
123
535
|
close(): void {
|
|
124
536
|
this.closed = true;
|
|
537
|
+
for (const pendingRequest of this.pending.values())
|
|
538
|
+
pendingRequest.reject(
|
|
539
|
+
new DaemonTransportError("connection", "daemon connection closed", "unknown"),
|
|
540
|
+
);
|
|
541
|
+
this.pending.clear();
|
|
125
542
|
this.socket?.end();
|
|
126
543
|
}
|
|
127
544
|
|
|
128
|
-
// ── typed
|
|
545
|
+
// ── typed primitives ─────────────────────────────
|
|
546
|
+
/** Read the agent transcript without starting a model request. */
|
|
547
|
+
agentGet(id: string, options?: DaemonRequestOptions): Promise<ThreadAgentState> {
|
|
548
|
+
return this.request("agent.get", { id }, ThreadAgentSchema, options);
|
|
549
|
+
}
|
|
550
|
+
/** Submit a question and optional selected passage to the daemon-owned agent. */
|
|
551
|
+
agentPrompt(
|
|
552
|
+
params: AgentPromptRequest,
|
|
553
|
+
options?: DaemonRequestOptions,
|
|
554
|
+
): Promise<ThreadAgentState> {
|
|
555
|
+
return this.request("agent.prompt", params, ThreadAgentSchema, options);
|
|
556
|
+
}
|
|
557
|
+
/** Cancel the active turn; the daemon retains partial output. */
|
|
558
|
+
agentCancel(id: string): Promise<ThreadAgentState> {
|
|
559
|
+
return this.request("agent.cancel", { id }, ThreadAgentSchema);
|
|
560
|
+
}
|
|
561
|
+
/** Save quote-primary feedback on a finalized agent answer. */
|
|
562
|
+
agentComment(params: { id: string; comment: AgentComment }): Promise<ThreadAgentState> {
|
|
563
|
+
return this.request("agent.comment", params, ThreadAgentSchema);
|
|
564
|
+
}
|
|
565
|
+
/** Configure only an option advertised by the current harness. */
|
|
566
|
+
agentConfigure(params: {
|
|
567
|
+
id: string;
|
|
568
|
+
configId?: string;
|
|
569
|
+
value?: string;
|
|
570
|
+
}): Promise<ThreadAgentState> {
|
|
571
|
+
return this.request("agent.configure", params, ThreadAgentSchema);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Select an option from the active agent permission card. */
|
|
575
|
+
agentPermission(params: {
|
|
576
|
+
id: string;
|
|
577
|
+
requestId: string;
|
|
578
|
+
optionId: string;
|
|
579
|
+
}): Promise<ThreadAgentState> {
|
|
580
|
+
return this.request("agent.permission", params, ThreadAgentSchema);
|
|
581
|
+
}
|
|
129
582
|
ping(): Promise<{ pid: number }> {
|
|
130
|
-
return this.request("daemon.ping", {});
|
|
583
|
+
return this.request("daemon.ping", {}, PingResultSchema);
|
|
131
584
|
}
|
|
132
585
|
subscribe(): Promise<void> {
|
|
133
|
-
return this.request("events.subscribe", {});
|
|
586
|
+
return this.request("events.subscribe", {}, EmptyResultSchema).then(() => undefined);
|
|
134
587
|
}
|
|
135
|
-
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<
|
|
136
|
-
return this.request("session.create", { workspace, artifact });
|
|
588
|
+
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread> {
|
|
589
|
+
return this.request("session.create", { workspace, artifact }, ThreadRecordSchema);
|
|
137
590
|
}
|
|
138
|
-
sessionGet(id: string): Promise<
|
|
139
|
-
return this.request("session.get", { id });
|
|
591
|
+
sessionGet(id: string): Promise<Thread> {
|
|
592
|
+
return this.request("session.get", { id }, ThreadRecordSchema);
|
|
140
593
|
}
|
|
141
|
-
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<
|
|
142
|
-
return this.request("session.list", { filter });
|
|
594
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]> {
|
|
595
|
+
return this.request("session.list", { filter }, v.array(ThreadRecordSchema));
|
|
143
596
|
}
|
|
144
597
|
/** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
|
|
145
|
-
sessionWait(id: string, timeoutMs: number): Promise<
|
|
146
|
-
return this.request(
|
|
598
|
+
sessionWait(id: string, timeoutMs: number): Promise<Thread | null> {
|
|
599
|
+
return this.request(
|
|
600
|
+
"session.wait",
|
|
601
|
+
{ id, timeoutMs },
|
|
602
|
+
v.nullable(ThreadRecordSchema),
|
|
603
|
+
timeoutMs + 10_000,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
sessionComment(
|
|
607
|
+
id: string,
|
|
608
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
609
|
+
authorName?: string,
|
|
610
|
+
): Promise<Thread> {
|
|
611
|
+
return this.request("session.comment", { id, annotation, authorName }, ThreadRecordSchema);
|
|
612
|
+
}
|
|
613
|
+
sessionAnnotate(
|
|
614
|
+
id: string,
|
|
615
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
616
|
+
authorName?: string,
|
|
617
|
+
): Promise<Thread> {
|
|
618
|
+
return this.request("session.annotate", { id, annotation, authorName }, ThreadRecordSchema);
|
|
619
|
+
}
|
|
620
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread> {
|
|
621
|
+
return this.request("session.removeAnnotation", { id, annotationId }, ThreadRecordSchema);
|
|
622
|
+
}
|
|
623
|
+
/** Register a display name for a participant - a collaborator's or an agent's own, on a share or locally. */
|
|
624
|
+
sessionSetParticipantName(id: string, author: string, name: string): Promise<Thread> {
|
|
625
|
+
return this.request("session.setParticipantName", { id, author, name }, ThreadRecordSchema);
|
|
626
|
+
}
|
|
627
|
+
sessionSetWorkingCopy(
|
|
628
|
+
id: string,
|
|
629
|
+
workingCopy: string | undefined,
|
|
630
|
+
textCuts?: TextCut[],
|
|
631
|
+
): Promise<Thread> {
|
|
632
|
+
return this.request(
|
|
633
|
+
"session.setWorkingCopy",
|
|
634
|
+
{ id, workingCopy, textCuts },
|
|
635
|
+
ThreadRecordSchema,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
sessionCutBlock(id: string, blockIndex: number): Promise<Thread> {
|
|
639
|
+
return this.request("session.cutBlock", { id, blockIndex }, ThreadRecordSchema);
|
|
147
640
|
}
|
|
148
|
-
|
|
149
|
-
|
|
641
|
+
sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Promise<Thread> {
|
|
642
|
+
const params: v.InferInput<(typeof Params)["session.navigate"]> = { id, entryId };
|
|
643
|
+
|
|
644
|
+
if (summary !== undefined) params.summary = summary;
|
|
645
|
+
if (branch !== undefined) params.branch = branch;
|
|
646
|
+
|
|
647
|
+
return this.request("session.navigate", params, ThreadRecordSchema);
|
|
648
|
+
}
|
|
649
|
+
sessionBranch(id: string, name: string): Promise<Thread> {
|
|
650
|
+
return this.request("session.branch", { id, name }, ThreadRecordSchema);
|
|
651
|
+
}
|
|
652
|
+
sessionSwitch(id: string, branch: string): Promise<Thread> {
|
|
653
|
+
return this.request("session.switch", { id, branch }, ThreadRecordSchema);
|
|
654
|
+
}
|
|
655
|
+
sessionLabel(id: string, label: string): Promise<Thread> {
|
|
656
|
+
return this.request("session.label", { id, label }, ThreadRecordSchema);
|
|
657
|
+
}
|
|
658
|
+
sessionFork(id: string): Promise<Thread> {
|
|
659
|
+
return this.request("session.fork", { id }, ThreadRecordSchema);
|
|
660
|
+
}
|
|
661
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<Thread> {
|
|
662
|
+
return this.request(
|
|
663
|
+
"session.restoreBlock",
|
|
664
|
+
line === undefined ? { id, baseBlockIndex } : { id, baseBlockIndex, line },
|
|
665
|
+
ThreadRecordSchema,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
sessionCurate(id: string, rejections: HunkRejection[]): Promise<Thread> {
|
|
669
|
+
return this.request("session.curate", { id, rejections }, ThreadRecordSchema);
|
|
150
670
|
}
|
|
151
|
-
|
|
152
|
-
return this.request("session.
|
|
671
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<Thread> {
|
|
672
|
+
return this.request("session.setViewed", { id, viewedPaths }, ThreadRecordSchema);
|
|
153
673
|
}
|
|
154
|
-
|
|
155
|
-
return this.request("session.
|
|
674
|
+
sessionSetTitle(id: string, title: string): Promise<Thread> {
|
|
675
|
+
return this.request("session.setTitle", { id, title }, ThreadRecordSchema);
|
|
156
676
|
}
|
|
157
|
-
|
|
158
|
-
return this.request("session.
|
|
677
|
+
projectFiles(sessionId: string): Promise<string[]> {
|
|
678
|
+
return this.request("session.projectFiles", { id: sessionId }, v.array(v.string()));
|
|
159
679
|
}
|
|
160
|
-
|
|
161
|
-
return this.request("session.
|
|
680
|
+
fileContents(sessionId: string, path: string): Promise<string | null> {
|
|
681
|
+
return this.request("session.fileContents", { id: sessionId, path }, v.nullable(v.string()));
|
|
682
|
+
}
|
|
683
|
+
repoFiles(cwd: string): Promise<string[]> {
|
|
684
|
+
return this.request("repo.files", { cwd }, v.array(v.string()));
|
|
685
|
+
}
|
|
686
|
+
repoFileContents(cwd: string, path: string): Promise<string | null> {
|
|
687
|
+
return this.request("repo.fileContents", { cwd, path }, v.nullable(v.string()));
|
|
688
|
+
}
|
|
689
|
+
repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
|
|
690
|
+
return this.request(
|
|
691
|
+
"repo.changes",
|
|
692
|
+
{ cwd },
|
|
693
|
+
v.array(v.object({ path: v.string(), status: v.picklist(["added", "modified", "deleted"]) })),
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
repoDiff(
|
|
697
|
+
cwd: string,
|
|
698
|
+
vcs?: string,
|
|
699
|
+
): Promise<
|
|
700
|
+
WorkingTreeDiff & { vcs?: string; source?: { changeId?: string; revisionId: string } }
|
|
701
|
+
> {
|
|
702
|
+
return this.request(
|
|
703
|
+
"repo.diff",
|
|
704
|
+
{ cwd, vcs },
|
|
705
|
+
v.object({
|
|
706
|
+
patch: v.string(),
|
|
707
|
+
files: v.array(DiffFileContentsSchema),
|
|
708
|
+
vcs: v.optional(v.string()),
|
|
709
|
+
source: v.optional(v.object({ changeId: v.optional(v.string()), revisionId: v.string() })),
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
sessionWorkbench(cwd: string): Promise<Thread> {
|
|
714
|
+
return this.request("session.workbench", { cwd }, ThreadRecordSchema);
|
|
715
|
+
}
|
|
716
|
+
/** Open a workbench with the diff snapshot used for its first frame. */
|
|
717
|
+
workbenchReview(cwd: string): Promise<{ session: Thread; diff: WorkingTreeDiff }> {
|
|
718
|
+
return this.request(
|
|
719
|
+
"session.workbenchReview",
|
|
720
|
+
{ cwd },
|
|
721
|
+
v.object({
|
|
722
|
+
session: ThreadRecordSchema,
|
|
723
|
+
diff: v.object({
|
|
724
|
+
patch: v.string(),
|
|
725
|
+
files: v.array(DiffFileContentsSchema),
|
|
726
|
+
vcs: v.string(),
|
|
727
|
+
source: v.optional(
|
|
728
|
+
v.object({ changeId: v.optional(v.string()), revisionId: v.string() }),
|
|
729
|
+
),
|
|
730
|
+
}),
|
|
731
|
+
}),
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
735
|
+
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
736
|
+
return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread> {
|
|
740
|
+
return this.request("session.setShares", { id, shares }, ThreadRecordSchema);
|
|
741
|
+
}
|
|
742
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread> {
|
|
743
|
+
return this.request("session.mergeShared", { id, ...incoming }, ThreadRecordSchema);
|
|
744
|
+
}
|
|
745
|
+
sessionDelete(id: string): Promise<void> {
|
|
746
|
+
return this.request("session.delete", { id }, EmptyResultSchema).then(() => undefined);
|
|
747
|
+
}
|
|
748
|
+
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
749
|
+
sessionSetSelfName(id: string, _name: string): Promise<Thread> {
|
|
750
|
+
return this.sessionGet(id);
|
|
751
|
+
}
|
|
752
|
+
sessionSendMessage(
|
|
753
|
+
id: string,
|
|
754
|
+
outcome: MessageOutcome,
|
|
755
|
+
summary: string,
|
|
756
|
+
actionBodies?: Record<string, string>,
|
|
757
|
+
operationId?: string,
|
|
758
|
+
options?: DaemonRequestOptions,
|
|
759
|
+
): Promise<Thread> {
|
|
760
|
+
return this.request(
|
|
761
|
+
"session.sendMessage",
|
|
762
|
+
{ id, outcome, summary, actionBodies, operationId },
|
|
763
|
+
ThreadRecordSchema,
|
|
764
|
+
options,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
harnessBind(
|
|
768
|
+
threadId: string,
|
|
769
|
+
harness: string,
|
|
770
|
+
harnessSessionId: string,
|
|
771
|
+
): Promise<HarnessBinding> {
|
|
772
|
+
return this.request(
|
|
773
|
+
"harness.bind",
|
|
774
|
+
{ threadId, harness, harnessSessionId },
|
|
775
|
+
HarnessBindingSchema,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
harnessGetBinding(bindingId: string): Promise<HarnessBinding> {
|
|
779
|
+
return this.request("harness.getBinding", { bindingId }, HarnessBindingSchema);
|
|
780
|
+
}
|
|
781
|
+
harnessBindingsForSession(harness: string, harnessSessionId: string): Promise<HarnessBinding[]> {
|
|
782
|
+
return this.request(
|
|
783
|
+
"harness.bindingsForSession",
|
|
784
|
+
{ harness, harnessSessionId },
|
|
785
|
+
v.array(HarnessBindingSchema),
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
harnessConsumeApprovedRetry(
|
|
789
|
+
bindingId: string,
|
|
790
|
+
messageId: string,
|
|
791
|
+
content: string,
|
|
792
|
+
): Promise<boolean> {
|
|
793
|
+
return this.request(
|
|
794
|
+
"harness.consumeApprovedRetry",
|
|
795
|
+
{ bindingId, messageId, content },
|
|
796
|
+
v.boolean(),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
deliveryPending(bindingId: string): Promise<PendingDelivery[]> {
|
|
800
|
+
return this.request("delivery.pending", { bindingId }, v.array(PendingDeliverySchema));
|
|
801
|
+
}
|
|
802
|
+
deliveryAcknowledge(deliveryId: string): Promise<Delivery> {
|
|
803
|
+
return this.request("delivery.acknowledge", { deliveryId }, DeliverySchema);
|
|
804
|
+
}
|
|
805
|
+
sessionSubmitRevision(
|
|
806
|
+
id: string,
|
|
807
|
+
content: string,
|
|
808
|
+
addressedAnnotationIds: string[] = [],
|
|
809
|
+
files?: DiffFileContents[],
|
|
810
|
+
source?: import("@cueloop/schema").DiffSource,
|
|
811
|
+
): Promise<Thread> {
|
|
812
|
+
return this.request(
|
|
813
|
+
"session.submitRevision",
|
|
814
|
+
{ id, content, addressedAnnotationIds, files, source },
|
|
815
|
+
ThreadRecordSchema,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
/** Read Herdr's local Thread surface handle, outside the ThreadClient contract. */
|
|
819
|
+
herdrGetThreadSurface(id: string): Promise<HerdrThreadSurfaceHandle | null> {
|
|
820
|
+
return this.request("herdr.getThreadSurface", { id }, HerdrThreadSurfaceResultSchema);
|
|
821
|
+
}
|
|
822
|
+
async herdrSetThreadSurface(id: string, handle: HerdrThreadSurfaceHandle): Promise<void> {
|
|
823
|
+
await this.request("herdr.setThreadSurface", { id, ...handle }, EmptyResultSchema);
|
|
824
|
+
}
|
|
825
|
+
ghosttyGetThreadSurface(id: string): Promise<GhosttyThreadSurfaceHandle | null> {
|
|
826
|
+
return this.request("ghostty.getThreadSurface", { id }, GhosttyThreadSurfaceResultSchema);
|
|
827
|
+
}
|
|
828
|
+
async ghosttySetThreadSurface(id: string, handle: GhosttyThreadSurfaceHandle): Promise<void> {
|
|
829
|
+
await this.request("ghostty.setThreadSurface", { id, ...handle }, EmptyResultSchema);
|
|
830
|
+
}
|
|
831
|
+
ghosttyClaimThreadSurface(id: string): Promise<boolean> {
|
|
832
|
+
return this.request("ghostty.claimThreadSurface", { id }, v.boolean());
|
|
833
|
+
}
|
|
834
|
+
async ghosttyReleaseThreadSurface(id: string): Promise<void> {
|
|
835
|
+
await this.request("ghostty.releaseThreadSurface", { id }, EmptyResultSchema);
|
|
162
836
|
}
|
|
163
837
|
shutdown(): Promise<void> {
|
|
164
|
-
return this.request("daemon.shutdown", {});
|
|
838
|
+
return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
|
|
165
839
|
}
|
|
166
840
|
}
|
|
167
841
|
|
|
168
842
|
export class DaemonClientError extends Error {
|
|
843
|
+
readonly _tag = "DaemonClientError";
|
|
169
844
|
constructor(
|
|
170
845
|
readonly code: string,
|
|
171
846
|
message: string,
|
|
@@ -174,10 +849,61 @@ export class DaemonClientError extends Error {
|
|
|
174
849
|
}
|
|
175
850
|
}
|
|
176
851
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
852
|
+
// A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
|
|
853
|
+
// unlike the never-exiting foreground daemon); source also uses the CLI composition root. In
|
|
854
|
+
// dev (CUELOOP_DEV_WATCH=1, source only) it runs under --watch so daemon-code edits
|
|
855
|
+
// reload the daemon without a manual restart - the version handshake only catches
|
|
856
|
+
// release upgrades, not same-version source changes.
|
|
857
|
+
export function daemonSpawnCommand(
|
|
858
|
+
execPath: string,
|
|
859
|
+
moduleUrl: string,
|
|
860
|
+
devWatch = process.env.CUELOOP_DEV_WATCH === "1",
|
|
861
|
+
entry = process.env.CUELOOP_DAEMON_ENTRY,
|
|
862
|
+
): string[] {
|
|
863
|
+
const compiled =
|
|
864
|
+
moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
|
|
865
|
+
|
|
866
|
+
if (compiled) return [execPath, "daemon", "--autostart"];
|
|
867
|
+
const mainPath = entry ?? new URL("./main.ts", moduleUrl).pathname;
|
|
868
|
+
|
|
869
|
+
const args = entry ? ["daemon", "--autostart"] : [];
|
|
870
|
+
|
|
871
|
+
return devWatch
|
|
872
|
+
? [execPath, "--watch", "run", mainPath, ...args]
|
|
873
|
+
: [execPath, "run", mainPath, ...args];
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function spawnDaemon(home: string): string {
|
|
877
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
878
|
+
const startupLogPath = join(
|
|
879
|
+
home,
|
|
880
|
+
`daemon-startup-${process.pid}-${randomBytes(6).toString("hex")}.log`,
|
|
881
|
+
);
|
|
882
|
+
const logFd = openSync(startupLogPath, "wx", 0o600);
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
if (typeof Bun === "undefined") {
|
|
886
|
+
const child = spawn(process.env.CUELOOP_EXECUTABLE ?? "cueloop", ["daemon", "--autostart"], {
|
|
887
|
+
env: { ...process.env, CUELOOP_HOME: home },
|
|
888
|
+
stdio: ["ignore", logFd, logFd],
|
|
889
|
+
detached: true,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
child.on("error", (error) => appendFileSync(startupLogPath, `${String(error)}\n`));
|
|
893
|
+
child.unref();
|
|
894
|
+
} else {
|
|
895
|
+
Bun.spawn(daemonSpawnCommand(process.execPath, import.meta.url), {
|
|
896
|
+
env: { ...process.env, CUELOOP_HOME: home },
|
|
897
|
+
stdio: ["ignore", logFd, logFd],
|
|
898
|
+
}).unref();
|
|
899
|
+
}
|
|
900
|
+
} finally {
|
|
901
|
+
closeSync(logFd);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return startupLogPath;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function sleep(durationMs: number): Promise<void> {
|
|
908
|
+
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
183
909
|
}
|