@bermudi/pi-delegate 0.1.0
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/README.md +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
package/schema.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { Type, type SchemaOptions } from "@sinclair/typebox";
|
|
2
|
+
import { VALID_THINKING_LEVELS } from "./constants.ts";
|
|
3
|
+
import type { DelegateArguments } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// JSON Schema string enum that keeps the literal union in `Static<>`.
|
|
6
|
+
// `Type.String({ enum })` validates identically but widens to `string`;
|
|
7
|
+
// `Type.Union([Type.Literal…])` keeps the literals but serializes as `anyOf`,
|
|
8
|
+
// which some providers handle poorly. `Type.Unsafe` gives both: the wire
|
|
9
|
+
// format stays `{ type: "string", enum: [...] }` and the type stays narrow.
|
|
10
|
+
function StringEnum<const T extends readonly string[]>(
|
|
11
|
+
values: T,
|
|
12
|
+
options?: SchemaOptions,
|
|
13
|
+
) {
|
|
14
|
+
return Type.Unsafe<T[number]>({
|
|
15
|
+
...options,
|
|
16
|
+
type: "string",
|
|
17
|
+
enum: [...values],
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const delegateTaskSchema = Type.Object({
|
|
22
|
+
prompt: Type.Optional(
|
|
23
|
+
Type.String({
|
|
24
|
+
description: "Task prompt; omit only for close, list, or resumeFrom.",
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
agent: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
description: "Named agent profile; omit for an ad-hoc subagent.",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
cwd: Type.Optional(
|
|
33
|
+
Type.String({
|
|
34
|
+
description: "Subagent directory; relative paths use parent cwd.",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
systemPrompt: Type.Optional(
|
|
38
|
+
Type.String({
|
|
39
|
+
description: "Base system prompt; AgentSession adds project resources.",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
context: Type.Optional(
|
|
43
|
+
StringEnum(["fresh", "with-parent-transcript"], {
|
|
44
|
+
description:
|
|
45
|
+
"fresh omits parent transcript; with-parent-transcript copies it (token-expensive).",
|
|
46
|
+
default: "fresh",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
model: Type.Optional(
|
|
50
|
+
Type.String({
|
|
51
|
+
description: "Model override; omit to inherit parent.",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
tools: Type.Optional(
|
|
55
|
+
Type.Array(Type.String(), {
|
|
56
|
+
description:
|
|
57
|
+
"Omit to inherit; `*`=read/write/edit/bash (mutating); `ro`=read/grep/find/ls (read-only).",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
thinking: Type.Optional(
|
|
61
|
+
StringEnum(VALID_THINKING_LEVELS, {
|
|
62
|
+
description:
|
|
63
|
+
"Thinking: off/minimal/low/medium/high/xhigh/max; defaults to agent/off.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
sessionId: Type.Optional(
|
|
67
|
+
Type.String({
|
|
68
|
+
description:
|
|
69
|
+
"Optional live pool key for multi-turn reuse; omit for one-shot tasks.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
action: Type.Optional(
|
|
73
|
+
StringEnum(["prompt", "close", "list"], {
|
|
74
|
+
description:
|
|
75
|
+
"Session action; close needs sessionId; list shows active pooled sessions.",
|
|
76
|
+
default: "prompt",
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
resumeFrom: Type.Optional(
|
|
80
|
+
Type.String({
|
|
81
|
+
description:
|
|
82
|
+
"Exact absolute .jsonl session path from retry output; never a ticket ID.",
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Single source of truth for registration, generated help, and the
|
|
88
|
+
// DelegateArguments/TaskDef projections in types.ts.
|
|
89
|
+
export const delegateArgumentsSchema = Type.Object({
|
|
90
|
+
action: Type.Optional(
|
|
91
|
+
StringEnum(["poll", "cancel", "wait"], {
|
|
92
|
+
description:
|
|
93
|
+
"Ticket control: poll, cancel, or wait. Prefer wait; do not cancel for time.",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
async: Type.Optional(
|
|
97
|
+
Type.Boolean({
|
|
98
|
+
description:
|
|
99
|
+
"Detach work and return a ticket; results auto-deliver. Wait only when blocked.",
|
|
100
|
+
default: false,
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
ticket: Type.Optional(
|
|
104
|
+
Type.String({
|
|
105
|
+
description: "Ticket ID; omit only when polling all tickets.",
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
force: Type.Optional(
|
|
109
|
+
Type.Boolean({
|
|
110
|
+
description:
|
|
111
|
+
"True cancels after preview; completed writes/commands remain.",
|
|
112
|
+
default: false,
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
timeoutMs: Type.Optional(
|
|
116
|
+
Type.Number({
|
|
117
|
+
minimum: 0,
|
|
118
|
+
description:
|
|
119
|
+
"How long wait blocks (ms); timeout does not cancel the ticket.",
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
tasks: Type.Optional(
|
|
123
|
+
Type.Array(delegateTaskSchema, {
|
|
124
|
+
minItems: 0,
|
|
125
|
+
description:
|
|
126
|
+
"Fields in entries; tasks run concurrently; separate dependent/shared-file work. []=help.",
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** Validate the three operation modes after compatibility reshaping. */
|
|
132
|
+
export function validateDelegateOperation(
|
|
133
|
+
params: DelegateArguments,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
const tasks = params.tasks ?? [];
|
|
136
|
+
const isTicketControl = params.action !== undefined;
|
|
137
|
+
|
|
138
|
+
if (isTicketControl) {
|
|
139
|
+
if (params.tasks !== undefined || params.async === true) {
|
|
140
|
+
return "ticket control cannot include tasks or async; call it separately.";
|
|
141
|
+
}
|
|
142
|
+
if (params.action !== "poll" && !params.ticket) {
|
|
143
|
+
return `action '${params.action}' requires ticket.`;
|
|
144
|
+
}
|
|
145
|
+
if (params.action !== "cancel" && params.force === true) {
|
|
146
|
+
return "force is valid only with action 'cancel'.";
|
|
147
|
+
}
|
|
148
|
+
if (params.action !== "wait" && params.timeoutMs !== undefined) {
|
|
149
|
+
return "timeoutMs is valid only with action 'wait'.";
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (params.ticket !== undefined) {
|
|
155
|
+
return "ticket requires action 'poll', 'cancel', or 'wait'.";
|
|
156
|
+
}
|
|
157
|
+
if (params.force === true) return "force is valid only with action 'cancel'.";
|
|
158
|
+
if (params.timeoutMs !== undefined) {
|
|
159
|
+
return "timeoutMs is valid only with action 'wait'.";
|
|
160
|
+
}
|
|
161
|
+
if (!tasks.length) {
|
|
162
|
+
return params.async === true
|
|
163
|
+
? "async dispatch requires at least one task."
|
|
164
|
+
: undefined; // Intentional help request.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const [index, task] of tasks.entries()) {
|
|
168
|
+
if (task.action === "close") {
|
|
169
|
+
if (!task.sessionId) {
|
|
170
|
+
return `task ${index + 1}: action 'close' requires sessionId.`;
|
|
171
|
+
}
|
|
172
|
+
const extras = Object.keys(task).filter(
|
|
173
|
+
(key) => key !== "action" && key !== "sessionId",
|
|
174
|
+
);
|
|
175
|
+
if (extras.length) {
|
|
176
|
+
return `task ${index + 1}: action 'close' accepts only action and sessionId.`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (task.action === "list") {
|
|
180
|
+
const extras = Object.keys(task).filter((key) => key !== "action");
|
|
181
|
+
if (extras.length) {
|
|
182
|
+
return `task ${index + 1}: action 'list' accepts only action.`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Fields that belong to a task entry. Models sometimes place these at the
|
|
191
|
+
* top level of the arguments; the shim folds them back into a single task. */
|
|
192
|
+
const TASK_FIELD_NAMES = [
|
|
193
|
+
"prompt",
|
|
194
|
+
"agent",
|
|
195
|
+
"cwd",
|
|
196
|
+
"systemPrompt",
|
|
197
|
+
"context",
|
|
198
|
+
"model",
|
|
199
|
+
"tools",
|
|
200
|
+
"thinking",
|
|
201
|
+
"sessionId",
|
|
202
|
+
"resumeFrom",
|
|
203
|
+
] as const;
|
|
204
|
+
|
|
205
|
+
/** Actions that are only valid at task level. The top-level `action` is
|
|
206
|
+
* ticket-scoped (poll/cancel/wait), so a flat close/list/prompt belongs to
|
|
207
|
+
* the wrapped task. */
|
|
208
|
+
const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
|
|
209
|
+
|
|
210
|
+
function parseStringifiedArray(value: string): unknown[] | undefined {
|
|
211
|
+
try {
|
|
212
|
+
const parsed: unknown = JSON.parse(value);
|
|
213
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
214
|
+
} catch {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Recover `tools` given as a string: a JSON array string, or a bare group
|
|
220
|
+
* token like "*" / "ro" wrapped into a single-element array. Anything else
|
|
221
|
+
* is left for schema validation to reject. */
|
|
222
|
+
function normalizeToolsField(value: string): unknown {
|
|
223
|
+
const parsed = parseStringifiedArray(value);
|
|
224
|
+
if (parsed) return parsed;
|
|
225
|
+
const trimmed = value.trim();
|
|
226
|
+
return trimmed && !/[\s,]/.test(trimmed) ? [trimmed] : value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Compatibility shim run by pi before schema validation. Recovers the
|
|
230
|
+
* malformed shapes weaker models emit, instead of letting them silently
|
|
231
|
+
* degrade to the help response (an empty `tasks` returns the manual, which
|
|
232
|
+
* models then misread as "the tool is broken"):
|
|
233
|
+
* - `tasks` as a JSON string instead of an array;
|
|
234
|
+
* - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
|
|
235
|
+
* level instead of inside a `tasks` entry — wrapped into a single task;
|
|
236
|
+
* - `tools` as a JSON string (or bare token) inside a task entry.
|
|
237
|
+
* Skipped when a ticket action is in play. All other invalid input is left
|
|
238
|
+
* for normal schema validation to reject loudly.
|
|
239
|
+
*
|
|
240
|
+
* Silent by design: these rewrites are lossless re-shaping, so unlike the
|
|
241
|
+
* model-suffix warning in task-resolution (which fires because thinking
|
|
242
|
+
* intent is discarded), recovery warrants no signal. */
|
|
243
|
+
export function normalizeDelegateArguments(args: unknown): DelegateArguments {
|
|
244
|
+
if (!args || typeof args !== "object") return args as DelegateArguments;
|
|
245
|
+
const record: Record<string, unknown> = {
|
|
246
|
+
...(args as Record<string, unknown>),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Stringified `tasks` array → real array.
|
|
250
|
+
if (typeof record.tasks === "string") {
|
|
251
|
+
const parsed = parseStringifiedArray(record.tasks);
|
|
252
|
+
if (parsed) record.tasks = parsed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Flat task fields at the top level → wrap into a single task. Only fires
|
|
256
|
+
// when there is no usable tasks array and no ticket action (`ticket`,
|
|
257
|
+
// poll/cancel/wait) — those calls are legitimately taskless.
|
|
258
|
+
const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
|
|
259
|
+
const isTicketAction =
|
|
260
|
+
record.action === "poll" ||
|
|
261
|
+
record.action === "cancel" ||
|
|
262
|
+
record.action === "wait";
|
|
263
|
+
if (!hasTasks && !isTicketAction && record.ticket === undefined) {
|
|
264
|
+
const task: Record<string, unknown> = {};
|
|
265
|
+
for (const key of TASK_FIELD_NAMES) {
|
|
266
|
+
if (record[key] !== undefined) {
|
|
267
|
+
task[key] = record[key];
|
|
268
|
+
delete record[key];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (typeof record.action === "string" && TASK_ACTIONS.has(record.action)) {
|
|
272
|
+
task.action = record.action;
|
|
273
|
+
delete record.action;
|
|
274
|
+
}
|
|
275
|
+
if (Object.keys(task).length > 0) record.tasks = [task];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Stringified (or bare-token) `tools` inside task entries → real arrays.
|
|
279
|
+
if (Array.isArray(record.tasks)) {
|
|
280
|
+
record.tasks = record.tasks.map((entry: unknown) => {
|
|
281
|
+
if (!entry || typeof entry !== "object") return entry;
|
|
282
|
+
const e = entry as Record<string, unknown>;
|
|
283
|
+
if (typeof e.tools !== "string") return entry;
|
|
284
|
+
return { ...e, tools: normalizeToolsField(e.tools) };
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return record as DelegateArguments;
|
|
289
|
+
}
|
package/sessions.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/** Link a subagent session to its parent and persist the header when possible. */
|
|
5
|
+
export function setParentSession(sm: SessionManager, parentPath: string): void {
|
|
6
|
+
const inner = sm as unknown as {
|
|
7
|
+
fileEntries: Array<{ type: string; parentSession?: string }>;
|
|
8
|
+
getSessionFile?: () => string | undefined;
|
|
9
|
+
_rewriteFile?: () => void;
|
|
10
|
+
};
|
|
11
|
+
const header = inner.fileEntries[0];
|
|
12
|
+
if (header && header.type === "session") {
|
|
13
|
+
header.parentSession = parentPath;
|
|
14
|
+
// For a *resumed* session the file already exists on disk and the manager
|
|
15
|
+
// is flushed (SessionManager.open/setSessionFile sets flushed=true). The
|
|
16
|
+
// in-memory header mutation above is otherwise lost: upstream _persist()
|
|
17
|
+
// only *appends* new entries once flushed — it never rewrites the header.
|
|
18
|
+
// So a resumeFrom session would never surface as a child in /resume despite
|
|
19
|
+
// the link being set in memory. Rewrite the whole file (header + entries)
|
|
20
|
+
// so the parentSession field is actually persisted. Fresh sessions skip
|
|
21
|
+
// this (file doesn't exist yet); their first _persist() writes the mutated
|
|
22
|
+
// header along with the rest, and rewriting early would trip the
|
|
23
|
+
// duplicate-header bug in _persist()'s not-yet-flushed path.
|
|
24
|
+
const file = inner.getSessionFile?.();
|
|
25
|
+
if (file && fs.existsSync(file)) {
|
|
26
|
+
try {
|
|
27
|
+
inner._rewriteFile?.();
|
|
28
|
+
} catch {
|
|
29
|
+
/* best effort — link stays in-memory; not fatal */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a session manager for a subagent run.
|
|
37
|
+
*
|
|
38
|
+
* Always creates a standalone session file in the target cwd.
|
|
39
|
+
* Sets `parentSession` in the header so subagent work is discoverable
|
|
40
|
+
* as a child of the parent session in `/resume`.
|
|
41
|
+
*
|
|
42
|
+
* Returns the concrete `SessionManager` (ready to hand to `createAgentSession`)
|
|
43
|
+
* and its file path (for result reporting + pool bookkeeping).
|
|
44
|
+
*/
|
|
45
|
+
export function createSubagentSessionManager(
|
|
46
|
+
parentSessionManager: unknown,
|
|
47
|
+
cwd: string,
|
|
48
|
+
): { manager: SessionManager; file: string } | undefined {
|
|
49
|
+
// Resolve parent session file path for linking.
|
|
50
|
+
const parentFile = (
|
|
51
|
+
parentSessionManager as
|
|
52
|
+
{ getSessionFile?(): string | undefined } | undefined
|
|
53
|
+
)?.getSessionFile?.();
|
|
54
|
+
|
|
55
|
+
// Always persist subagent work so the main agent can search it later.
|
|
56
|
+
const sm = SessionManager.create(cwd);
|
|
57
|
+
const sessionFile = sm.getSessionFile();
|
|
58
|
+
if (!sessionFile) return undefined;
|
|
59
|
+
|
|
60
|
+
// Link to parent session so subagent appears as a child in /resume.
|
|
61
|
+
if (parentFile) {
|
|
62
|
+
setParentSession(sm, parentFile);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { manager: sm, file: sessionFile };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Force-flush a session's header (and any buffered entries) to disk.
|
|
70
|
+
*
|
|
71
|
+
* pi-coding-agent's SessionManager intentionally does not write the `.jsonl`
|
|
72
|
+
* until the first assistant message lands (its `_persist()` gates the first
|
|
73
|
+
* write behind an "assistant message exists" check — a documented contract).
|
|
74
|
+
* When a subagent's *first* model call dies before producing one — e.g. a
|
|
75
|
+
* Cloudflare 524 gateway timeout — no file is ever created, yet the planned
|
|
76
|
+
* path is already recorded. That leaves delegate reporting a sessionFile that
|
|
77
|
+
* doesn't exist, which in turn leads the parent to attempt (and fail) a
|
|
78
|
+
* `resumeFrom` against a nonexistent file.
|
|
79
|
+
*
|
|
80
|
+
* This flushes via the upstream `_rewriteFile()` seam — the same private method
|
|
81
|
+
* upstream itself calls to recover from empty/corrupt session files — so the
|
|
82
|
+
* reported path becomes real and resumable. Idempotent: no-op when the file
|
|
83
|
+
* already exists or the session manager has no `sessionFile`.
|
|
84
|
+
*
|
|
85
|
+
* Returns true when a resumable file exists on return (whether this call wrote
|
|
86
|
+
* it or it pre-existed), false otherwise.
|
|
87
|
+
*/
|
|
88
|
+
export function persistSessionHeader(sm: unknown): boolean {
|
|
89
|
+
const inner = sm as {
|
|
90
|
+
getSessionFile?: () => string | undefined;
|
|
91
|
+
_rewriteFile?: () => void;
|
|
92
|
+
};
|
|
93
|
+
const file = inner.getSessionFile?.();
|
|
94
|
+
if (!file) return false;
|
|
95
|
+
if (fs.existsSync(file)) return true;
|
|
96
|
+
try {
|
|
97
|
+
inner._rewriteFile?.();
|
|
98
|
+
} catch {
|
|
99
|
+
/* best effort — caller falls back to reporting no sessionFile */
|
|
100
|
+
}
|
|
101
|
+
return fs.existsSync(file);
|
|
102
|
+
}
|
package/settings.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface DelegateSettings {
|
|
6
|
+
agentOverrides?: Record<
|
|
7
|
+
string,
|
|
8
|
+
{ model?: string; thinking?: string; tools?: string[]; skills?: string[] }
|
|
9
|
+
>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Read and validate a JSON settings object, returning null on I/O or parse errors. */
|
|
13
|
+
export function readDelegateSettingsFile(
|
|
14
|
+
filePath: string,
|
|
15
|
+
): Record<string, unknown> | null {
|
|
16
|
+
try {
|
|
17
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
20
|
+
return null;
|
|
21
|
+
return parsed as Record<string, unknown>;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getDelegateSettings(filePath: string): DelegateSettings | null {
|
|
28
|
+
const settings = readDelegateSettingsFile(filePath);
|
|
29
|
+
if (
|
|
30
|
+
!settings?.delegate ||
|
|
31
|
+
typeof settings.delegate !== "object" ||
|
|
32
|
+
Array.isArray(settings.delegate)
|
|
33
|
+
)
|
|
34
|
+
return null;
|
|
35
|
+
return settings.delegate as DelegateSettings;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const delegateSettingsCache = new Map<string, DelegateSettings | null>();
|
|
39
|
+
|
|
40
|
+
/** Load merged delegate settings: project overrides user.
|
|
41
|
+
* Result is cached per cwd for the lifetime of the delegate call. */
|
|
42
|
+
export function loadDelegateSettings(cwd: string): DelegateSettings | null {
|
|
43
|
+
const key = path.resolve(cwd);
|
|
44
|
+
const cached = delegateSettingsCache.get(key);
|
|
45
|
+
if (cached !== undefined) return cached;
|
|
46
|
+
|
|
47
|
+
const userPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
48
|
+
// Look for project root by finding .pi/ directory, not just .pi/agents/
|
|
49
|
+
let projectPath: string | null = null;
|
|
50
|
+
let dir = key;
|
|
51
|
+
const root = path.resolve("/");
|
|
52
|
+
while (true) {
|
|
53
|
+
if (fs.existsSync(path.join(dir, ".pi"))) {
|
|
54
|
+
projectPath = path.join(dir, ".pi", "settings.json");
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (dir === root) break;
|
|
58
|
+
const parent = path.dirname(dir);
|
|
59
|
+
if (parent === dir) break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const user = getDelegateSettings(userPath);
|
|
64
|
+
const project = projectPath ? getDelegateSettings(projectPath) : null;
|
|
65
|
+
|
|
66
|
+
if (!user && !project) {
|
|
67
|
+
delegateSettingsCache.set(key, null);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const result: DelegateSettings = {
|
|
71
|
+
agentOverrides: {
|
|
72
|
+
...(user?.agentOverrides ?? {}),
|
|
73
|
+
...(project?.agentOverrides ?? {}),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
delegateSettingsCache.set(key, result);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
package/spill.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
// ── Spill: keep subagent final-output bloat out of the LLM context ───────
|
|
8
|
+
//
|
|
9
|
+
// Two audiences share one source of truth (`result.output`):
|
|
10
|
+
// - the human's expanded TUI view (always the full output, via
|
|
11
|
+
// render-branches.ts `new Markdown(r.output)`)
|
|
12
|
+
// - the LLM-facing `content` string (bounded here — tail kept, head spilled)
|
|
13
|
+
//
|
|
14
|
+
// The spill is a greppable plain-text `.md` projection of the *final output*
|
|
15
|
+
// only. The full transcript already lives in the session `.jsonl`; this does
|
|
16
|
+
// not duplicate it. Design: lossless always — if the spill write fails, we
|
|
17
|
+
// degrade to today's behavior (full output in context) rather than hard-truncate.
|
|
18
|
+
|
|
19
|
+
/** Decision over an output string: spill or not, and what stays in-context. */
|
|
20
|
+
export interface SpillDecision {
|
|
21
|
+
/** True when the output exceeds the threshold and should be spilled. */
|
|
22
|
+
spill: boolean;
|
|
23
|
+
/** The text to keep in-context — full output when not spilling, the tail when spilling. */
|
|
24
|
+
inContext: string;
|
|
25
|
+
/** Length of the full output (chars). */
|
|
26
|
+
fullChars: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure decision: given an output string and bounds, decide whether to spill
|
|
31
|
+
* and what tail to keep in-context. Testable without any filesystem.
|
|
32
|
+
*
|
|
33
|
+
* - Output at or under the threshold → passthrough (no spill).
|
|
34
|
+
* - Over the threshold → keep the suffix of length `tailChars`.
|
|
35
|
+
* - The tail is the *suffix*, not the prefix: a subagent's verdict is at the
|
|
36
|
+
* end; the preamble is usually regurgitated tool output.
|
|
37
|
+
*
|
|
38
|
+
* The tail slice is surrogate-pair-aware: if the cut lands on a trailing
|
|
39
|
+
* surrogate, it advances one so the in-context string never begins with a
|
|
40
|
+
* lone (replacement-char-rendering) half of an astral character.
|
|
41
|
+
*/
|
|
42
|
+
export function decideSpill(
|
|
43
|
+
output: string,
|
|
44
|
+
opts: { thresholdChars: number; tailChars: number },
|
|
45
|
+
): SpillDecision {
|
|
46
|
+
const fullChars = output.length;
|
|
47
|
+
if (fullChars <= opts.thresholdChars) {
|
|
48
|
+
return { spill: false, inContext: output, fullChars };
|
|
49
|
+
}
|
|
50
|
+
return { spill: true, inContext: tailOf(output, opts.tailChars), fullChars };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write the full output to a temp `.md` file and return its path, or `null`
|
|
55
|
+
* on failure. Never throws — callers rely on the lossless-degrade guarantee.
|
|
56
|
+
*
|
|
57
|
+
* Path shape: `os.tmpdir()/delegate-output-<sanitized-label>-<6hex>.md`,
|
|
58
|
+
* mode 0o600. The label is sanitized (subagent precedent) so agent names
|
|
59
|
+
* can't escape the filename. `suffix` (and `dir`) are injectable so tests get
|
|
60
|
+
* deterministic, collision-free names and can point at an unwritable dir to
|
|
61
|
+
* exercise the failure path without mocking.
|
|
62
|
+
*/
|
|
63
|
+
export function spillToTempFile(
|
|
64
|
+
output: string,
|
|
65
|
+
label: string,
|
|
66
|
+
suffix: string = randomBytes(3).toString("hex"),
|
|
67
|
+
dir: string = os.tmpdir(),
|
|
68
|
+
): string | null {
|
|
69
|
+
const safeLabel = label.replace(/[^\w.-]+/g, "_");
|
|
70
|
+
const filePath = path.join(dir, `delegate-output-${safeLabel}-${suffix}.md`);
|
|
71
|
+
try {
|
|
72
|
+
fs.writeFileSync(filePath, output, { mode: 0o600 });
|
|
73
|
+
return filePath;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.warn(
|
|
76
|
+
`[delegate] spill write failed (${filePath}): ${e instanceof Error ? e.message : String(e)}`,
|
|
77
|
+
);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render a subagent's final output for the LLM-facing `content` string.
|
|
84
|
+
*
|
|
85
|
+
* What the callers use. Behavior:
|
|
86
|
+
* - under threshold (or empty/placeholder) → output unchanged
|
|
87
|
+
* - over threshold + write ok → tail + pointer to the spill file
|
|
88
|
+
* - over threshold + write fail → output unchanged (degrade), warn-logged
|
|
89
|
+
*
|
|
90
|
+
* `opts` lets tests inject small thresholds (and a `dir` to force write
|
|
91
|
+
* failures) without touching the config singleton (there are no config
|
|
92
|
+
* mutators — `delegate.json` is the only write path). Production callers omit
|
|
93
|
+
* it and pick up `delegate.json` defaults.
|
|
94
|
+
*/
|
|
95
|
+
export function renderOutputForLLM(
|
|
96
|
+
output: string,
|
|
97
|
+
label: string,
|
|
98
|
+
opts?: { thresholdChars?: number; tailChars?: number; dir?: string },
|
|
99
|
+
): string {
|
|
100
|
+
// Skip empty / placeholder — nothing to spill, nothing to bound.
|
|
101
|
+
if (!output || !output.trim() || output === "(no output)") return output;
|
|
102
|
+
|
|
103
|
+
const thresholdChars = opts?.thresholdChars ?? getOutputSpillThreshold();
|
|
104
|
+
const tailChars = opts?.tailChars ?? getOutputSpillTail();
|
|
105
|
+
|
|
106
|
+
const decision = decideSpill(output, { thresholdChars, tailChars });
|
|
107
|
+
if (!decision.spill) return output;
|
|
108
|
+
|
|
109
|
+
const filePath = spillToTempFile(output, label, undefined, opts?.dir);
|
|
110
|
+
// Lossless degrade: write failed → return full output, never hard-truncate.
|
|
111
|
+
if (!filePath) return output;
|
|
112
|
+
|
|
113
|
+
return spillPointer(decision.inContext, filePath, decision.fullChars);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Render output for the running-ticket poll view: **tail only, no file.**
|
|
118
|
+
*
|
|
119
|
+
* The output is a moving target mid-flight (a done task's full spill lands at
|
|
120
|
+
* ticket completion via `formatCompletedTask`); writing a file per poll would
|
|
121
|
+
* churn paths and confuse the LLM. So the poll stays bounded with a tail and
|
|
122
|
+
* a note pointing to the eventual spill. Under the tail budget → unchanged.
|
|
123
|
+
*/
|
|
124
|
+
export function renderOutputForPoll(
|
|
125
|
+
output: string,
|
|
126
|
+
opts?: { tailChars?: number },
|
|
127
|
+
): string {
|
|
128
|
+
if (!output || !output.trim() || output === "(no output)") return output;
|
|
129
|
+
const tailChars = opts?.tailChars ?? getOutputSpillTail();
|
|
130
|
+
if (output.length <= tailChars) return output;
|
|
131
|
+
const tail = tailOf(output, tailChars);
|
|
132
|
+
return `…${tail}\n[truncated — full output is spilled to a file when the ticket completes]`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Assemble the tail + pointer block emitted on a successful spill. */
|
|
136
|
+
function spillPointer(
|
|
137
|
+
tail: string,
|
|
138
|
+
filePath: string,
|
|
139
|
+
fullChars: number,
|
|
140
|
+
): string {
|
|
141
|
+
return `…${tail}\n\n[full output (${humanSize(fullChars)}) spilled to ${filePath} —\n \`read\`/\`grep\` it if completeness matters here; above is the tail]`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Suffix of `s` at most `n` chars long, surrogate-pair-aware. If the cut
|
|
146
|
+
* would land on a trailing surrogate, advance one so the result never starts
|
|
147
|
+
* with a lone half of an astral character.
|
|
148
|
+
*/
|
|
149
|
+
function tailOf(s: string, n: number): string {
|
|
150
|
+
if (s.length <= n) return s;
|
|
151
|
+
let start = s.length - n;
|
|
152
|
+
if (start > 0 && (s.charCodeAt(start) & 0xfc00) === 0xdc00) start++;
|
|
153
|
+
return s.slice(start);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Compact human-readable size from a char count (≈ bytes for ASCII). */
|
|
157
|
+
function humanSize(chars: number): string {
|
|
158
|
+
if (chars < 1024) return `${chars} B`;
|
|
159
|
+
if (chars < 1024 * 1024) return `${Math.round(chars / 1024)} KB`;
|
|
160
|
+
return `${(chars / (1024 * 1024)).toFixed(1)} MB`;
|
|
161
|
+
}
|