pi-delegation-policy 0.21.0 → 0.22.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/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0 - 2026-10-21
4
+
5
+ ### Added
6
+
7
+ - One ephemeral configuration-change notice on the next user turn after an effective configuration
8
+ change, whether a `/delegate` save or an edit of the global defaults that reaches the session. It
9
+ lists the values that changed since the configuration the model was last shown: intensity,
10
+ preference, ordinary roles, Visual Design, thinking policies, Advisor entries, mode and launch, the
11
+ ContextShunt mode, and the reader role, toggle and answer limit. The ContextShunt enforcement
12
+ tuning (`limits`, `exceptionPatterns`, `delegationHintPatterns`) is left out on purpose: it changes
13
+ how much the shunt preserves or compacts, not the assignments the model follows. The notice exists
14
+ only in the requests of that turn, never in the conversation, and stays identical across their
15
+ retries. With both axes off by configuration it reports one line,
16
+ `Delegation and Advisor disabled by this policy.`, and then injects nothing; an Advisor left on but
17
+ unusable is not reported as disabled.
18
+ - The reference is the configuration the model was actually shown. Changes made before a turn are
19
+ grouped, a reversion reports nothing, an edit that leaves effective values unchanged reports
20
+ nothing, and a change made while a turn is running waits for the next turn. The notice is consumed
21
+ when the run ends with its last recorded outcome completed, read from the final `agent_end`, so a
22
+ retried error that then succeeds consumes it while an error or abort reported before that point
23
+ leaves the change pending. Residual: an abort or a thrown error after the last successful response,
24
+ which the host reports through no later public event, can still consume it. Session starts,
25
+ reloads, forks and tree moves set the reference silently, and a prompt another extension sends with
26
+ `sendUserMessage` neither receives nor consumes the notice.
27
+
28
+ ### Compatibility and verification
29
+
30
+ - No configuration, schema or dependency change. The notice uses public Pi `0.87.1` events,
31
+ is converted to user text by the host like any custom message and identifies itself as an extension
32
+ notice; it is not system policy and changes no model, permission or authority. The extension writes
33
+ no notice text to session history or its local log. This does not limit request retention by the
34
+ provider or other authorized host components. The existing local JSONL log keeps its fields.
35
+ - Covered against the registered hooks with a synthetic host: grouping and reversion, no-op saves,
36
+ retries, a mid-turn change, a completed turn followed by a failed continuation, error and abort, an
37
+ unknown run outcome, the accepted abort/throw window after a completed response, the both-off
38
+ notice, an extension-sent prompt, session and tree resets, global changes, the Advisor context mode,
39
+ and the real host conversion and payload repair. No real-provider run was performed. The synthetic
40
+ host encodes Pi `0.87.1`'s event order instead of exercising a host process, so a future host that
41
+ reordered `input` and `before_agent_start` would not be detected.
42
+
43
+ ## 0.21.1 - 2026-10-19
44
+
45
+ ### Fixed
46
+
47
+ - Accept valid Advisor replies in fork context. Preflight now uses the public executor wrapper to bind the same task that the host executes, while the emitted question and strict result identity checks remain unchanged. Version 0.21.0 discarded successful fork replies because the task digests differed.
48
+ - Explain Advisor launch failures instead of returning only `Advisor failed`. Messages distinguish missing launch prerequisites, preflight failures, timeouts, mismatched result identity, and unusable answers. If the host supplies no safely displayable cause, the message says so; raw errors, prompts, paths, and secrets are never copied into replies or the local log.
49
+
50
+ ### Compatibility and verification
51
+
52
+ - No configuration or schema migration. Pi `0.87.1+` and the exact `pi-subagents` `0.76.1` executor requirement are unchanged. Reload after upgrading.
53
+ - The regression is covered against a synthetic host matching the installed executor, including rejection of a genuinely mismatched digest. A real-provider fork check remains pending after installation.
54
+
3
55
  ## 0.21.0 - 2026-10-19
4
56
 
5
57
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@ general subagent runner. Only explicit tools can request reader work or Advisor
6
6
  the authorized host executor; hooks never launch children.
7
7
 
