@cueloop/daemon 0.1.0-alpha.37 → 0.1.0-alpha.38
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 +2 -2
- package/src/api.ts +13 -0
- package/src/client.ts +8 -0
- package/src/herdr-pane.test.ts +100 -40
- package/src/herdr-pane.ts +81 -59
- package/src/herdr-tab-store.test.ts +95 -0
- package/src/herdr-tab-store.ts +67 -0
- package/src/paths.ts +5 -0
- package/src/server.ts +7 -0
- package/src/validate.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.38",
|
|
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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
31
|
+
"@cueloop/schema": "0.1.0-alpha.38",
|
|
32
32
|
"valibot": "^1.4.2"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
package/src/api.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type WorkspaceKey,
|
|
23
23
|
} from "@cueloop/schema";
|
|
24
24
|
import { SessionStore } from "./store";
|
|
25
|
+
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
25
26
|
import { DaemonError } from "./errors";
|
|
26
27
|
|
|
27
28
|
export type EventName =
|
|
@@ -40,6 +41,7 @@ type EventListener = (event: DaemonEvent) => void;
|
|
|
40
41
|
|
|
41
42
|
export class DaemonCore {
|
|
42
43
|
readonly store: SessionStore;
|
|
44
|
+
readonly herdrTabs: HerdrTabStore;
|
|
43
45
|
private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
|
|
44
46
|
private listeners = new Set<EventListener>();
|
|
45
47
|
private seq = 0;
|
|
@@ -47,6 +49,16 @@ export class DaemonCore {
|
|
|
47
49
|
constructor(home: string) {
|
|
48
50
|
this.store = new SessionStore(home);
|
|
49
51
|
this.store.recover();
|
|
52
|
+
this.herdrTabs = new HerdrTabStore(home);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
|
|
56
|
+
herdrGetTab(sessionId: string): HerdrTabHandle | null {
|
|
57
|
+
return this.herdrTabs.get(sessionId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
|
|
61
|
+
this.herdrTabs.set(sessionId, handle);
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
onEvent(listener: EventListener): () => void {
|
|
@@ -190,6 +202,7 @@ export class DaemonCore {
|
|
|
190
202
|
/** Remove a session for good (inbox delete); resolved or pending, both go. */
|
|
191
203
|
sessionDelete(id: string): void {
|
|
192
204
|
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
205
|
+
this.herdrTabs.delete(id);
|
|
193
206
|
this.emit("inbox.changed", id);
|
|
194
207
|
}
|
|
195
208
|
|
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 { HerdrTabHandle } from "./herdr-tab-store";
|
|
17
18
|
import { cueloopHome, socketPath } from "./paths";
|
|
18
19
|
|
|
19
20
|
export type { EventFrame } from "./protocol";
|
|
@@ -225,6 +226,13 @@ export class DaemonClient implements SessionClient {
|
|
|
225
226
|
): Promise<ReviewSession> {
|
|
226
227
|
return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
|
|
227
228
|
}
|
|
229
|
+
/** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
|
|
230
|
+
herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
|
|
231
|
+
return this.request("herdr.getTab", { id });
|
|
232
|
+
}
|
|
233
|
+
async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
|
|
234
|
+
await this.request("herdr.setTab", { id, ...handle });
|
|
235
|
+
}
|
|
228
236
|
shutdown(): Promise<void> {
|
|
229
237
|
return this.request("daemon.shutdown", {});
|
|
230
238
|
}
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Creation-side herdr pane opener: a stubbed herdr binary logs its argv,
|
|
1
|
+
/** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, openHerdrPane returns the tab handle, and openHerdrPaneForReview opens+records for a new review, focuses a still-alive recorded tab, and reopens a dead one - collision-free by pane id. */
|
|
2
2
|
|
|
3
3
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
4
4
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -6,23 +6,30 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import type { ReviewSession } from "@cueloop/schema";
|
|
8
8
|
import { openHerdrPane, openHerdrPaneForReview } from "./herdr-pane";
|
|
9
|
+
import type { HerdrTabPersistence } from "./herdr-pane";
|
|
10
|
+
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
9
11
|
|
|
10
12
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* A stub herdr binary. It
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
15
|
+
* A stub herdr binary. It logs argv, prints the tab-create result carrying both
|
|
16
|
+
* pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
|
|
17
|
+
* per `paneAlive` so the liveness branch can be exercised.
|
|
19
18
|
*/
|
|
20
|
-
function makeStub(name: string,
|
|
19
|
+
function makeStub(name: string, paneAlive = false): { binPath: string; logPath: string } {
|
|
21
20
|
const logPath = join(dir, `${name}.log`);
|
|
22
21
|
const binPath = join(dir, `${name}.sh`);
|
|
22
|
+
const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
|
|
23
23
|
writeFileSync(
|
|
24
24
|
binPath,
|
|
25
|
-
`#!/bin/sh
|
|
25
|
+
`#!/bin/sh
|
|
26
|
+
printf '%s\\n' "$*" >> "${logPath}"
|
|
27
|
+
if [ "$1" = "tab" ] && [ "$2" = "create" ]; then
|
|
28
|
+
printf '{"result":{"root_pane":{"pane_id":"w1:p2","tab_id":"w1:t2"}}}'
|
|
29
|
+
fi
|
|
30
|
+
if [ "$1" = "pane" ] && [ "$2" = "get" ]; then ${paneGet}; fi
|
|
31
|
+
exit 0
|
|
32
|
+
`,
|
|
26
33
|
);
|
|
27
34
|
chmodSync(binPath, 0o755);
|
|
28
35
|
return { binPath, logPath };
|
|
@@ -33,6 +40,23 @@ function readLines(logPath: string): string[] {
|
|
|
33
40
|
return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
/** In-memory persistence double: records what openHerdrPaneForReview saves. */
|
|
44
|
+
function fakePersistence(initial: HerdrTabHandle | null = null): {
|
|
45
|
+
persistence: HerdrTabPersistence;
|
|
46
|
+
saved: () => HerdrTabHandle | null;
|
|
47
|
+
} {
|
|
48
|
+
let stored = initial;
|
|
49
|
+
return {
|
|
50
|
+
persistence: {
|
|
51
|
+
herdrGetTab: async () => stored,
|
|
52
|
+
herdrSetTab: async (_sessionId, handle) => {
|
|
53
|
+
stored = handle;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
saved: () => stored,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
36
60
|
function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
37
61
|
return {
|
|
38
62
|
schemaVersion: "1",
|
|
@@ -49,12 +73,12 @@ function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
describe("openHerdrPane", () => {
|
|
52
|
-
test("creates a focused tab,
|
|
76
|
+
test("creates a focused tab, launches the review, and returns the handle", () => {
|
|
53
77
|
// Arrange
|
|
54
78
|
const stub = makeStub("open");
|
|
55
79
|
|
|
56
80
|
// Act
|
|
57
|
-
const
|
|
81
|
+
const handle = openHerdrPane({
|
|
58
82
|
sessionId: "ses_abc",
|
|
59
83
|
cwd: "/repo/work",
|
|
60
84
|
binPath: stub.binPath,
|
|
@@ -62,7 +86,7 @@ describe("openHerdrPane", () => {
|
|
|
62
86
|
});
|
|
63
87
|
|
|
64
88
|
// Assert
|
|
65
|
-
expect(
|
|
89
|
+
expect(handle).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
66
90
|
expect(readLines(stub.logPath)).toEqual([
|
|
67
91
|
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
68
92
|
"pane send-text w1:p2 cueloop ses_abc",
|
|
@@ -70,20 +94,18 @@ describe("openHerdrPane", () => {
|
|
|
70
94
|
]);
|
|
71
95
|
});
|
|
72
96
|
|
|
73
|
-
test("returns
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// Assert
|
|
83
|
-
expect(ok).toBeFalse();
|
|
97
|
+
test("returns null and never throws on a broken binary", () => {
|
|
98
|
+
expect(
|
|
99
|
+
openHerdrPane({
|
|
100
|
+
sessionId: "ses_abc",
|
|
101
|
+
cwd: "/repo/work",
|
|
102
|
+
binPath: join(dir, "missing-bin"),
|
|
103
|
+
label: "x",
|
|
104
|
+
}),
|
|
105
|
+
).toBeNull();
|
|
84
106
|
});
|
|
85
107
|
|
|
86
|
-
test("
|
|
108
|
+
test("returns null when tab create yields no ids - no send-text, no send-keys", () => {
|
|
87
109
|
// Arrange
|
|
88
110
|
const logPath = join(dir, "nopane.log");
|
|
89
111
|
const binPath = join(dir, "nopane.sh");
|
|
@@ -94,22 +116,23 @@ describe("openHerdrPane", () => {
|
|
|
94
116
|
chmodSync(binPath, 0o755);
|
|
95
117
|
|
|
96
118
|
// Act
|
|
97
|
-
const
|
|
119
|
+
const handle = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
|
|
98
120
|
|
|
99
121
|
// Assert
|
|
100
|
-
expect(
|
|
122
|
+
expect(handle).toBeNull();
|
|
101
123
|
expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
|
|
102
124
|
});
|
|
103
125
|
});
|
|
104
126
|
|
|
105
127
|
describe("openHerdrPaneForReview", () => {
|
|
106
|
-
test("opens a
|
|
128
|
+
test("opens and records a tab for a review with no recorded tab", async () => {
|
|
107
129
|
// Arrange
|
|
108
130
|
const stub = makeStub("gated-new");
|
|
109
131
|
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
132
|
+
const store = fakePersistence(null);
|
|
110
133
|
|
|
111
134
|
// Act
|
|
112
|
-
openHerdrPaneForReview(newSession({ id: "ses_xyz" }), env);
|
|
135
|
+
await openHerdrPaneForReview(newSession({ id: "ses_xyz" }), store.persistence, env);
|
|
113
136
|
|
|
114
137
|
// Assert
|
|
115
138
|
expect(readLines(stub.logPath)).toEqual([
|
|
@@ -117,35 +140,72 @@ describe("openHerdrPaneForReview", () => {
|
|
|
117
140
|
"pane send-text w1:p2 cueloop ses_xyz",
|
|
118
141
|
"pane send-keys w1:p2 enter",
|
|
119
142
|
]);
|
|
143
|
+
expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
120
144
|
});
|
|
121
145
|
|
|
122
|
-
test("no-op outside herdr - no herdr process is spawned", () => {
|
|
146
|
+
test("no-op outside herdr - no herdr process is spawned", async () => {
|
|
123
147
|
// Arrange
|
|
124
148
|
const stub = makeStub("gated-outside");
|
|
149
|
+
const store = fakePersistence(null);
|
|
125
150
|
|
|
126
151
|
// Act
|
|
127
|
-
openHerdrPaneForReview(newSession(),
|
|
152
|
+
await openHerdrPaneForReview(newSession(), store.persistence, {
|
|
153
|
+
HERDR_PANE_ID: "w1:p1",
|
|
154
|
+
HERDR_BIN_PATH: stub.binPath,
|
|
155
|
+
});
|
|
128
156
|
|
|
129
157
|
// Assert
|
|
130
158
|
expect(existsSync(stub.logPath)).toBeFalse();
|
|
159
|
+
expect(store.saved()).toBeNull();
|
|
131
160
|
});
|
|
132
161
|
|
|
133
|
-
test("
|
|
162
|
+
test("focuses the recorded tab when its pane is still alive, without reopening", async () => {
|
|
134
163
|
// Arrange
|
|
135
|
-
const stub = makeStub("gated-
|
|
164
|
+
const stub = makeStub("gated-alive", true);
|
|
136
165
|
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
137
|
-
const
|
|
138
|
-
revisions: [
|
|
139
|
-
{ revision: 1, content: "# P", submittedAt: "now" },
|
|
140
|
-
{ revision: 2, content: "# P2", submittedAt: "now" },
|
|
141
|
-
],
|
|
142
|
-
});
|
|
166
|
+
const store = fakePersistence({ tabId: "w9:t9", paneId: "w9:p9" });
|
|
143
167
|
|
|
144
168
|
// Act
|
|
145
|
-
openHerdrPaneForReview(
|
|
169
|
+
await openHerdrPaneForReview(newSession(), store.persistence, env);
|
|
146
170
|
|
|
147
171
|
// Assert
|
|
148
|
-
|
|
172
|
+
const lines = readLines(stub.logPath);
|
|
173
|
+
expect(lines).toContain("pane get w9:p9");
|
|
174
|
+
expect(lines).toContain("tab focus w9:t9");
|
|
175
|
+
expect(lines.some((line) => line.startsWith("tab create"))).toBeFalse();
|
|
176
|
+
expect(store.saved()).toEqual({ tabId: "w9:t9", paneId: "w9:p9" });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a persistence failure never escapes - review creation stays best-effort", async () => {
|
|
180
|
+
// Arrange - the daemon store rejects; the opener must still resolve quietly
|
|
181
|
+
const stub = makeStub("gated-persist-fail");
|
|
182
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
183
|
+
const persistence: HerdrTabPersistence = {
|
|
184
|
+
herdrGetTab: async () => null,
|
|
185
|
+
herdrSetTab: async () => {
|
|
186
|
+
throw new Error("disk full");
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Act & Assert - no throw
|
|
191
|
+
await openHerdrPaneForReview(newSession(), persistence, env);
|
|
192
|
+
expect(readLines(stub.logPath).some((line) => line.startsWith("tab create"))).toBeTrue();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("reopens and re-records when the recorded pane is dead", async () => {
|
|
196
|
+
// Arrange
|
|
197
|
+
const stub = makeStub("gated-dead", false);
|
|
198
|
+
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
199
|
+
const store = fakePersistence({ tabId: "old:t", paneId: "old:p" });
|
|
200
|
+
|
|
201
|
+
// Act
|
|
202
|
+
await openHerdrPaneForReview(newSession({ id: "ses_re" }), store.persistence, env);
|
|
203
|
+
|
|
204
|
+
// Assert
|
|
205
|
+
const lines = readLines(stub.logPath);
|
|
206
|
+
expect(lines).toContain("pane get old:p");
|
|
207
|
+
expect(lines).toContain("tab create --cwd /repo/work --label Rollout Plan --focus");
|
|
208
|
+
expect(store.saved()).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
149
209
|
});
|
|
150
210
|
});
|
|
151
211
|
|
package/src/herdr-pane.ts
CHANGED
|
@@ -1,100 +1,122 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
2
|
+
* Opens a herdr tab that renders a review, from the creation sites (the Claude
|
|
3
|
+
* Code hook and `cueloop session create`) that run inside a herdr pane. On a
|
|
4
|
+
* resubmit it reopens only when the recorded pane is dead, so a live tab is
|
|
5
|
+
* focused rather than duplicated. Best-effort: any spawn failure is swallowed
|
|
6
|
+
* and never blocks review creation; each spawn is bounded by a timeout.
|
|
21
7
|
*/
|
|
22
8
|
|
|
23
9
|
import { detectHerdr, type HerdrEnv, type ReviewSession } from "@cueloop/schema";
|
|
10
|
+
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
24
11
|
|
|
25
|
-
/** Upper bound per herdr spawn, so a stuck binary can never block creation. */
|
|
26
12
|
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
27
13
|
|
|
28
14
|
export interface OpenHerdrPaneOptions {
|
|
29
|
-
/** The review session id; `cueloop <sessionId>` opens it in the new pane. */
|
|
30
15
|
sessionId: string;
|
|
31
|
-
/** Working directory for the new tab; the review's cwd. */
|
|
32
16
|
cwd: string;
|
|
33
|
-
/** Absolute path to the herdr binary, from the ambient env contract. */
|
|
34
17
|
binPath: string;
|
|
35
|
-
/** Sidebar label for the new tab. */
|
|
36
18
|
label: string;
|
|
37
19
|
}
|
|
38
20
|
|
|
21
|
+
/** The daemon-side handle store, as the two methods this module needs. DaemonClient satisfies it. */
|
|
22
|
+
export interface HerdrTabPersistence {
|
|
23
|
+
herdrGetTab(sessionId: string): Promise<HerdrTabHandle | null>;
|
|
24
|
+
herdrSetTab(sessionId: string, handle: HerdrTabHandle): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
39
27
|
/**
|
|
40
|
-
* Open a
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
28
|
+
* Open a focused tab and launch the review in it. `tab create` prints
|
|
29
|
+
* `{ result: { root_pane: { pane_id, tab_id } } }` (herdr 0.8.2); both ids are
|
|
30
|
+
* returned so a later resubmit can liveness-check and focus. Null on any
|
|
45
31
|
* best-effort bail-out.
|
|
46
32
|
*/
|
|
47
|
-
export function openHerdrPane(options: OpenHerdrPaneOptions):
|
|
33
|
+
export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | null {
|
|
48
34
|
const { sessionId, cwd, binPath, label } = options;
|
|
49
35
|
try {
|
|
50
36
|
const created = Bun.spawnSync(
|
|
51
37
|
[binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
|
|
52
|
-
{
|
|
53
|
-
stdout: "pipe",
|
|
54
|
-
stderr: "ignore",
|
|
55
|
-
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
56
|
-
},
|
|
38
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
57
39
|
);
|
|
58
|
-
if (created.exitCode !== 0) return
|
|
40
|
+
if (created.exitCode !== 0) return null;
|
|
59
41
|
const parsed = JSON.parse(created.stdout.toString()) as {
|
|
60
|
-
result?: { root_pane?: { pane_id?: string } };
|
|
42
|
+
result?: { root_pane?: { pane_id?: string; tab_id?: string } };
|
|
61
43
|
};
|
|
62
44
|
const paneId = parsed.result?.root_pane?.pane_id;
|
|
63
|
-
|
|
45
|
+
const tabId = parsed.result?.root_pane?.tab_id;
|
|
46
|
+
if (!paneId || !tabId) return null;
|
|
64
47
|
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
|
65
48
|
const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
|
|
66
49
|
stdout: "ignore",
|
|
67
50
|
stderr: "ignore",
|
|
68
51
|
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
69
52
|
});
|
|
70
|
-
if (typed.exitCode !== 0) return
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
53
|
+
if (typed.exitCode !== 0) return null;
|
|
54
|
+
const entered = Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
|
|
55
|
+
stdout: "ignore",
|
|
56
|
+
stderr: "ignore",
|
|
57
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
58
|
+
});
|
|
59
|
+
return entered.exitCode === 0 ? { tabId, paneId } : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when the pane still exists (`herdr pane get` returns it). */
|
|
66
|
+
function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "ignore",
|
|
71
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
72
|
+
});
|
|
73
|
+
if (got.exitCode !== 0) return false;
|
|
74
|
+
const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: unknown } };
|
|
75
|
+
return parsed.result?.pane != null;
|
|
78
76
|
} catch {
|
|
79
77
|
return false;
|
|
80
78
|
}
|
|
81
79
|
}
|
|
82
80
|
|
|
81
|
+
/** Bring an existing review tab to the front; best-effort. */
|
|
82
|
+
function focusHerdrTab(binPath: string, tabId: string): void {
|
|
83
|
+
try {
|
|
84
|
+
Bun.spawnSync([binPath, "tab", "focus", tabId], {
|
|
85
|
+
stdout: "ignore",
|
|
86
|
+
stderr: "ignore",
|
|
87
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
// best-effort
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* never spawns another tab. A no-op outside herdr keeps a plain terminal
|
|
88
|
-
* byte-identical.
|
|
95
|
+
* Ensure a herdr tab is rendering this review. Inside herdr only: focus the
|
|
96
|
+
* recorded tab if its pane is still alive, otherwise open a fresh one and record
|
|
97
|
+
* its handle. No-op outside herdr.
|
|
89
98
|
*/
|
|
90
|
-
export function openHerdrPaneForReview(
|
|
99
|
+
export async function openHerdrPaneForReview(
|
|
100
|
+
session: ReviewSession,
|
|
101
|
+
persistence: HerdrTabPersistence,
|
|
102
|
+
env: HerdrEnv = process.env,
|
|
103
|
+
): Promise<void> {
|
|
91
104
|
const herdr = detectHerdr(env);
|
|
92
105
|
if (!herdr) return;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
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);
|
|
119
|
+
} catch {
|
|
120
|
+
// best-effort: a herdr spawn or a daemon persistence hiccup never blocks review creation
|
|
121
|
+
}
|
|
100
122
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** herdr tab side-store: set/get/delete round-trips, survives a reload from disk, and ignores a corrupt file. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { HerdrTabStore } from "./herdr-tab-store";
|
|
8
|
+
import { herdrTabsPath } from "./paths";
|
|
9
|
+
|
|
10
|
+
let home: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-herdr-store-"));
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(home, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("HerdrTabStore", () => {
|
|
20
|
+
test("set then get returns the handle; get for an unknown session is null", () => {
|
|
21
|
+
// Arrange
|
|
22
|
+
const store = new HerdrTabStore(home);
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(store.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
29
|
+
expect(store.get("ses_missing")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("handles survive a reload from disk", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
new HerdrTabStore(home).set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
|
|
35
|
+
|
|
36
|
+
// Act - a fresh store over the same home loads the persisted file
|
|
37
|
+
const reloaded = new HerdrTabStore(home);
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(reloaded.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("delete removes the handle", () => {
|
|
44
|
+
// Arrange
|
|
45
|
+
const store = new HerdrTabStore(home);
|
|
46
|
+
store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
|
|
47
|
+
|
|
48
|
+
// Act
|
|
49
|
+
store.delete("ses_1");
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
52
|
+
expect(store.get("ses_1")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a corrupt file starts empty instead of throwing", () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
writeFileSync(herdrTabsPath(home), "not json");
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
const store = new HerdrTabStore(home);
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(store.get("ses_1")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("malformed entries are dropped, well-formed ones survive", () => {
|
|
67
|
+
// Arrange - valid JSON, but entries with the wrong shape
|
|
68
|
+
writeFileSync(
|
|
69
|
+
herdrTabsPath(home),
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
ses_ok: { tabId: "w1:t2", paneId: "w1:p2" },
|
|
72
|
+
ses_partial: { tabId: "w1:t2" },
|
|
73
|
+
ses_typed: { tabId: 1, paneId: 2 },
|
|
74
|
+
ses_null: null,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
const store = new HerdrTabStore(home);
|
|
80
|
+
|
|
81
|
+
// Assert
|
|
82
|
+
expect(store.get("ses_ok")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
|
|
83
|
+
expect(store.get("ses_partial")).toBeNull();
|
|
84
|
+
expect(store.get("ses_typed")).toBeNull();
|
|
85
|
+
expect(store.get("ses_null")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a non-object top level starts empty", () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
writeFileSync(herdrTabsPath(home), JSON.stringify(["not", "a", "map"]));
|
|
91
|
+
|
|
92
|
+
// Act & Assert
|
|
93
|
+
expect(new HerdrTabStore(home).get("ses_1")).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr adapter scratch, kept out of the core session record: the tab cueloop
|
|
3
|
+
* opened to render each review, keyed by session id. One JSON map, atomic write.
|
|
4
|
+
* The core `ReviewSession` never mentions herdr; this side-store is the whole
|
|
5
|
+
* leak boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { herdrTabsPath } from "./paths";
|
|
10
|
+
|
|
11
|
+
/** The tab and its root pane: pane id checks liveness, tab id focuses it. */
|
|
12
|
+
export interface HerdrTabHandle {
|
|
13
|
+
tabId: string;
|
|
14
|
+
paneId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Guard a persisted entry - both ids must be present strings, or it is dropped. */
|
|
18
|
+
function isHerdrTabHandle(value: unknown): value is HerdrTabHandle {
|
|
19
|
+
return (
|
|
20
|
+
typeof value === "object" &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
typeof (value as HerdrTabHandle).tabId === "string" &&
|
|
23
|
+
typeof (value as HerdrTabHandle).paneId === "string"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class HerdrTabStore {
|
|
28
|
+
private tabs = new Map<string, HerdrTabHandle>();
|
|
29
|
+
private readonly path: string;
|
|
30
|
+
|
|
31
|
+
constructor(home: string) {
|
|
32
|
+
this.path = herdrTabsPath(home);
|
|
33
|
+
this.load();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private load(): void {
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8"));
|
|
39
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
40
|
+
for (const [sessionId, value] of Object.entries(parsed)) {
|
|
41
|
+
if (isHerdrTabHandle(value))
|
|
42
|
+
this.tabs.set(sessionId, { tabId: value.tabId, paneId: value.paneId });
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private persist(): void {
|
|
50
|
+
const tempPath = this.path + ".tmp";
|
|
51
|
+
writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
|
|
52
|
+
renameSync(tempPath, this.path);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get(sessionId: string): HerdrTabHandle | null {
|
|
56
|
+
return this.tabs.get(sessionId) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
set(sessionId: string, handle: HerdrTabHandle): void {
|
|
60
|
+
this.tabs.set(sessionId, handle);
|
|
61
|
+
this.persist();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
delete(sessionId: string): void {
|
|
65
|
+
if (this.tabs.delete(sessionId)) this.persist();
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -25,3 +25,8 @@ export function pidPath(home = cueloopHome()): string {
|
|
|
25
25
|
export function lockPath(home = cueloopHome()): string {
|
|
26
26
|
return join(home, "cueloop.lock");
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
|
|
30
|
+
export function herdrTabsPath(home = cueloopHome()): string {
|
|
31
|
+
return join(home, "herdr-tabs.json");
|
|
32
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -283,6 +283,13 @@ export class DaemonServer {
|
|
|
283
283
|
const params = parseParams("session.submitRevision", request.params);
|
|
284
284
|
return core.sessionSubmitRevision(params.id, params.content, params.addressedAnnotationIds);
|
|
285
285
|
}
|
|
286
|
+
case "herdr.getTab":
|
|
287
|
+
return core.herdrGetTab(parseParams("herdr.getTab", request.params).id);
|
|
288
|
+
case "herdr.setTab": {
|
|
289
|
+
const params = parseParams("herdr.setTab", request.params);
|
|
290
|
+
core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
|
|
291
|
+
return {};
|
|
292
|
+
}
|
|
286
293
|
default:
|
|
287
294
|
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
288
295
|
}
|
package/src/validate.ts
CHANGED
|
@@ -150,6 +150,9 @@ export const Params = {
|
|
|
150
150
|
"events.subscribe": v.object({}),
|
|
151
151
|
"daemon.ping": v.object({}),
|
|
152
152
|
"daemon.shutdown": v.object({}),
|
|
153
|
+
// herdr adapter scratch: the review's opened tab, kept off the session record.
|
|
154
|
+
"herdr.getTab": v.object({ id: SessionId }),
|
|
155
|
+
"herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
|
|
153
156
|
} as const;
|
|
154
157
|
|
|
155
158
|
export type MethodName = keyof typeof Params;
|