@akagilnc/pi-workflow-roles 0.1.3826 → 0.1.3873

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.
Files changed (52) hide show
  1. package/dist/acp-host/production-host.js +22757 -22676
  2. package/dist/canonical-skill-binding.js +0 -3
  3. package/dist/engine-detour-tool.js +2 -2
  4. package/dist/engine-detour.js +23 -7
  5. package/dist/headless-host/production-host.js +4658 -4593
  6. package/dist/host-descriptions.js +5 -2
  7. package/dist/pi/adapter.js +11 -0
  8. package/dist/pi/role-turn-host.js +44 -93
  9. package/dist/public-cli/invocation.js +4 -5
  10. package/dist/public-cli/load-production-acp-host.js +1 -1
  11. package/dist/public-cli/load-production-external-host.js +10 -0
  12. package/dist/public-cli/load-production-headless-host.js +1 -1
  13. package/dist/public-cli/main.js +145 -140
  14. package/dist/public-role-summons.js +7 -49
  15. package/dist/reviewer-agent.js +1 -0
  16. package/dist/reviewer-child-executor.js +3 -2
  17. package/dist/role-activation-flags.js +50 -0
  18. package/dist/role-runtime.js +12 -10
  19. package/dist/worker-role.js +14 -12
  20. package/package.json +1 -1
  21. package/scripts/build-package.mjs +2 -2
  22. package/src/acp-host/production-host.ts +31 -94
  23. package/src/acp-host/role-turn-host.ts +178 -263
  24. package/src/canonical-skill-binding.ts +4 -4
  25. package/src/diagnostic-tail.ts +18 -0
  26. package/src/engine-detour-tool.ts +5 -4
  27. package/src/engine-detour.ts +44 -9
  28. package/src/external-host-turn-loop.ts +172 -0
  29. package/src/headless-host/production-host.ts +10 -7
  30. package/src/headless-host/role-turn-host.ts +271 -263
  31. package/src/host-contracts.ts +6 -0
  32. package/src/host-descriptions.ts +5 -2
  33. package/src/host-session-record.ts +41 -0
  34. package/src/package-resources/method-skill-binding.ts +0 -3
  35. package/src/pi/adapter.ts +13 -0
  36. package/src/pi/role-turn-host.ts +46 -94
  37. package/src/prepared-role-turn.ts +68 -0
  38. package/src/public-cli/cli.ts +11 -20
  39. package/src/public-cli/invocation.ts +4 -5
  40. package/src/public-cli/load-production-acp-host.ts +2 -1
  41. package/src/public-cli/load-production-external-host.ts +17 -0
  42. package/src/public-cli/load-production-headless-host.ts +2 -1
  43. package/src/public-role-summons.ts +12 -69
  44. package/src/reviewer-agent.ts +12 -1
  45. package/src/reviewer-child-executor.ts +10 -2
  46. package/src/role-activation-flags.ts +53 -0
  47. package/src/{acp-host/role-envelope.ts → role-envelope.ts} +41 -98
  48. package/src/role-runtime-dependencies.ts +91 -0
  49. package/src/role-runtime.ts +21 -11
  50. package/src/{acp-host/session-identity.ts → session-identity.ts} +6 -6
  51. package/src/worker-role.ts +14 -13
  52. /package/src/{acp-host/mcp-relay.mjs → mcp-relay.mjs} +0 -0
@@ -2,7 +2,18 @@ import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
 
4
4
  import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
5
- import { renderAgentStartMaterials } from "../agent-start-materials.ts";
5
+ import {
6
+ createSerializedRoleTurnHost,
7
+ driveExternalRoleTurnRounds,
8
+ raceAgainstHostAbort,
9
+ } from "../external-host-turn-loop.ts";
10
+ import { retainDiagnosticTail } from "../diagnostic-tail.ts";
11
+ import { reportHostSessionEvent } from "../host-session-record.ts";
12
+ import {
13
+ renderSystemPromptOverride,
14
+ type PreparedRoleTurn,
15
+ type SessionIdentityAuthority,
16
+ } from "../prepared-role-turn.ts";
6
17
  import { acpModelId, type AcpHostDescription } from "./description.ts";
7
18
 
8
19
  /** ACP v1 surface used by the generic ACP adapter. Protocol details stay in this module. */
@@ -14,74 +25,10 @@ export interface AcpConnection {
14
25
  close(): Promise<void>;
15
26
  }
