@cueloop/daemon 0.1.0-alpha.0 → 0.1.0-alpha.100

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.
Files changed (80) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +43 -16
  4. package/src/agent-tools.ts +98 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1425 -89
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +189 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +820 -94
  15. package/src/curate.ts +109 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +397 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +419 -0
  21. package/src/diff-watcher.test.ts +117 -0
  22. package/src/diff-watcher.ts +326 -0
  23. package/src/examples/daemon-sdk.ts +55 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +74 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +101 -0
  28. package/src/ghostty-thread-surface.test.ts +398 -0
  29. package/src/ghostty-thread-surface.ts +267 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +30 -0
  33. package/src/harness-state-store.test.ts +93 -0
  34. package/src/harness-state-store.ts +213 -0
  35. package/src/herdr-split.test.ts +106 -0
  36. package/src/herdr-split.ts +85 -0
  37. package/src/herdr-thread-surface-store.test.ts +81 -0
  38. package/src/herdr-thread-surface-store.ts +66 -0
  39. package/src/herdr-thread-surface.test.ts +465 -0
  40. package/src/herdr-thread-surface.ts +378 -0
  41. package/src/index.ts +8 -8
  42. package/src/interruptible-wait.test.ts +127 -0
  43. package/src/interruptible-wait.ts +66 -0
  44. package/src/jj-working-tree.ts +131 -0
  45. package/src/main.ts +4 -1
  46. package/src/operation-receipts.test.ts +111 -0
  47. package/src/operation-receipts.ts +91 -0
  48. package/src/paths.ts +49 -0
  49. package/src/pr-poller.test.ts +102 -0
  50. package/src/pr-poller.ts +73 -0
  51. package/src/project-files.test.ts +134 -0
  52. package/src/project-files.ts +68 -0
  53. package/src/protocol.ts +78 -7
  54. package/src/retention.test.ts +191 -0
  55. package/src/retention.ts +123 -0
  56. package/src/sdk-identities.ts +35 -0
  57. package/src/server.test.ts +465 -40
  58. package/src/server.ts +601 -88
  59. package/src/share-blob.test.ts +119 -0
  60. package/src/share-blob.ts +57 -0
  61. package/src/single-instance.test.ts +162 -13
  62. package/src/store.conformance.test.ts +41 -0
  63. package/src/store.jsonl.test.ts +173 -0
  64. package/src/store.ts +267 -34
  65. package/src/testing/store-conformance.ts +192 -0
  66. package/src/thread-agent-validation.ts +119 -0
  67. package/src/thread-agent.test.ts +412 -0
  68. package/src/thread-agent.ts +713 -0
  69. package/src/thread-review.test.ts +483 -0
  70. package/src/thread-review.ts +382 -0
  71. package/src/thread-subscription.test.ts +174 -0
  72. package/src/thread-subscription.ts +129 -0
  73. package/src/thread-surface-config.test.ts +63 -0
  74. package/src/thread-surface-config.ts +61 -0
  75. package/src/validate.test.ts +366 -21
  76. package/src/validate.ts +458 -47
  77. package/src/vcs-source.test.ts +180 -0
  78. package/src/vcs-source.ts +229 -0
  79. package/src/version.ts +6 -0
  80. package/src/working-tree.ts +150 -0
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { asDaemonRole, DEFAULT_ROLE, roleAllowsMethod } from "./capabilities";
3
+ import type { MethodName } from "./validate";
4
+
5
+ describe("roleAllowsMethod", () => {
6
+ test("the owner may call every primitive", () => {
7
+ // Assert
8
+ const owned: MethodName[] = ["session.sendMessage", "session.submitRevision", "session.delete"];
9
+
10
+ for (const method of owned) expect(roleAllowsMethod("owner", method)).toBe(true);
11
+ });
12
+
13
+ test("a capped role may read and annotate but not escalate", () => {
14
+ // Assert - allowed
15
+ const open: MethodName[] = ["session.get", "session.list", "session.wait", "session.annotate"];
16
+
17
+ for (const method of open) expect(roleAllowsMethod("agent", method)).toBe(true);
18
+ // Assert - denied
19
+ const owned: MethodName[] = [
20
+ "session.sendMessage",
21
+ "session.submitRevision",
22
+ "session.setWorkingCopy",
23
+ "session.delete",
24
+ "session.setShares",
25
+ "session.refreshDiff",
26
+ "session.create",
27
+ "session.navigate",
28
+ "session.branch",
29
+ "session.switch",
30
+ "session.label",
31
+ "session.fork",
32
+ ];
33
+
34
+ for (const method of owned) expect(roleAllowsMethod("agent", method)).toBe(false);
35
+ expect(roleAllowsMethod("collaborator", "session.sendMessage")).toBe(false);
36
+ // removal and naming are open to every role; the primitive itself scopes a
37
+ // non-owner to the author it acts as
38
+ expect(roleAllowsMethod("agent", "session.removeAnnotation")).toBe(true);
39
+ expect(roleAllowsMethod("agent", "session.setParticipantName")).toBe(true);
40
+ });
41
+
42
+ test("a connection starts as a collaborator, never as the owner", () => {
43
+ // Assert
44
+ expect(DEFAULT_ROLE).toBe("collaborator");
45
+ });
46
+ });
47
+
48
+ describe("asDaemonRole", () => {
49
+ test("passes owner and collaborator through and caps anything unknown to agent", () => {
50
+ // Assert
51
+ expect(asDaemonRole("owner")).toBe("owner");
52
+ expect(asDaemonRole("collaborator")).toBe("collaborator");
53
+ expect(asDaemonRole("agent")).toBe("agent");
54
+ expect(asDaemonRole("root")).toBe("agent");
55
+ expect(asDaemonRole(undefined)).toBe("agent");
56
+ });
57
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Daemon roles: which primitives a connection may call. A connection is a
3
+ * collaborator until it proves ownership with the owner token in daemon.hello
4
+ * - never an owner by default. The owner (the local planner) may call every
5
+ * primitive; a collaborator or a review-side agent is capped to reading the
6
+ * session and adding comments, so a bring-your-own agent cannot resolve,
7
+ * rewrite, cut, share, or delete (ADR 0009). The table below is exhaustive
8
+ * over the primitive schemas: adding a primitive without naming its roles
9
+ * does not compile.
10
+ */
11
+
12
+ import * as v from "valibot";
13
+ import type { MethodName } from "./validate";
14
+
15
+ export type DaemonRole = "owner" | "collaborator" | "agent";
16
+
17
+ /** Who may call a primitive: everyone, or the owner only. */
18
+ type Audience = "any" | "owner";
19
+
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",
28
+ "daemon.ping": "any",
29
+ "daemon.hello": "any",
30
+ "daemon.shutdown": "owner",
31
+ "events.subscribe": "any",
32
+ "session.create": "owner",
33
+ "session.get": "any",
34
+ "session.list": "any",
35
+ "session.wait": "any",
36
+ "session.annotate": "any",
37
+ "session.comment": "any",
38
+ // any role may call it; a non-owner must act on behalf of an author and stays scoped to it
39
+ "session.removeAnnotation": "any",
40
+ "session.setParticipantName": "any",
41
+ "session.setWorkingCopy": "owner",
42
+ "session.cutBlock": "owner",
43
+ "session.restoreBlock": "owner",
44
+ "session.curate": "owner",
45
+ "session.setViewed": "owner",
46
+ "session.setTitle": "owner",
47
+ // reading the local working tree is the owner's alone; a share carries no repo to read
48
+ "session.projectFiles": "owner",
49
+ "session.fileContents": "owner",
50
+ // the launch repo is the local owner's; a share has no local repo to browse
51
+ "repo.files": "owner",
52
+ "repo.fileContents": "owner",
53
+ "repo.changes": "owner",
54
+ "repo.diff": "owner",
55
+ "session.workbench": "owner",
56
+ "session.workbenchReview": "owner",
57
+ // the tree is the owner's: collaborators comment on the branch a share follows
58
+ "session.navigate": "owner",
59
+ "session.branch": "owner",
60
+ "session.switch": "owner",
61
+ "session.label": "owner",
62
+ "session.fork": "owner",
63
+ "session.refreshDiff": "owner",
64
+ "session.setShares": "owner",
65
+ "session.delete": "owner",
66
+ "session.mergeShared": "owner",
67
+ "session.sendMessage": "owner",
68
+ "harness.bind": "owner",
69
+ "harness.getBinding": "owner",
70
+ "harness.bindingsForSession": "owner",
71
+ "harness.consumeApprovedRetry": "owner",
72
+ "delivery.pending": "owner",
73
+ "delivery.acknowledge": "owner",
74
+ "session.submitRevision": "owner",
75
+ "herdr.getThreadSurface": "owner",
76
+ "herdr.setThreadSurface": "owner",
77
+ "ghostty.getThreadSurface": "owner",
78
+ "ghostty.setThreadSurface": "owner",
79
+ "ghostty.claimThreadSurface": "owner",
80
+ "ghostty.releaseThreadSurface": "owner",
81
+ } as const satisfies Record<MethodName, Audience>;
82
+
83
+ /** Whether `role` may call `method`. */
84
+ export function roleAllowsMethod(role: DaemonRole, method: MethodName): boolean {
85
+ return role === "owner" || PRIMITIVE_ROLES[method] === "any";
86
+ }
87
+
88
+ /** The role a connection starts with, before any proof. */
89
+ export const DEFAULT_ROLE: DaemonRole = "collaborator";
90
+
91
+ export function asDaemonRole(value: Parameters<typeof v.safeParse>[1]): DaemonRole {
92
+ const result = v.safeParse(v.picklist(["owner", "collaborator"]), value);
93
+
94
+ return result.success ? result.output : "agent";
95
+ }
@@ -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
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { daemonSpawnCommand } from "./client";
3
+
4
+ describe("daemonSpawnCommand", () => {
5
+ test("re-execs the binary as `daemon` when compiled (bun virtual-fs markers)", () => {
6
+ for (const moduleUrl of [
7
+ "file:///$bunfs/root/cueloop",
8
+ "file:///B:/~BUN/root/cueloop",
9
+ "file:///B:/%7EBUN/root/cueloop",
10
+ ]) {
11
+ expect(daemonSpawnCommand("/usr/local/bin/cueloop", moduleUrl)).toEqual([
12
+ "/usr/local/bin/cueloop",
13
+ "daemon",
14
+ "--autostart",
15
+ ]);
16
+ }
17
+ });
18
+
19
+ test("runs main.ts with bun from source", () => {
20
+ expect(
21
+ daemonSpawnCommand("/path/to/bun", "file:///repo/packages/daemon/src/client.ts"),
22
+ ).toEqual(["/path/to/bun", "run", "/repo/packages/daemon/src/main.ts"]);
23
+ });
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
+ });