@akagilnc/pi-workflow-roles 0.1.4586 → 0.1.4620

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 (47) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/acp-host/production-host.js +951 -497
  4. package/dist/archivist-record-topology.js +27 -35
  5. package/dist/headless-host/description.js +7 -4
  6. package/dist/headless-host/production-host.js +783 -393
  7. package/dist/migrate-book-topology.js +3 -6
  8. package/dist/navigator-attendance.js +146 -241
  9. package/dist/navigator-public-session.js +81 -15
  10. package/dist/package-contracts/navigator-output.js +30 -12
  11. package/dist/public-cli/judge-run.js +2 -2
  12. package/dist/public-cli/main.js +69 -94
  13. package/dist/public-cli/option-definitions.js +1 -1
  14. package/dist/public-cli/post-admission.js +2 -2
  15. package/dist/public-cli/run-lifecycle.js +29 -7
  16. package/dist/public-cli/settlement.js +46 -32
  17. package/dist/public-cli/terminal.js +8 -27
  18. package/dist/public-role-summons.js +22 -4
  19. package/dist/submission-ledger.js +56 -23
  20. package/package.json +1 -1
  21. package/resources/836-deleted-machine-instruction-inventory.md +2 -0
  22. package/resources/navigator-route-playbook.md +9 -0
  23. package/scripts/build-package.mjs +6 -1
  24. package/src/acp-host/role-turn-host.ts +129 -13
  25. package/src/headless-host/description.ts +17 -7
  26. package/src/headless-host/role-turn-host.ts +42 -11
  27. package/src/navigator-attendance.ts +290 -425
  28. package/src/navigator-public-session.ts +126 -25
  29. package/src/navigator-role.ts +2 -2
  30. package/src/package-contracts/navigator-output.ts +41 -25
  31. package/src/public-cli/coder-run.ts +2 -2
  32. package/src/public-cli/fixer-run.ts +2 -2
  33. package/src/public-cli/judge-run.ts +2 -2
  34. package/src/public-cli/merger-run.ts +2 -2
  35. package/src/public-cli/option-definitions.ts +1 -1
  36. package/src/public-cli/post-admission.ts +2 -1
  37. package/src/public-cli/run-lifecycle.ts +37 -7
  38. package/src/public-cli/settlement.ts +42 -33
  39. package/src/public-cli/terminal.ts +12 -37
  40. package/src/public-role-summons.ts +34 -5
  41. package/src/role-envelope.ts +89 -4
  42. package/src/role-runtime.ts +74 -14
  43. package/src/submission-ledger.ts +63 -21
  44. package/dist/public-cli/command-renderer.js +0 -5
  45. package/dist/public-command-renderer.js +0 -29
  46. package/src/public-cli/command-renderer.ts +0 -9
  47. package/src/public-command-renderer.ts +0 -55
