@cueloop/daemon 0.1.0-alpha.39 → 0.1.0-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.39",
3
+ "version": "0.1.0-alpha.40",
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": {
@@ -22,13 +22,14 @@
22
22
  "./client": "./src/client.ts",
23
23
  "./review": "./src/review.ts",
24
24
  "./herdr-pane": "./src/herdr-pane.ts",
25
+ "./herdr-split": "./src/herdr-split.ts",
25
26
  "./share-blob": "./src/share-blob.ts"
26
27
  },
27
28
  "publishConfig": {
28
29
  "access": "public"
29
30
  },
30
31
  "dependencies": {
31
- "@cueloop/schema": "0.1.0-alpha.39",
32
+ "@cueloop/schema": "0.1.0-alpha.40",
32
33
  "valibot": "^1.4.2"
33
34
  },
34
35
  "engines": {
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { asDaemonRole, roleAllowsMethod } from "./capabilities";
3
+
4
+ describe("roleAllowsMethod", () => {
5
+ test("the owner may call every verb", () => {
6
+ // Assert
7
+ for (const method of ["session.resolve", "session.submitRevision", "session.delete"])
8
+ expect(roleAllowsMethod("owner", method)).toBe(true);
9
+ });
10
+
11
+ test("a capped role may read and annotate but not escalate", () => {
12
+ // Assert - allowed
13
+ for (const method of ["session.get", "session.list", "session.wait", "session.annotate"])
14
+ expect(roleAllowsMethod("agent", method)).toBe(true);
15
+ // Assert - denied
16
+ for (const method of [
17
+ "session.resolve",
18
+ "session.submitRevision",
19
+ "session.setWorkingCopy",
20
+ "session.delete",
21
+ "session.setShareId",
22
+ ])
23
+ expect(roleAllowsMethod("agent", method)).toBe(false);
24
+ expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("asDaemonRole", () => {
29
+ test("passes owner and collaborator through and caps anything unknown to agent", () => {
30
+ // Assert
31
+ expect(asDaemonRole("owner")).toBe("owner");
32
+ expect(asDaemonRole("collaborator")).toBe("collaborator");
33
+ expect(asDaemonRole("agent")).toBe("agent");
34
+ expect(asDaemonRole("root")).toBe("agent");
35
+ expect(asDaemonRole(undefined)).toBe("agent");
36
+ });
37
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Daemon role capabilities: which socket methods a connection may call. The
3
+ * owner (the local planner) may call everything; a collaborator or a review-side
4
+ * agent is capped to reading the session and adding annotations, so a bring-your-
5
+ * own agent literally cannot resolve, rewrite, cut, share, or delete (ADR 0009).
6
+ * This is the single source of truth the server dispatch enforces.
7
+ */
8
+
9
+ export type DaemonRole = "owner" | "collaborator" | "agent";
10
+
11
+ /** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
12
+ const NON_OWNER_METHODS = new Set<string>([
13
+ "daemon.ping",
14
+ "daemon.hello",
15
+ "events.subscribe",
16
+ "session.get",
17
+ "session.list",
18
+ "session.wait",
19
+ "session.annotate",
20
+ ]);
21
+
22
+ /** Whether `role` may call `method`. The owner may call anything; others are capped to read + annotate. */
23
+ export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
24
+ return role === "owner" || NON_OWNER_METHODS.has(method);
25
+ }
26
+
27
+ /** Parse an untrusted role string, defaulting unknown values to the capped "agent". */
28
+ export function asDaemonRole(value: unknown): DaemonRole {
29
+ return value === "owner" || value === "collaborator" ? value : "agent";
30
+ }
package/src/client.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  WorkspaceKey,
15
15
  } from "@cueloop/schema";
16
16
  import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
17
+ import type { DaemonRole } from "./capabilities";
17
18
  import type { HerdrTabHandle } from "./herdr-tab-store";
18
19
  import { cueloopHome, socketPath } from "./paths";
19
20
 
@@ -23,6 +24,8 @@ export interface ConnectOptions {
23
24
  home?: string;
24
25
  /** Spawn the daemon when the socket is not alive. */
25
26
  autostart?: boolean;
27
+ /** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
28
+ role?: DaemonRole;
26
29
  }
27
30
 
28
31
  type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
@@ -65,11 +68,13 @@ export class DaemonClient implements SessionClient {
65
68
  private nextId = 1;
66
69
  private eventListeners = new Set<(event: EventFrame) => void>();
67
70
  private closed = false;
71
+ private role: DaemonRole = "owner";
68
72
 
69
73
  static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
70
74
  const home = options.home ?? cueloopHome();
71
75
  const path = socketPath(home);
72
76
  const client = new DaemonClient();
77
+ client.role = options.role ?? "owner";
73
78
  try {
74
79
  await client.dial(path);
75
80
  return client;
@@ -119,6 +124,8 @@ export class DaemonClient implements SessionClient {
119
124
  // Verify liveness: a dead socket file accepts connects on some platforms
120
125
  // only to fail later, so a ping is the actual handshake.
121
126
  await this.request("daemon.ping", {}, 2_000);
127
+ // Cap this connection's role for the daemon's capability gate (owner is the default).
128
+ if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
122
129
  }
123
130
 
124
131
  onEvent(listener: (event: EventFrame) => void): () => void {
@@ -192,6 +192,28 @@ describe("openHerdrPaneForReview", () => {
192
192
  expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
193
193
  });
194
194
 
195
+ test("a recall failure (stale daemon) still opens a fresh tab", async () => {
196
+ // Arrange - a daemon predating the herdr-tab verbs rejects the recall
197
+ const stub = makeStub("gated-recall-fail");
198
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
199
+ const persistence: HerdrTabPersistence = {
200
+ herdrGetTab: async () => {
201
+ throw new Error("unknown method herdr.getTab");
202
+ },
203
+ herdrSetTab: async () => {},
204
+ };
205
+
206
+ // Act
207
+ await openHerdrPaneForReview(newSession({ id: "ses_stale" }), persistence, env);
208
+
209
+ // Assert - the recall throwing must not abort the open
210
+ expect(readLines(stub.logPath)).toEqual([
211
+ "tab create --cwd /repo/work --label Rollout Plan --focus",
212
+ "pane send-text w1:p2 cueloop ses_stale",
213
+ "pane send-keys w1:p2 enter",
214
+ ]);
215
+ });
216
+
195
217
  test("reopens and re-records when the recorded pane is dead", async () => {
196
218
  // Arrange
197
219
  const stub = makeStub("gated-dead", false);
package/src/herdr-pane.ts CHANGED
@@ -103,20 +103,41 @@ export async function openHerdrPaneForReview(
103
103
  ): Promise<void> {
104
104
  const herdr = detectHerdr(env);
105
105
  if (!herdr) return;
106
+ const recorded = await recallHerdrTab(persistence, session.id);
107
+ if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
108
+ focusHerdrTab(herdr.binPath, recorded.tabId);
109
+ return;
110
+ }
111
+ const opened = openHerdrPane({
112
+ sessionId: session.id,
113
+ cwd: session.artifact.meta.cwd ?? process.cwd(),
114
+ binPath: herdr.binPath,
115
+ label: session.artifact.meta.title ?? session.id,
116
+ });
117
+ if (opened) await rememberHerdrTab(persistence, session.id, opened);
118
+ }
119
+
120
+ /** The recorded handle, or null on any recall failure - a stale daemon must still open a fresh tab, not abort. */
121
+ async function recallHerdrTab(
122
+ persistence: HerdrTabPersistence,
123
+ sessionId: string,
124
+ ): Promise<HerdrTabHandle | null> {
106
125
  try {
107
- const recorded = await persistence.herdrGetTab(session.id);
108
- if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
109
- focusHerdrTab(herdr.binPath, recorded.tabId);
110
- return;
111
- }
112
- const opened = openHerdrPane({
113
- sessionId: session.id,
114
- cwd: session.artifact.meta.cwd ?? process.cwd(),
115
- binPath: herdr.binPath,
116
- label: session.artifact.meta.title ?? session.id,
117
- });
118
- if (opened) await persistence.herdrSetTab(session.id, opened);
126
+ return await persistence.herdrGetTab(sessionId);
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /** Record the handle for later liveness checks; a store failure loses dedup, never the already-open tab. */
133
+ async function rememberHerdrTab(
134
+ persistence: HerdrTabPersistence,
135
+ sessionId: string,
136
+ handle: HerdrTabHandle,
137
+ ): Promise<void> {
138
+ try {
139
+ await persistence.herdrSetTab(sessionId, handle);
119
140
  } catch {
120
- // best-effort: a herdr spawn or a daemon persistence hiccup never blocks review creation
141
+ // stale daemon: the tab is already shown; only the liveness-dedup handle is lost
121
142
  }
122
143
  }
@@ -0,0 +1,103 @@
1
+ /** launchHarnessInSplit: a stubbed herdr binary logs its argv, and the launcher splits the current pane, runs the harness in the new pane, and optionally types the plan-context seed - a no-op outside herdr. */
2
+
3
+ import { afterAll, describe, expect, test } from "bun:test";
4
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { launchHarnessInSplit } from "./herdr-split";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-split-"));
10
+
11
+ /** A stub herdr binary that logs argv and prints the pane-split result (pane_id of the new split). */
12
+ function makeStub(name: string, splitOk = true): { binPath: string; logPath: string } {
13
+ const logPath = join(dir, `${name}.log`);
14
+ const binPath = join(dir, `${name}.sh`);
15
+ const splitResult = splitOk
16
+ ? `printf '{"result":{"pane":{"pane_id":"w1:p9"}}}'`
17
+ : `printf '{"result":{}}'`;
18
+ writeFileSync(
19
+ binPath,
20
+ `#!/bin/sh
21
+ printf '%s\\n' "$*" >> "${logPath}"
22
+ if [ "$1" = "pane" ] && [ "$2" = "split" ]; then ${splitResult}; fi
23
+ exit 0
24
+ `,
25
+ );
26
+ chmodSync(binPath, 0o755);
27
+ return { binPath, logPath };
28
+ }
29
+
30
+ function readLines(logPath: string): string[] {
31
+ if (!existsSync(logPath)) return [];
32
+ return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
33
+ }
34
+
35
+ describe("launchHarnessInSplit", () => {
36
+ test("splits the current pane and runs the harness in the new pane", () => {
37
+ // Arrange
38
+ const stub = makeStub("run");
39
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
40
+
41
+ // Act
42
+ const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo/work" }, env);
43
+
44
+ // Assert
45
+ expect(launched).toBeTrue();
46
+ expect(readLines(stub.logPath)).toEqual([
47
+ "pane split w1:p1 --direction right --ratio 0.4 --cwd /repo/work",
48
+ "pane send-text w1:p9 cc",
49
+ "pane send-keys w1:p9 enter",
50
+ ]);
51
+ });
52
+
53
+ test("types the plan-context seed into the split after launching", () => {
54
+ // Arrange
55
+ const stub = makeStub("seeded");
56
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
57
+
58
+ // Act
59
+ launchHarnessInSplit({ command: "pi", cwd: "/repo", seedText: "read plan ses_1" }, env);
60
+
61
+ // Assert
62
+ expect(readLines(stub.logPath)).toEqual([
63
+ "pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
64
+ "pane send-text w1:p9 pi",
65
+ "pane send-keys w1:p9 enter",
66
+ "pane send-text w1:p9 read plan ses_1",
67
+ ]);
68
+ });
69
+
70
+ test("no-op outside herdr - returns false and spawns nothing", () => {
71
+ // Arrange
72
+ const stub = makeStub("outside");
73
+
74
+ // Act
75
+ const launched = launchHarnessInSplit(
76
+ { command: "cc", cwd: "/repo" },
77
+ { HERDR_BIN_PATH: stub.binPath },
78
+ );
79
+
80
+ // Assert
81
+ expect(launched).toBeFalse();
82
+ expect(existsSync(stub.logPath)).toBeFalse();
83
+ });
84
+
85
+ test("returns false when the split yields no pane id", () => {
86
+ // Arrange
87
+ const stub = makeStub("nopane", false);
88
+ const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
89
+
90
+ // Act
91
+ const launched = launchHarnessInSplit({ command: "cc", cwd: "/repo" }, env);
92
+
93
+ // Assert - the split ran but no harness was launched into a missing pane
94
+ expect(launched).toBeFalse();
95
+ expect(readLines(stub.logPath)).toEqual([
96
+ "pane split w1:p1 --direction right --ratio 0.4 --cwd /repo",
97
+ ]);
98
+ });
99
+ });
100
+
101
+ afterAll(() => {
102
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
103
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Launches a bring-your-own review harness (claude code / pi / codex) in a herdr
3
+ * split beside the current pane - the Agent rail's "run it right here" spawn, so
4
+ * a reviewer chats with an agent about the plan without leaving the review tab.
5
+ * Best-effort and herdr-only: a no-op returning false outside herdr or on any
6
+ * spawn failure, each spawn bounded by a timeout.
7
+ */
8
+
9
+ import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
10
+
11
+ const HERDR_SPAWN_TIMEOUT_MS = 2000;
12
+
13
+ /** How much of the current pane the launched harness split takes (herdr --ratio). */
14
+ const HARNESS_SPLIT_RATIO = "0.4";
15
+
16
+ export interface LaunchHarnessInSplitOptions {
17
+ /** The harness launch command run in the split, e.g. "cc", "pi", "codex". */
18
+ command: string;
19
+ /** Working directory for the split - the reviewed session's cwd. */
20
+ cwd: string;
21
+ /**
22
+ * A plan-context briefing typed into the split after the harness command, left
23
+ * unsubmitted for the reviewer to send. Best-effort: on a slow harness start it
24
+ * may reach the shell instead, so the launch never depends on it.
25
+ */
26
+ seedText?: string;
27
+ }
28
+
29
+ /**
30
+ * Split the current pane and run the harness in it. Returns true when the split
31
+ * opened, false on any best-effort bail-out. No-op outside herdr.
32
+ */
33
+ export function launchHarnessInSplit(
34
+ options: LaunchHarnessInSplitOptions,
35
+ env: HerdrEnv = process.env,
36
+ ): boolean {
37
+ const herdr = detectHerdr(env);
38
+ if (!herdr) return false;
39
+ try {
40
+ const split = Bun.spawnSync(
41
+ [
42
+ herdr.binPath,
43
+ "pane",
44
+ "split",
45
+ herdr.paneId,
46
+ "--direction",
47
+ "right",
48
+ "--ratio",
49
+ HARNESS_SPLIT_RATIO,
50
+ "--cwd",
51
+ options.cwd,
52
+ ],
53
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
54
+ );
55
+ if (split.exitCode !== 0) return false;
56
+ const parsed = JSON.parse(split.stdout.toString()) as {
57
+ result?: { pane?: { pane_id?: string } };
58
+ };
59
+ const paneId = parsed.result?.pane?.pane_id;
60
+ if (!paneId) return false;
61
+ sendText(herdr.binPath, paneId, options.command);
62
+ sendKeys(herdr.binPath, paneId, "enter");
63
+ if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function sendText(binPath: string, paneId: string, text: string): void {
71
+ Bun.spawnSync([binPath, "pane", "send-text", paneId, text], {
72
+ stdout: "ignore",
73
+ stderr: "ignore",
74
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
75
+ });
76
+ }
77
+
78
+ function sendKeys(binPath: string, paneId: string, keys: string): void {
79
+ Bun.spawnSync([binPath, "pane", "send-keys", paneId, keys], {
80
+ stdout: "ignore",
81
+ stderr: "ignore",
82
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
83
+ });
84
+ }
package/src/server.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "node:fs";
17
17
  import { DaemonCore, type DaemonEvent } from "./api";
18
18
  import { DaemonError } from "./errors";
19
+ import { roleAllowsMethod, type DaemonRole } from "./capabilities";
19
20
  import { isKnownMethod, parseParams } from "./validate";
20
21
  import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
21
22
  import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
@@ -23,6 +24,8 @@ import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
23
24
  interface Connection {
24
25
  write(data: string): void;
25
26
  subscribed: boolean;
27
+ /** Capability role for this connection; the owner until a daemon.hello caps it. */
28
+ role: DaemonRole;
26
29
  }
27
30
 
28
31
  export interface DaemonOptions {
@@ -136,7 +139,11 @@ export class DaemonServer {
136
139
  socket: {
137
140
  open: (socket) => {
138
141
  const writer = new BackpressureWriter(socket);
139
- const connection: Connection = { write: (data) => writer.write(data), subscribed: false };
142
+ const connection: Connection = {
143
+ write: (data) => writer.write(data),
144
+ subscribed: false,
145
+ role: "owner",
146
+ };
140
147
  socket.data = { buffer: new LineBuffer(), connection, writer };
141
148
  this.connections.add(connection);
142
149
  this.scheduleIdleCheck();
@@ -219,10 +226,18 @@ export class DaemonServer {
219
226
  if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
220
227
  throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
221
228
  }
229
+ // Capability gate: a capped role (a review-side agent) cannot escalate past
230
+ // read + annotate, whatever verb it sends.
231
+ if (!roleAllowsMethod(connection.role, request.method)) {
232
+ throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
233
+ }
222
234
  const core = this.core;
223
235
  switch (request.method) {
224
236
  case "daemon.ping":
225
237
  return { pid: process.pid };
238
+ case "daemon.hello":
239
+ connection.role = parseParams("daemon.hello", request.params).role;
240
+ return {};
226
241
  case "daemon.shutdown":
227
242
  setTimeout(() => {
228
243
  this.stop();
package/src/validate.ts CHANGED
@@ -153,6 +153,7 @@ export const Params = {
153
153
  }),
154
154
  "events.subscribe": v.object({}),
155
155
  "daemon.ping": v.object({}),
156
+ "daemon.hello": v.object({ role: v.picklist(["owner", "collaborator", "agent"]) }),
156
157
  "daemon.shutdown": v.object({}),
157
158
  // herdr adapter scratch: the review's opened tab, kept off the session record.
158
159
  "herdr.getTab": v.object({ id: SessionId }),