@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.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @cueloop/daemon
2
+
3
+ The local cueloop daemon owns Thread mutations, persisted review history, harness
4
+ bindings and socket subscriptions. Integrations use its typed NDJSON client.
5
+
6
+ See the [daemon SDK contract](SDK.md) for explicit capabilities, safe mutation
7
+ retries, reconnecting subscriptions and the optional scoped Effect service.
package/SDK.md ADDED
@@ -0,0 +1,102 @@
1
+ # Daemon SDK
2
+
3
+ The SDK is a facade over the existing NDJSON socket client. It lives at
4
+ `@cueloop/daemon/sdk`; the optional Effect service is at
5
+ `@cueloop/daemon/sdk-effect`. Both use the same daemon, Thread records and harness
6
+ adapter. Importing the Promise SDK does not load Effect.
7
+
8
+ ## Capabilities
9
+
10
+ `connectOwnerSdk({ home, autostart })` authenticates with the local owner token.
11
+ It exposes Thread creation, review comments, review messages and embedded agent
12
+ operations. Agent methods still require `[experimental] thread_agent = true` in
13
+ the daemon's workspace configuration. Shared collaborators cannot invoke them.
14
+
15
+ `connectReviewSdk({ home, role: "collaborator" | "agent", author })` exposes
16
+ Thread reads and review comments only. An `agent` role here means a review-side
17
+ agent, not the daemon-owned embedded harness. The daemon enforces authorization;
18
+ TypeScript narrows the available operations but does not grant authority.
19
+ The SDK brands Thread, operation and submission IDs separately. Thread reads
20
+ return branded identities; `sdk.ids.thread`, `sdk.ids.operation` and
21
+ `sdk.ids.submission` parse external strings for incremental adoption.
22
+
23
+ ## Acceptance, completion and retries
24
+
25
+ `sessions.sendMessage` and `agents.prompt` require a caller-chosen `operationId`
26
+ (up to 128 characters). Persist that ID with the normalized input if a workflow
27
+ needs to resume. Receipts are scoped to a Thread and method. The same ID and
28
+ payload return the original accepted message or submission batch after a lost
29
+ response, disconnect or daemon restart. Different payloads reject with
30
+ `operation_conflict`. Submission acceptance does not imply agent completion.
31
+
32
+ Receipts commit in the same persisted record as their mutation. Each method
33
+ retains up to 128 receipts for the lifetime of the Thread; it rejects new IDs with
34
+ `operation_capacity` rather than evicting receipts and risking duplicate work.
35
+ Deleting or retaining out an old Thread removes its receipts. A missing Thread
36
+ rejects retries. Private receipt metadata is omitted from share blobs.
37
+
38
+ `agents.wait(accepted)` waits for that exact batch and returns `completed`,
39
+ `failed`, or `cancelled`. Terminal outcomes stay fixed even when a failed
40
+ submission is explicitly retried with a new operation ID. Queued or running
41
+ submissions recovered after a daemon restart become failed; they are never
42
+ silently re-executed.
43
+
44
+ There is no automatic mutation replay. A transport failure after writing has
45
+ `certainty: "unknown"`; work may already have committed. Explicitly retry the two
46
+ receipt-backed methods with the same operation ID and payload. Thread creation,
47
+ comment replies and other legacy methods have no acceptance receipts: do not
48
+ retry an ambiguous write automatically. Comments accept caller-chosen annotation
49
+ IDs for upsert semantics, which are separate from mutation acceptance receipts.
50
+
51
+ ## Failures and cancellation
52
+
53
+ `DaemonClientError` carries a daemon rejection `code` (including `forbidden`,
54
+ `resolved`, `operation_conflict` and `operation_capacity`). A rejection is not a
55
+ blanket rollback guarantee: a daemon internal error can follow a commit.
56
+
57
+ `DaemonTransportError` carries `kind`: `protocol`, `connection`, `timeout` or
58
+ `cancelled`, plus `certainty`: `not_sent` or `unknown`. A malformed result rejects
59
+ its request promptly. A malformed outer frame closes the connection and rejects
60
+ all outstanding requests. Pending requests remove timers and abort listeners on
61
+ every completion or failure path.
62
+
63
+ Requests accept `{ timeoutMs, signal }`, defaulting to a 30-second local deadline.
64
+ Cancelling a request or wait stops local observation; it does not undo a mutation
65
+ or stop a harness. `agents.cancel(threadId)` explicitly requests cancellation of
66
+ the active turn and preserves partial output. It does not clear queued inputs.
67
+ Closing the owner SDK also cancels its outstanding local waits.
68
+
69
+ ## Subscriptions
70
+
71
+ `subscribeThreadState` owns an observation connection, subscribes before reading,
72
+ refreshes after matching event hints and reconnects after connection loss. Every
73
+ reconnect reads authoritative state, including changes made while disconnected.
74
+ It never replays a write or treats event notifications as a durable log.
75
+
76
+ Generation and request counters prevent late reads from overwriting newer state.
77
+ Disposal removes listeners, cancels reconnect timers, aborts reads and closes the
78
+ observation connection. Protocol errors and authorization/version rejections
79
+ stop reconnection. Transient connection failures retry at the configured delay.
80
+ The Thread-agent UI uses this shared lifecycle.
81
+
82
+ ## Effect and example
83
+
84
+ `CueloopSdk.ownerLayer` acquires the owner connection with `Effect.acquireRelease`.
85
+ Its operations have a typed `DaemonSdkError` channel. `threads.watch(id)` is a
86
+ scoped stream of refreshed snapshots with a one-item sliding buffer. It coalesces
87
+ state updates rather than promising delivery of every event. Effect interruption is passed
88
+ through an AbortSignal, including to completion waits. Scope finalization closes
89
+ the connection. The layer does not replace the daemon or rewrite UI state in
90
+ Effect.
91
+
92
+ With the experimental flag enabled and a harness configured, run:
93
+
94
+ ```sh
95
+ bun run packages/daemon/src/examples/daemon-sdk.ts
96
+ ```
97
+
98
+ The example creates and reads a Thread, submits a prompt, waits for its terminal
99
+ outcome, lists and replies to a comment, then sends approval. It creates a real
100
+ Thread and invokes the configured model. IDs are generated once per invocation;
101
+ a resumable application should save them rather than generate replacements when
102
+ retrying.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.97",
3
+ "version": "0.1.0-alpha.99",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "src",
17
- "README.md"
17
+ "README.md",
18
+ "SDK.md"
18
19
  ],
