@cueloop/daemon 0.1.0-alpha.59 → 0.1.0-alpha.60
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.test.ts +14 -0
- package/src/api.ts +44 -3
- package/src/client.ts +12 -0
- package/src/diff-hot-reload.test.ts +11 -0
- package/src/diff-watcher.ts +6 -0
- package/src/herdr-pane.test.ts +7 -0
- package/src/herdr-pane.ts +11 -0
- package/src/herdr-split.test.ts +3 -0
- package/src/herdr-split.ts +4 -0
- package/src/herdr-tab-store.test.ts +1 -0
- package/src/herdr-tab-store.ts +2 -0
- package/src/main.ts +1 -0
- package/src/protocol.ts +7 -0
- package/src/retention.test.ts +8 -0
- package/src/retention.ts +16 -0
- package/src/review.test.ts +8 -0
- package/src/review.ts +26 -2
- package/src/server.test.ts +7 -0
- package/src/server.ts +32 -0
- package/src/share-blob.ts +4 -0
- package/src/single-instance.test.ts +10 -0
- package/src/store.ts +6 -0
- package/src/validate.test.ts +12 -0
- package/src/validate.ts +6 -1
- package/src/working-tree.ts +13 -0
package/src/herdr-pane.ts
CHANGED
|
@@ -32,17 +32,20 @@ export interface HerdrTabPersistence {
|
|
|
32
32
|
*/
|
|
33
33
|
export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | null {
|
|
34
34
|
const { sessionId, cwd, binPath, label } = options;
|
|
35
|
+
|
|
35
36
|
try {
|
|
36
37
|
const created = Bun.spawnSync(
|
|
37
38
|
[binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
|
|
38
39
|
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
39
40
|
);
|
|
41
|
+
|
|
40
42
|
if (created.exitCode !== 0) return null;
|
|
41
43
|
const parsed = JSON.parse(created.stdout.toString()) as {
|
|
42
44
|
result?: { root_pane?: { pane_id?: string; tab_id?: string } };
|
|
43
45
|
};
|
|
44
46
|
const paneId = parsed.result?.root_pane?.pane_id;
|
|
45
47
|
const tabId = parsed.result?.root_pane?.tab_id;
|
|
48
|
+
|
|
46
49
|
if (!paneId || !tabId) return null;
|
|
47
50
|
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
|
48
51
|
const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
|
|
@@ -50,12 +53,14 @@ export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | n
|
|
|
50
53
|
stderr: "ignore",
|
|
51
54
|
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
52
55
|
});
|
|
56
|
+
|
|
53
57
|
if (typed.exitCode !== 0) return null;
|
|
54
58
|
const entered = Bun.spawnSync([binPath, "pane", "send-keys", paneId, "enter"], {
|
|
55
59
|
stdout: "ignore",
|
|
56
60
|
stderr: "ignore",
|
|
57
61
|
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
58
62
|
});
|
|
63
|
+
|
|
59
64
|
return entered.exitCode === 0 ? { tabId, paneId } : null;
|
|
60
65
|
} catch {
|
|
61
66
|
return null;
|
|
@@ -70,8 +75,10 @@ function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
|
70
75
|
stderr: "ignore",
|
|
71
76
|
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
72
77
|
});
|
|
78
|
+
|
|
73
79
|
if (got.exitCode !== 0) return false;
|
|
74
80
|
const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: unknown } };
|
|
81
|
+
|
|
75
82
|
return parsed.result?.pane != null;
|
|
76
83
|
} catch {
|
|
77
84
|
return false;
|
|
@@ -102,10 +109,13 @@ export async function openHerdrPaneForReview(
|
|
|
102
109
|
env: HerdrEnv = process.env,
|
|
103
110
|
): Promise<void> {
|
|
104
111
|
const herdr = detectHerdr(env);
|
|
112
|
+
|
|
105
113
|
if (!herdr) return;
|
|
106
114
|
const recorded = await recallHerdrTab(persistence, session.id);
|
|
115
|
+
|
|
107
116
|
if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
|
|
108
117
|
focusHerdrTab(herdr.binPath, recorded.tabId);
|
|
118
|
+
|
|
109
119
|
return;
|
|
110
120
|
}
|
|
111
121
|
const opened = openHerdrPane({
|
|
@@ -114,6 +124,7 @@ export async function openHerdrPaneForReview(
|
|
|
114
124
|
binPath: herdr.binPath,
|
|
115
125
|
label: session.artifact.meta.title ?? session.id,
|
|
116
126
|
});
|
|
127
|
+
|
|
117
128
|
if (opened) await rememberHerdrTab(persistence, session.id, opened);
|
|
118
129
|
}
|
|
119
130
|
|
package/src/herdr-split.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ function makeStub(name: string, splitOk = true): { binPath: string; logPath: str
|
|
|
15
15
|
const splitResult = splitOk
|
|
16
16
|
? `printf '{"result":{"pane":{"pane_id":"w1:p9"}}}'`
|
|
17
17
|
: `printf '{"result":{}}'`;
|
|
18
|
+
|
|
18
19
|
writeFileSync(
|
|
19
20
|
binPath,
|
|
20
21
|
`#!/bin/sh
|
|
@@ -24,11 +25,13 @@ exit 0
|
|
|
24
25
|
`,
|
|
25
26
|
);
|
|
26
27
|
chmodSync(binPath, 0o755);
|
|
28
|
+
|
|
27
29
|
return { binPath, logPath };
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
function readLines(logPath: string): string[] {
|
|
31
33
|
if (!existsSync(logPath)) return [];
|
|
34
|
+
|
|
32
35
|
return readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
33
36
|
}
|
|
34
37
|
|
package/src/herdr-split.ts
CHANGED
|
@@ -35,6 +35,7 @@ export function launchHarnessInSplit(
|
|
|
35
35
|
env: HerdrEnv = process.env,
|
|
36
36
|
): boolean {
|
|
37
37
|
const herdr = detectHerdr(env);
|
|
38
|
+
|
|
38
39
|
if (!herdr) return false;
|
|
39
40
|
try {
|
|
40
41
|
const split = Bun.spawnSync(
|
|
@@ -52,15 +53,18 @@ export function launchHarnessInSplit(
|
|
|
52
53
|
],
|
|
53
54
|
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
54
55
|
);
|
|
56
|
+
|
|
55
57
|
if (split.exitCode !== 0) return false;
|
|
56
58
|
const parsed = JSON.parse(split.stdout.toString()) as {
|
|
57
59
|
result?: { pane?: { pane_id?: string } };
|
|
58
60
|
};
|
|
59
61
|
const paneId = parsed.result?.pane?.pane_id;
|
|
62
|
+
|
|
60
63
|
if (!paneId) return false;
|
|
61
64
|
sendText(herdr.binPath, paneId, options.command);
|
|
62
65
|
sendKeys(herdr.binPath, paneId, "enter");
|
|
63
66
|
if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
|
|
67
|
+
|
|
64
68
|
return true;
|
|
65
69
|
} catch {
|
|
66
70
|
return false;
|
package/src/herdr-tab-store.ts
CHANGED
|
@@ -36,6 +36,7 @@ export class HerdrTabStore {
|
|
|
36
36
|
private load(): void {
|
|
37
37
|
try {
|
|
38
38
|
const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8"));
|
|
39
|
+
|
|
39
40
|
if (typeof parsed !== "object" || parsed === null) return;
|
|
40
41
|
for (const [sessionId, value] of Object.entries(parsed)) {
|
|
41
42
|
if (isHerdrTabHandle(value))
|
|
@@ -48,6 +49,7 @@ export class HerdrTabStore {
|
|
|
48
49
|
|
|
49
50
|
private persist(): void {
|
|
50
51
|
const tempPath = this.path + ".tmp";
|
|
52
|
+
|
|
51
53
|
writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
|
|
52
54
|
renameSync(tempPath, this.path);
|
|
53
55
|
}
|
package/src/main.ts
CHANGED
|
@@ -7,6 +7,7 @@ const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
|
7
7
|
: undefined;
|
|
8
8
|
const server = new DaemonServer({ idleExitMs });
|
|
9
9
|
const path = server.start();
|
|
10
|
+
|
|
10
11
|
if (path === null) {
|
|
11
12
|
// another daemon already owns this home; the client will attach to it
|
|
12
13
|
console.log("cueloop daemon already running for this home");
|
package/src/protocol.ts
CHANGED
|
@@ -31,8 +31,10 @@ export class LineBuffer {
|
|
|
31
31
|
this.buffered += chunk;
|
|
32
32
|
for (;;) {
|
|
33
33
|
const newlineIndex = this.buffered.indexOf("\n");
|
|
34
|
+
|
|
34
35
|
if (newlineIndex === -1) return;
|
|
35
36
|
const line = this.buffered.slice(0, newlineIndex).trim();
|
|
37
|
+
|
|
36
38
|
this.buffered = this.buffered.slice(newlineIndex + 1);
|
|
37
39
|
if (line) onLine(line);
|
|
38
40
|
}
|
|
@@ -58,11 +60,14 @@ export class BackpressureWriter {
|
|
|
58
60
|
|
|
59
61
|
write(data: string): void {
|
|
60
62
|
const bytes = Buffer.from(data, "utf8");
|
|
63
|
+
|
|
61
64
|
if (this.pending) {
|
|
62
65
|
this.pending = Buffer.concat([this.pending, bytes]);
|
|
66
|
+
|
|
63
67
|
return;
|
|
64
68
|
}
|
|
65
69
|
const written = Math.max(0, this.socket.write(bytes));
|
|
70
|
+
|
|
66
71
|
if (written < bytes.length) this.pending = bytes.subarray(written);
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -70,8 +75,10 @@ export class BackpressureWriter {
|
|
|
70
75
|
drain(): void {
|
|
71
76
|
if (!this.pending) return;
|
|
72
77
|
const tail = this.pending;
|
|
78
|
+
|
|
73
79
|
this.pending = null;
|
|
74
80
|
const written = Math.max(0, this.socket.write(tail));
|
|
81
|
+
|
|
75
82
|
if (written < tail.length) this.pending = tail.subarray(written);
|
|
76
83
|
}
|
|
77
84
|
}
|
package/src/retention.test.ts
CHANGED
|
@@ -57,6 +57,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
57
57
|
test("honors [cleanup] period_days", () => {
|
|
58
58
|
// Arrange
|
|
59
59
|
const path = join(tempDir("cueloop-retention-"), "config.toml");
|
|
60
|
+
|
|
60
61
|
writeFileSync(path, "[cleanup]\nperiod_days = 7\n");
|
|
61
62
|
|
|
62
63
|
// Act
|
|
@@ -69,6 +70,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
69
70
|
test("falls back to the default when the value is not a number", () => {
|
|
70
71
|
// Arrange
|
|
71
72
|
const path = join(tempDir("cueloop-retention-"), "config.toml");
|
|
73
|
+
|
|
72
74
|
writeFileSync(path, '[cleanup]\nperiod_days = "soon"\n');
|
|
73
75
|
|
|
74
76
|
// Act
|
|
@@ -111,6 +113,7 @@ describe("pruneExpiredSessions", () => {
|
|
|
111
113
|
// Arrange
|
|
112
114
|
const home = tempDir("cueloop-retention-home-");
|
|
113
115
|
const store = new SessionStore(home);
|
|
116
|
+
|
|
114
117
|
store.upsert(session("ses_old", daysAgo(40)));
|
|
115
118
|
store.upsert(session("ses_new", daysAgo(2)));
|
|
116
119
|
|
|
@@ -127,6 +130,7 @@ describe("pruneExpiredSessions", () => {
|
|
|
127
130
|
// Arrange
|
|
128
131
|
const home = tempDir("cueloop-retention-home-");
|
|
129
132
|
const store = new SessionStore(home);
|
|
133
|
+
|
|
130
134
|
store.upsert(session("ses_active", daysAgo(400), "pending"));
|
|
131
135
|
|
|
132
136
|
// Act
|
|
@@ -141,6 +145,7 @@ describe("pruneExpiredSessions", () => {
|
|
|
141
145
|
// Arrange
|
|
142
146
|
const home = tempDir("cueloop-retention-home-");
|
|
143
147
|
const store = new SessionStore(home);
|
|
148
|
+
|
|
144
149
|
store.upsert(session("ses_old", daysAgo(999)));
|
|
145
150
|
|
|
146
151
|
// Act
|
|
@@ -157,12 +162,15 @@ describe("pruneExpiredReports", () => {
|
|
|
157
162
|
// Arrange
|
|
158
163
|
const home = tempDir("cueloop-retention-home-");
|
|
159
164
|
const directory = reportsDir(home);
|
|
165
|
+
|
|
160
166
|
mkdirSync(directory, { recursive: true });
|
|
161
167
|
const latest = join(directory, LATEST_REPORT_FILENAME);
|
|
162
168
|
const stale = join(directory, "refine-2026-06-01T00-00-00-000Z.md");
|
|
163
169
|
const fresh = join(directory, "refine-2026-08-25T00-00-00-000Z.md");
|
|
170
|
+
|
|
164
171
|
for (const path of [latest, stale, fresh]) writeFileSync(path, "report");
|
|
165
172
|
const staleSeconds = (NOW_MS - 60 * DAY_MS) / 1000;
|
|
173
|
+
|
|
166
174
|
utimesSync(stale, staleSeconds, staleSeconds);
|
|
167
175
|
|
|
168
176
|
// Act
|
package/src/retention.ts
CHANGED
|
@@ -22,28 +22,35 @@ export type RefineState = v.InferOutput<typeof RefineStateSchema>;
|
|
|
22
22
|
|
|
23
23
|
export function parseRefineState(raw: unknown): Map<string, string> {
|
|
24
24
|
const parsed = v.safeParse(RefineStateSchema, raw);
|
|
25
|
+
|
|
25
26
|
if (!parsed.success) return new Map();
|
|
27
|
+
|
|
26
28
|
return new Map(Object.entries(parsed.output.analyzed));
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function userConfigPath(env: NodeJS.ProcessEnv): string {
|
|
30
32
|
const explicit = env.CUELOOP_CONFIG;
|
|
33
|
+
|
|
31
34
|
if (explicit && explicit.trim()) return explicit;
|
|
32
35
|
const xdgConfigHome = env.XDG_CONFIG_HOME;
|
|
33
36
|
const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : join(homedir(), ".config");
|
|
37
|
+
|
|
34
38
|
return join(base, "cueloop", "config.toml");
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function readConfiguredPeriodDays(path: string): number | undefined {
|
|
38
42
|
let raw: unknown;
|
|
43
|
+
|
|
39
44
|
try {
|
|
40
45
|
raw = Bun.TOML.parse(readFileSync(path, "utf8"));
|
|
41
46
|
} catch {
|
|
42
47
|
return undefined;
|
|
43
48
|
}
|
|
44
49
|
const parsed = v.safeParse(CleanupConfigSchema, raw);
|
|
50
|
+
|
|
45
51
|
if (!parsed.success) return undefined;
|
|
46
52
|
const value = parsed.output.cleanup?.period_days;
|
|
53
|
+
|
|
47
54
|
return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
48
55
|
}
|
|
49
56
|
|
|
@@ -54,7 +61,9 @@ export function resolveCleanupPeriodDays(env: NodeJS.ProcessEnv = process.env):
|
|
|
54
61
|
export function isExpired(createdAt: string, periodDays: number, nowMs: number): boolean {
|
|
55
62
|
if (periodDays <= 0) return false;
|
|
56
63
|
const createdMs = Date.parse(createdAt);
|
|
64
|
+
|
|
57
65
|
if (Number.isNaN(createdMs)) return false;
|
|
66
|
+
|
|
58
67
|
return nowMs - createdMs > periodDays * MILLISECONDS_PER_DAY;
|
|
59
68
|
}
|
|
60
69
|
|
|
@@ -65,17 +74,20 @@ export function pruneExpiredSessions(
|
|
|
65
74
|
): string[] {
|
|
66
75
|
if (periodDays <= 0) return [];
|
|
67
76
|
const pruned: string[] = [];
|
|
77
|
+
|
|
68
78
|
for (const session of store.list()) {
|
|
69
79
|
if (session.status !== "resolved") continue;
|
|
70
80
|
if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
|
|
71
81
|
pruned.push(session.id);
|
|
72
82
|
}
|
|
73
83
|
}
|
|
84
|
+
|
|
74
85
|
return pruned;
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
export function timestampedReportFilename(nowMs: number): string {
|
|
78
89
|
const stamp = new Date(nowMs).toISOString().replace(/[:.]/g, "-");
|
|
90
|
+
|
|
79
91
|
return `${TIMESTAMPED_REPORT_PREFIX}${stamp}.md`;
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -86,6 +98,7 @@ export function pruneExpiredReports(
|
|
|
86
98
|
): string[] {
|
|
87
99
|
if (periodDays <= 0) return [];
|
|
88
100
|
let entries: string[];
|
|
101
|
+
|
|
89
102
|
try {
|
|
90
103
|
entries = readdirSync(reportsDirectory);
|
|
91
104
|
} catch {
|
|
@@ -93,14 +106,17 @@ export function pruneExpiredReports(
|
|
|
93
106
|
}
|
|
94
107
|
const cutoffMs = nowMs - periodDays * MILLISECONDS_PER_DAY;
|
|
95
108
|
const pruned: string[] = [];
|
|
109
|
+
|
|
96
110
|
for (const entry of entries) {
|
|
97
111
|
if (entry === LATEST_REPORT_FILENAME) continue;
|
|
98
112
|
if (!entry.startsWith(TIMESTAMPED_REPORT_PREFIX)) continue;
|
|
99
113
|
const path = join(reportsDirectory, entry);
|
|
114
|
+
|
|
100
115
|
if (statSync(path).mtimeMs < cutoffMs) {
|
|
101
116
|
rmSync(path, { force: true });
|
|
102
117
|
pruned.push(entry);
|
|
103
118
|
}
|
|
104
119
|
}
|
|
120
|
+
|
|
105
121
|
return pruned;
|
|
106
122
|
}
|
package/src/review.test.ts
CHANGED
|
@@ -29,12 +29,14 @@ afterEach(() => {
|
|
|
29
29
|
|
|
30
30
|
function sh(args: string[], cwd: string): void {
|
|
31
31
|
const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
|
|
32
|
+
|
|
32
33
|
if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
describe("resolveWorkspace", () => {
|
|
36
37
|
test("a git repo resolves to its root and branch", async () => {
|
|
37
38
|
const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
|
|
39
|
+
|
|
38
40
|
try {
|
|
39
41
|
// Arrange
|
|
40
42
|
sh(["git", "init", "-q", "-b", "main"], repo);
|
|
@@ -75,6 +77,7 @@ describe("openReview", () => {
|
|
|
75
77
|
|
|
76
78
|
// Assert
|
|
77
79
|
const session = review.session;
|
|
80
|
+
|
|
78
81
|
expect(session.status).toBe("pending");
|
|
79
82
|
expect(session.artifact.type).toBe("plan");
|
|
80
83
|
expect(session.artifact.meta.agent).toBe("test-agent");
|
|
@@ -170,6 +173,7 @@ describe("openReview", () => {
|
|
|
170
173
|
|
|
171
174
|
// Assert
|
|
172
175
|
const notes = review.session.annotations;
|
|
176
|
+
|
|
173
177
|
expect(notes.length).toBe(2);
|
|
174
178
|
expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
|
|
175
179
|
expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
|
|
@@ -296,6 +300,7 @@ describe("awaitResolve: the adapter wake seam (session id only)", () => {
|
|
|
296
300
|
test("returns the stored verdict immediately when the session already resolved", async () => {
|
|
297
301
|
// Arrange - a detached waiter that only attaches after the human decided
|
|
298
302
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
303
|
+
|
|
299
304
|
await client.sessionResolve(review.id, "request_changes", "One stage only.");
|
|
300
305
|
|
|
301
306
|
// Act
|
|
@@ -332,8 +337,10 @@ describe("awaitResolve: the held wait keeps the daemon alive", () => {
|
|
|
332
337
|
idleExitMs: 30,
|
|
333
338
|
onIdleExit: () => idleExits++,
|
|
334
339
|
});
|
|
340
|
+
|
|
335
341
|
idleServer.start();
|
|
336
342
|
const waiterClient = await DaemonClient.connect({ home: idleHome });
|
|
343
|
+
|
|
337
344
|
try {
|
|
338
345
|
const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
|
|
339
346
|
const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
|
|
@@ -347,6 +354,7 @@ describe("awaitResolve: the held wait keeps the daemon alive", () => {
|
|
|
347
354
|
|
|
348
355
|
// Act - the human returns a verdict; the parked wait collects it
|
|
349
356
|
const resolver = await DaemonClient.connect({ home: idleHome });
|
|
357
|
+
|
|
350
358
|
await resolver.sessionResolve(review.id, "approve", "Good to go.");
|
|
351
359
|
resolver.close();
|
|
352
360
|
const verdict = await waiting;
|
package/src/review.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
isMarkdownArtifact,
|
|
11
12
|
newAnnotationId,
|
|
12
13
|
type ArtifactType,
|
|
13
14
|
type DiffFileContents,
|
|
@@ -25,7 +26,9 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
25
26
|
try {
|
|
26
27
|
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
27
28
|
const out = await new Response(proc.stdout).text();
|
|
29
|
+
|
|
28
30
|
if ((await proc.exited) !== 0) return null;
|
|
31
|
+
|
|
29
32
|
return out.trim();
|
|
30
33
|
} catch {
|
|
31
34
|
return null;
|
|
@@ -36,11 +39,13 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
36
39
|
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
37
40
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
38
41
|
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
42
|
+
|
|
39
43
|
return { repoRoot, branch };
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
function firstHeading(markdown: string): string | undefined {
|
|
43
47
|
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
|
48
|
+
|
|
44
49
|
return headingMatch?.[1]?.trim();
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -63,7 +68,7 @@ export interface OpenReviewOptions {
|
|
|
63
68
|
* the artifact so hunk curation produces an exactly applyable patch.
|
|
64
69
|
*/
|
|
65
70
|
files?: DiffFileContents[];
|
|
66
|
-
/** Defaults to
|
|
71
|
+
/** Defaults to a markdown artifact's first heading (plan, reply); diffs get no derived title. */
|
|
67
72
|
title?: string;
|
|
68
73
|
/**
|
|
69
74
|
* Per-file agent notes for diff sessions: the submitting agent's own
|
|
@@ -123,8 +128,10 @@ function raceAbort<T>(
|
|
|
123
128
|
if (!signal) return promise;
|
|
124
129
|
if (signal.aborted) {
|
|
125
130
|
promise.catch(() => {});
|
|
131
|
+
|
|
126
132
|
return Promise.resolve(ABORTED);
|
|
127
133
|
}
|
|
134
|
+
|
|
128
135
|
return new Promise<T | typeof ABORTED>((resolve, reject) => {
|
|
129
136
|
const onAbort = () => {
|
|
130
137
|
// The daemon request keeps running until the client closes; swallow its
|
|
@@ -132,6 +139,7 @@ function raceAbort<T>(
|
|
|
132
139
|
promise.catch(() => {});
|
|
133
140
|
resolve(ABORTED);
|
|
134
141
|
};
|
|
142
|
+
|
|
135
143
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
136
144
|
promise.then(
|
|
137
145
|
(value) => {
|
|
@@ -164,20 +172,26 @@ export class ReviewHandle {
|
|
|
164
172
|
*/
|
|
165
173
|
async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
|
|
166
174
|
const { timeoutMs, pollMs, onProgress, signal } = options;
|
|
175
|
+
|
|
167
176
|
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
168
177
|
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
178
|
+
|
|
169
179
|
return resolved === null ? "pending" : outcome(resolved);
|
|
170
180
|
}
|
|
171
181
|
const chunkMs = pollMs ?? 10_000;
|
|
172
182
|
const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
|
|
183
|
+
|
|
173
184
|
for (;;) {
|
|
174
185
|
const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
|
|
186
|
+
|
|
175
187
|
if (budget <= 0 || signal?.aborted) return "pending";
|
|
176
188
|
const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
|
|
189
|
+
|
|
177
190
|
if (resolved === ABORTED) return "pending";
|
|
178
191
|
if (resolved !== null) return outcome(resolved);
|
|
179
192
|
// Still pending after this chunk: re-read to surface reviewer progress.
|
|
180
193
|
const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
|
|
194
|
+
|
|
181
195
|
if (current === ABORTED) return "pending";
|
|
182
196
|
onProgress?.(current);
|
|
183
197
|
}
|
|
@@ -213,9 +227,11 @@ export async function awaitResolve(
|
|
|
213
227
|
): Promise<VerdictOutcome | null> {
|
|
214
228
|
const chunkMs = options.pollMs ?? 30_000;
|
|
215
229
|
const { signal } = options;
|
|
230
|
+
|
|
216
231
|
for (;;) {
|
|
217
232
|
if (signal?.aborted) return null;
|
|
218
233
|
const resolved = await raceAbort(client.sessionWait(sessionId, chunkMs), signal);
|
|
234
|
+
|
|
219
235
|
if (resolved === ABORTED) return null;
|
|
220
236
|
if (resolved !== null) return outcome(resolved);
|
|
221
237
|
}
|
|
@@ -228,17 +244,21 @@ export async function openReview(
|
|
|
228
244
|
): Promise<ReviewHandle> {
|
|
229
245
|
const cwd = options.cwd ?? process.cwd();
|
|
230
246
|
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
247
|
+
|
|
231
248
|
// Resubmits from the same agent session become revisions, not new sessions.
|
|
232
249
|
if (options.agentSessionId !== undefined) {
|
|
233
250
|
const existing = (await client.sessionList()).find(
|
|
234
251
|
(candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
|
|
235
252
|
);
|
|
253
|
+
|
|
236
254
|
if (existing !== undefined) {
|
|
237
255
|
let revised = await client.sessionSubmitRevision(existing.id, options.content);
|
|
256
|
+
|
|
238
257
|
if (options.notes?.length) {
|
|
239
258
|
await attachNotes(client, revised.id, options.notes);
|
|
240
259
|
revised = await client.sessionGet(revised.id);
|
|
241
260
|
}
|
|
261
|
+
|
|
242
262
|
return new ReviewHandle(client, revised);
|
|
243
263
|
}
|
|
244
264
|
}
|
|
@@ -253,13 +273,17 @@ export async function openReview(
|
|
|
253
273
|
prototypePath: options.prototypePath,
|
|
254
274
|
pr: options.pr,
|
|
255
275
|
herdrPane: options.herdrPane,
|
|
256
|
-
title:
|
|
276
|
+
title:
|
|
277
|
+
options.title ??
|
|
278
|
+
(isMarkdownArtifact(options.type) ? firstHeading(options.content) : undefined),
|
|
257
279
|
cwd,
|
|
258
280
|
},
|
|
259
281
|
});
|
|
282
|
+
|
|
260
283
|
if (options.notes?.length) {
|
|
261
284
|
await attachNotes(client, session.id, options.notes);
|
|
262
285
|
session = await client.sessionGet(session.id);
|
|
263
286
|
}
|
|
287
|
+
|
|
264
288
|
return new ReviewHandle(client, session);
|
|
265
289
|
}
|
package/src/server.test.ts
CHANGED
|
@@ -44,6 +44,7 @@ describe("socket round-trip", () => {
|
|
|
44
44
|
body: "More detail please.",
|
|
45
45
|
});
|
|
46
46
|
const wait = client.sessionWait(session.id, 5_000);
|
|
47
|
+
|
|
47
48
|
await client.sessionResolve(session.id, "request_changes", "Expand it.");
|
|
48
49
|
const resolved = (await wait)!;
|
|
49
50
|
|
|
@@ -65,11 +66,13 @@ describe("socket round-trip", () => {
|
|
|
65
66
|
// Arrange
|
|
66
67
|
const observer = await DaemonClient.connect({ home });
|
|
67
68
|
const seen: string[] = [];
|
|
69
|
+
|
|
68
70
|
observer.onEvent((event) => seen.push(event.event));
|
|
69
71
|
await observer.subscribe();
|
|
70
72
|
|
|
71
73
|
// Act
|
|
72
74
|
const session = await client.sessionCreate(WS, PLAN);
|
|
75
|
+
|
|
73
76
|
await client.sessionResolve(session.id, "approve", "");
|
|
74
77
|
|
|
75
78
|
// Assert
|
|
@@ -107,6 +110,7 @@ describe("socket round-trip", () => {
|
|
|
107
110
|
client.request("toString", {}),
|
|
108
111
|
client.request("constructor", {}),
|
|
109
112
|
]);
|
|
113
|
+
|
|
110
114
|
for (const result of prototypeMethods) {
|
|
111
115
|
expect(result.status).toBe("rejected");
|
|
112
116
|
expect(((result as PromiseRejectedResult).reason as DaemonClientError).code).toBe(
|
|
@@ -115,6 +119,7 @@ describe("socket round-trip", () => {
|
|
|
115
119
|
}
|
|
116
120
|
// the daemon is still fully alive afterwards
|
|
117
121
|
const session = await client.sessionCreate(WS, PLAN);
|
|
122
|
+
|
|
118
123
|
expect((await client.sessionGet(session.id)).id).toBe(session.id);
|
|
119
124
|
});
|
|
120
125
|
|
|
@@ -136,6 +141,7 @@ describe("socket round-trip", () => {
|
|
|
136
141
|
// Given a shared session with one local note
|
|
137
142
|
const session = await client.sessionCreate(WS, PLAN);
|
|
138
143
|
const anchor = { quote: "Body text", prefix: "", suffix: "." };
|
|
144
|
+
|
|
139
145
|
await client.sessionSetShareId(session.id, "p_abc123xy");
|
|
140
146
|
await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
|
|
141
147
|
expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
|
|
@@ -174,6 +180,7 @@ describe("socket round-trip", () => {
|
|
|
174
180
|
test("mergeShared validates each annotation at the socket boundary", async () => {
|
|
175
181
|
// Given a session
|
|
176
182
|
const session = await client.sessionCreate(WS, PLAN);
|
|
183
|
+
|
|
177
184
|
try {
|
|
178
185
|
// When a malformed annotation is merged
|
|
179
186
|
// @ts-expect-error intentionally malformed annotation (empty id, missing fields)
|