16
27
 
17
- /** The shared envelope, prepared before session/new (systemPrompt delivery) and
18
- * able to observe the host's real builtin tool surface once it arrives post-session. */
19
- export type AcpPreparedTurn = Readonly<{
20
- mcpServers: readonly Readonly<Record<string, unknown>>[];
21
- /**
22
- * Structured system-prompt authority. `body` is the provider-facing prompt
23
- * bytes; `materials` are typed agent-start reading materials (e.g. Notary
24
- * session bound) that the adapter folds into the override at the provider
25
- * boundary. This structure is the authoritative production input of the
26
- * send path — never a test-only parallel face.
27
- */
28
- systemPrompt: { readonly body: string; readonly materials: readonly unknown[] };
29
- /** Effective user prompt after host-side input transform (canonical Skill invocation). */
30
- prompt: string;
31
- /**
32
- * Host abort signal armed only by typed infrastructure failure (envelope
33
- * rememberInfrastructureFailure / non-correctable MCP catch). Lawful
34
- * context.abort() (seal / non-sole) does not arm it. executeTurn races
35
- * session/prompt against this so infra declarations terminate even when
36
- * ACP never resolves (#593).
37
- */
38
- abortSignal?: AbortSignal;
39
- /** Shared ledger consumes the complete ACP round after session/prompt resolves. */
40
- closeRound(): Promise<
41
- | { readonly accepted: true }
42
- | {
43
- readonly accepted: false;
44
- /** Shared envelope provides officer/correctable text; adapters only deliver it (#813). */
45
- readonly retry: {
46
- readonly code: string;
47
- readonly toolCallIds: readonly string[];
48
- readonly message: string;
49
- };
50
- }
51
- | { readonly accepted: false; readonly failure: RoleTurnKnownFailure }
52
- >;
53
- dispose?(): Promise<void>;
54
- /**
55
- * Headless CLI family (#645): role terminating-tool schema for host-native
56
- * `--json-schema`. Present for every prepared turn; ACP ignores it.
57
- */
58
- jsonSchema: Readonly<Record<string, unknown>>;
59
- /** Terminating tool name whose schema is `jsonSchema`. */
60
- terminatingToolName: string;
61
- /**
62
- * Headless CLI family: feed host-native `structured_output` through the same
63
- * terminating-tool path the MCP relay uses (ledger + gates). ACP ignores it.
64
- */
65
- ingestStructuredOutput(params: unknown): Promise<void>;
66
- }>;
67
-
68
- /** Fold structured system-prompt authority into the provider-visible ACP override. */
69
- export function renderAcpSystemPromptOverride(authority: {
70
- readonly body: string;
71
- readonly materials: readonly unknown[];
72
- }): string {
73
- return renderAgentStartMaterials(authority.body, authority.materials);
74
- }
75
-
76
- export type AcpSessionIdentityAuthority = Readonly<{
77
- load(principal: RoleTurnRequest["principal"]): Promise<string | undefined>;
78
- bind(principal: RoleTurnRequest["principal"], sessionId: string): Promise<void>;
79
- /** Durable principal session path for layout ownership / isAvailable — not a rebuild source (#617 DK-4). */
80
- resolveSessionFile(principal: RoleTurnRequest["principal"]): string;
81
- }>;
82
-
83
28
  export type AcpRoleTurnHostConfig = Readonly<{
84
- sessionIdentity: AcpSessionIdentityAuthority;
29
+ sessionIdentity: SessionIdentityAuthority;
30
+ /** Seat-table host key (e.g. grok-build) for sitian host field. */
31
+ hostName: string;
85
32
  /** Whether a bound resume reuses the native session or mints a fresh one. */
86
33
  boundResume: AcpHostDescription["boundResume"];
87
34
  /**
@@ -91,7 +38,7 @@ export type AcpRoleTurnHostConfig = Readonly<{
91
38
  */
92
39
  modelPassing: AcpHostDescription["modelPassing"];
93
40
  connect(request: RoleTurnRequest): Promise<AcpConnection>;
94
- prepare(request: RoleTurnRequest): Promise<AcpPreparedTurn>;
41
+ prepare(request: RoleTurnRequest): Promise<PreparedRoleTurn>;
95
42
  }>;
96
43
 