19
20
  "type": "module",
20
21
  "exports": {
@@ -33,17 +34,20 @@
33
34
  "./thread-surface-config": "./src/thread-surface-config.ts",
34
35
  "./herdr-split": "./src/herdr-split.ts",
35
36
  "./share-blob": "./src/share-blob.ts",
36
- "./curate": "./src/curate.ts"
37
+ "./curate": "./src/curate.ts",
38
+ "./sdk": "./src/daemon-sdk.ts",
39
+ "./sdk-effect": "./src/daemon-sdk-effect.ts",
40
+ "./thread-subscription": "./src/thread-subscription.ts"
37
41
  },
38
42
  "publishConfig": {
39
43
  "access": "public"
40
44
  },
41
45
  "dependencies": {
42
- "@cueloop/extension-api": "0.1.0-alpha.97",
43
- "@cueloop/schema": "0.1.0-alpha.97",
44
- "@pierre/diffs": "^1.4.1",
45
- "effect": "^4.0.0-rc.117",
46
- "smol-toml": "^1.8.0",
46
+ "@cueloop/extension-api": "0.1.0-alpha.99",
47
+ "@cueloop/schema": "0.1.0-alpha.99",
48
+ "@pierre/diffs": "^1.5.1",
49
+ "effect": "^4.0.0",
50
+ "smol-toml": "^1.9.0",
47
51
  "valibot": "^1.5.0"
48
52
  },
