@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/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
+ }