97
44
  function failure(cause: "activation" | "session" | "output", name: string, code: string, details?: Readonly<Record<string, unknown>>): RoleTurnResult {
@@ -109,10 +56,6 @@ function failure(cause: "activation" | "session" | "output", name: string, code:
109
56
 
110
57
  type RpcReply = { readonly id?: unknown; readonly method?: unknown; readonly params?: unknown; readonly result?: unknown; readonly error?: unknown };
111
58
 
112
- function hostAbortedError(): Error & { readonly code: "host-aborted" } {
113
- return Object.assign(new Error("ACP host aborted"), { code: "host-aborted" as const });
114
- }
115
-
116
59
  function acpError(code: string, message: string, cause?: unknown): Error & { readonly code: string } {
117
60
  return Object.assign(new Error(message, cause === undefined ? undefined : { cause }), { code });
118
61
  }
@@ -132,6 +75,7 @@ export function connectAcpStdio(options: {
132
75
  let nextId = 0;
133
76
  let closed = false;
134
77
  let terminalError: Error | undefined;
78
+ // Rolling diagnostic tail only — not an unbounded transcript face (same class as headless).
135
79
  let stderr = "";
136
80
  const settleClosed = (error: Error): void => {
137
81
  if (closed) return;
@@ -148,7 +92,9 @@ export function connectAcpStdio(options: {
148
92
  child.stdin.end();
149
93
  child.kill("SIGTERM");
150
94
  };
151
- child.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; });
95
+ child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
96
+ stderr = retainDiagnosticTail(stderr + chunk);
97
+ });
152
98
  child.on("error", (error) => settleClosed(acpError("acp-process-error", `ACP process error: ${error.message}`, error)));
153
99
  createInterface({ input: child.stdout }).on("line", (line) => {
154
100
  let message: RpcReply;
@@ -223,210 +169,179 @@ export function connectAcpStdio(options: {
223
169
  });
224
170
  }
225
171
 
226
- /**
227
- * Main-session ACP adapter. The injected composition callbacks are the shared
228
- * envelope boundary: this module owns ACP lifecycle, never role policy.
229
- */
172
+ /** ACP last hop (#820): session open/load/close, prompt, MCP mount, capability/model. */
230
173
  export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHost {
231
- let serial = Promise.resolve();
232
- return {
233
- executeTurn(request) {
234
- const execution = serial.then(async (): Promise<RoleTurnResult> => {
235
- const continuation = request.continuation;
236
- const prepared = await config.prepare(request);
237
- const systemPromptOverride = renderAcpSystemPromptOverride(prepared.systemPrompt);
238
- let connection: AcpConnection | undefined;
239
- let sessionId: string | undefined;
240
- let accepted = false;
174
+ return createSerializedRoleTurnHost(async (request): Promise<RoleTurnResult> => {
175
+ const prepared = await config.prepare(request);
176
+ const systemPromptOverride = renderSystemPromptOverride(prepared.systemPrompt);
177
+ let connection: AcpConnection | undefined;
178
+ let sessionId: string | undefined;
179
+ let accepted = false;
180
+ try {
181
+ if (prepared.mcpServers.length === 0) {
182
+ return failure("activation", "UncontrolledAcpSession", "ak-config-missing");
183
+ }
184
+ connection = await config.connect(request);
185
+ // Live host-session records: ACP session/update → sitian sole entry (#811).
186
+ // One abort + one race helper + one outer projection — write failure ends
187
+ // any in-flight RPC as typed session infrastructure failure.
188
+ const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal);
189
+ const recordAbort = new AbortController();
190
+ let hostSessionRecordFailure: RoleTurnKnownFailure | undefined;
191
+ const hostSessionRecordResult = (): RoleTurnResult => ({
192
+ code: null,
193
+ stderr: "",
194
+ timedOut: false,
195
+ knownFailure: hostSessionRecordFailure!,
196
+ });
197
+ const noteHostSessionRecordFailure = (error: unknown): void => {
198
+ if (hostSessionRecordFailure !== undefined) return;
199
+ hostSessionRecordFailure = {
200
+ cause: "session",
201
+ identity: { name: "HostSessionRecordFailure", code: "host-session-record-failed" },
202
+ diagnostic: error instanceof Error ? error.message : String(error),
203
+ };
204
+ try { recordAbort.abort(); } catch { /* already aborted */ }
205
+ };
206
+ const rpc = (
207
+ method: string,
208
+ params: Readonly<Record<string, unknown>>,
209
+ ): Promise<Readonly<Record<string, unknown>>> =>
210
+ raceAgainstHostAbort(connection!.request(method, params), recordAbort.signal, "host-session-record-failed");
211
+
212
+ connection.onNotification?.((method, params) => {
213
+ if (method !== "session/update" || hostSessionRecordFailure !== undefined) return;
241
214
  try {
242
- // AK injection proof is prepared MCP composition (envelope).
243
- if (prepared.mcpServers.length === 0) {
244
- return failure("activation", "UncontrolledAcpSession", "ak-config-missing");
245
- }
246
- connection = await config.connect(request);
247
- const initialized = await connection.request("initialize", {
248
- protocolVersion: 1,
249
- clientCapabilities: {},
215
+ reportHostSessionEvent({
216
+ host: config.hostName,
217
+ cwd: request.cwd,
218
+ sessionParent,
219
+ source: "acp-host",
220
+ event: { method, params },
250
221
  });
251
- const initializeMeta = initialized._meta as {
252
- modelState?: { availableModels?: unknown };
253
- } | undefined;
254
- const modelState = initializeMeta?.modelState;
255
- const availableModels = Array.isArray(modelState?.availableModels) ? modelState.availableModels : undefined;
256
- if (request.model !== undefined && availableModels !== undefined && !availableModels.some((entry) =>
257
- typeof entry === "object" && entry !== null
258
- && (entry as { modelId?: unknown }).modelId === acpModelId(config.modelPassing, request.model))) {
259
- return failure("activation", "AcpHostModelMismatch", "host-model-mismatch", {
260
- provider: request.model.provider,
261
- model: request.model.model,
262
- });
263
- }
222
+ } catch (error) {
223
+ noteHostSessionRecordFailure(error);
224
+ }
225
+ });
264
226
 
265
- // #617 DK-7 / #732: hand the projected prior-native session paths once,
266
- // whichever record family they came from; the host reads the files itself.
267
- const priorNativePaths =
268
- continuation.kind === "resume"
269
- ? request.hostTransition?.priorNativePaths
270
- : undefined;
271
- const loadConnection = connection;
272
- // Shared session/new + session/load body (cwd/mcpServers/_meta).
273
- const sessionBindParams = {
274
- cwd: request.cwd,
275
- mcpServers: prepared.mcpServers,
276
- _meta: { systemPromptOverride, yoloMode: false },
277
- };
278
- const loadSession = async (bindSessionId: string): Promise<string> => {
279
- const loaded = await loadConnection.request("session/load", {
280
- sessionId: bindSessionId,
281
- ...sessionBindParams,
282
- });
283
- return typeof loaded.sessionId === "string" && loaded.sessionId !== ""
284
- ? loaded.sessionId
285
- : bindSessionId;
286
- };
287
- if (continuation.kind === "resume" && config.boundResume === "session/load") {
288
- // Same-host resume reuses the native ACP session via session/load.
289
- const boundSessionId = await config.sessionIdentity.load(request.principal);
290
- if (boundSessionId !== undefined && boundSessionId !== "") {
291
- sessionId = await loadSession(boundSessionId);
292
- }
293
- }
294
- if (sessionId === undefined) {
295
- // Initial run, unbound resume (cross-host / lost binding), or a host
296
- // whose bound resume is session/new: mint the session and bind it.
297
- const session = await connection.request("session/new", sessionBindParams);
298
- sessionId = typeof session.sessionId === "string" ? session.sessionId : undefined;
299
- if (sessionId === undefined || sessionId === "") {
300
- return failure("session", "AcpSessionFailure", "session-id-missing");
301
- }
302
- await config.sessionIdentity.bind(request.principal, sessionId);
303
- }
227
+ try {
228
+ const initialized = await rpc("initialize", {
229
+ protocolVersion: 1,
230
+ clientCapabilities: {},
231
+ });
232
+ const initializeMeta = initialized._meta as {
233
+ modelState?: { availableModels?: unknown };
234
+ } | undefined;
235
+ const availableModels = Array.isArray(initializeMeta?.modelState?.availableModels)
236
+ ? initializeMeta.modelState.availableModels
237
+ : undefined;
238
+ if (request.model !== undefined && availableModels !== undefined && !availableModels.some((entry) =>
239
+ typeof entry === "object" && entry !== null
240
+ && (entry as { modelId?: unknown }).modelId === acpModelId(config.modelPassing, request.model))) {
241
+ return failure("activation", "AcpHostModelMismatch", "host-model-mismatch", {
242
+ provider: request.model.provider,
243
+ model: request.model.model,
244
+ });
245
+ }
304
246
 
305
- // set_model hosts address the seat model by `provider:model` once the
306
- // session exists; argv hosts never reach this RPC. Provider is the seat
307
- // table value after owner host-alias projection (#778) — concatenated
308
- // here, never dropped, never remapped in package code. set_model may
309
- // rebuild the session agent and drop ACP-injected mcpServers, so re-bind
310
- // via the same loadSession authority (return value is the live session id).
311
- if (
312
- config.modelPassing === "set_model"
313
- && request.model !== undefined
314
- && sessionId !== undefined
315
- ) {
316
- await connection.request("session/set_model", {
317
- sessionId,
318
- modelId: acpModelId(config.modelPassing, request.model),
319
- });
320
- sessionId = await loadSession(sessionId);
247
+ const sessionBindParams = {
248
+ cwd: request.cwd,
249
+ mcpServers: prepared.mcpServers,
250
+ _meta: { systemPromptOverride, yoloMode: false },
251
+ };
252
+ const loadSession = async (bindSessionId: string): Promise<string> => {
253
+ const loaded = await rpc("session/load", {
254
+ sessionId: bindSessionId,
255
+ ...sessionBindParams,
256
+ });
257
+ return typeof loaded.sessionId === "string" && loaded.sessionId !== ""
258
+ ? loaded.sessionId
259
+ : bindSessionId;
260
+ };
261
+ if (request.continuation.kind === "resume" && config.boundResume === "session/load") {
262
+ const boundSessionId = await config.sessionIdentity.load(request.principal);
263
+ if (boundSessionId !== undefined && boundSessionId !== "") {
264
+ sessionId = await loadSession(boundSessionId);
321
265
  }
266
+ }
267
+ if (sessionId === undefined) {
268
+ const session = await rpc("session/new", sessionBindParams);
269
+ sessionId = typeof session.sessionId === "string" ? session.sessionId : undefined;
270
+ if (sessionId === undefined || sessionId === "") {
271
+ return failure("session", "AcpSessionFailure", "session-id-missing");
272
+ }
273
+ await config.sessionIdentity.bind(request.principal, sessionId);
274
+ }
322
275
 
323
- let prompt =
324
- priorNativePaths !== undefined && priorNativePaths.length > 0
325
- ? `${prepared.prompt}\n${priorNativePaths.join("\n")}`
326
- : prepared.prompt;
327
- // Envelope infra abort and parent cancellation (#675 nested summons) are
328
- // the same race face here: either one ends the ACP round and the finally
329
- // block cancels + closes the child session.
330
- const abortSignal =
331
- request.signal === undefined
332
- ? prepared.abortSignal
333
- : prepared.abortSignal === undefined
334
- ? request.signal
335
- : AbortSignal.any([prepared.abortSignal, request.signal]);
336
- const activeConnection = connection;
276
+ // set_model seat provider:model (#778); may rebuild agent — re-bind via loadSession.
277
+ if (config.modelPassing === "set_model" && request.model !== undefined && sessionId !== undefined) {
278
+ await rpc("session/set_model", {
279
+ sessionId,
280
+ modelId: acpModelId(config.modelPassing, request.model),
281
+ });
282
+ sessionId = await loadSession(sessionId);
283
+ }
337
284
 
338
- /** Race ACP prompt against envelope abort so infra failInfrastructure cannot hang (#593). */
339
- const promptOrAbort = async (
340
- params: Readonly<Record<string, unknown>>,
341
- ): Promise<Readonly<Record<string, unknown>>> => {
342
- if (abortSignal?.aborted) {
343
- // Prefer closeRound's typed failure over a bare abort race winner.
344
- throw hostAbortedError();
345
- }
346
- const promptRequest = activeConnection.request("session/prompt", params);
347
- if (abortSignal === undefined) return promptRequest;
348
- return new Promise<Readonly<Record<string, unknown>>>((resolve, reject) => {
349
- let settled = false;
350
- const onAbort = (): void => {
351
- if (settled) return;
352
- settled = true;
353
- promptRequest.catch(() => {});
354
- reject(hostAbortedError());
355
- };
356
- abortSignal.addEventListener("abort", onAbort, { once: true });
357
- promptRequest.then(
358
- (value) => {
359
- if (settled) return;
360
- settled = true;
361
- abortSignal.removeEventListener("abort", onAbort);
362
- resolve(value);
363
- },
364
- (error) => {
365
- if (settled) return;
366
- settled = true;
367
- abortSignal.removeEventListener("abort", onAbort);
368
- reject(error);
369
- },
370
- );
371
- });
372
- };
373
- for (let attempt = 0; attempt < 8; attempt += 1) {
285
+ const activeConnection = connection;
286
+ const activeSessionId = sessionId;
287
+ const turnResult = await driveExternalRoleTurnRounds(prepared, request, {
288
+ roundLimitName: "AcpRoundLimit",
289
+ currentSessionId: () => sessionId,
290
+ async runRound({ prompt, abortSignal }) {
291
+ if (hostSessionRecordFailure !== undefined) return { status: "terminal", result: hostSessionRecordResult() };
292
+ // Envelope infra abort, parent cancellation (#675), and host-session
293
+ // record abort (#811) share one race face for in-flight prompt.
294
+ const abortParts: AbortSignal[] = [recordAbort.signal];
295
+ if (abortSignal !== undefined) abortParts.push(abortSignal);
296
+ const combinedAbort = AbortSignal.any(abortParts);
374
297
  let result: Readonly<Record<string, unknown>>;
375
298
  try {
376
- result = await promptOrAbort({
377
- sessionId,
378
- prompt: [{ type: "text", text: prompt }],
379
- });
299
+ result = await raceAgainstHostAbort(
300
+ activeConnection.request("session/prompt", {
301
+ sessionId: activeSessionId,
302
+ prompt: [{ type: "text", text: prompt }],
303
+ }),
304
+ combinedAbort,
305
+ "ACP host aborted",
306
+ );
380
307
  } catch (error) {
381
- // Envelope abort (typed infra declaration): closeRound owns the failure record.
382
- if (
383
- typeof error === "object"
384
- && error !== null
385
- && (error as { code?: unknown }).code === "host-aborted"
386
- ) {
387
- const closure = await prepared.closeRound();
388
- if ("failure" in closure) {
389
- return { code: null, stderr: "", timedOut: false, knownFailure: closure.failure };
390
- }
391
- return failure("session", "HostAborted", "host-aborted", { sessionId });
308
+ if (hostSessionRecordFailure !== undefined) {
309
+ return { status: "terminal", result: hostSessionRecordResult() };
392
310
  }
393
311
  throw error;
394
312
  }
395
313
  if (result.stopReason === "refusal") {
396
- return failure("output", "AcpRefusal", "refusal", { sessionId });
397
- }
398
- // session/prompt resolution is the sole typed round boundary before seal
399
- // when the turn ends without host abort; abort path closes above.
400
- const closure = await prepared.closeRound();
401
- if (closure.accepted) {
402
- // Wait for ACP's typed close acknowledgement before tearing down the
403
- // process; Stop hooks and fire-and-forget cancellation are not closure.
404
- await connection.request("session/close", { sessionId });
405
- accepted = true;
406
- return { code: 0, stderr: "", timedOut: false };
407
- }
408
- if ("failure" in closure) {
409
- return { code: null, stderr: "", timedOut: false, knownFailure: closure.failure };
410
- }
411
- // Shared envelope already owns the officer/correctable text (#813).
412
- prompt = closure.retry.message;
413
- }
414
- return failure("output", "AcpRoundLimit", "round-retry-limit", { sessionId });
415
- } finally {
416
- if (connection !== undefined) {
417
- if (sessionId !== undefined && !accepted) {
418
- try { connection.notify("session/cancel", { sessionId }); }
419
- catch { /* Preserve the original turn result or failure. */ }
314
+ return {
315
+ status: "terminal",
316
+ result: failure("output", "AcpRefusal", "refusal", { sessionId }),
317
+ };
420
318
  }
421
- try { await connection.close(); }
422
- catch { /* Preserve the original turn result or failure. */ }
423
- }
424
- try { await prepared.dispose?.(); }
425
- catch { /* Preserve the original turn result or failure. */ }
319
+ return { status: "delivered" };
320
+ },
321
+ async afterAccepted() {
322
+ await rpc("session/close", { sessionId: activeSessionId });
323
+ accepted = true;
324
+ },
325
+ });
326
+ if (hostSessionRecordFailure !== undefined) return hostSessionRecordResult();
327
+ return turnResult;
328
+ } catch (error) {
329
+ // recordAbort races setup RPCs via raceAgainstHostAbort (host-aborted code);
330
+ // noteHostSessionRecordFailure always sets the typed failure before aborting.
331
+ if (hostSessionRecordFailure !== undefined) return hostSessionRecordResult();
332
+ throw error;
333
+ }
334
+ } finally {
335
+ if (connection !== undefined) {
336
+ if (sessionId !== undefined && !accepted) {
337
+ try { connection.notify("session/cancel", { sessionId }); }
338
+ catch { /* keep turn result */ }
426
339
  }
427
- });
428
- serial = execution.then(() => undefined, () => undefined);
429
- return execution;
430
- },
431
- };
340
+ try { await connection.close(); }
341
+ catch { /* keep turn result */ }
342
+ }
343
+ try { await prepared.dispose?.(); }
344
+ catch { /* keep turn result */ }
345
+ }
346
+ });
432
347
  }
@@ -25,7 +25,10 @@ export type CanonicalSkillEvidence<Name extends CanonicalSkillName = CanonicalSk
25
25
  export type CanonicalSkillBinding<Name extends CanonicalSkillName = CanonicalSkillName> = Readonly<{
26
26
  name: Name;
27
27
  snapshot: CanonicalSkillSnapshot;
28
- invocation(originalRequest: string): string;
28
+ /**
29
+ * Accept host expansion evidence when name/location/content match the binding
30
+ * and userMessage equals the captured original request (ADR 0032).
31
+ */
29
32
  captureExpansion(
30
33
  evidence: HostSkillExpansionEvidence | undefined,
31
34
  originalRequest: string,
@@ -100,9 +103,6 @@ export async function loadCanonicalSkillBinding(
100
103
  const binding: CanonicalSkillBinding<typeof name> = {
101
104
  name,
102
105
  snapshot,
103
- invocation(originalRequest) {
104
- return `/skill:${name} ${originalRequest}`;
105
- },
106
106
  captureExpansion(evidence, originalRequest) {
107
107
  return captureCanonicalSkillExpansion(
108
108
  name,
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Bound diagnostic text: rolling tail only, not a dossier/transcript face.
3
+ * One authority for host stderr retention (headless + ACP).
4
+ */
5
+
6
+ /** Fixed cap for retained diagnostic tails. */
7
+ export const DIAGNOSTIC_TAIL_CAP = 16 * 1024;
8
+
9
+ const CLIP_MARK = "…[stderr clipped]\n";
10
+
11
+ /**
12
+ * Retain at most DIAGNOSTIC_TAIL_CAP characters, keeping the newest bytes
13
+ * when over budget. Clip mark is included inside the fixed cap.
14
+ */
15
+ export function retainDiagnosticTail(text: string): string {
16
+ if (text.length <= DIAGNOSTIC_TAIL_CAP) return text;
17
+ return CLIP_MARK + text.slice(text.length - (DIAGNOSTIC_TAIL_CAP - CLIP_MARK.length));
18
+ }
@@ -11,8 +11,8 @@ import type { HostContext, HostToolDefinition, HostToolResult, RoleHost } from "
11
11
  import {
12
12
  ENGINE_DETOUR_TOOL_NAME,
13
13
  engineDetourFailureDiagnostic,
14
- engineNameFromEnv,
15
14
  isEngineDetourFailure,
15
+ resolveEngineName,
16
16
  runEngineDetourOnce,
17
17
  } from "./engine-detour.ts";
18
18
 
@@ -125,14 +125,15 @@ export function createEngineDetourToolDefinition(input: {
125
125
  }
126
126
 
127
127
  /**
128
- * Register the engine-generic detour tool once for this process when any role has
129
- * an engine activation signal. Returns whether registration occurred.
128
+ * Register the engine-generic detour tool once when any role has an engine
129
+ * activation signal. Signal is request-scoped via RoleHost flag, with pi
130
+ * child-process env as fallback (resolveEngineName). Returns whether registration occurred.
130
131
  */
131
132
  export function registerEngineDetourTool(
132
133
  roleHost: RoleHost,
133
134
  hostActions: EngineDetourHostActions,
134
135
  ): boolean {
135
- const engineName = engineNameFromEnv();
136
+ const engineName = resolveEngineName((name) => roleHost.getFlag(name));
136
137
  if (engineName === undefined) {
137
138
  return false;
138
139
  }
@@ -16,24 +16,42 @@ export const ENGINE_DETOUR_TOOL_NAME = "ak_engine_detour" as const;
16
16
  /** Env presence/name signal injected by public role runs (registration gate only). */
17
17
  export const AK_ROLE_ENGINE_ENV = "AK_ROLE_ENGINE" as const;
18
18
 
19
+ /**
20
+ * Request-scoped engine flag on RoleHost (#818 P1).
21
+ * Envelope projects RoleTurnRequest.engine here so concurrent in-process hosts
22
+ * do not share process.env. Empty string = explicitly no engine (blocks ambient).
23
+ * Pi never sets this flag — resolve falls through to child-process env.
24
+ */
25
+ export const ENGINE_FLAG_NAME = "ak-engine" as const;
26
+
19
27
  /**
20
28
  * Argv placeholder replaced by a seam-owned temp prompt file path when
21
29
  * `stagedPrompt` is set. Exactly one argv entry must equal this token.
22
30
  */
23
31
  export const ENGINE_DETOUR_STAGED_PROMPT_TOKEN = "<<ak-engine-staged-prompt>>" as const;
24
32
 
33
+ /** Non-empty trimmed engine name, else undefined. */
34
+ export function normalizeEngineName(engine: string | undefined): string | undefined {
35
+ if (engine === undefined) return undefined;
36
+ const trimmed = engine.trim();
37
+ return trimmed === "" ? undefined : trimmed;
38
+ }
39
+
25
40
  /**
26
- * Sole AK_ROLE_ENGINE write seam for public role child env (#391 E2).
27
- * Delete ambient inheritance first; own-key undefined mask survives process.env re-merge.
41
+ * Sole AK_ROLE_ENGINE write seam (#391 E2 / #818).
42
+ * Child-process env only (pi adapter). Delete ambient first. Child-env objects
43
+ * keep an own-key undefined mask so a later process.env re-merge cannot revive
44
+ * ambient; process.env itself only deletes (Node stringifies undefined assignments).
28
45
  */
29
46
  export function applyEngineChildEnv(
30
47
  childEnv: NodeJS.ProcessEnv,
31
48
  engine?: string,
32
49
  ): void {
33
50
  delete childEnv[AK_ROLE_ENGINE_ENV];
34
- if (engine !== undefined && engine.trim() !== "") {
35
- childEnv[AK_ROLE_ENGINE_ENV] = engine.trim();
36
- } else {
51
+ const normalized = normalizeEngineName(engine);
52
+ if (normalized !== undefined) {
53
+ childEnv[AK_ROLE_ENGINE_ENV] = normalized;
54
+ } else if (childEnv !== process.env) {
37
55
  childEnv[AK_ROLE_ENGINE_ENV] = undefined;
38
56
  }
39
57
  }
@@ -239,8 +257,25 @@ export function engineDetourFailureDiagnostic(result: {
239
257
 
240
258
  /** Non-empty trimmed engine name from process.env, else undefined. */
241
259
  export function engineNameFromEnv(): string | undefined {
242
- const raw = process.env[AK_ROLE_ENGINE_ENV];
243
- if (typeof raw !== "string") return undefined;
244
- const trimmed = raw.trim();
245
- return trimmed === "" ? undefined : trimmed;
260
+ return normalizeEngineName(
261
+ typeof process.env[AK_ROLE_ENGINE_ENV] === "string"
262
+ ? process.env[AK_ROLE_ENGINE_ENV]
263
+ : undefined,
264
+ );
265
+ }
266
+
267
+ /**
268
+ * One activation-signal resolver (#818 P1 / ADR 0069 one gate).
269
+ * Request-scoped RoleHost flag wins when present (envelope always projects it,
270
+ * including "" for no-engine so ambient env cannot arm). Flag absent → pi
271
+ * child-process env via engineNameFromEnv.
272
+ */
273
+ export function resolveEngineName(
274
+ getFlag?: (name: string) => boolean | string | undefined,
275
+ ): string | undefined {
276
+ if (getFlag !== undefined) {
277
+ const flag = getFlag(ENGINE_FLAG_NAME);
278
+ if (typeof flag === "string") return normalizeEngineName(flag);
279
+ }
280
+ return engineNameFromEnv();
246
281
  }