@cueloop/daemon 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +13 -5
- package/src/api.test.ts +223 -44
- package/src/api.ts +128 -50
- package/src/client.ts +76 -29
- package/src/herdr-pane.test.ts +146 -0
- package/src/herdr-pane.ts +95 -0
- package/src/index.ts +8 -8
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +243 -0
- package/src/review.ts +208 -0
- package/src/server.test.ts +135 -20
- package/src/server.ts +70 -48
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +48 -0
- package/src/single-instance.test.ts +28 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +152 -12
- package/src/validate.ts +93 -32
package/src/client.ts
CHANGED
|
@@ -1,38 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Daemon client: the one library every consumer shares - CLI verbs, the TUI,
|
|
3
|
-
* adapters, and tests. Also owns the lazy-launch story
|
|
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
7
|
import { existsSync, rmSync } from "node:fs";
|
|
8
|
-
import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
|
|
9
|
-
import { LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
8
|
+
import type { Annotation, Artifact, Identity, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
|
|
9
|
+
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
10
10
|
import { cueloopHome, socketPath } from "./paths";
|
|
11
11
|
|
|
12
|
+
export type { EventFrame } from "./protocol";
|
|
13
|
+
|
|
12
14
|
export interface ConnectOptions {
|
|
13
15
|
home?: string;
|
|
14
16
|
/** Spawn the daemon when the socket is not alive. */
|
|
15
17
|
autostart?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
type
|
|
20
|
+
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The session verbs the review controller drives. DaemonClient is the local
|
|
24
|
+
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
25
|
+
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
26
|
+
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
27
|
+
*/
|
|
28
|
+
export interface SessionClient {
|
|
29
|
+
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
30
|
+
subscribe(): Promise<void>;
|
|
31
|
+
sessionGet(id: string): Promise<ReviewSession>;
|
|
32
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]>;
|
|
33
|
+
sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession>;
|
|
34
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
|
|
35
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
|
|
36
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
37
|
+
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
|
|
38
|
+
sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession>;
|
|
39
|
+
sessionDelete(id: string): Promise<void>;
|
|
40
|
+
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
41
|
+
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
42
|
+
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
|
|
43
|
+
close(): void;
|
|
44
|
+
}
|
|
19
45
|
|
|
20
|
-
export class DaemonClient {
|
|
46
|
+
export class DaemonClient implements SessionClient {
|
|
21
47
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
22
|
-
private
|
|
48
|
+
private writer: BackpressureWriter | null = null;
|
|
49
|
+
private pending = new Map<number, PendingRequest>();
|
|
23
50
|
private nextId = 1;
|
|
24
|
-
private eventListeners = new Set<(
|
|
51
|
+
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
25
52
|
private closed = false;
|
|
26
53
|
|
|
27
|
-
static async connect(
|
|
28
|
-
const home =
|
|
54
|
+
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
55
|
+
const home = options.home ?? cueloopHome();
|
|
29
56
|
const path = socketPath(home);
|
|
30
57
|
const client = new DaemonClient();
|
|
31
58
|
try {
|
|
32
59
|
await client.dial(path);
|
|
33
60
|
return client;
|
|
34
61
|
} catch (err) {
|
|
35
|
-
if (!
|
|
62
|
+
if (!options.autostart) throw err;
|
|
36
63
|
}
|
|
37
64
|
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
38
65
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -40,17 +67,17 @@ export class DaemonClient {
|
|
|
40
67
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
41
68
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
42
69
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
43
|
-
let
|
|
70
|
+
let lastError: unknown;
|
|
44
71
|
while (Date.now() < deadline) {
|
|
45
72
|
try {
|
|
46
73
|
await client.dial(path);
|
|
47
74
|
return client;
|
|
48
75
|
} catch (err) {
|
|
49
|
-
|
|
76
|
+
lastError = err;
|
|
50
77
|
await Bun.sleep(50);
|
|
51
78
|
}
|
|
52
79
|
}
|
|
53
|
-
throw new Error(`daemon did not come up at ${path}: ${String(
|
|
80
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
private async dial(path: string): Promise<void> {
|
|
@@ -60,27 +87,31 @@ export class DaemonClient {
|
|
|
60
87
|
unix: path,
|
|
61
88
|
socket: {
|
|
62
89
|
data(_socket, data) {
|
|
63
|
-
buffer.push(data.toString(), (line) => self.
|
|
90
|
+
buffer.push(data.toString(), (line) => self.routeInboundFrame(line));
|
|
91
|
+
},
|
|
92
|
+
drain() {
|
|
93
|
+
self.writer?.drain();
|
|
64
94
|
},
|
|
65
95
|
close() {
|
|
66
96
|
self.closed = true;
|
|
67
|
-
for (const
|
|
97
|
+
for (const pendingRequest of self.pending.values()) pendingRequest.reject(new Error("daemon connection closed"));
|
|
68
98
|
self.pending.clear();
|
|
69
99
|
},
|
|
70
100
|
error() {},
|
|
71
101
|
},
|
|
72
102
|
});
|
|
103
|
+
this.writer = new BackpressureWriter(this.socket);
|
|
73
104
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
74
105
|
// only to fail later, so a ping is the actual handshake.
|
|
75
106
|
await this.request("daemon.ping", {}, 2_000);
|
|
76
107
|
}
|
|
77
108
|
|
|
78
|
-
onEvent(listener: (
|
|
109
|
+
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
79
110
|
this.eventListeners.add(listener);
|
|
80
111
|
return () => this.eventListeners.delete(listener);
|
|
81
112
|
}
|
|
82
113
|
|
|
83
|
-
private
|
|
114
|
+
private routeInboundFrame(line: string): void {
|
|
84
115
|
let frame: Response | EventFrame;
|
|
85
116
|
try {
|
|
86
117
|
frame = JSON.parse(line) as Response | EventFrame;
|
|
@@ -88,14 +119,14 @@ export class DaemonClient {
|
|
|
88
119
|
return;
|
|
89
120
|
}
|
|
90
121
|
if ("event" in frame) {
|
|
91
|
-
for (const
|
|
122
|
+
for (const listener of this.eventListeners) listener(frame);
|
|
92
123
|
return;
|
|
93
124
|
}
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
125
|
+
const pendingRequest = this.pending.get(frame.id);
|
|
126
|
+
if (!pendingRequest) return;
|
|
96
127
|
this.pending.delete(frame.id);
|
|
97
|
-
if (frame.error)
|
|
98
|
-
else
|
|
128
|
+
if (frame.error) pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
129
|
+
else pendingRequest.resolve(frame.result);
|
|
99
130
|
}
|
|
100
131
|
|
|
101
132
|
request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
@@ -107,16 +138,16 @@ export class DaemonClient {
|
|
|
107
138
|
reject(new Error(`request ${method} timed out`));
|
|
108
139
|
}, timeoutMs);
|
|
109
140
|
this.pending.set(id, {
|
|
110
|
-
resolve: (
|
|
141
|
+
resolve: (value) => {
|
|
111
142
|
clearTimeout(timer);
|
|
112
|
-
resolve(
|
|
143
|
+
resolve(value as T);
|
|
113
144
|
},
|
|
114
|
-
reject: (
|
|
145
|
+
reject: (error) => {
|
|
115
146
|
clearTimeout(timer);
|
|
116
|
-
reject(
|
|
147
|
+
reject(error);
|
|
117
148
|
},
|
|
118
149
|
});
|
|
119
|
-
this.
|
|
150
|
+
this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
|
|
120
151
|
});
|
|
121
152
|
}
|
|
122
153
|
|
|
@@ -154,11 +185,27 @@ export class DaemonClient {
|
|
|
154
185
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
155
186
|
return this.request("session.setWorkingCopy", { id, workingCopy });
|
|
156
187
|
}
|
|
188
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
189
|
+
return this.request("session.setViewed", { id, viewedPaths });
|
|
190
|
+
}
|
|
191
|
+
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
192
|
+
return this.request("session.setShareId", { id, shareId });
|
|
193
|
+
}
|
|
194
|
+
sessionMergeShared(id: string, incoming: { annotations: Annotation[]; participants?: Identity[] }): Promise<ReviewSession> {
|
|
195
|
+
return this.request("session.mergeShared", { id, ...incoming });
|
|
196
|
+
}
|
|
197
|
+
sessionDelete(id: string): Promise<void> {
|
|
198
|
+
return this.request("session.delete", { id });
|
|
199
|
+
}
|
|
200
|
+
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
201
|
+
sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
|
|
202
|
+
return this.sessionGet(id);
|
|
203
|
+
}
|
|
157
204
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
158
205
|
return this.request("session.resolve", { id, verdictKind, summary });
|
|
159
206
|
}
|
|
160
|
-
sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
|
|
161
|
-
return this.request("session.submitRevision", { id, content });
|
|
207
|
+
sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): Promise<ReviewSession> {
|
|
208
|
+
return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
|
|
162
209
|
}
|
|
163
210
|
shutdown(): Promise<void> {
|
|
164
211
|
return this.request("daemon.shutdown", {});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, and the gating wrapper is checked for its three outcomes (no-op outside herdr, no-op on revision, full open for a new session). */
|
|
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 type { ReviewSession } from "@cueloop/schema";
|
|
8
|
+
import { openHerdrPane, openHerdrPaneForReview } from "./herdr-pane";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A stub herdr binary. It appends its argv to logPath and, when the verb is
|
|
14
|
+
* `tab create`, prints a JSON result carrying the pane id on stdout so the
|
|
15
|
+
* helper can drive send-text and send-keys. The printed shape mirrors the
|
|
16
|
+
* REAL herdr `tab create` output ({ result: { root_pane: { pane_id } } },
|
|
17
|
+
* verified against herdr 0.8.0) - an earlier stub invented a different shape
|
|
18
|
+
* and let a parser that never matched real herdr pass the suite.
|
|
19
|
+
*/
|
|
20
|
+
function makeStub(name: string, paneId = "w1:p2"): { binPath: string; logPath: string } {
|
|
21
|
+
const logPath = join(dir, `${name}.log`);
|
|
22
|
+
const binPath = join(dir, `${name}.sh`);
|
|
23
|
+
writeFileSync(
|
|
24
|
+
binPath,
|
|
25
|
+
`#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nif [ "$1" = "tab" ] && [ "$2" = "create" ]; then\n printf '{"result":{"root_pane":{"pane_id":"${paneId}"}}}'\nfi\n`,
|
|
26
|
+
);
|
|
27
|
+
chmodSync(binPath, 0o755);
|
|
28
|
+
return { binPath, logPath };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readLines(logPath: string): string[] {
|
|
32
|
+
if (!existsSync(logPath)) return [];
|
|
33
|
+
return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: "1",
|
|
39
|
+
id: "ses_new1",
|
|
40
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
41
|
+
artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
|
|
42
|
+
revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
|
|
43
|
+
annotations: [],
|
|
44
|
+
verdict: null,
|
|
45
|
+
status: "pending",
|
|
46
|
+
createdAt: "now",
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("openHerdrPane", () => {
|
|
52
|
+
test("creates a focused tab, types the cueloop command, and presses enter", () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
const stub = makeStub("open");
|
|
55
|
+
|
|
56
|
+
// Act
|
|
57
|
+
const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath: stub.binPath, label: "Rollout Plan" });
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
60
|
+
expect(ok).toBeTrue();
|
|
61
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
62
|
+
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
63
|
+
"pane send-text w1:p2 cueloop ses_abc",
|
|
64
|
+
"pane send-keys w1:p2 enter",
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns false and never throws on a broken binary", () => {
|
|
69
|
+
// Act
|
|
70
|
+
const ok = openHerdrPane({
|
|
71
|
+
sessionId: "ses_abc",
|
|
72
|
+
cwd: "/repo/work",
|
|
73
|
+
binPath: join(dir, "missing-bin"),
|
|
74
|
+
label: "x",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
expect(ok).toBeFalse();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("bails out when tab create yields no pane id - no send-text, no send-keys", () => {
|
|
82
|
+
// Arrange
|
|
83
|
+
const logPath = join(dir, "nopane.log");
|
|
84
|
+
const binPath = join(dir, "nopane.sh");
|
|
85
|
+
writeFileSync(binPath, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`);
|
|
86
|
+
chmodSync(binPath, 0o755);
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
92
|
+
expect(ok).toBeFalse();
|
|
93
|
+
expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("openHerdrPaneForReview", () => {
|
|
98
|
+
test("opens a pane for a genuinely new session inside herdr", () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
const stub = makeStub("gated-new");
|
|
101
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
openHerdrPaneForReview(newSession({ id: "ses_xyz" }), env);
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(readLines(stub.logPath)).toEqual([
|
|
108
|
+
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
109
|
+
"pane send-text w1:p2 cueloop ses_xyz",
|
|
110
|
+
"pane send-keys w1:p2 enter",
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("no-op outside herdr - no herdr process is spawned", () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const stub = makeStub("gated-outside");
|
|
117
|
+
|
|
118
|
+
// Act
|
|
119
|
+
openHerdrPaneForReview(newSession(), { HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath });
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
122
|
+
expect(existsSync(stub.logPath)).toBeFalse();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("no-op for a revision - the already open pane is reused, not spammed", () => {
|
|
126
|
+
// Arrange
|
|
127
|
+
const stub = makeStub("gated-revision");
|
|
128
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
129
|
+
const revised = newSession({
|
|
130
|
+
revisions: [
|
|
131
|
+
{ revision: 1, content: "# P", submittedAt: "now" },
|
|
132
|
+
{ revision: 2, content: "# P2", submittedAt: "now" },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Act
|
|
137
|
+
openHerdrPaneForReview(revised, env);
|
|
138
|
+
|
|
139
|
+
// Assert
|
|
140
|
+
expect(existsSync(stub.logPath)).toBeFalse();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterAll(() => {
|
|
145
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
146
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creation-side herdr pane opener: when a review is created from inside a
|
|
3
|
+
* herdr pane, cueloop opens a NEW herdr tab that renders the review, so the
|
|
4
|
+
* creator does not have to run a command by hand. This is the counterpart to
|
|
5
|
+
* the reviewer-side focusHerdrPane in @cueloop/client. It lives here, beside
|
|
6
|
+
* the shared review core (openReview), because both creation sites - the
|
|
7
|
+
* Claude Code hook and the `cueloop session create` CLI - already depend on
|
|
8
|
+
* this package and drive creation through it, so a single home reaches both
|
|
9
|
+
* with no new cross-package dependency.
|
|
10
|
+
*
|
|
11
|
+
* A fresh tab hosts a plain shell with no command argument, so the review is
|
|
12
|
+
* launched exactly as a human would: type `cueloop <sessionId>` into the new
|
|
13
|
+
* pane and press enter. The herdr CLI is spawned synchronously because the
|
|
14
|
+
* send-text and send-keys steps need the pane id that `tab create` returns.
|
|
15
|
+
*
|
|
16
|
+
* Best-effort like the rest of the herdr tier: a missing or broken binary, an
|
|
17
|
+
* unexpected JSON shape, or any spawn failure is swallowed and never blocks
|
|
18
|
+
* review creation. Each spawn is bounded by HERDR_SPAWN_TIMEOUT_MS so a hung
|
|
19
|
+
* herdr binary cannot stall the review-creation path - a timeout kills the
|
|
20
|
+
* child and reads as a non-zero exit, i.e. a best-effort bail-out.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { detectHerdr, type HerdrEnv, type ReviewSession } from "@cueloop/schema";
|
|
24
|
+
|
|
25
|
+
/** Upper bound per herdr spawn, so a stuck binary can never block creation. */
|
|
26
|
+
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
27
|
+
|
|
28
|
+
export interface OpenHerdrPaneOptions {
|
|
29
|
+
/** The review session id; `cueloop <sessionId>` opens it in the new pane. */
|
|
30
|
+
sessionId: string;
|
|
31
|
+
/** Working directory for the new tab; the review's cwd. */
|
|
32
|
+
cwd: string;
|
|
33
|
+
/** Absolute path to the herdr binary, from the ambient env contract. */
|
|
34
|
+
binPath: string;
|
|
35
|
+
/** Sidebar label for the new tab. */
|
|
36
|
+
label: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Open a herdr tab that renders the review. `tab create` prints a JSON result
|
|
41
|
+
* shaped like `{ result: { root_pane: { pane_id } } }` (verified against
|
|
42
|
+
* herdr 0.8.0; `pane get` uses a different `result.pane` nesting, which is
|
|
43
|
+
* what focusHerdrPane reads). The pane id drives the two follow-up steps.
|
|
44
|
+
* Returns true when the full open-and-launch sequence completed, false on any
|
|
45
|
+
* best-effort bail-out.
|
|
46
|
+
*/
|
|
47
|
+
export function openHerdrPane(options: OpenHerdrPaneOptions): boolean {
|
|
48
|
+
const { sessionId, cwd, binPath, label } = options;
|
|
49
|
+
try {
|
|
50
|
+
const created = Bun.spawnSync([binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"], {
|
|
51
|
+
stdout: "pipe",
|
|
52
|
+
stderr: "ignore",
|
|
53
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
54
|
+
});
|
|
55
|
+
if (created.exitCode !== 0) return false;
|
|
56
|
+
const parsed = JSON.parse(created.stdout.toString()) as { result?: { root_pane?: { pane_id?: string } } };
|
|
57
|
+
const paneId = parsed.result?.root_pane?.pane_id;
|
|
58
|
+
if (!paneId) return false;
|
|
59
|
+
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
|
60
|
+
const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
|
|
61
|
+
stdout: "ignore",
|
|
62
|
+
stderr: "ignore",
|
|
63
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
64
|
+
});
|
|
65
|
+
if (typed.exitCode !== 0) return false;
|
|
66
|
+
return (
|
|
67
|
+
Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
|
|
68
|
+
stdout: "ignore",
|
|
69
|
+
stderr: "ignore",
|
|
70
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
71
|
+
}).exitCode === 0
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open a pane for a freshly created review, guarded for the one case that
|
|
80
|
+
* wants it: inside herdr and genuinely new. A revision reuses the pane the
|
|
81
|
+
* original review already opened, so a resubmit (revisions beyond the first)
|
|
82
|
+
* never spawns another tab. A no-op outside herdr keeps a plain terminal
|
|
83
|
+
* byte-identical.
|
|
84
|
+
*/
|
|
85
|
+
export function openHerdrPaneForReview(session: ReviewSession, env: HerdrEnv = process.env): void {
|
|
86
|
+
const herdr = detectHerdr(env);
|
|
87
|
+
if (!herdr) return;
|
|
88
|
+
if (session.revisions.length !== 1) return;
|
|
89
|
+
openHerdrPane({
|
|
90
|
+
sessionId: session.id,
|
|
91
|
+
cwd: session.artifact.meta.cwd ?? process.cwd(),
|
|
92
|
+
binPath: herdr.binPath,
|
|
93
|
+
label: session.artifact.meta.title ?? session.id,
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export {
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's public surface. Consumers reach the client and the review
|
|
3
|
+
* helpers through the ./client and ./review subpaths; this barrel carries
|
|
4
|
+
* only the two names imported bare: the server and the home-dir resolver.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DaemonServer, type DaemonOptions } from "./server";
|
|
8
|
+
export { cueloopHome } from "./paths";
|
package/src/protocol.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Wire protocol
|
|
2
|
+
* Wire protocol: newline-delimited JSON over the unix socket.
|
|
3
3
|
* Requests: { id, method, params } Responses: { id, result } | { id, error }
|
|
4
4
|
* Events (push, after events.subscribe): { event, sessionId }
|
|
5
5
|
*/
|
|
@@ -25,16 +25,53 @@ export type Frame = Response | EventFrame;
|
|
|
25
25
|
|
|
26
26
|
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
27
|
export class LineBuffer {
|
|
28
|
-
private
|
|
28
|
+
private buffered = "";
|
|
29
29
|
|
|
30
30
|
push(chunk: string, onLine: (line: string) => void): void {
|
|
31
|
-
this.
|
|
31
|
+
this.buffered += chunk;
|
|
32
32
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
const line = this.
|
|
36
|
-
this.
|
|
33
|
+
const newlineIndex = this.buffered.indexOf("\n");
|
|
34
|
+
if (newlineIndex === -1) return;
|
|
35
|
+
const line = this.buffered.slice(0, newlineIndex).trim();
|
|
36
|
+
this.buffered = this.buffered.slice(newlineIndex + 1);
|
|
37
37
|
if (line) onLine(line);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/** The surface both Bun.listen and Bun.connect sockets satisfy. */
|
|
43
|
+
interface WritableSocket {
|
|
44
|
+
write(data: Buffer): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Outbound writes honoring socket backpressure. A frame larger than the
|
|
49
|
+
* kernel buffer is written partially by the socket; the unwritten tail must
|
|
50
|
+
* wait for the drain callback or the frame truncates mid-line and the NDJSON
|
|
51
|
+
* stream corrupts for good. Frames never reorder: while a tail is pending,
|
|
52
|
+
* new writes append behind it.
|
|
53
|
+
*/
|
|
54
|
+
export class BackpressureWriter {
|
|
55
|
+
private pending: Buffer | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(private socket: WritableSocket) {}
|
|
58
|
+
|
|
59
|
+
write(data: string): void {
|
|
60
|
+
const bytes = Buffer.from(data, "utf8");
|
|
61
|
+
if (this.pending) {
|
|
62
|
+
this.pending = Buffer.concat([this.pending, bytes]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const written = Math.max(0, this.socket.write(bytes));
|
|
66
|
+
if (written < bytes.length) this.pending = bytes.subarray(written);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Call from the socket's drain handler. */
|
|
70
|
+
drain(): void {
|
|
71
|
+
if (!this.pending) return;
|
|
72
|
+
const tail = this.pending;
|
|
73
|
+
this.pending = null;
|
|
74
|
+
const written = Math.max(0, this.socket.write(tail));
|
|
75
|
+
if (written < tail.length) this.pending = tail.subarray(written);
|
|
76
|
+
}
|
|
77
|
+
}
|