49
53
  "engines": {
@@ -0,0 +1,98 @@
1
+ import * as v from "valibot";
2
+ import { resolve } from "node:path";
3
+ import { MESSAGE_OUTCOMES, type Thread } from "@cueloop/schema";
4
+ import type { MethodName } from "./validate";
5
+
6
+ /** Tool arguments are validated before entering the owner's daemon API. */
7
+ export function parseAgentToolInput(name: string, serialized: string) {
8
+ const input: unknown = JSON.parse(serialized);
9
+
10
+ if (name === "reply_to_comment") {
11
+ const args = v.parse(
12
+ v.object({
13
+ id: v.string(),
14
+ commentId: v.string(),
15
+ body: v.pipe(v.string(), v.minLength(1), v.maxLength(32768)),
16
+ }),
17
+ input,
18
+ );
19
+
20
+ return { kind: "reply" as const, ...args };
21
+ }
22
+ if (name === "send_message") {
23
+ const args = v.parse(
24
+ v.object({
25
+ id: v.string(),
26
+ outcome: v.picklist(MESSAGE_OUTCOMES),
27
+ summary: v.optional(v.string(), ""),
28
+ }),
29
+ input,
30
+ );
31
+
32
+ return { kind: "api" as const, method: "session.sendMessage", params: args };
33
+ }
34
+ if (name !== "cueloop_api") throw new Error("Thread agent tool name is unavailable");
35
+ const args = v.parse(v.object({ method: v.string(), params: v.unknown() }), input);
36
+
37
+ return { kind: "api" as const, ...args };
38
+ }
39
+
40
+ const THREAD_AGENT_SESSION_METHODS = new Set<string>([
41
+ "session.get",
42
+ "session.wait",
43
+ "session.annotate",
44
+ "session.comment",
45
+ "session.removeAnnotation",
46
+ "session.setParticipantName",
47
+ "session.setWorkingCopy",
48
+ "session.cutBlock",
49
+ "session.navigate",
50
+ "session.branch",
51
+ "session.switch",
52
+ "session.label",
53
+ "session.restoreBlock",
54
+ "session.curate",
55
+ "session.setViewed",
56
+ "session.setTitle",
57
+ "session.projectFiles",
58
+ "session.fileContents",
59
+ "session.refreshDiff",
60
+ "session.sendMessage",
61
+ "session.submitRevision",
62
+ ] satisfies MethodName[]);
63
+ const THREAD_AGENT_REPO_METHODS = new Set<string>([
64
+ "repo.files",
65
+ "repo.fileContents",
66
+ "repo.changes",
67
+ "repo.diff",
68
+ ] satisfies MethodName[]);
69
+
70
+ /** Harness tools are bound to their originating Thread; model arguments cannot widen that scope. */
71
+ export function assertAgentToolScope(
72
+ thread: Thread,
73
+ input: ReturnType<typeof parseAgentToolInput>,
74
+ ): void {
75
+ if (input.kind === "reply") {
76
+ if (input.id !== thread.id) throw new Error("Thread agent tool cannot access another Thread");
77
+
78
+ return;
79
+ }
80
+ if (input.method === "session.list") return;
81
+ const params = v.parse(v.record(v.string(), v.unknown()), input.params);
82
+
83
+ if (THREAD_AGENT_SESSION_METHODS.has(input.method)) {
84
+ if (params.id !== thread.id) throw new Error("Thread agent tool cannot access another Thread");
85
+
86
+ return;
87
+ }
88
+ if (THREAD_AGENT_REPO_METHODS.has(input.method)) {
89
+ const { cwd } = v.parse(v.object({ cwd: v.string() }), params);
90
+
91
+ if (resolve(cwd) !== resolve(thread.workspace.repoRoot))
92
+ throw new Error("Thread agent tool cannot access another repository");
93
+
94
+ return;
95
+ }
96
+
97
+ throw new Error("Thread agent tool API method is unavailable");
98
+ }
@@ -0,0 +1,24 @@
1
+ import { expect, test } from "bun:test";
2
+ import { stepAgentTurn, type AgentTurn } from "./agent-turn";
3
+
4
+ test("an initialization stop settles before accepting a fresh turn", () => {
5
+ const starting = stepAgentTurn({ kind: "idle" }, "start");
6
+ const stopped = stepAgentTurn(starting, "stop");
7
+ const ready = stepAgentTurn(stopped, "ready");
8
+
9
+ expect(ready).toEqual({ kind: "idle" });
10
+ expect(stepAgentTurn(ready, "start")).toEqual({ kind: "starting", cancelled: false });
11
+ });
12
+
13
+ test("an active stop waits for completion before accepting another turn", () => {
14
+ const running: AgentTurn = { kind: "running" };
15
+ const stopped = stepAgentTurn(running, "stop");
16
+
17
+ expect(stopped).toEqual({ kind: "stopping" });
18
+ expect(stepAgentTurn(stopped, "start")).toEqual(stopped);
19
+ expect(stepAgentTurn(stopped, "ready")).toEqual(stopped);
20
+ expect(stepAgentTurn(stepAgentTurn(stopped, "finished"), "start")).toEqual({
21
+ kind: "starting",
22
+ cancelled: false,
23
+ });
24
+ });
@@ -0,0 +1,34 @@
1
+ /** A harness turn distinguishes initialization cancellation from a running stop request. */
2
+ export type AgentTurn =
3
+ | { kind: "idle" }
4
+ | { kind: "starting"; cancelled: boolean }
5
+ | { kind: "running" }
6
+ | { kind: "stopping" };
7
+
8
+ /** Lifecycle events are independent of the harness wire protocol. */
9
+ export type AgentTurnEvent = "start" | "ready" | "stop" | "finished";
10
+
11
+ /** Start is accepted only at idle; readiness consumes an initialization cancellation. */
12
+ export function stepAgentTurn(state: AgentTurn, event: AgentTurnEvent): AgentTurn {
13
+ switch (event) {
14
+ case "start":
15
+ return state.kind === "idle" ? { kind: "starting", cancelled: false } : state;
16
+ case "ready":
17
+ return state.kind === "starting"
18
+ ? state.cancelled
19
+ ? { kind: "idle" }
20
+ : { kind: "running" }
21
+ : state;
22
+ case "stop":
23
+ if (state.kind === "starting") return { kind: "starting", cancelled: true };
24
+
25
+ return state.kind === "running" ? { kind: "stopping" } : state;
26
+ case "finished":
27
+ return { kind: "idle" };
28
+ }
29
+ }
30
+
31
+ /** Pending stop requests suppress later tool and permission events from this turn. */
32
+ export function agentTurnCancelled(state: AgentTurn): boolean {
33
+ return state.kind === "stopping" || (state.kind === "starting" && state.cancelled);
34
+ }
package/src/api.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  type MessageOutcome,
53
53
  type WorkspaceKey,
54
54
  } from "@cueloop/schema";