@@ -157,7 +157,7 @@ async function closedLedgerOutcome(admitted, role, scope) {
157
157
  function coordinatesFromAdmitted(authority, admitted) {
158
158
  return authority.decode(admitted.principal);
159
159
  }
160
- import { exitCodeForTerminalOutcome, formatTerminalResult, isLawfulTypedTerminalOutcome, recommendationNavigatorFact, } from "./terminal.js";
160
+ import { exitCodeForTerminalOutcome, formatTerminalResult, isLawfulTypedTerminalOutcome, adviceNavigatorFact, } from "./terminal.js";
161
161
  export { exitCodeForTerminalOutcome, formatTerminalResult, isLawfulTypedTerminalOutcome, };
162
162
  /**
163
163
  * Host stderr as recorded — full bytes, no flood filter, no char clip (#836).
@@ -457,7 +457,13 @@ export function explicitInternalKnownFailureClassificationInput(failure) {
457
457
  ...(failure.details === undefined ? {} : { knownDetails: failure.details }),
458
458
  };
459
459
  }
460
- /** Post-role Navigator delivery grace (Issue #11 / #101 / #106 / #159). */
460
+ /**
461
+ * Post-role Navigator delivery grace (Issue #11 / #101 / #106 / #159).
462
+ * After the parent finishes: wait at most this long for navigator output, then
463
+ * stop waiting. Navigator must already be running from parent start (prepare
464
+ * host round in parallel); this window is only the tail after parent end — not
465
+ * the budget to start a cold full host turn (owner 2026-09-17 #959).
466
+ */
461
467
  export const NAVIGATOR_POST_ROLE_GRACE_MS = 10_000;
462
468
  function isMissingPathError(error) {
463
469
  return (error instanceof Error &&
@@ -1334,11 +1340,6 @@ export async function appendRunAttemptHistory(source, outcome) {
1334
1340
  }
1335
1341
  catch { }
1336
1342
  }
1337
- function navigatorPhaseValue(value) {
1338
- if (value === "plan" || value === "apply")
1339
- return value;
1340
- return null;
1341
- }
1342
1343
  /**
1343
1344
  * Minimal attendance provenance against the bound marker (ADR 0043).
1344
1345
  * Keep only invocationId + post-terminal ordering. Runtime-self-produced
@@ -1357,35 +1358,48 @@ function parseNavigatorAttendanceDetails(details) {
1357
1358
  const advisoryDiagnostic = typeof details.routePlaybookReadFailure === "string"
1358
1359
  ? { advisoryDiagnostic: details.routePlaybookReadFailure }
1359
1360
  : {};
1360
- if (disposition === "recommendation") {
1361
- const next = details.next;
1362
- if (!isRecord(next) || typeof next.role !== "string") {
1361
+ // #959: advice prose is presented as-is. Legacy "recommendation" with next/reason/
1362
+ // command is projected into prose so historical sessions still render — never wash
1363
+ // a real recommendation into no-advice when any advice body is recoverable.
1364
+ if (disposition === "advice" || disposition === "recommendation") {
1365
+ let prose;
1366
+ if (typeof details.prose === "string" && details.prose.trim() !== "") {
1367
+ prose = details.prose;
1368
+ }
1369
+ else {
1370
+ const next = isRecord(details.next) && typeof details.next.role === "string"
1371
+ ? details.next.role
1372
+ : undefined;
1373
+ const reason = typeof details.reason === "string" && details.reason.trim() !== ""
1374
+ ? details.reason
1375
+ : undefined;
1376
+ const command = typeof details.command === "string" && details.command.trim() !== ""
1377
+ ? details.command
1378
+ : undefined;
1379
+ if (reason !== undefined && next !== undefined) {
1380
+ prose = `${reason}(下一步:${next})`;
1381
+ }
1382
+ else if (reason !== undefined) {
1383
+ prose = reason;
1384
+ }
1385
+ else if (next !== undefined) {
1386
+ // Historical recommendation with only typed next — still real advice.
1387
+ prose = `下一步:${next}`;
1388
+ }
1389
+ else if (command !== undefined) {
1390
+ prose = command;
1391
+ }
1392
+ }
1393
+ if (prose === undefined || prose.trim() === "") {
1394
+ // Attended but empty body is affirmative no-advice, not unavailable (#959).
1363
1395
  return {
1364
- disposition: "unavailable",
1365
- source: "unknown",
1366
- reason: "navigator recommendation missing typed next role",
1396
+ disposition: "no-advice",
1397
+ ...advisoryDiagnostic,
1367
1398
  };
1368
1399
  }
1369
- const reason = typeof details.reason === "string" ? details.reason : "";
1370
- const route = Array.isArray(details.route)
1371
- ? details.route
1372
- .filter(isRecord)
1373
- .map((target) => ({
1374
- role: String(target.role),
1375
- phase: navigatorPhaseValue(target.phase),
1376
- }))
1377
- : undefined;
1378
- return recommendationNavigatorFact({
1400
+ return adviceNavigatorFact({
1401
+ prose,
1379
1402
  ...advisoryDiagnostic,
1380
- next: {
1381
- role: next.role,
1382
- phase: navigatorPhaseValue(next.phase),
1383
- },
1384
- reason,
1385
- ...(route === undefined ? {} : { route }),
1386
- ...(typeof details.command === "string"
1387
- ? { modelCommand: details.command }
1388
- : {}),
1389
1403
  });
1390
1404
  }
1391
1405
  if (disposition === "unavailable") {
@@ -1,11 +1,3 @@
1
- /**
2
- * Terminal result: typed semantic regions for one admitted Role run (ADR 0052 / #106).
3
- * Presentation may rearrange labels/order; only regions and typed facts are stable.
4
- *
5
- * Free-text cells are JSON-string encoded so legitimate newlines/tabs cannot forge
6
- * extra rows or shift column boundaries (note / fix.summary / decision question / reason).
7
- */
8
- import { isPublicCallableRole, renderPublicAkRoleCommand, } from "./command-renderer.js";
9
1
  /** Encode one free-text Terminal cell. JSON string form cannot embed raw tab/newline. */
10
2
  export function encodeTerminalField(value) {
11
3
  return JSON.stringify(value);
@@ -34,21 +26,13 @@ export function roleResultPayloads(outcome) {
34
26
  return [];
35
27
  }
36
28
  /**
37
- * Build a recommendation navigator fact. Model command is kept when present;
38
- * registry render is fallback only. Unknown seats stay recommendations (#836 B4.1).
29
+ * Build an advice navigator fact (#959). Prose is presented as submitted;
30
+ * code does not parse route/next or judge usability.
39
31
  */
40
- export function recommendationNavigatorFact(input) {
41
- const command = typeof input.modelCommand === "string" && input.modelCommand.trim() !== ""
42
- ? input.modelCommand
43
- : isPublicCallableRole(input.next.role)
44
- ? renderPublicAkRoleCommand(input.next)
45
- : undefined;
32
+ export function adviceNavigatorFact(input) {
46
33
  return {
47
- disposition: "recommendation",
48
- next: input.next,
49
- reason: input.reason,
50
- ...(command === undefined ? {} : { command }),
51
- ...(input.route === undefined ? {} : { route: input.route }),
34
+ disposition: "advice",
35
+ prose: input.prose,
52
36
  ...(input.advisoryDiagnostic === undefined ? {} : { advisoryDiagnostic: input.advisoryDiagnostic }),
53
37
  };
54
38
  }
@@ -82,12 +66,9 @@ export function formatTerminalResult(result) {
82
66
  if (result.navigator.advisoryDiagnostic !== undefined) {
83
67
  lines.push(`navigator-advisory\t${encodeTerminalField(result.navigator.advisoryDiagnostic)}`);
84
68
  }
85
- if (result.navigator.disposition === "recommendation") {
86
- lines.push(`next\t${result.navigator.next.role}\t${result.navigator.next.phase ?? "none"}`);
87
- lines.push(`reason\t${encodeTerminalField(result.navigator.reason)}`);
88
- if (result.navigator.command !== undefined) {
89
- lines.push(`command\t${encodeTerminalField(result.navigator.command)}`);
90
- }
69
+ if (result.navigator.disposition === "advice") {
70
+ // #959: present navigator prose as-is — no next/reason/command parsing.
71
+ lines.push(`prose\t${encodeTerminalField(result.navigator.prose)}`);
91
72
  }
92
73
  else if (result.navigator.disposition === "unavailable") {
93
74
  lines.push(`unavailable\t${result.navigator.source}\t${encodeTerminalField(result.navigator.reason)}`);
@@ -269,14 +269,32 @@ async function summonPublicRole(options) {
269
269
  case "navigator":
270
270
  case "gatekeeper": {
271
271
  const seat2 = options.role;
272
- const [{ runPublicInstructionSeat }, invocation] = await Promise.all([
272
+ const [{ runPublicInstructionSeat, runPublicInstructionSeatResume }, invocation] = await Promise.all([
273
273
  import("./public-cli/instruction-seat-run.js"),
274
274
  import("./public-cli/invocation.js")
275
275
  ]);
276
276
  const parse = seat2 === "auditor" ? invocation.parseAuditorArgv : seat2 === "navigator" ? invocation.parseNavigatorArgv : invocation.parseGatekeeperArgv;
277
- const stepped = await runPrepared(parse, (env, once) => runPublicInstructionSeat(options.argv, env, io, seat2, once));
278
- if ("fail" in stepped) return stepped.fail;
279
- result = stepped.ok;
277
+ const resumeRunId = typeof options.resumeRunId === "string" && options.resumeRunId.trim() !== "" ? options.resumeRunId.trim() : void 0;
278
+ if (resumeRunId !== void 0) {
279
+ const instruction = options.argv[0] ?? "";
280
+ const stepped = await runPrepared(parse, (env) => runPublicInstructionSeatResume(
281
+ {
282
+ runId: resumeRunId,
283
+ summons: {
284
+ instruction,
285
+ instructionEmpty: instruction.trim() === ""
286
+ }
287
+ },
288
+ env,
289
+ io
290
+ ));
291
+ if ("fail" in stepped) return stepped.fail;
292
+ result = stepped.ok;
293
+ } else {
294
+ const stepped = await runPrepared(parse, (env, once) => runPublicInstructionSeat(options.argv, env, io, seat2, once));
295
+ if ("fail" in stepped) return stepped.fail;
296
+ result = stepped.ok;
297
+ }
280
298
  break;
281
299
  }
282
300
  case "judge": {
@@ -338,6 +338,59 @@ async function restoreState(cwd, runId, scope) {
338
338
  }, 0),
339
339
  };
340
340
  }
341
+ /** Sole HostContext-derived session parent for ledger restore/append (never process.env). */
342
+ function sessionParentFromHostContext(context) {
343
+ const runDirectory = runDirectoryFromHostContext(context);
344
+ if (runDirectory !== undefined)
345
+ return join(runDirectory, "session", "session.jsonl");
346
+ const sessionFile = context.sessionManager.getSessionFile?.();
347
+ if (typeof sessionFile === "string" && sessionFile.length > 0)
348
+ return sessionFile;
349
+ const sessionDir = context.sessionManager.getSessionDir?.();
350
+ if (typeof sessionDir === "string" && sessionDir.length > 0) {
351
+ return join(sessionDir, "session.jsonl");
352
+ }
353
+ return undefined;
354
+ }
355
+ function homeFromHostContext(context, home) {
356
+ if (home !== undefined)
357
+ return home;
358
+ const sessionParent = sessionParentFromHostContext(context);
359
+ return sessionParent !== undefined ? tryHomeFromAkRolesPath(sessionParent) : undefined;
360
+ }
361
+ /**
362
+ * #959: seal one accepted submission without a model tool call.
363
+ * Same ledger row shape and priorEventId chain as the terminating-tool wrap.
364
+ * Used when a prose-exit seat (navigator) harvests the final assistant text.
365
+ */
366
+ export async function sealAcceptedSubmission(options) {
367
+ const runId = runIdentity(options.context);
368
+ const attemptId = attemptIdentity(options.context, runId);
369
+ const sessionParent = sessionParentFromHostContext(options.context);
370
+ const home = homeFromHostContext(options.context, options.home);
371
+ const state = await restoreState(options.context.cwd, runId, {
372
+ ...(home === undefined ? {} : { home }),
373
+ ...(sessionParent === undefined ? {} : { sessionParent }),
374
+ });
375
+ const pointer = sitianReport({
376
+ level: "event",
377
+ kind: "sealed",
378
+ subject: { runId, attemptId },
379
+ ...(state.prior === undefined ? {} : { priorEventId: state.prior.identity }),
380
+ payload: {
381
+ type: "sealed",
382
+ attemptId,
383
+ toolCallId: options.toolCallId,
384
+ role: options.role,
385
+ accepted: options.accepted,
386
+ },
387
+ source: "role-runtime",
388
+ cwd: options.context.cwd,
389
+ ...(home !== undefined ? { home } : {}),
390
+ ...(sessionParent === undefined ? {} : { sessionParent }),
391
+ });
392
+ state.prior = pointer;
393
+ }
341
394
  /**
342
395
  * Submission ledger host — record only (#836).
343
396
  * Each terminating submission is appended with the role's original payload.
@@ -346,30 +399,10 @@ async function restoreState(cwd, runId, scope) {
346
399
  */
347
400
  export function createSubmissionLedgerHost(host, outputTools, failInfrastructure = (error) => { throw error; }, projectClosure = () => undefined, options) {
348
401
  const states = new Map();
349
- /** Sole HostContext-derived run coordinate for restore and append (never process.env). */
350
- const sessionParentFromContext = (context) => {
351
- const runDirectory = runDirectoryFromHostContext(context);
352
- if (runDirectory !== undefined) {
353
- return join(runDirectory, "session", "session.jsonl");
354
- }
355
- const sessionFile = context.sessionManager.getSessionFile?.();
356
- if (typeof sessionFile === "string" && sessionFile.length > 0)
357
- return sessionFile;
358
- const sessionDir = context.sessionManager.getSessionDir?.();
359
- if (typeof sessionDir === "string" && sessionDir.length > 0) {
360
- return join(sessionDir, "session.jsonl");
361
- }
362
- return undefined;
363
- };
364
- const resolveHomeFromContext = (context) => {
365
- if (options?.home !== undefined)
366
- return options.home;
367
- const sessionParent = sessionParentFromContext(context);
368
- return sessionParent !== undefined ? tryHomeFromAkRolesPath(sessionParent) : undefined;
369
- };
402
+ const resolveHomeFromContext = (context) => homeFromHostContext(context, options?.home);
370
403
  const stateFor = (context, runId) => states.get(runId) ?? (() => {
371
404
  const home = resolveHomeFromContext(context);
372
- const sessionParent = sessionParentFromContext(context);
405
+ const sessionParent = sessionParentFromHostContext(context);
373
406
  const pending = restoreState(context.cwd, runId, {
374
407
  ...(home === undefined ? {} : { home }),
375
408
  ...(sessionParent === undefined ? {} : { sessionParent }),
@@ -379,7 +412,7 @@ export function createSubmissionLedgerHost(host, outputTools, failInfrastructure
379
412
  })();
380
413
  const appendFor = (state, context, runId, attemptId, event) => {
381
414
  const home = resolveHomeFromContext(context);
382
- const sessionParent = sessionParentFromContext(context);
415
+ const sessionParent = sessionParentFromHostContext(context);
383
416
  const pointer = sitianReport({
384
417
  level: "event",
385
418
  kind: event.type,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4586",
3
+ "version": "0.1.4620",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -14,5 +14,7 @@
14
14
  | `run-lifecycle.ts` A4.2 | `重新读 <path>` | 前轮已删改写。 |
15
15
  | `submission-correctable-error.ts` A4.5 | `终局交卷并非本轮唯一工具调用。` | 删。Pi/ACP `deliverSubmissionRejection` 不再注入该句。 |
16
16
  | `role-runtime.ts` 催交 | `本会话尚无已接受的 typed 回执。` | **留**(轮次/预算,Q2)。 |
17
+ | `navigator-attendance.ts` early prepare 开场 | `父衙门进行中。请在本宿主会话熟悉下列材料并待命;结算结果送达后,再给出下一步建议。本轮不要提交最终路线建议。` | #959 迁入 `resources/navigator-route-playbook.md`「到场阶段」;代码只留中立指针句「本轮为待命轮。」 |
18
+ | `navigator-attendance.ts` settlement feed 开场 | `父衙门结算已送达。请根据下列材料给出下一步建议。` | #959 迁入同手册;代码只留中立指针句「本轮为结算投喂轮。」 |
17
19
 
18
20
  催交句与 2.1–2.3 打回文、重问三句不在删除列。
@@ -1,3 +1,12 @@
1
+ ## 到场阶段(#959 / ADR 0073)
2
+
3
+ 代码只传中立阶段指针句与 typed 材料;阶段行为说明住本手册,随案附卷。
4
+
5
+ - **待命轮**:父衙门进行中。请在本宿主会话熟悉下列材料并待命;结算结果送达后,再给出下一步建议。本轮不要提交最终路线建议。
6
+ - **结算投喂轮**:父衙门结算已送达。请根据下列材料给出下一步建议。
7
+
8
+ Early 待命轮的输出由执行机制丢弃;最终路线建议只在结算投喂轮给出。
9
+
1
10
  ## 宿主轴(与角色命令面)
2
11
 
3
12
  游奕使自动出席,亦可 `ak-role navigator` 直调(自动出席不变)。主会话宿主由公开角色的 host 轴决定:调用 `--host` → 席位 `config set-host` → 默认 `pi`。配置默认 host 后,调用者仍用与 Pi 相同的 `ak-role <role> …` 命令面;游奕使随该宿主下的角色跑次出席,席位模型走 run 目录 `institutional-resolution.json` 的 navigator 座(显式 `config set navigator` 优先,否则继承父席有效模型),不再依赖父宿主 ExtensionContext。
@@ -5,7 +5,6 @@ import { build } from "esbuild";
5
5
 
6
6
  const entries = [
7
7
  "packaged-role-registry",
8
- "public-command-renderer",
9
8
  "work-subject-identity",
10
9
  "navigator-invocation-identity",
11
10
  // Static import of navigator-invocation-identity (#603: non-bundle graph closure).
@@ -22,6 +21,10 @@ const entries = [
22
21
  "activation-ledger-topology",
23
22
  "activation-reconciliation",
24
23
  "archivist-record-entry",
24
+ // Pure subject nest topology — static import of archivist-record-entry;
25
+ // cold discovery without SessionManager (#636). Keep listed while that
26
+ // relative edge remains (#857 removal left the import graph open).
27
+ "archivist-record-topology",
25
28
  // Session material loaders used by published non-bundle roots.
26
29
  "session-opening-materials",
27
30
  // Value-import closure of published non-bundle roots (build-package-only loadable).
@@ -205,6 +208,8 @@ export async function buildPackageArtifacts() {
205
208
  await mkdir(pluginDir, { recursive: true });
206
209
  await cp("resources/method-host-plugin/.claude-plugin", join(pluginDir, ".claude-plugin"), { recursive: true });
207
210
  await cp("resources/methods", join(pluginDir, "skills"), { recursive: true });
211
+ // Packaged handbook/playbook paths resolve via injected packageRoot (#962) —
212
+ // do not mirror resources/ into dist/resources (one authority, no parallel copy).
208
213
  }
209
214
 
210
215
  const isMain =
@@ -9,6 +9,10 @@ import {
9
9
  } from "../external-host-turn-loop.ts";
10
10
 
11
11
  import { reportHostSessionEvent } from "../host-session-record.ts";
12
+ import {
13
+ NAVIGATOR_OUTPUT_TOOL_NAME,
14
+ navigatorProseFromUnknown,
15
+ } from "../package-contracts/navigator-output.ts";
12
16
  import {
13
17
  renderSystemPromptOverride,
14
18
  type PreparedRoleTurn,
@@ -16,6 +20,53 @@ import {
16
20
  } from "../prepared-role-turn.ts";
17
21
  import { acpModelId, type AcpHostDescription } from "./description.ts";
18
22
 
23
+ /**
24
+ * #959: collect free-form agent text from ACP session/update stream.
25
+ * Used only when the navigator seat spoke prose without calling the output tool.
26
+ * ACP agent speech is only agent_message / agent_message_chunk — never user,
27
+ * thought, or other *_message kinds (load replay must not poison the bucket).
28
+ *
29
+ * Standard ACP nests under params.update: { sessionUpdate, content }.
30
+ * Flat params.sessionUpdate / string params.update remain accepted for host variants.
31
+ */
32
+ function acpAgentTextChunk(params: Readonly<Record<string, unknown>>): string | undefined {
33
+ let kind: unknown;
34
+ let content: unknown;
35
+ let textFallback: unknown;
36
+
37
+ const nested = params.update;
38
+ if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) {
39
+ const record = nested as Record<string, unknown>;
40
+ kind = record.sessionUpdate;
41
+ content = record.content;
42
+ textFallback = record.text;
43
+ } else {
44
+ kind = params.sessionUpdate ?? nested;
45
+ content = params.content;
46
+ textFallback = params.text;
47
+ }
48
+
49
+ if (kind !== "agent_message_chunk" && kind !== "agent_message") return undefined;
50
+ if (typeof content === "string" && content.length > 0) return content;
51
+ if (typeof content === "object" && content !== null && !Array.isArray(content)) {
52
+ const record = content as Record<string, unknown>;
53
+ if (typeof record.text === "string" && record.text.length > 0) return record.text;
54
+ }
55
+ if (Array.isArray(content)) {
56
+ const parts: string[] = [];
57
+ for (const part of content) {
58
+ if (typeof part === "string" && part.length > 0) parts.push(part);
59
+ else if (typeof part === "object" && part !== null) {
60
+ const record = part as Record<string, unknown>;
61
+ if (typeof record.text === "string" && record.text.length > 0) parts.push(record.text);
62
+ }
63
+ }
64
+ if (parts.length > 0) return parts.join("");
65
+ }
66
+ if (typeof textFallback === "string" && textFallback.length > 0) return textFallback;
67
+ return undefined;
68
+ }
69
+
19
70
  /** ACP v1 surface used by the generic ACP adapter. Protocol details stay in this module. */
20
71
  export interface AcpConnection {
21
72
  request(method: string, params: Readonly<Record<string, unknown>>): Promise<Readonly<Record<string, unknown>>>;
@@ -43,7 +94,13 @@ export type AcpRoleTurnHostConfig = Readonly<{
43
94
  prepare(request: RoleTurnRequest): Promise<PreparedRoleTurn>;
44
95
  }>;
45
96
 
46
- function failure(cause: "activation" | "session" | "output", name: string, code: string, details?: Readonly<Record<string, unknown>>): RoleTurnResult {
97
+ function failure(
98
+ cause: "activation" | "session" | "output",
99
+ name: string,
100
+ code: string,
101
+ details?: Readonly<Record<string, unknown>>,
102
+ diagnostic?: string,
103
+ ): RoleTurnResult {
47
104
  return {
48
105
  code: null,
49
106
  stderr: "",
@@ -51,11 +108,27 @@ function failure(cause: "activation" | "session" | "output", name: string, code:
51
108
  knownFailure: {
52
109
  cause,
53
110
  identity: { name, code },
111
+ ...(diagnostic === undefined ? {} : { diagnostic }),
54
112
  ...(details === undefined ? {} : { details }),
55
113
  },
56
114
  };
57
115
  }
58
116
 
117
+ /** Success→dispose failure; existing failure keeps primary cause + cleanup detail. */
118
+ function withCleanupFailure(outcome: RoleTurnResult, cleanupError: unknown): RoleTurnResult {
119
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
120
+ if (outcome.knownFailure === undefined) {
121
+ return failure("session", "AcpDisposeFailure", "dispose-failed", { cleanupError: message }, message);
122
+ }
123
+ return {
124
+ ...outcome,
125
+ knownFailure: {
126
+ ...outcome.knownFailure,
127
+ details: { ...(outcome.knownFailure.details ?? {}), cleanupError: message },
128
+ },
129
+ };
130
+ }
131
+
59
132
  type RpcReply = { readonly id?: unknown; readonly method?: unknown; readonly params?: unknown; readonly result?: unknown; readonly error?: unknown };
60
133
 
61
134
  function acpError(code: string, message: string, cause?: unknown): Error & { readonly code: string } {
@@ -182,10 +255,12 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
182
255
  let connection: AcpConnection | undefined;
183
256
  let sessionId: string | undefined;
184
257
  let accepted = false;
258
+ // Mutable so dispose failure can outrank a clean turn (headless withCleanupFailure face).
259
+ let outcome: RoleTurnResult = failure("session", "AcpNoOutcome", "no-outcome");
185
260
  try {
186
261
  if (prepared.mcpServers.length === 0) {
187
- return failure("activation", "UncontrolledAcpSession", "ak-config-missing");
188
- }
262
+ outcome = failure("activation", "UncontrolledAcpSession", "ak-config-missing");
263
+ } else {
189
264
  connection = await config.connect(request);
190
265
  // Live host-session records: ACP session/update → sitian sole entry (#811).
191
266
  // One abort + one race helper + one outer projection — write failure ends
@@ -214,8 +289,17 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
214
289
  ): Promise<Readonly<Record<string, unknown>>> =>
215
290
  raceAgainstHostAbort(connection!.request(method, params), recordAbort.signal, "host-session-record-failed");
216
291
 
292
+ // #959: navigator free-form agent text — only while session/prompt is in flight.
293
+ // session/load replays history via session/update; those must not enter the bucket
294
+ // (resume / set_model load would otherwise prepend prior turns as "this turn" prose).
295
+ const agentProseChunks: string[] = [];
296
+ let collectAgentProse = false;
217
297
  connection.onNotification?.((method, params) => {
218
298
  if (method !== "session/update" || hostSessionRecordFailure !== undefined) return;
299
+ if (collectAgentProse) {
300
+ const chunk = acpAgentTextChunk(params);
301
+ if (chunk !== undefined) agentProseChunks.push(chunk);
302
+ }
219
303
  try {
220
304
  reportHostSessionEvent({
221
305
  host: config.hostName,
@@ -243,11 +327,11 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
243
327
  if (request.model !== undefined && availableModels !== undefined && !availableModels.some((entry) =>
244
328
  typeof entry === "object" && entry !== null
245
329
  && (entry as { modelId?: unknown }).modelId === acpModelId(config.modelPassing, request.model))) {
246
- return failure("activation", "AcpHostModelMismatch", "host-model-mismatch", {
330
+ outcome = failure("activation", "AcpHostModelMismatch", "host-model-mismatch", {
247
331
  provider: request.model.provider,
248
332
  model: request.model.model,
249
333
  });
250
- }
334
+ } else {
251
335
 
252
336
  const sessionBindParams = {
253
337
  cwd: request.cwd,
@@ -269,15 +353,19 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
269
353
  sessionId = await loadSession(boundSessionId);
270
354
  }
271
355
  }
356
+ let sessionReady = true;
272
357
  if (sessionId === undefined) {
273
358
  const session = await rpc("session/new", sessionBindParams);
274
359
  sessionId = typeof session.sessionId === "string" ? session.sessionId : undefined;
275
360
  if (sessionId === undefined || sessionId === "") {
276
- return failure("session", "AcpSessionFailure", "session-id-missing");
361
+ outcome = failure("session", "AcpSessionFailure", "session-id-missing");
362
+ sessionReady = false;
363
+ } else {
364
+ await config.sessionIdentity.bind(request.principal, sessionId);
277
365
  }
278
- await config.sessionIdentity.bind(request.principal, sessionId);
279
366
  }
280
367
 
368
+ if (sessionReady) {
281
369
  // set_model seat provider:model (#778); may rebuild agent — re-bind via loadSession.
282
370
  if (config.modelPassing === "set_model" && request.model !== undefined && sessionId !== undefined) {
283
371
  await rpc("session/set_model", {
@@ -300,6 +388,9 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
300
388
  if (abortSignal !== undefined) abortParts.push(abortSignal);
301
389
  const combinedAbort = AbortSignal.any(abortParts);
302
390
  let result: Readonly<Record<string, unknown>>;
391
+ // Open the prose gate only for this prompt round; clear any stale chunks first.
392
+ agentProseChunks.length = 0;
393
+ collectAgentProse = prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME;
303
394
  try {
304
395
  result = await raceAgainstHostAbort(
305
396
  activeConnection.request("session/prompt", {
@@ -310,17 +401,33 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
310
401
  "ACP host aborted",
311
402
  );
312
403
  } catch (error) {
404
+ collectAgentProse = false;
405
+ agentProseChunks.length = 0;
313
406
  if (hostSessionRecordFailure !== undefined) {
314
407
  return { status: "terminal", result: hostSessionRecordResult() };
315
408
  }
316
409
  throw error;
317
410
  }
411
+ collectAgentProse = false;
318
412
  if (result.stopReason === "refusal") {
413
+ agentProseChunks.length = 0;
319
414
  return {
320
415
  status: "terminal",
321
416
  result: failure("output", "AcpRefusal", "refusal", { sessionId }),
322
417
  };
323
418
  }
419
+ // #959: navigator prose exit when the model spoke without the output tool.
420
+ // Tool path still wins via MCP; ingest is a no-op once the tool already sealed.
421
+ // Emptiness via shared projector; payload keeps original bytes (LLM 原话过手).
422
+ if (prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME) {
423
+ const prose = agentProseChunks.join("");
424
+ agentProseChunks.length = 0;
425
+ if (navigatorProseFromUnknown(prose) !== undefined) {
426
+ await prepared.ingestStructuredOutput({ prose });
427
+ }
428
+ } else {
429
+ agentProseChunks.length = 0;
430
+ }
324
431
  return { status: "delivered", stderr: activeConnection.stderr?.() ?? "" };
325
432
  },
326
433
  async afterAccepted() {
@@ -328,14 +435,19 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
328
435
  accepted = true;
329
436
  },
330
437
  });
331
- if (hostSessionRecordFailure !== undefined) return hostSessionRecordResult();
332
- return turnResult;
438
+ outcome = hostSessionRecordFailure !== undefined ? hostSessionRecordResult() : turnResult;
439
+ } // sessionReady
440
+ } // model match else
333
441
  } catch (error) {
334
442
  // recordAbort races setup RPCs via raceAgainstHostAbort (host-aborted code);
335
443
  // noteHostSessionRecordFailure always sets the typed failure before aborting.
336
- if (hostSessionRecordFailure !== undefined) return hostSessionRecordResult();
337
- throw error;
444
+ if (hostSessionRecordFailure !== undefined) {
445
+ outcome = hostSessionRecordResult();
446
+ } else {
447
+ throw error;
448
+ }
338
449
  }
450
+ } // mcpServers else
339
451
  } finally {
340
452
  if (connection !== undefined) {
341
453
  if (sessionId !== undefined && !accepted) {
@@ -345,8 +457,12 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
345
457
  try { await connection.close(); }
346
458
  catch { /* keep turn result */ }
347
459
  }
348
- try { await prepared.dispose?.(); }
349
- catch { /* keep turn result */ }
460
+ try {
461
+ await prepared.dispose?.();
462
+ } catch (cleanupError) {
463
+ outcome = withCleanupFailure(outcome, cleanupError);
464
+ }
350
465
  }
466
+ return outcome;
351
467
  });
352
468
  }