8
8
  > **Docs:** [yivas.github.io/pi-delegation-policy](https://yivas.github.io/pi-delegation-policy/).
9
- > Version **0.21.0** requires Pi `0.87.1` or later (`>=0.87.1`); it re-applies its marked policy block in the final provider payload.
9
+ > Version **0.22.0** requires Pi `0.87.1` or later (`>=0.87.1`); it re-applies its marked policy block in the final provider payload.
10
10
  > Version `0.14.1` supports Pi `0.84.3` or later (`>=0.84.3`).
11
11
  >
12
12
  > Independent Advisor mode and its companion file are available from version `0.17.0`.
@@ -15,7 +15,7 @@ the authorized host executor; hooks never launch children.
15
15
  ## Install
16
16
 
17
17
  ```bash
18
- pi install npm:pi-delegation-policy@0.21.0
18
+ pi install npm:pi-delegation-policy@0.22.0
19
19
  # restart Pi, or run /reload
20
20
  ```
21
21
 
@@ -33,7 +33,7 @@ to inheritance later.
33
33
 
34
34
  | Intensity | What the guidance asks |
35
35
  | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
- | `off` | No execution-delegation guidance. An independently enabled Advisor still injects its own section; with both off nothing is injected. |
36
+ | `off` | No execution-delegation guidance. An independently enabled Advisor still injects its own section; with both off no policy block is injected. |
37
37
  | `normal` | Delegate substantial, separable work only when the expected benefit clearly outweighs briefing, supervision, review, and integration. Borderline work stays with the main agent. |
38
38
  | `aggressive` | Delegate suitable substantial, separable, independently checkable work by default when objective and acceptance criteria are clear. |
39
39
  | `orchestrator` | Delegate all transferable execution before it begins whenever an enabled capable role and an authorized launcher exist, regardless of size: small lookups, reading, detailed planning, edits, tests, writing, detailed review, and integration mechanics. Direct work needs a briefly stated exception, and final acceptance stays with the main agent. |
@@ -105,7 +105,7 @@ Local timeouts and cancellation do not cap external generation, retries or cost.
105
105
  | `off` | Inject no Advisor guidance and keep the saved model and thinking policy. |
106
106
  | `with-delegation` | Consult only while delegation is active and an Advisor is configured. Default when the key is absent. |
107
107
 
108
- `D:OFF A:ON` shows consultation-only guidance; `D:OFF A:OFF` shows nothing injected. `/delegate reset`
108
+ `D:OFF A:ON` shows consultation-only guidance; `D:OFF A:OFF` means neither axis injects a policy block. `/delegate reset`
109
109
  turns both off for the branch; `/delegate off` leaves an independently enabled Advisor active.
110
110
 
111
111
  Errors are reported per axis. Each entry validates independently: `A:ON` means all valid, `A:PART` means one of two valid
@@ -190,6 +190,49 @@ example `advisor-mode=with-delegation (global); A:OFF` and `thinking-advisor=uns
190
190
  `D:OFF`, `D:NORM`, `D:AGG`, and `D:ORCH` describe delegation; `A:OFF`, `A:ON`, `A:PART`, and `A:ERR` describe
191
191
  Advisor. None of them proves that a launch happened or that another system followed the guidance.
192
192
 
193
+ ### Configuration change notice
194
+
195
+ Available from `0.22.0`. The declared Pi peer remains
196
+ `>=0.87.1`; the notice hooks and conversion are verified against `0.87.1` with a synthetic harness,
197
+ not a full host process or later host versions.
198
+
199
+ After an effective configuration change — a `/delegate` save, or an edit of the global defaults that
200
+ changes effective values — the next user turn carries one extension-owned notice in the request
201
+ context. It lists the values that changed since the configuration the model was last shown:
202
+ intensity, preference, ordinary roles, Visual Design, thinking policies, Advisor entries, mode and
203
+ launch, the ContextShunt mode, and the reader role, toggle and answer limit. The ContextShunt
204
+ enforcement tuning (`limits`, `exceptionPatterns`, `delegationHintPatterns`) is left out on purpose:
205
+ it changes how much the shunt preserves or compacts, not the assignments the model follows. The
206
+ notice is not system policy and never enters the conversation: it exists only in the requests of
207
+ that turn, identical across their retries, and it disappears with the turn.
208
+
209
+ The reference is the configuration the model was actually shown, so changes made before a turn are
210
+ grouped, a reversion before the turn reports nothing, and an edit that leaves effective values
211
+ unchanged reports nothing. Opening or cancelling the editor writes nothing and reports nothing. A
212
+ change made while a turn is already running reaches the policy immediately and is reported on the
213
+ next turn; a revision that has not been shown is never consumed by an earlier turn.
214
+
215
+ Consumption happens when the run ends and its last recorded outcome is a completed response: the
216
+ extension reads the last assistant message of the final `agent_end` and keeps the presented revision
217
+ only when that response completed. An error the host retries and that then succeeds counts as
218
+ completed, so the notice is consumed there. An error or an abort reported before that point leaves
219
+ the change pending, and the next user turn reports it again. Residual: an abort or a thrown error
220
+ after the last successful response, which the host reports through no later public event, can still
221
+ consume the notice; no public signal separates that window.
222
+
223
+ With delegation off and Advisor off by configuration, one notice says `Delegation and Advisor
224
+ disabled by this policy.` and nothing further is injected while both stay off. An Advisor left on but
225
+ unusable is not reported as disabled, because the configuration did not disable it. Every notice is
226
+ scoped to this extension and revokes no other instruction, permission, or authority. Session starts,
227
+ reloads, forks and tree navigation set the reference silently, so no notice appears at startup.
228
+ The extension writes no notice text to session history or its local log, so compaction summaries
229
+ and branches cannot inherit one from stored history. This does not limit the provider's or other
230
+ authorized host components' request retention. A prompt another extension sends with
231
+ `sendUserMessage` is not a user turn: it neither receives nor consumes the notice. The notice reports configuration, not availability: an unusable model keeps its value in the
232
+ list and the policy block that would assign it may simply be absent, so nothing in the notice
233
+ presents that model as usable. For new work, the notice points the model at the current valid
234
+ assignments and thinking rules in this policy rather than earlier settings in the conversation.
235
+
193
236
  ## ContextShunt
194
237
 
195
238
  ContextShunt is a separate opt-in layer, off by default. `observe` records what enforcement would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-delegation-policy",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "private": false,
5
5
  "description": "A Pi extension for configurable delegation intensity and exact subagent role model references.",
6
6
  "type": "module",
@@ -3,11 +3,16 @@ import { resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import {
5
5
  AgentLaunch,
6
+ LAUNCH_PREFLIGHT_TIMEOUT_MS,
7
+ LAUNCH_TERMINAL_TIMEOUT_MS,
6
8
  type AgentLaunchDefinition,
7
9
  type AgentLaunchDependencies,
10
+ type AgentLaunchFailure,
8
11
  type AgentLaunchHost,
9
12
  type AgentLaunchRequest,
10
13
  type AgentLaunchResult,
14
+ type AgentLaunchTerminalField,
15
+ type AgentLaunchTerminalStatus,
11
16
  } from "./agent-launch.ts";
12
17
  import type { AdvisorContext } from "./prompt.ts";
13
18
  import type { AdvisorSetting, ThinkingLevelName } from "./types.ts";
@@ -49,6 +54,115 @@ export function advisorThinking(setting: AdvisorSetting, model: Model<Api>): Thi
49
54
  return supported.includes("high") ? "high" : supported[0]!;
50
55
  }
51
56
 
57
+ type AdvisorFailureResult = Exclude<AgentLaunchResult<string>, { kind: "completed" }>;
58
+
59
+ function terminalFieldText(field: AgentLaunchTerminalField | undefined): string {
60
+ switch (field) {
61
+ case "agent":
62
+ return "agent profile";
63
+ case "model":
64
+ return "model";
65
+ case "thinking":
66
+ return "thinking level";
67
+ case "launchContractDigest":
68
+ return "launch contract digest";
69
+ default:
70
+ return "terminal metadata";
71
+ }
72
+ }
73
+
74
+ function hostStatusText(status: AgentLaunchTerminalStatus | undefined): string {
75
+ switch (status) {
76
+ case "invalid_request":
77
+ return "The host rejected the launch as an invalid request. Retry once; if it repeats, check the question and the packaged Advisor profile.";
78
+ case "unavailable_context":
79
+ return "The host could not supply the requested context. Call again with brief for a fresh consultation.";
80
+ case "duplicate_node":
81
+ return "The host treated this launch as a duplicate. Retry once.";
82
+ case "turn_budget_exhausted":
83
+ return "The host ended the Advisor run at its turn limit before answering. Retry once with a narrower question.";
84
+ case "tool_budget_exhausted":
85
+ return "The host ended the Advisor run at its tool limit before answering. Retry once with a narrower question.";
86
+ case "structured_output_failed":
87
+ return "The host ended the Advisor run while collecting its structured result. Retry once; if it repeats, inspect host diagnostics and check the compatibility of the installed pi-delegation-policy and pi-subagents 0.76.1 build.";
88
+ case "acceptance_failed":
89
+ return "The host ended the Advisor run at its acceptance check. Retry once; if it repeats, inspect host diagnostics and check the compatibility of the installed pi-delegation-policy and pi-subagents 0.76.1 build.";
90
+ case "failed":
91
+ return "The host ended the Advisor run with status failed and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
92
+ default:
93
+ return "The host ended the Advisor run and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
94
+ }
95
+ }
96
+
97
+ function unavailableFailureText(reason: AgentLaunchFailure | undefined): string {
98
+ const preflightSeconds = LAUNCH_PREFLIGHT_TIMEOUT_MS / 1000;
99
+ switch (reason?.code) {
100
+ case "fork-task-normalizer-unavailable":
101
+ return "Fork launch is unavailable: the installed pi-subagents does not export wrapForkTask, so a fork task cannot be bound to the child. Retrying or supplying a brief will not switch this session to fresh; install the compatible pi-subagents 0.76.1 build and reload the session.";
102
+ case "modules-unavailable":
103
+ return "Advisor launch is unavailable: the pi-subagents 0.76.1 exports this extension requires were not found. Install the compatible pi-subagents 0.76.1 build, then reload the session.";
104
+ case "preflight-unavailable":
105
+ return "Advisor preflight returned no launch contract. Retry once; if it repeats, check the compatible pi-subagents 0.76.1 build and the packaged Advisor profile.";
106
+ case "preflight-timeout":
107
+ return `Advisor preflight did not finish within ${preflightSeconds} seconds. Retry once; if it repeats, check the installed pi-subagents version.`;
108
+ case "contract-rejected":
109
+ return "Advisor launch contract was rejected: preflight did not confirm the required profile, model, thinking, and tool set. Inspect host diagnostics and check the installed package versions and profile; no child was launched.";
110
+ case "request-start-timeout":
111
+ return `The host did not start the Advisor within ${preflightSeconds} seconds. Retry once; if it repeats, check the installed pi-subagents version.`;
112
+ case "request-emit-failed":
113
+ return "The host could not set up or send the Advisor launch event. Reload the session, then retry; if it repeats, check the installed extension against the compatible pi-subagents 0.76.1 build.";
114
+ case "host-session-missing":
115
+ return "Advisor unavailable: the host supplied no session identity.";
116
+ case "fork-not-allowed":
117
+ return "This launch cannot use fork context: the packaged profile does not allow it, and a brief does not change that. Check the installed package version and reload the session.";
118
+ case "launch-closed":
119
+ return "Advisor launch is closed for this session. Reload the session, then retry.";
120
+ case "host-terminal-status":
121
+ return hostStatusText(reason.status);
122
+ case "host-status-unrecognized":
123
+ return "The host ended the Advisor run with a status this extension does not recognize and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
124
+ default:
125
+ return "Advisor launch failed without a classified cause, and the host supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
126
+ }
127
+ }
128
+
129
+ function failedFailureText(reason: AgentLaunchFailure | undefined): string {
130
+ switch (reason?.code) {
131
+ case "terminal-metadata-mismatch":
132
+ return `The host returned an Advisor reply whose ${terminalFieldText(reason.field)} did not match the preflight contract, so the reply was discarded. Retry once; if it repeats, report the mismatch.`;
133
+ case "terminal-result-invalid":
134
+ return "The host reported completion without a usable text answer, so the result was discarded. Retry once; if it repeats, inspect host diagnostics and check the compatibility of the extension with the installed pi-subagents 0.76.1 build.";
135
+ case "host-terminal-status":
136
+ return hostStatusText(reason.status);
137
+ case "host-status-unrecognized":
138
+ return "The host ended the Advisor run with a status this extension does not recognize and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
139
+ default:
140
+ return "The Advisor run failed and the host supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Bounded English cause for a failed Advisor launch. It renders only closed reason codes and
146
+ * admitted protocol metadata, so host error text, prompts, URLs, paths and secrets never reach the
147
+ * transcript or the local log.
148
+ */
149
+ export function advisorFailureText(result: AdvisorFailureResult): string {
150
+ switch (result.kind) {
151
+ case "unavailable-context":
152
+ return "Fork unavailable. Call again with brief for a fresh consultation.";
153
+ case "busy":
154
+ return "Advisor busy: another consultation group is already in flight.";
155
+ case "cancelled":
156
+ return "Advisor cancelled before replying. Call again when ready.";
157
+ case "timed-out":
158
+ return `Advisor did not reply within ${LAUNCH_TERMINAL_TIMEOUT_MS / 1000} seconds. Inspect host diagnostics before retrying; cancellation does not guarantee that external generation or cost has stopped.`;
159
+ case "unavailable":
160
+ return unavailableFailureText(result.reason);
161
+ case "failed":
162
+ return failedFailureText(result.reason);
163
+ }
164
+ }
165
+
52
166
  /**
53
167
  * One pending call per tool: an advisor group launches one child per configured advisor. The
54
168
  * independent reader may hold its own pending call, so together they can own three children.
@@ -60,6 +60,43 @@ export type AgentLaunchJsonSchema = Readonly<Record<string, unknown>>;
60
60
  type AgentLaunchContract = Readonly<Record<string, unknown>>;
61
61
  export type AgentLaunchPreflight = (input: unknown) => Promise<unknown>;
62
62
 
63
+ /** Terminal metadata the strict path binds; only these names may reach a consumer. */
64
+ export type AgentLaunchTerminalField = "agent" | "model" | "thinking" | "launchContractDigest";
65
+ /** Statuses the admitted delegation protocol can report for a terminal response. */
66
+ export type AgentLaunchTerminalStatus =
67
+ | "invalid_request"
68
+ | "unavailable_context"
69
+ | "duplicate_node"
70
+ | "turn_budget_exhausted"
71
+ | "tool_budget_exhausted"
72
+ | "structured_output_failed"
73
+ | "acceptance_failed"
74
+ | "failed";
75
+ export type AgentLaunchFailureCode =
76
+ | "launch-closed"
77
+ | "fork-not-allowed"
78
+ | "host-session-missing"
79
+ | "modules-unavailable"
80
+ | "fork-task-normalizer-unavailable"
81
+ | "preflight-unavailable"
82
+ | "preflight-timeout"
83
+ | "contract-rejected"
84
+ | "request-start-timeout"
85
+ | "request-emit-failed"
86
+ | "terminal-metadata-mismatch"
87
+ | "terminal-result-invalid"
88
+ | "host-terminal-status"
89
+ | "host-status-unrecognized";
90
+ /**
91
+ * Facts a consumer may render for a failed launch. Closed literals only: no host payload text,
92
+ * prompt, URL, path or provider message ever enters this type.
93
+ */
94
+ export type AgentLaunchFailure = Readonly<{
95
+ code: AgentLaunchFailureCode;
96
+ field?: AgentLaunchTerminalField;
97
+ status?: AgentLaunchTerminalStatus;
98
+ }>;
99
+
63
100
  export type AgentLaunchModules = Readonly<{
64
101
  resolveSubagentLaunchContract: AgentLaunchPreflight;
65
102
  /**
@@ -72,16 +109,22 @@ export type AgentLaunchModules = Readonly<{
72
109
  startedEvent: string;
73
110
  responseEvent: string;
74
111
  cancelEvent: string;
112
+ /**
113
+ * The public `wrapForkTask` export from `pi-subagents/shared-types`. The executor wraps a fork
114
+ * task before hashing the launch binding, so the preflight must hash the same wrapped task or
115
+ * its digest can never match the terminal one. Absent on builds without that export.
116
+ */
117
+ normalizeForkTask?: (task: string) => string;
75
118
  }>;
76
119
 
77
120
  export type AgentLaunchResult<Value> =
78
121
  | { kind: "completed"; value: Value }
79
- | { kind: "unavailable" }
80
- | { kind: "unavailable-context" }
122
+ | { kind: "unavailable"; reason?: AgentLaunchFailure }
123
+ | { kind: "unavailable-context"; reason?: AgentLaunchFailure }
81
124
  | { kind: "busy" }
82
125
  | { kind: "cancelled" }
83
126
  | { kind: "timed-out" }
84
- | { kind: "failed" };
127
+ | { kind: "failed"; reason?: AgentLaunchFailure };
85
128
 
86
129
  export type AgentLaunchResultExpectation<Request, Value> =
87
130
  | Readonly<{
@@ -192,8 +235,9 @@ export function createDefaultAgentLaunchLoader(
192
235
  loadModule("pi-subagents/preflight"),
193
236
  loadModule("pi-subagents/delegation"),
194
237
  loadModule("pi-subagents/child-tool-plan"),
238
+ loadForkTaskNormalizer(loadModule),
195
239
  ])
196
- .then(([preflight, delegation, childToolPlan]) => {
240
+ .then(([preflight, delegation, childToolPlan, normalizeForkTask]) => {
197
241
  if (!isRecord(preflight) || !isRecord(delegation) || !isRecord(childToolPlan)) {
198
242
  return undefined;
199
243
  }
@@ -239,11 +283,33 @@ export function createDefaultAgentLaunchLoader(
239
283
  startedEvent,
240
284
  responseEvent,
241
285
  cancelEvent,
286
+ ...(normalizeForkTask ? { normalizeForkTask } : {}),
242
287
  };
243
288
  })
244
289
  .catch(() => undefined);
245
290
  }
246
291
 
292
+ /**
293
+ * The public `wrapForkTask` export, loaded on its own: `pi-subagents` wraps a fork task before it
294
+ * hashes the launch binding, so the preflight needs the same wrapper to bind the same digest. A
295
+ * build without the export only loses fork launches, so its absence never disables the modules the
296
+ * fresh path uses.
297
+ */
298
+ function loadForkTaskNormalizer(
299
+ loadModule: DynamicModuleLoader,
300
+ ): Promise<((task: string) => string) | undefined> {
301
+ return loadModule("pi-subagents/shared-types")
302
+ .then((module) => {
303
+ if (!isRecord(module) || typeof module.wrapForkTask !== "function") return undefined;
304
+ const wrapForkTask = module.wrapForkTask;
305
+ return (task: string): string => {
306
+ const wrapped: unknown = wrapForkTask(task);
307
+ return typeof wrapped === "string" ? wrapped : "";
308
+ };
309
+ })
310
+ .catch(() => undefined);
311
+ }
312
+
247
313
  /**
248
314
  * The permission runtime entry the host added, when the public probe reports exactly one extra path
249
315
  * after the prompt runtime and that path is the pinned scoped package. Any other shape, a probe that
@@ -313,13 +379,14 @@ function createPreflightInput<Request extends AgentLaunchRequest, Value>(
313
379
  host: AgentLaunchHost,
314
380
  requestId: string,
315
381
  definition: AgentLaunchDefinition<Request, Value>,
382
+ task: string,
316
383
  ): Record<string, unknown> {
317
384
  return {
318
385
  agent: definition.agentName,
319
386
  cwd: host.cwd,
320
387
  parentSessionId: host.sessionId,
321
- task: definition.buildTask(request),
322
- context: definition.allowFork ? (request.context ?? "fresh") : "fresh",
388
+ task,
389
+ context: launchContext(request, definition),
323
390
  model: `${request.model.provider}/${request.model.id}`,
324
391
  thinking: request.model.thinking,
325
392
  availableModels: request.availableModels,
@@ -370,7 +437,7 @@ function validateLaunchContract<Request extends AgentLaunchRequest, Value>(
370
437
  const resolvedModel = modelName(request.model);
371
438
  if (
372
439
  !isLowerHexDigest(contract.launchContractDigest) ||
373
- contract.context !== (definition.allowFork ? (request.context ?? "fresh") : "fresh") ||
440
+ contract.context !== launchContext(request, definition) ||
374
441
  // The contract used to carry `modelCandidates`, pinned here as exactly `[resolvedModel]`.
375
442
  // `pi-subagents` 0.68.0 removed it along with `fallbackModels` and every same-launch model
376
443
  // switch ("each launch uses one resolved model instead of switching models automatically"),
@@ -456,10 +523,35 @@ function validateLaunchContract<Request extends AgentLaunchRequest, Value>(
456
523
  }
457
524
  }
458
525
 
526
+ function launchContext<Request extends AgentLaunchRequest, Value>(
527
+ request: Request,
528
+ definition: AgentLaunchDefinition<Request, Value>,
529
+ ): "fresh" | "fork" {
530
+ return definition.allowFork ? (request.context ?? "fresh") : "fresh";
531
+ }
532
+
533
+ /**
534
+ * The wrapped task the executor hashes for a fork, or `undefined` when no usable public wrapper is
535
+ * loaded. The request keeps the raw task: the host wraps it again on its own side.
536
+ */
537
+ function preflightForkTask(
538
+ task: string,
539
+ normalizeForkTask: ((task: string) => string) | undefined,
540
+ ): string | undefined {
541
+ if (!normalizeForkTask) return undefined;
542
+ try {
543
+ const normalized = normalizeForkTask(task);
544
+ return typeof normalized === "string" && normalized.length > 0 ? normalized : undefined;
545
+ } catch {
546
+ return undefined;
547
+ }
548
+ }
549
+
459
550
  function requestPayload<Request extends AgentLaunchRequest, Value>(
460
551
  active: ActiveRun<Value>,
461
552
  request: Request,
462
553
  definition: AgentLaunchDefinition<Request, Value>,
554
+ task: string,
463
555
  ): Record<string, unknown> {
464
556
  return {
465
557
  requestId: active.requestId,
@@ -467,8 +559,8 @@ function requestPayload<Request extends AgentLaunchRequest, Value>(
467
559
  nodeId: active.nodeId,
468
560
  agent: definition.agentName,
469
561
  cwd: active.host.cwd,
470
- task: definition.buildTask(request),
471
- context: definition.allowFork ? (request.context ?? "fresh") : "fresh",
562
+ task,
563
+ context: launchContext(request, definition),
472
564
  model: `${request.model.provider}/${request.model.id}`,
473
565
  thinking: request.model.thinking,
474
566
  timeoutMs: LAUNCH_TERMINAL_TIMEOUT_MS,
@@ -523,11 +615,14 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
523
615
  signal?: AbortSignal,
524
616
  ): Promise<AgentLaunchResult<Value>> {
525
617
  if (this.closed || (request.context === "fork" && !this.definition.allowFork))
526
- return Promise.resolve({ kind: "unavailable" });
618
+ return Promise.resolve({
619
+ kind: "unavailable",
620
+ reason: { code: this.closed ? "launch-closed" : "fork-not-allowed" },
621
+ });
527
622
  if (this.active) return Promise.resolve({ kind: "busy" });
528
623
  // Without a host session the preflight could not bind required extensions; launch nothing.
529
624
  if (typeof host.sessionId !== "string" || !host.sessionId.trim()) {
530
- return Promise.resolve({ kind: "unavailable" });
625
+ return Promise.resolve({ kind: "unavailable", reason: { code: "host-session-missing" } });
531
626
  }
532
627
  return new Promise((resolveResult) => {
533
628
  const active: ActiveRun<Value> = {
@@ -556,7 +651,7 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
556
651
  };
557
652
  this.active = active;
558
653
  active.preflightTimer = this.setTimer(
559
- () => active.settle({ kind: "unavailable" }),
654
+ () => active.settle({ kind: "unavailable", reason: { code: "preflight-timeout" } }),
560
655
  LAUNCH_PREFLIGHT_TIMEOUT_MS,
561
656
  );
562
657
  if (signal) {
@@ -609,22 +704,48 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
609
704
  }
610
705
  if (!this.isLive(active)) return;
611
706
  if (!modules) {
612
- active.settle({ kind: "unavailable" });
707
+ active.settle({ kind: "unavailable", reason: { code: "modules-unavailable" } });
613
708
  return;
614
709
  }
615
710
  active.modules = modules;
616
711
 
712
+ const task = this.definition.buildTask(request);
713
+ let preflightTask = task;
714
+ if (launchContext(request, this.definition) === "fork") {
715
+ const normalized = preflightForkTask(task, modules.normalizeForkTask);
716
+ if (normalized === undefined) {
717
+ active.settle({
718
+ kind: "unavailable",
719
+ reason: { code: "fork-task-normalizer-unavailable" },
720
+ });
721
+ return;
722
+ }
723
+ preflightTask = normalized;
724
+ }
725
+
617
726
  let resolved: unknown;
618
727
  try {
619
728
  resolved = await modules.resolveSubagentLaunchContract(
620
- createPreflightInput(request, active.host, active.requestId, this.definition),
729
+ createPreflightInput(
730
+ request,
731
+ active.host,
732
+ active.requestId,
733
+ this.definition,
734
+ preflightTask,
735
+ ),
621
736
  );
622
737
  } catch {
623
738
  resolved = undefined;
624
739
  }
625
740
  if (!this.isLive(active)) return;
741
+ // A thrown or empty preflight never described a contract; only a returned nonempty value can be
742
+ // rejected as a contract that failed the strict checks below.
743
+ if (resolved === undefined || resolved === null) {
744
+ active.settle({ kind: "unavailable", reason: { code: "preflight-unavailable" } });
745
+ return;
746
+ }
626
747
  if (!validateLaunchContract(resolved, request, active, this.definition, modules)) {
627
- active.settle({ kind: "unavailable" });
748
+ active.settle({ kind: "unavailable", reason: { code: "contract-rejected" } });
628
749
  return;
629
750
  }
630
751
 
@@ -643,17 +764,17 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
643
764
  active.requestSent = true;
644
765
  this.clearPreflightTimer(active);
645
766
  active.startTimer = this.setTimer(
646
- () => this.cancelActive(active, "unavailable"),
767
+ () => this.cancelActive(active, "unavailable", { code: "request-start-timeout" }),
647
768
  LAUNCH_PREFLIGHT_TIMEOUT_MS,
648
769
  );
649
770
  active.host.events.emit(
650
771
  modules.requestEvent,
651
- requestPayload(active, request, this.definition),
772
+ requestPayload(active, request, this.definition, task),
652
773
  );
653
774
  if (!this.isLive(active)) return;
654
775
  } catch {
655
776
  if (!active.settled) {
656
- active.settle({ kind: "unavailable" });
777
+ active.settle({ kind: "unavailable", reason: { code: "request-emit-failed" } });
657
778
  this.emitCancel(active);
658
779
  }
659
780
  }
@@ -687,21 +808,23 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
687
808
  );
688
809
  }
689
810
 
690
- private terminalMetadataMatches(
811
+ private terminalMetadataMismatch(
691
812
  payload: Record<string, unknown>,
692
813
  contract: AgentLaunchContract,
693
814
  request: Request,
694
815
  requireAll: boolean,
695
- ): boolean {
696
- const expected = {
697
- agent: this.definition.agentName,
698
- model: contract.model,
699
- thinking: request.model.thinking,
700
- launchContractDigest: contract.launchContractDigest,
701
- };
702
- return Object.entries(expected).every(([key, value]) =>
703
- requireAll ? payload[key] === value : !(key in payload) || payload[key] === value,
704
- );
816
+ ): AgentLaunchTerminalField | undefined {
817
+ const expected: ReadonlyArray<readonly [AgentLaunchTerminalField, unknown]> = [
818
+ ["agent", this.definition.agentName],
819
+ ["model", contract.model],
820
+ ["thinking", request.model.thinking],
821
+ ["launchContractDigest", contract.launchContractDigest],
822
+ ];
823
+ for (const [field, value] of expected) {
824
+ if (!requireAll && !(field in payload)) continue;
825
+ if (payload[field] !== value) return field;
826
+ }
827
+ return undefined;
705
828
  }
706
829
 
707
830
  private onResponse(
@@ -719,34 +842,46 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
719
842
 
720
843
  if (!active.started) {
721
844
  if (!earlyUnavailable) return;
845
+ const mismatch = this.terminalMetadataMismatch(payload, contract, request, false);
722
846
  active.settle(
723
- this.terminalMetadataMatches(payload, contract, request, false)
847
+ mismatch === undefined
724
848
  ? {
725
849
  kind:
726
850
  status === "unavailable_context" && this.definition.allowFork
727
851
  ? "unavailable-context"
728
852
  : "unavailable",
853
+ reason: { code: "host-terminal-status", status },
729
854
  }
730
- : { kind: "failed" },
855
+ : { kind: "failed", reason: { code: "terminal-metadata-mismatch", field: mismatch } },
731
856
  );
732
857
  return;
733
858
  }
734
859
 
735
860
  if (status === "completed") {
736
- if (!this.terminalMetadataMatches(payload, contract, request, true)) {
737
- active.settle({ kind: "failed" });
861
+ const mismatch = this.terminalMetadataMismatch(payload, contract, request, true);
862
+ if (mismatch !== undefined) {
863
+ active.settle({
864
+ kind: "failed",
865
+ reason: { code: "terminal-metadata-mismatch", field: mismatch },
866
+ });
738
867
  return;
739
868
  }
740
869
  const completed = completedValue(payload.result, this.definition.result);
741
870
  active.settle(
742
- completed.ok ? { kind: "completed", value: completed.value } : { kind: "failed" },
871
+ completed.ok
872
+ ? { kind: "completed", value: completed.value }
873
+ : { kind: "failed", reason: { code: "terminal-result-invalid" } },
743
874
  );
744
875
  return;
745
876
  }
746
877
 
747
878
  if (typeof status !== "string") return;
748
- if (!this.terminalMetadataMatches(payload, contract, request, false)) {
749
- active.settle({ kind: "failed" });
879
+ const mismatch = this.terminalMetadataMismatch(payload, contract, request, false);
880
+ if (mismatch !== undefined) {
881
+ active.settle({
882
+ kind: "failed",
883
+ reason: { code: "terminal-metadata-mismatch", field: mismatch },
884
+ });
750
885
  return;
751
886
  }
752
887
  if (status === "cancelled" || status === "interrupted") {
@@ -767,6 +902,7 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
767
902
  status === "unavailable_context" && this.definition.allowFork
768
903
  ? "unavailable-context"
769
904
  : "unavailable",
905
+ reason: { code: "host-terminal-status", status },
770
906
  });
771
907
  return;
772
908
  }
@@ -777,8 +913,10 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
777
913
  status === "acceptance_failed" ||
778
914
  status === "failed"
779
915
  ) {
780
- active.settle({ kind: "failed" });
916
+ active.settle({ kind: "failed", reason: { code: "host-terminal-status", status } });
917
+ return;
781
918
  }
919
+ active.settle({ kind: "failed", reason: { code: "host-status-unrecognized" } });
782
920
  }
783
921
 
784
922
  private emitCancel(active: ActiveRun<Value>): void {
@@ -798,9 +936,10 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
798
936
  private cancelActive(
799
937
  active: ActiveRun<Value>,
800
938
  result: "cancelled" | "unavailable" | "timed-out",
939
+ reason?: AgentLaunchFailure,
801
940
  ): void {
802
941
  if (active.settled) return;
803
- active.settle({ kind: result });
942
+ active.settle(reason ? { kind: result, reason } : { kind: result });
804
943
  this.emitCancel(active);
805
944
  }
806
945
 
@@ -0,0 +1,211 @@
1
+ import type { ContextWithSystemEvent } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ ROLE_LABELS,
4
+ type AdvisorLaunch,
5
+ type AdvisorMode,
6
+ type ContextShuntMode,
7
+ type EffectiveDelegateState,
8
+ type Intensity,
9
+ type ModelRef,
10
+ type ModelRole,
11
+ type OrdinaryRoleSetting,
12
+ type Preference,
13
+ type ThinkingPolicy,
14
+ } from "./types.ts";
15
+
16
+ /** Custom message type of the notice. The host keeps it on the request copy only. */
17
+ export const CONFIG_NOTICE_TYPE = "pi-delegation-policy.config-notice";
18
+ /** The scope belongs to this policy, never to any other instruction or authority. */
19
+ export const BOTH_OFF_LINE = "Delegation and Advisor disabled by this policy.";
20
+
21
+ export type ConfigNoticeMessage = ContextWithSystemEvent["messages"][number];
22
+
23
+ /**
24
+ * An ordinary role has three effective states, and they must stay apart: not configured is an error
25
+ * while delegation is on, explicitly disabled is a valid partial policy, and a model reference
26
+ * delegates. The effective state of Visual Design only distinguishes present from absent, so it
27
+ * never carries `unset`.
28
+ */
29
+ type RoleText = { kind: "unset" } | { kind: "disabled" } | { kind: "model"; reference: string };
30
+ /** `level:<level>`, `range:<min>..<max>`, or `null` when the role has no thinking policy. */
31
+ type ThinkingText = string | null;
32
+
33
+ /**
34
+ * Deterministic view of the effective configuration that the notice compares. Derived status stays
35
+ * out: model availability, credentials, diagnostics, value provenance and the Advisor context mode
36
+ * cannot produce a notice, and neither can the rendered policy text.
37
+ */
38
+ export type NoticeProjection = {
39
+ intensity: Intensity;
40
+ preference: Preference;
41
+ roles: Record<ModelRole, RoleText>;
42
+ uiDesign: RoleText;
43
+ thinking: Record<ModelRole | "uiDesign", ThinkingText>;
44
+ advisors: Array<{ model: string; thinking: ThinkingText }>;
45
+ advisorMode: AdvisorMode;
46
+ advisorLaunch: AdvisorLaunch;
47
+ context: {
48
+ mode: ContextShuntMode;
49
+ readerEnabled: boolean;
50
+ readerRole: ModelRole;
51
+ answerMaxBytes: number;
52
+ };
53
+ };
54
+
55
+ function thinkingText(policy: ThinkingPolicy | undefined): ThinkingText {
56
+ if (!policy) return null;
57
+ return "level" in policy ? `level:${policy.level}` : `range:${policy.min}..${policy.max}`;
58
+ }
59
+
60
+ function ordinaryRoleText(setting: OrdinaryRoleSetting | undefined): RoleText {
61
+ if (setting === null) return { kind: "disabled" };
62
+ if (!setting) return { kind: "unset" };
63
+ return { kind: "model", reference: `${setting.provider}/${setting.model}` };
64
+ }
65
+
66
+ function optionalRoleText(setting: ModelRef | undefined): RoleText {
67
+ if (!setting) return { kind: "disabled" };
68
+ return { kind: "model", reference: `${setting.provider}/${setting.model}` };
69
+ }
70
+
71
+ export function projectNoticeConfig(effective: EffectiveDelegateState): NoticeProjection {
72
+ return {
73
+ intensity: effective.intensity,
74
+ preference: effective.preference,
75
+ roles: {
76
+ small: ordinaryRoleText(effective.small),
77
+ medium: ordinaryRoleText(effective.medium),
78
+ large: ordinaryRoleText(effective.large),
79
+ },
80
+ uiDesign: optionalRoleText(effective.uiDesign),
81
+ thinking: {
82
+ small: thinkingText(effective.thinking.small),
83
+ medium: thinkingText(effective.thinking.medium),
84
+ large: thinkingText(effective.thinking.large),
85
+ uiDesign: thinkingText(effective.thinking.uiDesign),
86
+ },
87
+ advisors: effective.advisors.map((advisor) => ({
88
+ model: `${advisor.provider}/${advisor.model}`,
89
+ thinking: thinkingText(advisor.thinking),
90
+ })),
91
+ advisorMode: effective.advisorMode,
92
+ advisorLaunch: effective.advisorLaunch,
93
+ // The configured mode keeps an intensity edit from fabricating a second change.
94
+ context: {
95
+ mode: effective.contextShunt.configuredMode,
96
+ readerEnabled: effective.contextShunt.readerEnabled,
97
+ readerRole: effective.contextShunt.readerRole,
98
+ answerMaxBytes: effective.contextShunt.answerMaxBytes,
99
+ },
100
+ };
101
+ }
102
+
103
+ type NoticeField = { label: string; value: string };
104
+
105
+ const TEXT_ROLES: readonly (ModelRole | "uiDesign")[] = ["small", "medium", "large", "uiDesign"];
106
+
107
+ function roleValue(text: RoleText): string {
108
+ if (text.kind === "model") return text.reference;
109
+ return text.kind === "disabled" ? "disabled" : "not configured";
110
+ }
111
+
112
+ function advisorModelValue(model: string | undefined): string {
113
+ return model ?? "none";
114
+ }
115
+
116
+ function thinkingValue(text: ThinkingText | undefined): string {
117
+ if (text === null || text === undefined) return "unset";
118
+ return text.startsWith("level:")
119
+ ? `fixed ${text.slice("level:".length)}`
120
+ : `range ${text.slice("range:".length)}`;
121
+ }
122
+
123
+ /**
124
+ * Fields in one canonical order. Advisor count is taken from both projections so a removed Advisor
125
+ * still reports its own two fields.
126
+ */
127
+ function projectionFields(projection: NoticeProjection, advisorCount: number): NoticeField[] {
128
+ return [
129
+ { label: "Delegation intensity", value: projection.intensity },
130
+ { label: "Model preference", value: projection.preference },
131
+ ...TEXT_ROLES.map((role) => ({
132
+ label: `${ROLE_LABELS[role]} model`,
133
+ value: roleValue(role === "uiDesign" ? projection.uiDesign : projection.roles[role]),
134
+ })),
135
+ ...TEXT_ROLES.map((role) => ({
136
+ label: `${ROLE_LABELS[role]} thinking`,
137
+ value: thinkingValue(projection.thinking[role]),
138
+ })),
139
+ { label: "Advisor mode", value: projection.advisorMode },
140
+ { label: "Advisor launch", value: projection.advisorLaunch },
141
+ ...Array.from({ length: advisorCount }, (_, index) => {
142
+ const advisor = projection.advisors[index];
143
+ return [
144
+ { label: `Advisor ${index + 1} model`, value: advisorModelValue(advisor?.model) },
145
+ { label: `Advisor ${index + 1} thinking`, value: thinkingValue(advisor?.thinking) },
146
+ ];
147
+ }).flat(),
148
+ { label: "Context protection", value: projection.context.mode },
149
+ {
150
+ label: "Context reader",
151
+ value: projection.context.readerEnabled ? "enabled" : "disabled",
152
+ },
153
+ { label: "Context reader role", value: projection.context.readerRole },
154
+ {
155
+ label: "Context reader answer limit",
156
+ value: `${projection.context.answerMaxBytes} bytes`,
157
+ },
158
+ ];
159
+ }
160
+
161
+ /** One `- label: previous -> current` line per changed field, in the canonical field order. */
162
+ export function changedValueLines(previous: NoticeProjection, current: NoticeProjection): string[] {
163
+ const advisorCount = Math.max(previous.advisors.length, current.advisors.length);
164
+ const before = projectionFields(previous, advisorCount);
165
+ const after = projectionFields(current, advisorCount);
166
+ return after.flatMap((field, index) => {
167
+ const previousValue = before[index]!.value;
168
+ return previousValue === field.value
169
+ ? []
170
+ : [`- ${field.label}: ${previousValue} -> ${field.value}`];
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Lines the notice reports for one observed change. While both axes are already off a value edit
176
+ * changes nothing the model can act on, so only the transition into that state speaks, once.
177
+ */
178
+ export function noticeLines(change: {
179
+ previous: NoticeProjection;
180
+ current: NoticeProjection;
181
+ previouslyOff: boolean;
182
+ currentlyOff: boolean;
183
+ }): string[] {
184
+ const lines = changedValueLines(change.previous, change.current);
185
+ if (!lines.length || !change.currentlyOff) return lines;
186
+ return change.previouslyOff ? [] : [`- ${BOTH_OFF_LINE}`];
187
+ }
188
+
189
+ export function renderConfigNotice(lines: string[]): string {
190
+ return [
191
+ "<delegation_config_notice>",
192
+ "<!-- pi-delegation-policy:notice -->",
193
+ "This notice comes from the pi-delegation-policy extension, not from the user.",
194
+ "The /delegate configuration changed before this turn:",
195
+ ...lines,
196
+ "The delegation policy in the system prompt, when present, states the values in force for the present request.",
197
+ "For new work, follow the current valid assignments and thinking rules in this policy, not earlier settings in the conversation.",
198
+ "This notice only records what changed in this extension's configuration and revokes no other instruction, permission, or authority.",
199
+ "</delegation_config_notice>",
200
+ ].join("\n");
201
+ }
202
+
203
+ export function createConfigNotice(lines: string[], timestamp: number): ConfigNoticeMessage {
204
+ return {
205
+ role: "custom",
206
+ customType: CONFIG_NOTICE_TYPE,
207
+ content: renderConfigNotice(lines),
208
+ display: false,
209
+ timestamp,
210
+ };
211
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type {
2
+ AgentActivityOutcome,
3
+ AgentEndEvent,
2
4
  ExtensionAPI,
3
5
  ExtensionCommandContext,
4
6
  ExtensionContext,
7
+ InputSource,
5
8
  } from "@earendil-works/pi-coding-agent";
6
9
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
7
10
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
@@ -12,8 +15,20 @@ import {
12
15
  parseSessionState,
13
16
  type GuardedAppendResult,
14
17
  } from "./config.ts";
15
- import { AdvisorExecutor, advisorThinking, ADVISOR_AGENT_NAME } from "./advisor-executor.ts";
18
+ import {
19
+ AdvisorExecutor,
20
+ advisorFailureText,
21
+ advisorThinking,
22
+ ADVISOR_AGENT_NAME,
23
+ } from "./advisor-executor.ts";
16
24
  import { AdvisorLog } from "./advisor-log.ts";
25
+ import {
26
+ createConfigNotice,
27
+ noticeLines,
28
+ projectNoticeConfig,
29
+ type ConfigNoticeMessage,
30
+ type NoticeProjection,
31
+ } from "./config-notice.ts";
17
32
  import { ContextShuntAdapter } from "./context-shunt-adapter.ts";
18
33
  import { ContextShuntExecutor } from "./context-shunt-executor.ts";
19
34
  import {
@@ -171,6 +186,28 @@ function isContextShuntDisabled(state: RuntimeState): boolean {
171
186
  state.effective.contextShunt.suspended
172
187
  );
173
188
  }
189
+ function bothAxesOff(state: RuntimeState): boolean {
190
+ return state.effective.intensity === "off" && !isAdvisorEnabled(state.effective);
191
+ }
192
+ /** What the model has been told: the configuration shown by the last consumed notice. */
193
+ type ConfigReference = { projection: NoticeProjection; axesOff: boolean };
194
+ /**
195
+ * The host decides a turn's outcome from the last assistant message, and reports `completed` for
196
+ * every stop reason except an abort or an error. The same rule keeps the notice's consume rule
197
+ * aligned with the host's own notion of a finished turn.
198
+ */
199
+ function endOutcome(messages: AgentEndEvent["messages"]): AgentActivityOutcome | undefined {
200
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
201
+ const message = messages[index];
202
+ if (message?.role !== "assistant") continue;
203
+ return message.stopReason === "aborted"
204
+ ? "aborted"
205
+ : message.stopReason === "error"
206
+ ? "error"
207
+ : "completed";
208
+ }
209
+ return undefined;
210
+ }
174
211
  function synchronizeContextShunt(shunt: ContextShuntAdapter, state: RuntimeState): void {
175
212
  if (isContextShuntDisabled(state)) shunt.clearPending();
176
213
  }
@@ -379,6 +416,58 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
379
416
  readerEpoch += 1;
380
417
  executor.cancel();
381
418
  };
419
+ // The notice follows the user's turns, not host turns. `input` reports where the next prompt
420
+ // comes from, `before_agent_start` opens one run per fresh prompt, and the comparison runs on
421
+ // that user turn's first request. A change made while a turn is already running updates the
422
+ // policy as usual and is reported on the next user turn, so no request carries a notice that
423
+ // contradicts the policy of the same request.
424
+ let configBaseline: ConfigReference | undefined;
425
+ let inputSource: InputSource | undefined;
426
+ let userTurn = 0;
427
+ let runToken = 0;
428
+ let diffedTurn: number | undefined;
429
+ let runOutcome: AgentActivityOutcome | undefined;
430
+ let presentedNotice:
431
+ { runToken: number; reference: ConfigReference; message: ConfigNoticeMessage } | undefined;
432
+ const startConfigBaseline = (state: RuntimeState): void => {
433
+ configBaseline = {
434
+ projection: projectNoticeConfig(state.effective),
435
+ axesOff: bothAxesOff(state),
436
+ };
437
+ userTurn = 0;
438
+ runToken = 0;
439
+ diffedTurn = undefined;
440
+ runOutcome = undefined;
441
+ presentedNotice = undefined;
442
+ };
443
+ const configNotice = (state: RuntimeState): ConfigNoticeMessage | undefined => {
444
+ if (diffedTurn !== userTurn) {
445
+ diffedTurn = userTurn;
446
+ presentedNotice = undefined;
447
+ const current: ConfigReference = {
448
+ projection: projectNoticeConfig(state.effective),
449
+ axesOff: bothAxesOff(state),
450
+ };
451
+ // Every session start and tree move sets a baseline. Only an unreadable configuration
452
+ // defers it to this first successful refresh, which then reports nothing.
453
+ if (configBaseline === undefined) configBaseline = current;
454
+ else {
455
+ const lines = noticeLines({
456
+ previous: configBaseline.projection,
457
+ current: current.projection,
458
+ previouslyOff: configBaseline.axesOff,
459
+ currentlyOff: current.axesOff,
460
+ });
461
+ if (lines.length)
462
+ presentedNotice = {
463
+ runToken,
464
+ reference: current,
465
+ message: createConfigNotice(lines, Date.now()),
466
+ };
467
+ }
468
+ }
469
+ return presentedNotice?.runToken === runToken ? presentedNotice.message : undefined;
470
+ };
382
471
  const rememberRuntime = (state: RuntimeState): RuntimeState => {
383
472
  if (
384
473
  readerAuthorization(latestRuntime)?.fingerprint !== readerAuthorization(state)?.fingerprint
@@ -497,9 +586,7 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
497
586
  `${result.model} [${result.context}]:\n${
498
587
  result.result.kind === "completed"
499
588
  ? result.result.value
500
- : result.result.kind === "unavailable-context"
501
- ? "Fork unavailable. Call again with brief for a fresh consultation."
502
- : `Advisor ${result.result.kind}.`
589
+ : advisorFailureText(result.result)
503
590
  }`,
504
591
  )
505
592
  .join("\n\n") +
@@ -799,7 +886,7 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
799
886
  });
800
887
  pi.on("session_start", async (_event, ctx) => {
801
888
  advisorContext = undefined;
802
- await refreshRuntime(ctx);
889
+ startConfigBaseline(await refreshRuntime(ctx));
803
890
  });
804
891
  pi.on("session_tree", async (_event, ctx) => {
805
892
  readerEpoch += 1;
@@ -807,7 +894,30 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
807
894
  advisorExecutor.rotate();
808
895
  shunt.clearPending();
809
896
  advisorContext = undefined;
810
- await refreshRuntime(ctx);
897
+ startConfigBaseline(await refreshRuntime(ctx));
898
+ });
899
+ pi.on("input", async (event) => {
900
+ // The host emits this for every submitted input immediately before the prompt it belongs to,
901
+ // and `before_agent_start` follows only for a fresh prompt. Attribution therefore depends on
902
+ // that order, which Pi 0.87.1 keeps for every path that reaches it; a host that emitted the
903
+ // anchor first would leave the previous source in place, so the notice would follow the
904
+ // earlier prompt instead of skipping an extension one. No public signal can detect that
905
+ // change, so it stays a documented residual.
906
+ inputSource = event.source;
907
+ });
908
+ pi.on("before_agent_start", async () => {
909
+ const source = inputSource;
910
+ inputSource = undefined;
911
+ runToken += 1;
912
+ runOutcome = undefined;
913
+ // Commands, input interception and tool iterations never reach this event, and the host
914
+ // emits it once per fresh prompt. A prompt an extension sends is not the user's turn, so it
915
+ // neither receives nor consumes a notice waiting for the user.
916
+ if (source === "extension") {
917
+ diffedTurn = userTurn;
918
+ return;
919
+ }
920
+ userTurn += 1;
811
921
  });
812
922
  pi.on("context_with_system", async (event, ctx) => {
813
923
  const state = await refreshRuntime(ctx);
@@ -819,7 +929,7 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
819
929
  typeof systemMessage.content !== "string"
820
930
  )
821
931
  return undefined;
822
-
932
+ const notice = configNotice(state);
823
933
  const ownedPolicyBlock =
824
934
  /(?:\n\n)?<delegation_policy>\n<!-- pi-delegation-policy:owned -->[\s\S]*?<\/delegation_policy>/g;
825
935
  let replacedPolicy = false;
@@ -853,15 +963,35 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
853
963
  ...(sections && Object.keys(sections).length > 0 ? { sections } : {}),
854
964
  },
855
965
  ...event.messages.slice(1),
966
+ // Appended last, never inside the system block: a tool call stays adjacent to its result,
967
+ // the stored transcript prefix of the request is unchanged, and the host already sends
968
+ // user-role text after a tool result for its own custom messages.
969
+ ...(notice ? [notice] : []),
856
970
  ],
857
971
  };
858
972
  });
973
+ pi.on("agent_settled", async () => {
974
+ // The notice is consumed when the last outcome the run reported was a completed response:
975
+ // `agent_end` reports one outcome per low-level run, and this event marks the end of the whole
976
+ // run, emitted once in the host's `finally` after retries, compaction and queued continuations.
977
+ // A completed settle boundary can still be followed by another request, or by an aborted one,
978
+ // so the last recorded outcome decides and an unknown one never consumes. Accepted residual:
979
+ // an abort or a throw after the last completed response, which the host reports through no
980
+ // later public event, leaves that `completed` standing and consumes the notice.
981
+ if (runOutcome !== "completed" || presentedNotice?.runToken !== runToken) return;
982
+ // The revision the model received, not the live one: a change made after that request stays
983
+ // pending for the next turn.
984
+ configBaseline = presentedNotice.reference;
985
+ });
859
986
  pi.on("before_provider_request", async (event, ctx) => {
860
987
  const state = await refreshProviderRuntime(ctx);
861
988
  if (!state) return event.payload;
862
989
  return updateProviderPolicy(event.payload, renderPolicy(ctx, state));
863
990
  });
864
- pi.on("agent_end", async () => {
991
+ pi.on("agent_end", async (event) => {
992
+ // The event carries the messages of this low-level run; a run that reports none has no
993
+ // outcome to trust, and an unknown outcome never consumes a notice.
994
+ runOutcome = endOutcome(event.messages ?? []);
865
995
  shunt.clearConsumed();
866
996
  });
867
997
  pi.on("tool_call", async (event) => {
@@ -920,6 +1050,9 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
920
1050
  pi.on("session_shutdown", async (_event, ctx) => {
921
1051
  readerEpoch += 1;
922
1052
  latestRuntime = undefined;
1053
+ configBaseline = undefined;
1054
+ presentedNotice = undefined;
1055
+ runOutcome = undefined;
923
1056
  executor.close();
924
1057
  advisorExecutor.close();
925
1058
  await advisorLog.close();