55
+ import { findOperationReceipt, operationFingerprint } from "./operation-receipts";
55
56
  import { curateDiff } from "./curate";
56
57
  import { ThreadStore, withHistory } from "./store";
57
58
  import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
@@ -429,6 +430,7 @@ export class DaemonCore {
429
430
  authorName?: string,
430
431
  ): Thread {
431
432
  const session = this.mutable(id);
433
+
432
434
  // a welcome-playground note is ephemeral by design: never persisted, so never fed back
433
435
  if (annotationTarget(annotation).kind === "welcome") return session;
434
436
  const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
@@ -650,6 +652,7 @@ export class DaemonCore {
650
652
  this.vcsSources.select(cwd),
651
653
  resolveWorkspace(cwd),
652
654
  ]);
655
+
653
656
  workspace.repoRoot = selected.repoRoot;
654
657
  const key = `${workspace.rootCommit ?? selected.repoRoot}:${selected.adapter.id}`;
655
658
  // an open workbench is reused; a resolved one is immutable and would reject the note, so a fresh
@@ -892,8 +895,19 @@ export class DaemonCore {
892
895
  outcome: MessageOutcome,
893
896
  summary: string,
894
897
  actionBodies?: Record<string, string>,
898
+ operationId?: string,
895
899
  ): Thread {
896
- const session = this.mutable(id);
900
+ const current = this.sessionGet(id);
901
+ const fingerprint = operationFingerprint([
902
+ outcome,
903
+ summary,
904
+ Object.entries(actionBodies ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
905
+ ]);
906
+ const receipt = findOperationReceipt(current.messageOperations, operationId, fingerprint);
907
+
908
+ if (receipt) return { ...structuredClone(current), message: structuredClone(receipt.result) };
909
+ const mutable = this.mutable(id);
910
+ const session = operationId ? structuredClone(mutable) : mutable;
897
911
  const sentAt = new Date().toISOString();
898
912
  const sentAnnotations = new Map(
899
913
  (session.history?.entries ?? [])
@@ -916,6 +930,8 @@ export class DaemonCore {
916
930
  sentAt,
917
931
  };
918
932
 
933
+ if (operationId)
934
+ (session.messageOperations ??= []).push({ operationId, fingerprint, result: message });
919
935
  session.message = message;
920
936
  session.status = outcome === "comment" ? "pending" : "resolved";
921
937
  const entryId = this.record(session, {
@@ -923,6 +939,7 @@ export class DaemonCore {
923
939
  message,
924
940
  createdAt: message.sentAt,
925
941
  });
942
+
926
943
  this.store.upsert(session);
927
944
  this.reconcileDeliveries(session);
928
945
  this.emit("message.sent", id, entryId);
@@ -980,6 +997,7 @@ export class DaemonCore {
980
997
  content,
981
998
  createdAt: now,
982
999
  });
1000
+
983
1001
  delete session.workingCopy;
984
1002
  delete session.textCuts;
985
1003
  session.message = null;
@@ -18,6 +18,13 @@ export type DaemonRole = "owner" | "collaborator" | "agent";
18
18
  type Audience = "any" | "owner";
19
19
 
20
20
  const PRIMITIVE_ROLES = {
21
+ "agent.configure": "owner",
22
+ "agent.get": "owner",
23
+ "agent.prompt": "owner",
24
+ "agent.cancel": "owner",
25
+ "agent.comment": "owner",
26
+ "agent.reply": "owner",
27
+ "agent.permission": "owner",
21
28
  "daemon.ping": "any",
22
29
  "daemon.hello": "any",
23
30
  "daemon.shutdown": "owner",
@@ -0,0 +1,24 @@
1
+ /** Request failure certainty never promises rollback after bytes may have reached the daemon. */
2
+ export type DaemonRequestCertainty = "not_sent" | "unknown";
3
+
4
+ /** Transport failures are distinct from daemon rejection codes and domain terminal outcomes. */
5
+ export class DaemonTransportError extends Error {
6
+ readonly _tag = "DaemonTransportError";
7
+
8
+ constructor(
9
+ readonly kind: "protocol" | "connection" | "timeout" | "cancelled",
10
+ message: string,
11
+ readonly certainty: DaemonRequestCertainty,
12
+ readonly method?: string,
13
+ options?: ErrorOptions,
14
+ ) {
15
+ super(message, options);
16
+ this.name = "DaemonTransportError";
17
+ }
18
+ }
19
+
20
+ /** Local cancellation stops observation; it does not cancel daemon-owned work. */
21
+ export interface DaemonRequestOptions {
22
+ timeoutMs?: number;
23
+ signal?: AbortSignal;
24
+ }
@@ -0,0 +1,189 @@
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 * as v from "valibot";
6
+ import { DaemonClient } from "./client";
7
+ import { DAEMON_VERSION } from "./version";
8
+ import { LineBuffer, parseRequestFrame } from "./protocol";
9
+ import { socketPath } from "./paths";
10
+
11
+ test("malformed response results reject promptly and leave the connection usable", async () => {
12
+ const home = mkdtempSync(join(tmpdir(), "cueloop-client-contract-"));
13
+ const buffers = new Map<object, LineBuffer>();
14
+ const server = Bun.listen({
15
+ unix: socketPath(home),
16
+ socket: {
17
+ open(socket) {
18
+ buffers.set(socket, new LineBuffer());
19
+ },
20
+ data(socket, data) {
21
+ buffers.get(socket)!.push(data.toString(), (line) => {
22
+ const request = parseRequestFrame(line);
23
+ const result =
24
+ request.method === "daemon.ping"
25
+ ? { pid: process.pid, version: DAEMON_VERSION }
26
+ : request.method === "daemon.hello"
27
+ ? {}
28
+ : "malformed";
29
+
30
+ socket.write(JSON.stringify({ id: request.id, result }) + "\n");
31
+ });
32
+ },
33
+ close(socket) {
34
+ buffers.delete(socket);
35
+ },
36
+ },
37
+ });
38
+ let client: DaemonClient | undefined;
39
+
40
+ try {
41
+ client = await DaemonClient.connect({ home });
42
+ await expect(client.request("test.number", {}, v.number(), 100)).rejects.toMatchObject({
43
+ _tag: "DaemonTransportError",
44
+ kind: "protocol",
45
+ certainty: "unknown",
46
+ });
47
+ await expect(
48
+ client.request(
49
+ "test.transform",
50
+ {},
51
+ v.pipe(
52
+ v.string(),
53
+ v.transform(() => {
54
+ throw new Error("Invalid transformation");
55
+ }),
56
+ ),
57
+ 100,
58
+ ),
59
+ ).rejects.toMatchObject({ kind: "protocol", certainty: "unknown" });
60
+ expect((await client.ping()).pid).toBe(process.pid);
61
+ } finally {
62
+ client?.close();
63
+ server.stop(true);
64
+ rmSync(home, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ test("deadlines, local abort and close reject outstanding requests without cancelling daemon work", async () => {
69
+ const home = mkdtempSync(join(tmpdir(), "cueloop-client-cancel-"));
70
+ const buffers = new Map<object, LineBuffer>();
71
+ const work: number[] = [];
72
+ const server = Bun.listen({
73
+ unix: socketPath(home),
74
+ socket: {
75
+ open(socket) {
76
+ buffers.set(socket, new LineBuffer());
77
+ },
78
+ data(socket, data) {
79
+ buffers.get(socket)!.push(data.toString(), (line) => {
80
+ const request = parseRequestFrame(line);
81
+
82
+ if (request.method === "work") {
83
+ work.push(request.id);
84
+
85
+ return;
86
+ }
87
+ socket.write(
88
+ JSON.stringify({
89
+ id: request.id,
90
+ result:
91
+ request.method === "daemon.ping"
92
+ ? { pid: process.pid, version: DAEMON_VERSION }
93
+ : {},
94
+ }) + "\n",
95
+ );
96
+ });
97
+ },
98
+ close(socket) {
99
+ buffers.delete(socket);
100
+ },
101
+ },
102
+ });
103
+ const client = await DaemonClient.connect({ home });
104
+
105
+ try {
106
+ const alreadyCancelled = new AbortController();
107
+
108
+ alreadyCancelled.abort();
109
+ await expect(
110
+ client.request("work", {}, v.number(), { signal: alreadyCancelled.signal }),
111
+ ).rejects.toMatchObject({ kind: "cancelled", certainty: "not_sent" });
112
+ await expect(client.request("work", {}, v.number(), { timeoutMs: 10 })).rejects.toMatchObject({
113
+ kind: "timeout",
114
+ certainty: "unknown",
115
+ });
116
+ expect(work).toHaveLength(1);
117
+ const controller = new AbortController();
118
+ const waiting = client.request("work", {}, v.number(), { signal: controller.signal });
119
+
120
+ await client.ping();
121
+ controller.abort();
122
+ await expect(waiting).rejects.toMatchObject({ kind: "cancelled", certainty: "unknown" });
123
+ expect(work).toHaveLength(2);
124
+ const pending = client.request("work", {}, v.number());
125
+
126
+ client.close();
127
+ await expect(pending).rejects.toMatchObject({ kind: "connection", certainty: "unknown" });
128
+ await expect(client.ping()).rejects.toMatchObject({
129
+ kind: "connection",
130
+ certainty: "not_sent",
131
+ });
132
+ } finally {
133
+ client.close();
134
+ server.stop(true);
135
+ rmSync(home, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ test("malformed frames reject all pending requests as protocol failures", async () => {
140
+ const home = mkdtempSync(join(tmpdir(), "cueloop-client-frame-"));
141
+ const buffers = new Map<object, LineBuffer>();
142
+ const server = Bun.listen({
143
+ unix: socketPath(home),
144
+ socket: {
145
+ open(socket) {
146
+ buffers.set(socket, new LineBuffer());
147
+ },
148
+ data(socket, data) {
149
+ buffers.get(socket)!.push(data.toString(), (line) => {
150
+ const request = parseRequestFrame(line);
151
+
152
+ if (request.method === "bad-frame") {
153
+ socket.write("{broken\n");
154
+
155
+ return;
156
+ }
157
+ socket.write(
158
+ JSON.stringify({
159
+ id: request.id,
160
+ result:
161
+ request.method === "daemon.ping"
162
+ ? { pid: process.pid, version: DAEMON_VERSION }
163
+ : {},
164
+ }) + "\n",
165
+ );
166
+ });
167
+ },
168
+ close(socket) {
169
+ buffers.delete(socket);
170
+ },
171
+ },
172
+ });
173
+ const client = await DaemonClient.connect({ home });
174
+
175
+ try {
176
+ await expect(client.request("bad-frame", {}, v.number())).rejects.toMatchObject({
177
+ kind: "protocol",
178
+ certainty: "unknown",
179
+ });
180
+ await expect(client.ping()).rejects.toMatchObject({
181
+ kind: "connection",
182
+ certainty: "not_sent",
183
+ });
184
+ } finally {
185
+ client.close();
186
+ server.stop(true);
187
+ rmSync(home, { recursive: true, force: true });
188
+ }
189
+ });
@@ -22,3 +22,16 @@ describe("daemonSpawnCommand", () => {
22
22
  ).toEqual(["/path/to/bun", "run", "/repo/packages/daemon/src/main.ts"]);
23
23
  });
24
24
  });
25
+
26
+ test("published daemon starts itself unless the CLI supplies its real entry path", () => {
27
+ const published = "file:///project/node_modules/@cueloop/daemon/src/client.ts";
28
+
29
+ expect(daemonSpawnCommand("bun", published, false)).toEqual([
30
+ "bun",
31
+ "run",
32
+ "/project/node_modules/@cueloop/daemon/src/main.ts",
33
+ ]);
34
+ expect(
35
+ daemonSpawnCommand("bun", published, false, "/project/node_modules/cueloop/src/main.ts"),
36
+ ).toEqual(["bun", "run", "/project/node_modules/cueloop/src/main.ts", "daemon", "--autostart"]);
37
+ });