@cueloop/daemon 0.1.0-alpha.102 → 0.1.0-alpha.103
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 +3 -3
- package/src/agent-tools.ts +6 -0
- package/src/api.ts +63 -0
- package/src/client-reliability.test.ts +2 -0
- package/src/client.ts +24 -2
- package/src/curate.ts +2 -0
- package/src/daemon-sdk.ts +8 -0
- package/src/diff-hot-reload.test.ts +4 -0
- package/src/diff-watcher.test.ts +2 -0
- package/src/diff-watcher.ts +33 -0
- package/src/examples/daemon-sdk.ts +2 -0
- package/src/gh.ts +4 -0
- package/src/ghostty-thread-surface-store.ts +3 -0
- package/src/ghostty-thread-surface.test.ts +2 -0
- package/src/ghostty-thread-surface.ts +6 -0
- package/src/harness-diagnostics.ts +1 -0
- package/src/harness-handoff.test.ts +725 -0
- package/src/harness-handoff.ts +27 -0
- package/src/harness-state-store.ts +5 -0
- package/src/herdr-split.ts +5 -0
- package/src/herdr-thread-surface.ts +14 -0
- package/src/interruptible-wait.ts +3 -0
- package/src/jj-working-tree.ts +9 -0
- package/src/main.ts +1 -0
- package/src/operation-receipts.ts +6 -0
- package/src/pr-poller.ts +7 -0
- package/src/project-files.ts +4 -0
- package/src/protocol.ts +4 -0
- package/src/retention.ts +8 -0
- package/src/server.ts +36 -7
- package/src/share-blob.test.ts +2 -0
- package/src/single-instance.test.ts +3 -0
- package/src/store.ts +11 -0
- package/src/thread-agent-validation.ts +20 -0
- package/src/thread-agent.test.ts +14 -1
- package/src/thread-agent.ts +353 -24
- package/src/thread-review.test.ts +4 -0
- package/src/thread-review.ts +8 -0
- package/src/thread-subscription.test.ts +3 -0
- package/src/thread-subscription.ts +9 -0
- package/src/validate.test.ts +4 -0
- package/src/validate.ts +3 -0
- package/src/vcs-source.ts +6 -0
- package/src/working-tree.ts +4 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Thread, ThreadAgentState } from "@cueloop/schema";
|
|
2
|
+
|
|
3
|
+
/** Handoff transfers continuation context, never provider credentials or private session files. */
|
|
4
|
+
export function harnessHandoffPrompt(thread: Thread, state: ThreadAgentState): string {
|
|
5
|
+
return `Prepare a continuation document for another agent taking over this Thread.
|
|
6
|
+
Do not execute tools or change files. Return only the handoff text.
|
|
7
|
+
Include the current goal, decisions, completed work, outstanding work, constraints, and suggested skills.
|
|
8
|
+
Reference existing artifacts by path or URL instead of duplicating them.
|
|
9
|
+
Redact credentials, passwords, and personal information.
|
|
10
|
+
Thread: ${thread.id}
|
|
11
|
+
Workspace: ${thread.workspace.repoRoot}
|
|
12
|
+
Reviewed artifact:
|
|
13
|
+
${thread.artifact.content.slice(0, 32768)}
|
|
14
|
+
Pending continuation context:
|
|
15
|
+
${state.continuation ?? ""}
|
|
16
|
+
Recent conversation:
|
|
17
|
+
${state.messages
|
|
18
|
+
.filter((message) => message.complete)
|
|
19
|
+
.map((message) => `${message.role}: ${message.text}`)
|
|
20
|
+
.join("\n")
|
|
21
|
+
.slice(-65536)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The receiving agent treats the handoff as context rather than a fresh user instruction. */
|
|
25
|
+
export function harnessContinuationPrompt(handoff: string, prompt: string): string {
|
|
26
|
+
return `Continuation context from the previous harness:\n${handoff}\n\nCurrent request:\n${prompt}`;
|
|
27
|
+
}
|
|
@@ -32,6 +32,7 @@ export class HarnessStateStore {
|
|
|
32
32
|
);
|
|
33
33
|
|
|
34
34
|
if (existing) return existing;
|
|
35
|
+
|
|
35
36
|
const binding: HarnessBinding = {
|
|
36
37
|
...input,
|
|
37
38
|
id: newRoutingId("bind"),
|
|
@@ -77,6 +78,7 @@ export class HarnessStateStore {
|
|
|
77
78
|
|
|
78
79
|
private deleteBindingsAndDeliveries(bindingIds: ReadonlySet<string>): boolean {
|
|
79
80
|
if (bindingIds.size === 0) return false;
|
|
81
|
+
|
|
80
82
|
for (const bindingId of bindingIds) this.bindings.delete(bindingId);
|
|
81
83
|
for (const delivery of this.deliveries.values()) {
|
|
82
84
|
if (bindingIds.has(delivery.bindingId)) this.deliveries.delete(delivery.id);
|
|
@@ -111,6 +113,7 @@ export class HarnessStateStore {
|
|
|
111
113
|
);
|
|
112
114
|
|
|
113
115
|
if (existing) return existing;
|
|
116
|
+
|
|
114
117
|
const delivery: Delivery = {
|
|
115
118
|
...input,
|
|
116
119
|
id: newRoutingId("del"),
|
|
@@ -136,6 +139,7 @@ export class HarnessStateStore {
|
|
|
136
139
|
|
|
137
140
|
for (const delivery of this.deliveries.values()) {
|
|
138
141
|
if (delivery.status !== "pending") continue;
|
|
142
|
+
|
|
139
143
|
const binding = this.bindings.get(delivery.bindingId);
|
|
140
144
|
|
|
141
145
|
if (binding) ids.add(binding.threadId);
|
|
@@ -163,6 +167,7 @@ export class HarnessStateStore {
|
|
|
163
167
|
if (!delivery) throw new Error(`no delivery ${deliveryId}`);
|
|
164
168
|
|
|
165
169
|
if (delivery.status === "acknowledged") return delivery;
|
|
170
|
+
|
|
166
171
|
const acknowledged: Delivery = {
|
|
167
172
|
...delivery,
|
|
168
173
|
status: "acknowledged",
|
package/src/herdr-split.ts
CHANGED
|
@@ -34,6 +34,7 @@ export function launchHarnessInSplit(
|
|
|
34
34
|
const herdr = detectHerdr(env);
|
|
35
35
|
|
|
36
36
|
if (!herdr) return false;
|
|
37
|
+
|
|
37
38
|
try {
|
|
38
39
|
const split = Bun.spawnSync(
|
|
39
40
|
[
|
|
@@ -52,14 +53,18 @@ export function launchHarnessInSplit(
|
|
|
52
53
|
);
|
|
53
54
|
|
|
54
55
|
if (split.exitCode !== 0) return false;
|
|
56
|
+
|
|
55
57
|
const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
|
|
56
58
|
|
|
57
59
|
if (!parsed.success) return false;
|
|
60
|
+
|
|
58
61
|
const paneId = parsed.output.result?.pane?.pane_id;
|
|
59
62
|
|
|
60
63
|
if (!paneId) return false;
|
|
64
|
+
|
|
61
65
|
sendText(herdr.binPath, paneId, options.command);
|
|
62
66
|
sendKeys(herdr.binPath, paneId, "enter");
|
|
67
|
+
|
|
63
68
|
if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
|
|
64
69
|
|
|
65
70
|
return true;
|
|
@@ -92,9 +92,11 @@ export function openHerdrThreadTab(
|
|
|
92
92
|
);
|
|
93
93
|
|
|
94
94
|
if (created.exitCode !== 0) return null;
|
|
95
|
+
|
|
95
96
|
const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
|
|
96
97
|
|
|
97
98
|
if (!parsed.success) return null;
|
|
99
|
+
|
|
98
100
|
const paneId = parsed.output.result?.root_pane?.pane_id;
|
|
99
101
|
const tabId = parsed.output.result?.root_pane?.tab_id;
|
|
100
102
|
|
|
@@ -147,10 +149,12 @@ export function openHerdrThreadPane(
|
|
|
147
149
|
);
|
|
148
150
|
|
|
149
151
|
if (created.exitCode !== 0) return null;
|
|
152
|
+
|
|
150
153
|
const parsed = v.safeParse(CreatedPaneSchema, JSON.parse(created.stdout.toString()));
|
|
151
154
|
const paneId = parsed.success ? parsed.output.result?.pane?.pane_id : undefined;
|
|
152
155
|
|
|
153
156
|
if (!paneId) return null;
|
|
157
|
+
|
|
154
158
|
if (!sendCueloopThreadCommand(binPath, paneId, sessionId, layout)) {
|
|
155
159
|
closeHerdrThreadSurface(binPath, "pane", paneId);
|
|
156
160
|
|
|
@@ -189,6 +193,7 @@ function sendCueloopThreadCommand(
|
|
|
189
193
|
});
|
|
190
194
|
|
|
191
195
|
if (typed.exitCode !== 0) return false;
|
|
196
|
+
|
|
192
197
|
const entered = runHerdrCommand([binPath, "pane", "send-keys", paneId, "enter"], {
|
|
193
198
|
stdout: "ignore",
|
|
194
199
|
stderr: "ignore",
|
|
@@ -208,6 +213,7 @@ function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
|
208
213
|
});
|
|
209
214
|
|
|
210
215
|
if (got.exitCode !== 0) return false;
|
|
216
|
+
|
|
211
217
|
const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
|
|
212
218
|
|
|
213
219
|
return parsed.success && parsed.output.result?.pane != null;
|
|
@@ -249,10 +255,13 @@ function focusHerdrThreadSurface(binPath: string, handle: HerdrThreadSurfaceHand
|
|
|
249
255
|
const neighbor = herdrPaneNeighbor(binPath, handle.paneId, direction);
|
|
250
256
|
|
|
251
257
|
if (neighbor === undefined) return false;
|
|
258
|
+
|
|
252
259
|
if (neighbor === null) continue;
|
|
260
|
+
|
|
253
261
|
hasNeighbor = true;
|
|
254
262
|
|
|
255
263
|
if (herdrPaneNeighbor(binPath, neighbor, opposite) !== handle.paneId) continue;
|
|
264
|
+
|
|
256
265
|
const focused = runHerdrCommand(
|
|
257
266
|
[binPath, "pane", "focus", "--pane", neighbor, "--direction", opposite],
|
|
258
267
|
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
@@ -286,6 +295,7 @@ function herdrPaneNeighbor(
|
|
|
286
295
|
);
|
|
287
296
|
|
|
288
297
|
if (neighbor.exitCode !== 0) return undefined;
|
|
298
|
+
|
|
289
299
|
const parsed = v.safeParse(PaneNeighborSchema, JSON.parse(neighbor.stdout.toString()));
|
|
290
300
|
|
|
291
301
|
return parsed.success ? (parsed.output.result.neighbor.neighbor_pane_id ?? null) : undefined;
|
|
@@ -307,6 +317,7 @@ export async function openHerdrThreadSurface(
|
|
|
307
317
|
if (!herdr) return "unavailable";
|
|
308
318
|
|
|
309
319
|
if (mode === "none") return "disabled";
|
|
320
|
+
|
|
310
321
|
const recorded = await recallHerdrThreadSurface(persistence, session.id);
|
|
311
322
|
|
|
312
323
|
if (
|
|
@@ -317,6 +328,7 @@ export async function openHerdrThreadSurface(
|
|
|
317
328
|
) {
|
|
318
329
|
return focusHerdrThreadSurface(herdr.binPath, recorded) ? "focused" : "failed";
|
|
319
330
|
}
|
|
331
|
+
|
|
320
332
|
const cwd = session.artifact.meta.cwd ?? session.workspace.repoRoot;
|
|
321
333
|
const opened =
|
|
322
334
|
mode === "pane"
|
|
@@ -340,6 +352,7 @@ export async function openHerdrThreadSurface(
|
|
|
340
352
|
});
|
|
341
353
|
|
|
342
354
|
if (!opened) return "failed";
|
|
355
|
+
|
|
343
356
|
if (recorded && recorded.paneId !== opened.paneId) {
|
|
344
357
|
closeHerdrThreadSurface(
|
|
345
358
|
herdr.binPath,
|
|
@@ -347,6 +360,7 @@ export async function openHerdrThreadSurface(
|
|
|
347
360
|
recorded.mode === "pane" ? recorded.paneId : recorded.tabId,
|
|
348
361
|
);
|
|
349
362
|
}
|
|
363
|
+
|
|
350
364
|
await rememberHerdrThreadSurface(persistence, session.id, opened);
|
|
351
365
|
|
|
352
366
|
return "opened";
|
|
@@ -9,6 +9,7 @@ const whenSignalAborts = (signal: AbortSignal): Effect.Effect<void> =>
|
|
|
9
9
|
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
|
+
|
|
12
13
|
const onAbort = () => resume(Effect.void);
|
|
13
14
|
|
|
14
15
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -37,6 +38,7 @@ export function raceAbort<T>(
|
|
|
37
38
|
signal: AbortSignal | undefined,
|
|
38
39
|
): Promise<T | typeof ABORTED> {
|
|
39
40
|
if (!signal) return promise;
|
|
41
|
+
|
|
40
42
|
if (signal.aborted) {
|
|
41
43
|
promise.catch(() => {});
|
|
42
44
|
|
|
@@ -56,6 +58,7 @@ export function pollUntilResolved<T>(
|
|
|
56
58
|
signal: AbortSignal | undefined,
|
|
57
59
|
): Promise<T | null> {
|
|
58
60
|
if (signal?.aborted) return Promise.resolve(null);
|
|
61
|
+
|
|
59
62
|
const loop = Effect.repeat(fromSettledPromise(attempt), {
|
|
60
63
|
until: (result): result is T => result !== null,
|
|
61
64
|
});
|
package/src/jj-working-tree.ts
CHANGED
|
@@ -26,6 +26,7 @@ function jjSummary(text: string): { path: string; status: DiffFileStatus; curata
|
|
|
26
26
|
const path = tokens[index + 1];
|
|
27
27
|
|
|
28
28
|
if (!code || !path) continue;
|
|
29
|
+
|
|
29
30
|
changes.push({
|
|
30
31
|
path,
|
|
31
32
|
status: code === "A" ? "added" : code === "D" ? "deleted" : "modified",
|
|
@@ -62,12 +63,15 @@ async function captureJjRevision(repoRoot: string, revset: string): Promise<VcsD
|
|
|
62
63
|
);
|
|
63
64
|
|
|
64
65
|
if (!identity) throw new Error(`Jujutsu diff capture failed: could not resolve ${revset}`);
|
|
66
|
+
|
|
65
67
|
const lines = identity.trim().split("\n");
|
|
66
68
|
|
|
67
69
|
if (lines.length !== 3) throw new Error(`Jujutsu change is ambiguous: ${revset}`);
|
|
70
|
+
|
|
68
71
|
const [changeId, revisionId, parents] = lines;
|
|
69
72
|
|
|
70
73
|
if (!changeId || !revisionId) throw new Error("Jujutsu diff capture failed: invalid identity");
|
|
74
|
+
|
|
71
75
|
const [patch, summary] = await Promise.all([
|
|
72
76
|
jj(["--ignore-working-copy", "diff", "--git", "-r", revisionId], repoRoot),
|
|
73
77
|
jj(["--ignore-working-copy", "diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", revisionId], repoRoot),
|
|
@@ -75,19 +79,23 @@ async function captureJjRevision(repoRoot: string, revset: string): Promise<VcsD
|
|
|
75
79
|
|
|
76
80
|
if (patch === null || summary === null)
|
|
77
81
|
throw new Error("Jujutsu diff capture failed: could not read pinned revision");
|
|
82
|
+
|
|
78
83
|
const files: DiffFileContents[] = [];
|
|
79
84
|
const parent = parents?.split(",")[0];
|
|
80
85
|
|
|
81
86
|
if (parent && !parents?.includes(",")) {
|
|
82
87
|
for (const change of jjSummary(summary)) {
|
|
83
88
|
if (!change.curatable) continue;
|
|
89
|
+
|
|
84
90
|
const oldContents =
|
|
85
91
|
change.status === "added" ? "" : await jjFileContents(repoRoot, parent, change.path);
|
|
86
92
|
const newContents =
|
|
87
93
|
change.status === "deleted" ? "" : await jjFileContents(repoRoot, revisionId, change.path);
|
|
88
94
|
|
|
89
95
|
if (oldContents === null || newContents === null) continue;
|
|
96
|
+
|
|
90
97
|
if (isBinary(oldContents) || isBinary(newContents)) continue;
|
|
98
|
+
|
|
91
99
|
files.push({ path: change.path, oldContents, newContents, status: change.status });
|
|
92
100
|
}
|
|
93
101
|
}
|
|
@@ -109,6 +117,7 @@ export const jjVcsAdapter: VcsAdapter = {
|
|
|
109
117
|
},
|
|
110
118
|
async captureChange(repoRoot, changeId) {
|
|
111
119
|
if (!/^[a-z]{32}$/.test(changeId)) throw new Error(`Jujutsu change ID is invalid: ${changeId}`);
|
|
120
|
+
|
|
112
121
|
if ((await jj(["status"], repoRoot)) === null)
|
|
113
122
|
throw new Error("Jujutsu diff capture failed: could not snapshot the working copy");
|
|
114
123
|
|
package/src/main.ts
CHANGED
|
@@ -16,6 +16,7 @@ export function findOperationReceipt<Result>(
|
|
|
16
16
|
fingerprint: string,
|
|
17
17
|
): OperationReceipt<Result> | undefined {
|
|
18
18
|
if (!operationId) return;
|
|
19
|
+
|
|
19
20
|
const receipt = receipts?.find((entry) => entry.operationId === operationId);
|
|
20
21
|
|
|
21
22
|
if (receipt && receipt.fingerprint !== fingerprint)
|
|
@@ -23,6 +24,7 @@ export function findOperationReceipt<Result>(
|
|
|
23
24
|
"operation_conflict",
|
|
24
25
|
"Operation payload conflict: use a new operation ID for changed input",
|
|
25
26
|
);
|
|
27
|
+
|
|
26
28
|
if (!receipt && (receipts?.length ?? 0) >= 128)
|
|
27
29
|
throw new DaemonError(
|
|
28
30
|
"operation_capacity",
|
|
@@ -39,12 +41,14 @@ export function recordPromptOperation(
|
|
|
39
41
|
input: AgentPromptRequest,
|
|
40
42
|
): void {
|
|
41
43
|
if (!input.operationId) return;
|
|
44
|
+
|
|
42
45
|
const previous = new Set(before.submissions?.map((entry) => entry.id));
|
|
43
46
|
const accepted = input.retry
|
|
44
47
|
? [input.retry]
|
|
45
48
|
: (state.submissions ?? []).filter((entry) => !previous.has(entry.id)).map((entry) => entry.id);
|
|
46
49
|
|
|
47
50
|
if (!accepted.length) throw new DaemonError("invalid_params", "Agent operation requires input");
|
|
51
|
+
|
|
48
52
|
const fingerprint = promptOperationFingerprint(input);
|
|
49
53
|
|
|
50
54
|
(state.promptOperations ??= []).push({
|
|
@@ -58,6 +62,7 @@ export function recordPromptOperation(
|
|
|
58
62
|
export function settlePromptOperations(state: ThreadAgentState): void {
|
|
59
63
|
for (const receipt of state.promptOperations ?? []) {
|
|
60
64
|
if (receipt.outcome || !receipt.result.length) continue;
|
|
65
|
+
|
|
61
66
|
const submissions = receipt.result.map((id) =>
|
|
62
67
|
state.submissions?.find((entry) => entry.id === id),
|
|
63
68
|
);
|
|
@@ -66,6 +71,7 @@ export function settlePromptOperations(state: ThreadAgentState): void {
|
|
|
66
71
|
submissions.some((entry) => !entry || entry.status === "queued" || entry.status === "running")
|
|
67
72
|
)
|
|
68
73
|
continue;
|
|
74
|
+
|
|
69
75
|
receipt.outcome = submissions.some((entry) => entry?.cancelled)
|
|
70
76
|
? "cancelled"
|
|
71
77
|
: submissions.some((entry) => entry?.status === "failed")
|
package/src/pr-poller.ts
CHANGED
|
@@ -27,6 +27,7 @@ export class PrReviewPoller {
|
|
|
27
27
|
/** Start polling a PR review. Idempotent per session; the persisted reviewed head is the baseline. */
|
|
28
28
|
trackPr(sessionId: string, pr: string, reviewedRefs: PullRequestRefs | null = null): void {
|
|
29
29
|
if (this.polls.has(sessionId)) return;
|
|
30
|
+
|
|
30
31
|
const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
|
|
31
32
|
|
|
32
33
|
// the poll timer must not by itself keep the daemon alive against idle-exit
|
|
@@ -40,20 +41,25 @@ export class PrReviewPoller {
|
|
|
40
41
|
const poll = this.polls.get(sessionId);
|
|
41
42
|
|
|
42
43
|
if (!poll) return;
|
|
44
|
+
|
|
43
45
|
const refs = await this.refs(poll.pr).catch(() => null);
|
|
44
46
|
|
|
45
47
|
if (refs === null) return;
|
|
48
|
+
|
|
46
49
|
// the await yields; re-read so an untrack during the call is honoured
|
|
47
50
|
const current = this.polls.get(sessionId);
|
|
48
51
|
|
|
49
52
|
if (!current) return;
|
|
53
|
+
|
|
50
54
|
if (current.lastRefs === null) {
|
|
51
55
|
current.lastRefs = refs;
|
|
52
56
|
|
|
53
57
|
return;
|
|
54
58
|
}
|
|
59
|
+
|
|
55
60
|
if (refs.baseSha === current.lastRefs.baseSha && refs.headSha === current.lastRefs.headSha)
|
|
56
61
|
return;
|
|
62
|
+
|
|
57
63
|
current.lastRefs = refs;
|
|
58
64
|
this.onPrAdvance(sessionId, refs);
|
|
59
65
|
}
|
|
@@ -62,6 +68,7 @@ export class PrReviewPoller {
|
|
|
62
68
|
const poll = this.polls.get(sessionId);
|
|
63
69
|
|
|
64
70
|
if (!poll) return;
|
|
71
|
+
|
|
65
72
|
clearInterval(poll.timer);
|
|
66
73
|
this.polls.delete(sessionId);
|
|
67
74
|
}
|
package/src/project-files.ts
CHANGED
|
@@ -21,6 +21,7 @@ async function gitFileList(repoRoot: string, args: string[]): Promise<string[] |
|
|
|
21
21
|
/** Existing tracked paths, sorted ascending and capped; [] on failure or a non-repo. */
|
|
22
22
|
export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
|
|
23
23
|
if (repoRoot === undefined || repoRoot.length === 0) return [];
|
|
24
|
+
|
|
24
25
|
try {
|
|
25
26
|
const [tracked, deleted] = await Promise.all([
|
|
26
27
|
gitFileList(repoRoot, ["--cached"]),
|
|
@@ -28,6 +29,7 @@ export async function listProjectFiles(repoRoot: string | undefined): Promise<st
|
|
|
28
29
|
]);
|
|
29
30
|
|
|
30
31
|
if (tracked === null || deleted === null) return [];
|
|
32
|
+
|
|
31
33
|
const missing = new Set(deleted);
|
|
32
34
|
|
|
33
35
|
return tracked
|
|
@@ -52,10 +54,12 @@ export async function readProjectFile(
|
|
|
52
54
|
path: string,
|
|
53
55
|
): Promise<string | null> {
|
|
54
56
|
if (repoRoot === undefined || repoRoot.length === 0) return null;
|
|
57
|
+
|
|
55
58
|
const root = resolve(repoRoot);
|
|
56
59
|
const target = resolve(root, path);
|
|
57
60
|
|
|
58
61
|
if (escapesRoot(root, target)) return null;
|
|
62
|
+
|
|
59
63
|
try {
|
|
60
64
|
const stats = await stat(target);
|
|
61
65
|
|
package/src/protocol.ts
CHANGED
|
@@ -60,9 +60,11 @@ export class LineBuffer {
|
|
|
60
60
|
const newlineIndex = this.buffered.indexOf("\n");
|
|
61
61
|
|
|
62
62
|
if (newlineIndex === -1) return;
|
|
63
|
+
|
|
63
64
|
const line = this.buffered.slice(0, newlineIndex).trim();
|
|
64
65
|
|
|
65
66
|
this.buffered = this.buffered.slice(newlineIndex + 1);
|
|
67
|
+
|
|
66
68
|
if (line) onLine(line);
|
|
67
69
|
}
|
|
68
70
|
}
|
|
@@ -93,6 +95,7 @@ export class BackpressureWriter {
|
|
|
93
95
|
|
|
94
96
|
return;
|
|
95
97
|
}
|
|
98
|
+
|
|
96
99
|
const written = Math.max(0, this.socket.write(bytes));
|
|
97
100
|
|
|
98
101
|
if (written < bytes.length) this.pending = bytes.subarray(written);
|
|
@@ -101,6 +104,7 @@ export class BackpressureWriter {
|
|
|
101
104
|
/** Call from the socket's drain handler. */
|
|
102
105
|
drain(): void {
|
|
103
106
|
if (!this.pending) return;
|
|
107
|
+
|
|
104
108
|
const tail = this.pending;
|
|
105
109
|
|
|
106
110
|
this.pending = null;
|
package/src/retention.ts
CHANGED
|
@@ -32,6 +32,7 @@ function userConfigPath(env: NodeJS.ProcessEnv): string {
|
|
|
32
32
|
const explicit = env.CUELOOP_CONFIG;
|
|
33
33
|
|
|
34
34
|
if (explicit && explicit.trim()) return explicit;
|
|
35
|
+
|
|
35
36
|
const xdgConfigHome = env.XDG_CONFIG_HOME;
|
|
36
37
|
const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : join(homedir(), ".config");
|
|
37
38
|
|
|
@@ -49,6 +50,7 @@ function readConfiguredPeriodDays(path: string): number | undefined {
|
|
|
49
50
|
const parsed = v.safeParse(CleanupConfigSchema, raw);
|
|
50
51
|
|
|
51
52
|
if (!parsed.success) return undefined;
|
|
53
|
+
|
|
52
54
|
const value = parsed.output.cleanup?.period_days;
|
|
53
55
|
|
|
54
56
|
return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
@@ -60,6 +62,7 @@ export function resolveCleanupPeriodDays(env: NodeJS.ProcessEnv = process.env):
|
|
|
60
62
|
|
|
61
63
|
export function isExpired(createdAt: string, periodDays: number, nowMs: number): boolean {
|
|
62
64
|
if (periodDays <= 0) return false;
|
|
65
|
+
|
|
63
66
|
const createdMs = Date.parse(createdAt);
|
|
64
67
|
|
|
65
68
|
if (Number.isNaN(createdMs)) return false;
|
|
@@ -74,10 +77,12 @@ export function pruneExpiredSessions(
|
|
|
74
77
|
protectedThreadIds: ReadonlySet<string> = new Set(),
|
|
75
78
|
): string[] {
|
|
76
79
|
if (periodDays <= 0) return [];
|
|
80
|
+
|
|
77
81
|
const pruned: string[] = [];
|
|
78
82
|
|
|
79
83
|
for (const session of store.list()) {
|
|
80
84
|
if (session.status !== "resolved" || protectedThreadIds.has(session.id)) continue;
|
|
85
|
+
|
|
81
86
|
if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
|
|
82
87
|
pruned.push(session.id);
|
|
83
88
|
}
|
|
@@ -98,6 +103,7 @@ export function pruneExpiredReports(
|
|
|
98
103
|
nowMs: number,
|
|
99
104
|
): string[] {
|
|
100
105
|
if (periodDays <= 0) return [];
|
|
106
|
+
|
|
101
107
|
let entries: string[];
|
|
102
108
|
|
|
103
109
|
try {
|
|
@@ -110,7 +116,9 @@ export function pruneExpiredReports(
|
|
|
110
116
|
|
|
111
117
|
for (const entry of entries) {
|
|
112
118
|
if (entry === LATEST_REPORT_FILENAME) continue;
|
|
119
|
+
|
|
113
120
|
if (!entry.startsWith(TIMESTAMPED_REPORT_PREFIX)) continue;
|
|
121
|
+
|
|
114
122
|
const path = join(reportsDirectory, entry);
|
|
115
123
|
|
|
116
124
|
if (statSync(path).mtimeMs < cutoffMs) {
|
package/src/server.ts
CHANGED
|
@@ -45,7 +45,11 @@ interface Connection {
|
|
|
45
45
|
type MethodHandler = (connection: Connection, request: Request) => Response["result"];
|
|
46
46
|
|
|
47
47
|
export interface DaemonOptions {
|
|
48
|
-
|
|
48
|
+
agentHarnessDefault?: () => string;
|
|
49
|
+
threadAgent?: Pick<
|
|
50
|
+
ThreadAgentOptions,
|
|
51
|
+
"enabled" | "enabledForThread" | "adapter" | "adapters" | "defaultHarnessForThread"
|
|
52
|
+
>;
|
|
49
53
|
home?: string;
|
|
50
54
|
onEvent?: (event: import("./client").EventFrame) => void;
|
|
51
55
|
idleExitMs?: number;
|
|
@@ -76,7 +80,7 @@ export class DaemonServer {
|
|
|
76
80
|
private readonly version: string;
|
|
77
81
|
private readonly onEvent?: (event: import("./client").EventFrame) => void;
|
|
78
82
|
|
|
79
|
-
constructor(options: DaemonOptions = {}) {
|
|
83
|
+
constructor(private readonly options: DaemonOptions = {}) {
|
|
80
84
|
this.onEvent = options.onEvent;
|
|
81
85
|
this.home = options.home ?? cueloopHome();
|
|
82
86
|
this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
|
|
@@ -154,6 +158,7 @@ export class DaemonServer {
|
|
|
154
158
|
*/
|
|
155
159
|
private acquireLock(): boolean {
|
|
156
160
|
if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
|
|
161
|
+
|
|
157
162
|
const path = lockPath(this.home);
|
|
158
163
|
|
|
159
164
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -171,6 +176,7 @@ export class DaemonServer {
|
|
|
171
176
|
return true;
|
|
172
177
|
} catch (error) {
|
|
173
178
|
closeSync(fd);
|
|
179
|
+
|
|
174
180
|
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
175
181
|
|
|
176
182
|
let ownerPid = 0;
|
|
@@ -184,12 +190,15 @@ export class DaemonServer {
|
|
|
184
190
|
Atomics.wait(LOCK_WAIT, 0, 0, 20);
|
|
185
191
|
ownerText = readFileSync(path, "utf8").trim();
|
|
186
192
|
}
|
|
193
|
+
|
|
187
194
|
if (!ownerText && Date.now() - statSync(path).mtimeMs < 5_000) return false;
|
|
195
|
+
|
|
188
196
|
ownerPid = Number(ownerText);
|
|
189
197
|
} catch {
|
|
190
198
|
// the owner vanished between open and read; retry
|
|
191
199
|
continue;
|
|
192
200
|
}
|
|
201
|
+
|
|
193
202
|
if (ownerPid && ownerPid !== process.pid) {
|
|
194
203
|
try {
|
|
195
204
|
process.kill(ownerPid, 0); // throws when the pid is gone
|
|
@@ -199,6 +208,7 @@ export class DaemonServer {
|
|
|
199
208
|
// stale lock from a crashed daemon
|
|
200
209
|
}
|
|
201
210
|
}
|
|
211
|
+
|
|
202
212
|
// ownerPid === our pid but HELD_HOMES says we do not own it: a stale
|
|
203
213
|
// file from an earlier instance in this process - reclaim it.
|
|
204
214
|
try {
|
|
@@ -216,6 +226,7 @@ export class DaemonServer {
|
|
|
216
226
|
|
|
217
227
|
private releaseLock(): void {
|
|
218
228
|
HELD_HOMES.delete(this.home);
|
|
229
|
+
|
|
219
230
|
if (this.lockFd !== null) {
|
|
220
231
|
try {
|
|
221
232
|
if (statSync(lockPath(this.home)).ino === fstatSync(this.lockFd).ino)
|
|
@@ -238,11 +249,13 @@ export class DaemonServer {
|
|
|
238
249
|
*/
|
|
239
250
|
start(): string | null {
|
|
240
251
|
if (!this.acquireLock()) return null;
|
|
252
|
+
|
|
241
253
|
const path = socketPath(this.home);
|
|
242
254
|
|
|
243
255
|
// safe now: holding the lock means no live daemon owns this home, so any
|
|
244
256
|
// socket file left behind is stale
|
|
245
257
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
258
|
+
|
|
246
259
|
// the token exists before the socket does: the first client to connect
|
|
247
260
|
// must be able to prove ownership
|
|
248
261
|
this.ownerToken = randomBytes(32).toString("hex");
|
|
@@ -294,12 +307,10 @@ export class DaemonServer {
|
|
|
294
307
|
resumeAgents(): void {
|
|
295
308
|
for (const thread of this.core.sessionList({ status: "pending" })) {
|
|
296
309
|
if (!this.threadAgent.isEnabled(thread.id)) continue;
|
|
310
|
+
|
|
297
311
|
const state = this.threadAgent.get(thread.id);
|
|
298
312
|
|
|
299
|
-
if (
|
|
300
|
-
state.harness?.id === "pi" &&
|
|
301
|
-
state.submissions?.some((submission) => submission.status === "queued")
|
|
302
|
-
)
|
|
313
|
+
if (state.handoff || state.submissions?.some((submission) => submission.status === "queued"))
|
|
303
314
|
void this.threadAgent
|
|
304
315
|
.configure({ id: thread.id })
|
|
305
316
|
.catch((error) => console.error("[agent recovery]", error));
|
|
@@ -314,13 +325,17 @@ export class DaemonServer {
|
|
|
314
325
|
stop(): void {
|
|
315
326
|
void this.threadAgent.dispose().catch((error) => console.error("[agent shutdown]", error));
|
|
316
327
|
this.core.dispose();
|
|
328
|
+
|
|
317
329
|
if (this.lockFd === null) return;
|
|
330
|
+
|
|
318
331
|
this.server?.stop(true);
|
|
319
332
|
this.server = null;
|
|
320
333
|
rmSync(socketPath(this.home), { force: true });
|
|
321
334
|
rmSync(pidPath(this.home), { force: true });
|
|
322
335
|
rmSync(ownerTokenPath(this.home), { force: true });
|
|
336
|
+
|
|
323
337
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
338
|
+
|
|
324
339
|
this.releaseLock();
|
|
325
340
|
}
|
|
326
341
|
|
|
@@ -335,7 +350,9 @@ export class DaemonServer {
|
|
|
335
350
|
/** Idle = no pending session and no attached client. */
|
|
336
351
|
private scheduleIdleCheck(): void {
|
|
337
352
|
if (this.idleExitMs <= 0) return;
|
|
353
|
+
|
|
338
354
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
355
|
+
|
|
339
356
|
this.idleTimer = setTimeout(() => {
|
|
340
357
|
if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
|
|
341
358
|
this.stop();
|
|
@@ -376,16 +393,19 @@ export class DaemonServer {
|
|
|
376
393
|
const origin = this.core.sessionGet(threadId);
|
|
377
394
|
|
|
378
395
|
assertAgentToolScope(origin, args);
|
|
396
|
+
|
|
379
397
|
if (args.kind === "api" && args.method === "session.list") {
|
|
380
398
|
const { filter } = parseParams("session.list", args.params);
|
|
381
399
|
|
|
382
400
|
return JSON.stringify(!filter?.status || origin.status === filter.status ? [origin] : []);
|
|
383
401
|
}
|
|
402
|
+
|
|
384
403
|
if (args.kind === "reply") {
|
|
385
404
|
const thread = origin;
|
|
386
405
|
const comment = thread.annotations.find((entry) => entry.id === args.commentId);
|
|
387
406
|
|
|
388
407
|
if (!comment) return JSON.stringify(this.replyAgentComment(args));
|
|
408
|
+
|
|
389
409
|
const root =
|
|
390
410
|
thread.annotations.find((entry) => entry.id === (comment.replyTo ?? comment.id)) ?? comment;
|
|
391
411
|
|
|
@@ -405,6 +425,7 @@ export class DaemonServer {
|
|
|
405
425
|
),
|
|
406
426
|
);
|
|
407
427
|
}
|
|
428
|
+
|
|
408
429
|
const method = args.method;
|
|
409
430
|
|
|
410
431
|
if (!isKnownMethod(method)) throw new Error("Thread agent tool API method is unavailable");
|
|
@@ -475,7 +496,11 @@ export class DaemonServer {
|
|
|
475
496
|
|
|
476
497
|
return this.threadAgent.permission(params);
|
|
477
498
|
},
|
|
478
|
-
"daemon.ping": () => ({
|
|
499
|
+
"daemon.ping": () => ({
|
|
500
|
+
pid: process.pid,
|
|
501
|
+
version: this.version,
|
|
502
|
+
agentHarness: this.options.agentHarnessDefault?.() ?? this.options.threadAgent?.adapter?.id,
|
|
503
|
+
}),
|
|
479
504
|
"daemon.hello": (connection, request) => {
|
|
480
505
|
const params = parseParams("daemon.hello", request.params);
|
|
481
506
|
|
|
@@ -484,6 +509,7 @@ export class DaemonServer {
|
|
|
484
509
|
if (params.role === "owner" && params.token !== this.ownerToken) {
|
|
485
510
|
throw new DaemonError("forbidden", "owner token required");
|
|
486
511
|
}
|
|
512
|
+
|
|
487
513
|
if (params.clientVersion !== this.version) {
|
|
488
514
|
throw new DaemonError(
|
|
489
515
|
"version_mismatch",
|
|
@@ -492,6 +518,7 @@ export class DaemonServer {
|
|
|
492
518
|
}
|
|
493
519
|
|
|
494
520
|
connection.role = params.role;
|
|
521
|
+
|
|
495
522
|
// identity is bound once, here; a non-owner never names it per call
|
|
496
523
|
if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
|
|
497
524
|
|
|
@@ -779,11 +806,13 @@ export class DaemonServer {
|
|
|
779
806
|
if (!isKnownMethod(request.method)) {
|
|
780
807
|
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
781
808
|
}
|
|
809
|
+
|
|
782
810
|
// Capability gate: a capped role (a review-side agent) cannot escalate past
|
|
783
811
|
// read + annotate, whatever primitive it sends.
|
|
784
812
|
if (!roleAllowsMethod(connection.role, request.method)) {
|
|
785
813
|
throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
|
|
786
814
|
}
|
|
815
|
+
|
|
787
816
|
if (!Object.hasOwn(this.handlers, request.method)) {
|
|
788
817
|
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
789
818
|
}
|
package/src/share-blob.test.ts
CHANGED
|
@@ -71,8 +71,10 @@ describe("the payload cap against a long review", () => {
|
|
|
71
71
|
body: "Fail fast with a readable error; the review never opens on a store that cannot write.",
|
|
72
72
|
replyTo: `a_${round}`,
|
|
73
73
|
});
|
|
74
|
+
|
|
74
75
|
if (round % 10 === 0)
|
|
75
76
|
core.sessionSetWorkingCopy(session.id, `${content}\n\nEdit ${round}.\n`);
|
|
77
|
+
|
|
76
78
|
if (round % 20 === 0) core.sessionRemoveAnnotation(session.id, `r_${round}`);
|
|
77
79
|
}
|
|
78
80
|
const shared = viewFollowing(core.sessionGet(session.id));
|