@bermudi/pi-delegate 0.1.17 → 0.1.19

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.
@@ -523,7 +523,8 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
523
523
  const integration = r.integration;
524
524
  const tone =
525
525
  integration.status === "applied_unverified" ||
526
- integration.status === "no_changes"
526
+ integration.status === "no_changes" ||
527
+ integration.status === "retained"
527
528
  ? "warning"
528
529
  : "error";
529
530
  lines.push(
package/render-result.ts CHANGED
@@ -213,6 +213,12 @@ export function renderDelegateResult(
213
213
  lines.push(truncLine(theme.fg("warning", `⚠ ${warning}`), w), "");
214
214
  }
215
215
  }
216
+ if (details?.serializedNotice) {
217
+ const notice = sanitizeTerminalLine(details.serializedNotice);
218
+ if (notice) {
219
+ lines.push(truncLine(theme.fg("dim", `⏳ ${notice}`), w), "");
220
+ }
221
+ }
216
222
  if (details?.overlapWarning) {
217
223
  const warning = sanitizeTerminalLine(details.overlapWarning);
218
224
  if (warning) {
package/runtime.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { SessionPool, defaultSessionPool } from "./pool.ts";
2
+ import { TicketRegistry, ticketRegistry } from "./tickets.ts";
3
+
4
+ /**
5
+ * Injectable runtime context for one delegate extension lifetime.
6
+ *
7
+ * A runtime bundles a {@link SessionPool} and a {@link TicketRegistry} so
8
+ * tests and nested callers can create fully isolated dispatch/lifecycle
9
+ * environments without touching the module-level default pool or ticket
10
+ * registry. Production Pi uses the single default runtime returned by
11
+ * {@link getDefaultDelegateRuntime}; the public barrel still exposes the
12
+ * familiar checkout/commit/ticket wrapper functions for compatibility.
13
+ */
14
+ export interface DelegateRuntime {
15
+ pool: SessionPool;
16
+ tickets: TicketRegistry;
17
+ }
18
+
19
+ /** Create a fresh, isolated runtime with its own pool and ticket registry. */
20
+ export function createDelegateRuntime(): DelegateRuntime {
21
+ return {
22
+ pool: new SessionPool(),
23
+ tickets: new TicketRegistry(),
24
+ };
25
+ }
26
+
27
+ const defaultRuntime: DelegateRuntime = {
28
+ pool: defaultSessionPool,
29
+ tickets: ticketRegistry,
30
+ };
31
+
32
+ /** The runtime used by the one-argument Pi extension entry point and the
33
+ * default-runtime compatibility wrappers exported from the barrel. */
34
+ export function getDefaultDelegateRuntime(): DelegateRuntime {
35
+ return defaultRuntime;
36
+ }
package/schema.ts CHANGED
@@ -42,7 +42,7 @@ export const delegateTaskSchema = Type.Object({
42
42
  agent: Type.Optional(
43
43
  Type.String({
44
44
  description:
45
- "default mirrors the parent's tools; scout/coder/reviewer specialists. Ad-hoc tasks get * tools even when the parent is narrower.",
45
+ "Use built-ins first: default=general; scout/coder/reviewer specialize. Omit only for inline; it gets * even if parent is narrower.",
46
46
  }),
47
47
  ),
48
48
  cwd: Type.Optional(
@@ -65,19 +65,20 @@ export const delegateTaskSchema = Type.Object({
65
65
  ),
66
66
  model: Type.Optional(
67
67
  Type.String({
68
- description: "Model override; omit to inherit parent.",
68
+ description:
69
+ "Override only if requested or required; else keep its default.",
69
70
  }),
70
71
  ),
71
72
  tools: Type.Optional(
72
73
  Type.Array(Type.String(), {
73
74
  description:
74
- "Names/presets: *=read/write/edit/bash (mutating); ro=read/grep/find/ls (read-only). Ad-hoc defaults to *.",
75
+ "Override only if requested or required. *=read/write/edit/bash; ro=read/grep/find/ls (read-only).",
75
76
  }),
76
77
  ),
77
78
  thinking: Type.Optional(
78
79
  StringEnum(VALID_THINKING_LEVELS, {
79
80
  description:
80
- "off/minimal/low/medium/high/xhigh/max. Omit for agents it overrides delegate.json tiers; default inherits.",
81
+ "off/minimal/low/medium/high/xhigh/max. Omit by default: overrides the agent's configured thinking budget.",
81
82
  }),
82
83
  ),
83
84
  sessionId: Type.Optional(
@@ -101,7 +102,7 @@ export const delegateTaskSchema = Type.Object({
101
102
  workspace: Type.Optional(
102
103
  StringEnum(["shared", "scratch", "isolated"], {
103
104
  description:
104
- "shared edits source; scratch discards; isolated orders Git worktree proposals; none confine access.",
105
+ "shared/scratch/isolated. Override if requested or required. scratch discards; isolated reconciles Git; not a security boundary.",
105
106
  }),
106
107
  ),
107
108
  });
@@ -159,7 +160,7 @@ export const delegateArgumentsSchema = Type.Object(
159
160
  Type.Array(delegateTaskSchema, {
160
161
  minItems: 0,
161
162
  description:
162
- "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
163
+ "Use built-in defaults first. Tasks run concurrently; overlapping shared writers run in task order. []=manual.",
163
164
  }),
164
165
  ),
165
166
  },
@@ -286,9 +287,21 @@ function validateTicketMode(params: DelegateArguments): string | undefined {
286
287
  [...TASK_FIELD_NAMES, "tasks", "sessionAction"] as const
287
288
  ).filter((field) => rawParams[field] !== undefined);
288
289
  if (incompatibleFields.length) {
289
- return `ticket control cannot be combined with field(s) ${incompatibleFields
290
+ const base = `ticket control cannot be combined with field(s) ${incompatibleFields
290
291
  .map((field) => `'${field}'`)
291
292
  .join(", ")}; call it separately.`;
293
+ // Kitchen-sink callers attach default-shaped ticket control to a real
294
+ // dispatch and then repeat the identical rejected call (observed in the
295
+ // wild: glm-5.3 sent `ticketAction:"wait"` + tasks + empty ticket six
296
+ // times in a row). With tasks present and no ticket id, address the
297
+ // likely intent — dispatch already blocks; async creates the tickets
298
+ // that ticketAction manages — instead of restating the field rule.
299
+ const wantsDispatch =
300
+ Array.isArray(params.tasks) && params.tasks.length > 0 && !params.ticket;
301
+ if (wantsDispatch) {
302
+ return `${base} Dispatched tasks run to completion before returning — omit ticketAction entirely; only async:true produces a ticket to wait on.`;
303
+ }
304
+ return base;
292
305
  }
293
306
  if (params.async === true) {
294
307
  return "ticket control cannot include async; call it separately.";
@@ -327,10 +340,6 @@ function validateDispatchOrHelpMode(
327
340
  : undefined; // Intentional help request.
328
341
  }
329
342
 
330
- if (params.async && tasks.some((task) => task.workspace === "isolated")) {
331
- return 'workspace "isolated" is synchronous; remove async.';
332
- }
333
-
334
343
  // Reject mixed shapes: flat task fields at the top level alongside a
335
344
  // nonempty tasks array. The normalize shim only wraps flat fields when
336
345
  // there is no tasks array, so a mixed call silently lets tasks win —
@@ -450,7 +459,7 @@ function wrapFlatTaskFields(record: Record<string, unknown>): void {
450
459
  }
451
460
 
452
461
  /** Per-entry recovery for one task: stringified (or bare-token) `tools` → a
453
- * real array, and `agent: ""` → omitted (ad-hoc). Other malformed input is
462
+ * real array, and `agent: ""` → omitted (inline). Other malformed input is
454
463
  * left for schema validation to reject loudly. */
455
464
  function normalizeTaskEntry(entry: unknown): unknown {
456
465
  if (!entry || typeof entry !== "object") return entry;
@@ -476,7 +485,7 @@ function normalizeTaskEntry(entry: unknown): unknown {
476
485
  * unless ticket- or session-control intent makes the call legitimately
477
486
  * taskless (see `hasTicketControlIntent` / `hasSessionControlIntent`);
478
487
  * - `tools` as a JSON string (or bare token) inside a task entry;
479
- * - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
488
+ * - `agent: ""` inside a task entry — treated as omitted (inline);
480
489
  * All other invalid input is left for normal schema validation to reject
481
490
  * loudly.
482
491
  *
package/status.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * there.
27
27
  */
28
28
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
- import { requestTicketCancel, ticketRegistry } from "./tickets.ts";
29
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
30
30
  import type { AsyncTicket } from "./types.ts";
31
31
 
32
32
  const STATUS_KEY = "delegate";
@@ -39,10 +39,12 @@ export interface ActiveTicketSummary {
39
39
  }
40
40
 
41
41
  /** Snapshot the live background work from the ticket registry. */
42
- export function activeTicketSummary(): ActiveTicketSummary {
42
+ export function activeTicketSummary(
43
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
44
+ ): ActiveTicketSummary {
43
45
  const tickets: AsyncTicket[] = [];
44
46
  let activeSubagents = 0;
45
- for (const ticket of ticketRegistry.values()) {
47
+ for (const ticket of runtime.tickets.values()) {
46
48
  if (ticket.status !== "running" && ticket.status !== "cancelling") continue;
47
49
  tickets.push(ticket);
48
50
  activeSubagents += ticket.progress.filter(
@@ -88,10 +90,13 @@ const settledWarnedTicketIds = new Set<string>();
88
90
  * Called on every ticket lifecycle mutation (create, progress, complete,
89
91
  * cancel); event-driven only — no timers, so the text never goes stale
90
92
  * (counts are the only content). */
91
- export function syncDelegateStatus(ctx?: ExtensionContext): void {
93
+ export function syncDelegateStatus(
94
+ ctx?: ExtensionContext,
95
+ runtime?: DelegateRuntime,
96
+ ): void {
92
97
  if (ctx) lastCtx = ctx;
93
98
 
94
- const summary = activeTicketSummary();
99
+ const summary = activeTicketSummary(runtime);
95
100
  const text = buildStatusText(summary);
96
101
 
97
102
  if (settledWarnedTicketIds.size) {
@@ -127,9 +132,12 @@ export function clearDelegateStatusContext(): void {
127
132
  /** Warn once per ticket at the first agent_settled with that ticket active —
128
133
  * the "looks idle but isn't" moment. The persistent footer status carries
129
134
  * the information from then on, so later settles stay quiet. */
130
- export function notifyActiveTicketsOnSettled(ctx: ExtensionContext): void {
135
+ export function notifyActiveTicketsOnSettled(
136
+ ctx: ExtensionContext,
137
+ runtime?: DelegateRuntime,
138
+ ): void {
131
139
  lastCtx = ctx;
132
- const summary = activeTicketSummary();
140
+ const summary = activeTicketSummary(runtime);
133
141
  const fresh = summary.tickets.filter(
134
142
  (t) => !settledWarnedTicketIds.has(t.id),
135
143
  );
@@ -167,9 +175,10 @@ export function notifyActiveTicketsOnSettled(ctx: ExtensionContext): void {
167
175
  export async function guardSessionReplacement(
168
176
  ctx: ExtensionContext,
169
177
  action: "switch" | "fork",
178
+ runtime?: DelegateRuntime,
170
179
  ): Promise<{ cancel: true } | undefined> {
171
180
  lastCtx = ctx;
172
- const summary = activeTicketSummary();
181
+ const summary = activeTicketSummary(runtime);
173
182
  if (!summary.tickets.length || !ctx.hasUI) return undefined;
174
183
 
175
184
  const ids = summary.tickets.map((t) => t.id).join(", ");
@@ -194,9 +203,11 @@ export async function guardSessionReplacement(
194
203
  * extension) is still handled at delivery time via leaf affinity. */
195
204
  export async function guardTreeNavigation(
196
205
  ctx: ExtensionContext,
206
+ runtime?: DelegateRuntime,
197
207
  ): Promise<{ cancel: true } | undefined> {
198
208
  lastCtx = ctx;
199
- const summary = activeTicketSummary();
209
+ const rt = runtime ?? getDefaultDelegateRuntime();
210
+ const summary = activeTicketSummary(rt);
200
211
  if (!summary.tickets.length || !ctx.hasUI) return undefined;
201
212
 
202
213
  const ids = summary.tickets.map((t) => t.id).join(", ");
@@ -220,8 +231,10 @@ export async function guardTreeNavigation(
220
231
 
221
232
  if (choice === hold) return undefined;
222
233
  if (choice === cancel) {
223
- for (const ticket of summary.tickets) requestTicketCancel(ticket);
224
- syncDelegateStatus(ctx);
234
+ for (const ticket of summary.tickets) {
235
+ rt.tickets.requestTicketCancel(ticket);
236
+ }
237
+ syncDelegateStatus(ctx, rt);
225
238
  return undefined;
226
239
  }
227
240
  return { cancel: true };
@@ -12,8 +12,7 @@ import {
12
12
  availableToolNames,
13
13
  resolveToolGroups,
14
14
  } from "./tools.ts";
15
- import { configFor } from "./pool.ts";
16
- import { isSessionBusy } from "./tickets.ts";
15
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
17
16
  import {
18
17
  isResumeFromQuarantined,
19
18
  isSessionIdQuarantined,
@@ -115,6 +114,7 @@ export function validateTasks(
115
114
  tasks: DispatchableTask[],
116
115
  agents: Map<string, AgentConfig>,
117
116
  parentModelId: string | undefined,
117
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
118
118
  ): DelegateToolResult | null {
119
119
  const unknown: string[] = [];
120
120
  for (const task of tasks) {
@@ -203,7 +203,7 @@ export function validateTasks(
203
203
  // Disallow sessionIds already claimed by a running async ticket.
204
204
  const busyConflicts: string[] = [];
205
205
  for (const sid of sessionIds) {
206
- const owner = isSessionBusy(sid);
206
+ const owner = runtime.tickets.isSessionBusy(sid);
207
207
  if (owner) busyConflicts.push(`${sid} (ticket ${owner})`);
208
208
  }
209
209
  if (busyConflicts.length) {
@@ -247,6 +247,7 @@ export function resolveTasks(
247
247
  agents: Map<string, AgentConfig>,
248
248
  parentDefaults: ParentAgentDefaults,
249
249
  dispatchConfig: DelegateConfig = getDelegateConfigSnapshot(),
250
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
250
251
  ): ResolveTasksResult {
251
252
  // Build parent transcript lazily — only computed once if any task uses with-parent-transcript
252
253
  let parentTranscript: string | null = null;
@@ -302,10 +303,12 @@ export function resolveTasks(
302
303
  : undefined;
303
304
 
304
305
  // Build system prompt. Explicit task prompts and named agent prompts
305
- // win; ad-hoc subagents inherit the parent's base prompt when Pi exposes
306
+ // win; inline subagents inherit the parent's base prompt when Pi exposes
306
307
  // it. The assembled parent project-context section was stripped above;
307
308
  // the child ResourceLoader supplies context for this task's cwd.
308
- const pooledConfig = t.sessionId ? configFor(t.sessionId) : undefined;
309
+ const pooledConfig = t.sessionId
310
+ ? runtime.pool.configFor(t.sessionId)
311
+ : undefined;
309
312
  const isPoolHit = pooledConfig !== undefined;
310
313
  const parentNativeTools = parentDefaults.tools.filter((name) =>
311
314
  Object.hasOwn(TOOL_FACTORIES, name),
@@ -625,12 +628,12 @@ export function resolveTasks(
625
628
  // display code treats "" and absent alike (`t.prompt || …`).
626
629
  prompt: prompt ?? "",
627
630
  // Keep the built-in selector visible in progress/results. Omitted-agent
628
- // inline tasks retain the established `ad-hoc` label and config namespace
629
- // — except resumes: a continued transcript is not a fresh ad-hoc spawn,
630
- // so it carries the resumed-transcript identity instead.
631
+ // tasks use the `inline` label and config namespace — except resumes: a
632
+ // continued transcript is not a fresh inline spawn, so it carries the
633
+ // resumed-transcript identity instead.
631
634
  agentName:
632
635
  agent?.name ??
633
- (resumeFromDisplay ? `resume:${resumeFromDisplay}` : "ad-hoc"),
636
+ (resumeFromDisplay ? `resume:${resumeFromDisplay}` : "inline"),
634
637
  resumeFromDisplay,
635
638
  warnings,
636
639
  reuseIntent: {
@@ -0,0 +1,81 @@
1
+ import {
2
+ createTestSession,
3
+ type TestSession,
4
+ type TestSessionOptions,
5
+ } from "@marcfargas/pi-test-harness";
6
+ import type {
7
+ AgentSession,
8
+ AgentToolResult,
9
+ ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import type { Component } from "@earendil-works/pi-tui";
12
+ import type { DelegateDetails, TaskResult } from "./types.ts";
13
+ import { resolve } from "node:path";
14
+
15
+ export const DELEGATE_EXTENSION = resolve(import.meta.dirname, "./delegate.ts");
16
+
17
+ export { type TestSession };
18
+
19
+ export function createDelegateTestSession(
20
+ options: TestSessionOptions = {},
21
+ ): Promise<TestSession> {
22
+ return createTestSession({
23
+ ...options,
24
+ extensions: [...(options.extensions ?? []), DELEGATE_EXTENSION],
25
+ });
26
+ }
27
+
28
+ export interface TestToolDefinition {
29
+ name: string;
30
+ label: string;
31
+ description: string;
32
+ promptSnippet?: string;
33
+ promptGuidelines?: string[];
34
+ parameters: unknown;
35
+ prepareArguments?: (args: unknown) => unknown;
36
+ execute(...args: unknown[]): Promise<AgentToolResult<DelegateDetails>>;
37
+ renderCall: (...args: unknown[]) => Component;
38
+ renderResult: (...args: unknown[]) => Component;
39
+ }
40
+
41
+ export function getToolDef(ts: TestSession, name: string): TestToolDefinition {
42
+ const tool = (ts.session as AgentSession).extensionRunner.getToolDefinition(
43
+ name,
44
+ );
45
+ if (!tool) throw new Error(`${name} tool not found`);
46
+ return tool as unknown as TestToolDefinition;
47
+ }
48
+
49
+ export function getDelegateTool(ts: TestSession): TestToolDefinition {
50
+ return getToolDef(ts, "delegate");
51
+ }
52
+
53
+ export function getExecContext(ts: TestSession): ExtensionContext {
54
+ return (ts.session as AgentSession).extensionRunner.createContext();
55
+ }
56
+
57
+ export function firstText(result: { content: readonly unknown[] }): string {
58
+ const content = result.content[0];
59
+ if (
60
+ !content ||
61
+ typeof content !== "object" ||
62
+ !("type" in content) ||
63
+ content.type !== "text" ||
64
+ !("text" in content) ||
65
+ typeof content.text !== "string"
66
+ ) {
67
+ throw new Error("Expected first tool result content item to be text");
68
+ }
69
+ return content.text;
70
+ }
71
+
72
+ export function taskResultAt(
73
+ results: readonly (TaskResult | { error: string })[],
74
+ index: number,
75
+ ): TaskResult {
76
+ const result = results[index];
77
+ if (!result || !("agent" in result)) {
78
+ throw new Error(`Expected task result at index ${index}`);
79
+ }
80
+ return result;
81
+ }
package/ticket-format.ts CHANGED
@@ -246,15 +246,16 @@ export function formatLiveTicketPoll(
246
246
  findTouchedOverlaps(completedForOverlap),
247
247
  );
248
248
  const guidance = liveTicketGuidance(ticket);
249
+ const serializedNotice = ticket.serializedNotice ?? "";
249
250
  const dispatchWarning = ticket.dispatchWarning
250
251
  ? `WARNING: ${ticket.dispatchWarning}`
251
252
  : "";
252
253
  return {
253
254
  text: `${formatLiveTicketHeader(ticket, now)}\n${lines.join("\n")}${
254
255
  guidance ? `\n\n${guidance}` : ""
255
- }${dispatchWarning ? `\n\n${dispatchWarning}` : ""}${
256
- overlapWarning ? `\n\n${overlapWarning}` : ""
257
- }`,
256
+ }${serializedNotice ? `\n\n${serializedNotice}` : ""}${
257
+ dispatchWarning ? `\n\n${dispatchWarning}` : ""
258
+ }${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
258
259
  completedResults,
259
260
  overlapWarning,
260
261
  };