@akagilnc/pi-workflow-roles 0.1.3718 → 0.1.3733

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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Seat-scoped host profile soul delivery (#644 / 拍 2).
3
+ *
4
+ * Hermes has no per-session systemPrompt channel; identity is the profile's
5
+ * SOUL.md. Each seat owns `profiles/<namePrefix><role>/`, and SOUL.md is a
6
+ * symlink to the packaged `souls/<role>.md` (package is the sole soul source).
7
+ */
8
+ import { constants } from "node:fs";
9
+ import { access, copyFile, lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
10
+ import { dirname, join, relative, resolve } from "node:path";
11
+ /** Profile id for one seat role. */
12
+ export function seatProfileName(spec, role) {
13
+ return `${spec.namePrefix}${role}`;
14
+ }
15
+ /** Packaged soul path for one role (`souls/<role>.md`). */
16
+ export function packageRoleSoulPath(packageRoot, role) {
17
+ return join(packageRoot, "souls", `${role}.md`);
18
+ }
19
+ async function pathExists(path) {
20
+ try {
21
+ await access(path, constants.F_OK);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /**
29
+ * Ensure the seat profile directory exists and SOUL.md is a symlink to the
30
+ * packaged role soul. On first create, copy credential surfaces from the host
31
+ * root (parent of the profiles root) so the profile can authenticate without
32
+ * touching the default profile in place. Returns the profile id for argv.
33
+ */
34
+ export async function ensureSeatProfileSoul(options) {
35
+ const { spec, operatorHome, packageRoot, role } = options;
36
+ const profileName = seatProfileName(spec, role);
37
+ const soulTarget = resolve(packageRoleSoulPath(packageRoot, role));
38
+ if (!(await pathExists(soulTarget))) {
39
+ throw new Error(`packaged role soul missing: ${soulTarget}`);
40
+ }
41
+ const profilesRoot = join(operatorHome, ...spec.profilesRootFromHome);
42
+ const profileDir = join(profilesRoot, profileName);
43
+ const hostRoot = dirname(profilesRoot);
44
+ const soulPath = join(profileDir, spec.soulFileName);
45
+ if (!(await pathExists(profileDir))) {
46
+ await mkdir(profileDir, { recursive: true });
47
+ // First-create credential bootstrap only. Never rewrite an existing profile's
48
+ // auth/config; never write into the host root / default profile.
49
+ for (const name of ["auth.json", ".env", "config.yaml"]) {
50
+ const source = join(hostRoot, name);
51
+ if (!(await pathExists(source)))
52
+ continue;
53
+ await copyFile(source, join(profileDir, name));
54
+ }
55
+ }
56
+ else {
57
+ await mkdir(profileDir, { recursive: true });
58
+ }
59
+ const desiredLink = relative(profileDir, soulTarget);
60
+ let current;
61
+ try {
62
+ const st = await lstat(soulPath);
63
+ if (st.isSymbolicLink()) {
64
+ current = await readlink(soulPath);
65
+ }
66
+ }
67
+ catch {
68
+ current = undefined;
69
+ }
70
+ if (current === desiredLink || current === soulTarget) {
71
+ return profileName;
72
+ }
73
+ if (await pathExists(soulPath) || current !== undefined) {
74
+ await unlink(soulPath);
75
+ }
76
+ await symlink(desiredLink, soulPath);
77
+ return profileName;
78
+ }
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { readableGateItem } from "./readable-gate-item.js";
3
4
  import { Type } from "typebox";
4
5
  const AUDITOR_DOSSIER_TOOL_NAME = "ak_get_run_dossier";
5
6
  const GATE_SUBMISSION_CANDIDATE_FILE = "gate-submission-candidate.json";
@@ -29,16 +30,20 @@ function readLatestToolCallLeaf(context) {
29
30
  function gateSubmissionCandidatePath(runDirectory) {
30
31
  return join(runDirectory, "artifacts", GATE_SUBMISSION_CANDIDATE_FILE);
31
32
  }
32
- function buildGateOfficerReviewInstruction(input) {
33
- const lines = [
34
- "\u672C\u8F6E\u7236\u5E2D\u4EA4\u5377\u5F85\u5BA1\u3002",
35
- `\u6765\u6E90 run\uFF1A${input.sourceRunDirectory}`
36
- ];
37
- const candidate = input.submissionCandidatePath?.trim();
38
- if (candidate !== void 0 && candidate.length > 0) {
39
- lines.push(`\u4EA4\u5377\u5019\u9009\uFF08\u51BB\u7ED3\u5FEB\u7167\uFF09\uFF1A${candidate}`);
33
+ function readLatestSubmissionArguments(context) {
34
+ const leaf = readLatestToolCallLeaf(context);
35
+ if (!isRecord(leaf) || !isRecord(leaf.message) || !Array.isArray(leaf.message.content)) {
36
+ return void 0;
37
+ }
38
+ for (const part of leaf.message.content) {
39
+ if (isRecord(part) && part.type === "toolCall" && "arguments" in part) {
40
+ return part.arguments;
41
+ }
40
42
  }
41
- return lines.join("\n");
43
+ return void 0;
44
+ }
45
+ function buildGateOfficerReviewInstruction(input) {
46
+ return readableGateItem(input.submission);
42
47
  }
43
48
  function persistGateSubmissionCandidate(runDirectory, context) {
44
49
  const leaf = readLatestToolCallLeaf(context);
@@ -86,5 +91,6 @@ export {
86
91
  createAuditorDossierTool,
87
92
  gateSubmissionCandidatePath,
88
93
  persistGateSubmissionCandidate,
94
+ readLatestSubmissionArguments,
89
95
  readLatestToolCallLeaf
90
96
  };
@@ -1,4 +1,4 @@
1
- import { auditorRunDirectory, persistGateSubmissionCandidate, } from "./auditor-dossier-tool.js";
1
+ import { auditorRunDirectory, persistGateSubmissionCandidate, readLatestSubmissionArguments, } from "./auditor-dossier-tool.js";
2
2
  import { GatekeeperDecisionError } from "./submission-errors.js";
3
3
  import { INSPECTOR_OUTPUT_TOOL_NAME } from "./inspector-contracts.js";
4
4
  import { GATEKEEPER_OUTPUT_TOOL_NAME, gatekeeperDecisionSchema, gatekeeperOutputSchema, } from "./package-contracts/gatekeeper-output.js";
@@ -173,14 +173,15 @@ export async function projectGatekeeperRun(options) {
173
173
  },
174
174
  };
175
175
  }
176
- // Pointer-only summons need a resolvable leaf: Grok session.jsonl is header-only
177
- // (#617 DK-4); write the in-memory tool-call candidate as a run artifact first (#632).
178
- // Candidate path also rides same-parent officer resume as 人读材料 (#753 / #750).
179
- const submissionCandidatePath = persistGateSubmissionCandidate(runDirectory, options.context);
176
+ // #632: Grok session.jsonl is header-only — freeze the in-memory tool-call leaf
177
+ // as a run artifact so dossier exploration still resolves. LLM→LLM resume does
178
+ // not ride this path: submission body goes verbatim on gateReviewInstruction (#786).
179
+ persistGateSubmissionCandidate(runDirectory, options.context);
180
+ const submission = readLatestSubmissionArguments(options.context);
180
181
  let summoned;
181
182
  try {
182
183
  const summon = options.summonOfficer
183
- ?? (async (nextOfficer, sourceRunDirectory, officerSignal, reask) => {
184
+ ?? (async (nextOfficer, sourceRunDirectory, officerSignal, reask, nextSubmission) => {
184
185
  const { summonGateOfficer } = await import("./public-role-summons.js");
185
186
  return summonGateOfficer({
186
187
  officer: nextOfficer,
@@ -188,12 +189,14 @@ export async function projectGatekeeperRun(options) {
188
189
  cwd: options.context.cwd ?? process.cwd(),
189
190
  ...(officerSignal === undefined ? {} : { signal: officerSignal }),
190
191
  ...(reask === undefined ? {} : { reask }),
191
- ...(submissionCandidatePath === undefined
192
- ? {}
193
- : { submissionCandidatePath }),
192
+ ...(nextSubmission === undefined ? {} : { submission: nextSubmission }),
193
+ ...(options.home === undefined ? {} : { home: options.home }),
194
+ ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
195
+ ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
196
+ ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
194
197
  });
195
198
  });
196
- summoned = await summon(officer, runDirectory, options.signal, options.reask);
199
+ summoned = await summon(officer, runDirectory, options.signal, options.reask, submission);
197
200
  }
198
201
  catch (error) {
199
202
  return {
@@ -10,6 +10,7 @@ export const HOST_DESCRIPTIONS = Object.freeze({
10
10
  suffix: Object.freeze(["stdio"]),
11
11
  modelFlag: "--model",
12
12
  }),
13
+ modelPassing: "argv",
13
14
  boundResume: "session/load",
14
15
  sessionBindingFile: "grok-acp-session.json",
15
16
  childEnv: Object.freeze({
@@ -18,6 +19,31 @@ export const HOST_DESCRIPTIONS = Object.freeze({
18
19
  GROK_SUBAGENTS: "0",
19
20
  }),
20
21
  }),
22
+ /**
23
+ * Operator home `~/.hermes`, native session/load resume, `acp` subcommand.
24
+ * Model arrives as an ACP `session/set_model` RPC with modelId `provider:model`
25
+ * (seat table provider + model concatenated). Reasoning is the global
26
+ * `--reasoning` flag before `acp`. Soul is the seat profile SOUL.md symlink
27
+ * (`hermes -p ak-<role> …`); package `souls/<role>.md` is the sole source.
28
+ */
29
+ "hermes": Object.freeze({
30
+ binaryFromHome: Object.freeze([".local", "bin", "hermes"]),
31
+ argv: Object.freeze({
32
+ prefix: Object.freeze(["acp"]),
33
+ suffix: Object.freeze([]),
34
+ thinkingFlag: "--reasoning",
35
+ }),
36
+ modelPassing: "set_model",
37
+ boundResume: "session/load",
38
+ sessionBindingFile: "hermes-acp-session.json",
39
+ childEnv: Object.freeze({}),
40
+ seatProfileSoul: Object.freeze({
41
+ flag: "-p",
42
+ namePrefix: "ak-",
43
+ profilesRootFromHome: Object.freeze([".hermes", "profiles"]),
44
+ soulFileName: "SOUL.md",
45
+ }),
46
+ }),
21
47
  });
22
48
  export function lookupHostDescription(host) {
23
49
  return Object.hasOwn(HOST_DESCRIPTIONS, host) ? HOST_DESCRIPTIONS[host] : undefined;
@@ -31,7 +31,7 @@ export async function runPublicInspector(argv, env, io, parseInspectorArgv) {
31
31
  // No bare catch→fresh: lookup/resume failures surface; only true absence mints new.
32
32
  const projectRoot = parsed.project ?? env.cwd;
33
33
  // #747: parentRunPath is the pure 卷宗指针 path only — never reask/materials text.
34
- // #753: gate re-ask / new-submission pointers ride summons.instruction on resume.
34
+ // #753/#786: gate re-ask / verbatim submission body ride summons.instruction on resume.
35
35
  const parentRunPath = parentRunPathFromGatePointerInstruction(parsed.instruction);
36
36
  if (parentRunPath !== undefined) {
37
37
  const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
@@ -57,7 +57,7 @@ export async function runPublicInspector(argv, env, io, parseInspectorArgv) {
57
57
  return resumed;
58
58
  // Reask without a prior same-parent run cannot deliver the plain-language ask
59
59
  // on a fresh mint without inventing a second prompt path — fail loud (#753).
60
- // gateReviewInstruction alone is resume-only; fresh mint keeps argv 卷宗指针.
60
+ // gateReviewInstruction (verbatim body) alone is resume-only; fresh mint keeps argv 卷宗指针.
61
61
  if (env.reviewReask !== undefined) {
62
62
  presentStructuralRejection(new CliUsageError("inspector review reask requires a prior same-parent run to resume"), io);
63
63
  return { exitCode: 2 };
@@ -150,7 +150,7 @@ export async function runPublicInstructionSeat(argv, env, io, role, parseArgv) {
150
150
  return { exitCode: 2 };
151
151
  }
152
152
  }
153
- // #756: auditor reask / new-submission pointers ride summons.instruction on resume.
153
+ // #756/#786: auditor reask / verbatim submission body ride summons.instruction on resume.
154
154
  const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
155
155
  const summons = {
156
156
  ...(resumeInstruction === undefined
@@ -15446,6 +15446,7 @@ var init_host_descriptions = __esm({
15446
15446
  suffix: Object.freeze(["stdio"]),
15447
15447
  modelFlag: "--model"
15448
15448
  }),
15449
+ modelPassing: "argv",
15449
15450
  boundResume: "session/load",
15450
15451
  sessionBindingFile: "grok-acp-session.json",
15451
15452
  childEnv: Object.freeze({
@@ -15453,6 +15454,31 @@ var init_host_descriptions = __esm({
15453
15454
  GROK_MEMORY: "0",
15454
15455
  GROK_SUBAGENTS: "0"
15455
15456
  })
15457
+ }),
15458
+ /**
15459
+ * Operator home `~/.hermes`, native session/load resume, `acp` subcommand.
15460
+ * Model arrives as an ACP `session/set_model` RPC with modelId `provider:model`
15461
+ * (seat table provider + model concatenated). Reasoning is the global
15462
+ * `--reasoning` flag before `acp`. Soul is the seat profile SOUL.md symlink
15463
+ * (`hermes -p ak-<role> …`); package `souls/<role>.md` is the sole source.
15464
+ */
15465
+ "hermes": Object.freeze({
15466
+ binaryFromHome: Object.freeze([".local", "bin", "hermes"]),
15467
+ argv: Object.freeze({
15468
+ prefix: Object.freeze(["acp"]),
15469
+ suffix: Object.freeze([]),
15470
+ thinkingFlag: "--reasoning"
15471
+ }),
15472
+ modelPassing: "set_model",
15473
+ boundResume: "session/load",
15474
+ sessionBindingFile: "hermes-acp-session.json",
15475
+ childEnv: Object.freeze({}),
15476
+ seatProfileSoul: Object.freeze({
15477
+ flag: "-p",
15478
+ namePrefix: "ak-",
15479
+ profilesRootFromHome: Object.freeze([".hermes", "profiles"]),
15480
+ soulFileName: "SOUL.md"
15481
+ })
15456
15482
  })
15457
15483
  });
15458
15484
  }
@@ -21973,6 +21999,7 @@ var init_method_skill = __esm({
21973
21999
  var init_auditor_dossier_tool = __esm({
21974
22000
  "src/auditor-dossier-tool.ts"() {
21975
22001
  "use strict";
22002
+ init_readable_gate_item();
21976
22003
  init_build();
21977
22004
  }
21978
22005
  });
@@ -52,7 +52,7 @@ export async function runPublicNotary(argv, env, io, parseNotaryArgv) {
52
52
  throw error;
53
53
  }
54
54
  // #747: officer resume key is this parent source-run path (not ticket number).
55
- // #753: gate re-ask and new-submission pointers share summons.instruction
55
+ // #753/#786: gate re-ask and verbatim submission body share summons.instruction
56
56
  // (reask wins when both present; no parallel stack).
57
57
  {
58
58
  const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
@@ -76,7 +76,7 @@ export async function runPublicNotary(argv, env, io, parseNotaryArgv) {
76
76
  return resumed;
77
77
  // Reask without a prior same-parent run cannot deliver the plain-language ask
78
78
  // on a fresh mint without inventing a second prompt path — fail loud (#753).
79
- // gateReviewInstruction alone is resume-only materials; fresh mint ignores it.
79
+ // gateReviewInstruction (verbatim body) alone is resume-only; fresh mint ignores it.
80
80
  if (env.reviewReask !== undefined) {
81
81
  presentStructuralRejection(new CliUsageError("notary review reask requires a prior same-parent run to resume"), io);
82
82
  return { exitCode: 2 };
@@ -165,8 +165,11 @@ async function summonPublicRole(options) {
165
165
  ...options.signal === void 0 ? {} : { signal: options.signal },
166
166
  // #753: reask rides the existing notary same-ticket resume summons.instruction.
167
167
  ...options.reviewReask === void 0 ? {} : { reviewReask: options.reviewReask },
168
- // #753/#750: new-submission pointers on same-parent resume (not conclusion re-ask).
169
- ...options.gateReviewInstruction === void 0 ? {} : { gateReviewInstruction: options.gateReviewInstruction }
168
+ // #786: verbatim submission body on same-parent resume (not conclusion re-ask).
169
+ ...options.gateReviewInstruction === void 0 ? {} : { gateReviewInstruction: options.gateReviewInstruction },
170
+ // Offline test injects — same faces as public CLI env (production leaves unset).
171
+ ...options.roleTurnHost === void 0 ? {} : { roleTurnHost: options.roleTurnHost },
172
+ ...options.createRunId === void 0 ? {} : { createRunId: options.createRunId }
170
173
  };
171
174
  const captured = options.io === void 0 ? createCapturingIo() : void 0;
172
175
  const io = options.io ?? captured.io;
@@ -263,11 +266,10 @@ async function summonGateOfficer(options) {
263
266
  home = homeFromRunDirectory(options.sourceRunDirectory);
264
267
  }
265
268
  let gateReviewInstruction;
266
- if (options.reask === void 0) {
269
+ if (options.reask === void 0 && options.submission !== void 0) {
267
270
  const { buildGateOfficerReviewInstruction } = await import("./auditor-dossier-tool.js");
268
271
  gateReviewInstruction = buildGateOfficerReviewInstruction({
269
- sourceRunDirectory: options.sourceRunDirectory,
270
- ...options.submissionCandidatePath === void 0 ? {} : { submissionCandidatePath: options.submissionCandidatePath }
272
+ submission: options.submission
271
273
  });
272
274
  }
273
275
  const common = {
@@ -277,7 +279,9 @@ async function summonGateOfficer(options) {
277
279
  ...options.io === void 0 ? {} : { io: options.io },
278
280
  ...options.signal === void 0 ? {} : { signal: options.signal },
279
281
  ...options.reask === void 0 ? {} : { reviewReask: options.reask },
280
- ...gateReviewInstruction === void 0 ? {} : { gateReviewInstruction }
282
+ ...gateReviewInstruction === void 0 ? {} : { gateReviewInstruction },
283
+ ...options.roleTurnHost === void 0 ? {} : { roleTurnHost: options.roleTurnHost },
284
+ ...options.createRunId === void 0 ? {} : { createRunId: options.createRunId }
281
285
  };
282
286
  if (options.officer === "notary") {
283
287
  return summonPublicRole({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.3718",
3
+ "version": "0.1.3733",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * One ACP host description. Every host-specific value the generic ACP adapter
3
- * needs — binary location, argv shape, resume verb, binding filename, child env
4
- * — is data here; the lifecycle in role-turn-host.ts stays one copy (#732).
3
+ * needs — binary location, argv shape, resume verb, binding filename, child env,
4
+ * optional seat-profile soul — is data here; the lifecycle in role-turn-host.ts
5
+ * stays one copy (#732).
5
6
  */
6
7
  import { join } from "node:path";
7
8
 
9
+ import type { SeatProfileSoul } from "./seat-profile-soul.ts";
10
+
8
11
  export type AcpHostDescription = Readonly<{
9
12
  /** Binary path segments relative to the operator home. */
10
13
  binaryFromHome: readonly string[];
@@ -12,13 +15,28 @@ export type AcpHostDescription = Readonly<{
12
15
  prefix: readonly string[];
13
16
  suffix: readonly string[];
14
17
  modelFlag?: string;
18
+ /** CLI flag whose value is the seat thinking level; placed before `prefix`
19
+ * so it lands ahead of the subcommand (hermes global `--reasoning`). */
15
20
  thinkingFlag?: string;
16
21
  }>;
22
+ /**
23
+ * How the seat model reaches the agent:
24
+ * - "argv": passed as the CLI `--model` flag (grok);
25
+ * - "set_model": sent as an ACP `session/set_model` RPC with modelId
26
+ * `provider:model` (hermes).
27
+ */
28
+ modelPassing: "argv" | "set_model";
17
29
  /** Which verb a bound resume uses; "session/new" hosts always mint + bind. */
18
30
  boundResume: "session/load" | "session/new";
19
31
  /** Durable ACP binding filename written beside the session principal. */
20
32
  sessionBindingFile: string;
21
33
  childEnv: Readonly<Record<string, string>>;
34
+ /**
35
+ * When set, the production factory ensures a seat profile whose SOUL.md is a
36
+ * symlink to the packaged role soul, and prefixes argv with `flag <name>`.
37
+ * Used by hosts that have no per-session systemPrompt channel (hermes).
38
+ */
39
+ seatProfileSoul?: SeatProfileSoul;
22
40
  }>;
23
41
 
24
42
  /** Absolute agent binary for one operator home. */
@@ -26,18 +44,35 @@ export function resolveAcpBinary(description: AcpHostDescription, operatorHome:
26
44
  return join(operatorHome, ...description.binaryFromHome);
27
45
  }
28
46
 
29
- /** Stdio argv: prefix, optional model/thinking flag pairs, suffix. */
47
+ /** Stdio argv: optional profile flag, thinking flag (before the subcommand),
48
+ * prefix, optional model flag pair, suffix. */
30
49
  export function acpStdioArgs(
31
50
  description: AcpHostDescription,
32
51
  model?: { readonly model?: string; readonly thinking?: string },
52
+ seat?: { readonly profileName?: string },
33
53
  ): string[] {
34
54
  const { prefix, suffix, modelFlag, thinkingFlag } = description.argv;
35
55
  const pair = (flag: string | undefined, value: string | undefined): string[] =>
36
56
  flag === undefined || value === undefined ? [] : [flag, value];
37
57
  return [
58
+ ...pair(description.seatProfileSoul?.flag, seat?.profileName),
59
+ ...pair(thinkingFlag, model?.thinking),
38
60
  ...prefix,
39
61
  ...pair(modelFlag, model?.model),
40
- ...pair(thinkingFlag, model?.thinking),
41
62
  ...suffix,
42
63
  ];
43
64
  }
65
+
66
+ /**
67
+ * The modelId the host addresses the seat model by.
68
+ * "argv" hosts address by bare model name; "set_model" hosts address by the
69
+ * `provider:model` modelId the ACP catalog exposes (seat provider after host
70
+ * alias projection, concatenated — never a package provider map).
71
+ */
72
+ export function acpModelId(
73
+ modelPassing: AcpHostDescription["modelPassing"],
74
+ model?: { readonly model?: string; readonly provider?: string },
75
+ ): string | undefined {
76
+ if (model?.model === undefined) return undefined;
77
+ return modelPassing === "set_model" ? `${model.provider}:${model.model}` : model.model;
78
+ }
@@ -27,6 +27,7 @@ import { loadGatekeeperSessionMaterials, loadMainRoleSessionMaterials } from "..
27
27
  import { acpStdioArgs, resolveAcpBinary, type AcpHostDescription } from "./description.ts";
28
28
  import { createComposedAcpRoleTurnHost } from "./role-envelope.ts";
29
29
  import { connectAcpStdio } from "./role-turn-host.ts";
30
+ import { ensureSeatProfileSoul } from "./seat-profile-soul.ts";
30
31
  import { createAcpSessionIdentityAuthority } from "./session-identity.ts";
31
32
 
32
33
  export type ProductionAcpHostOptions = Readonly<{
@@ -121,11 +122,25 @@ export function createProductionAcpRoleTurnHost(options: ProductionAcpHostOption
121
122
  return createComposedAcpRoleTurnHost({
122
123
  sessionIdentity: createAcpSessionIdentityAuthority(principalAuthority, description.sessionBindingFile),
123
124
  boundResume: description.boundResume,
125
+ modelPassing: description.modelPassing,
124
126
  roleRuntimeDependencies: createAcpRoleRuntimeDependencies(packageRoot),
125
127
  async connect(request) {
128
+ const seatProfile = description.seatProfileSoul;
129
+ const profileName = seatProfile === undefined
130
+ ? undefined
131
+ : await ensureSeatProfileSoul({
132
+ spec: seatProfile,
133
+ operatorHome: request.home,
134
+ packageRoot,
135
+ role: request.activation.role,
136
+ });
126
137
  return connectAcpStdio({
127
138
  binary: resolveAcpBinary(description, request.home),
128
- args: acpStdioArgs(description, request.model),
139
+ args: acpStdioArgs(
140
+ description,
141
+ request.model,
142
+ profileName === undefined ? undefined : { profileName },
143
+ ),
129
144
  cwd: request.cwd,
130
145
  env,
131
146
  });
@@ -622,7 +622,8 @@ export async function prepareAcpRoleEnvelope(options: {
622
622
  };
623
623
 
624
624
  // Shared envelope activation. systemPrompt must be ready before session/new
625
- // (ACP delivers it there), so activation runs during prepare.
625
+ // (delivered via _meta.systemPromptOverride where the host honors it), so
626
+ // activation runs during prepare.
626
627
  try {
627
628
  await emit("session_start", { reason: request.continuation.kind });
628
629
  const inputResults = await emit("input", { text: request.continuation.prompt, source: "interactive" });
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline";
3
3
 
4
4
  import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
5
5
  import { renderAgentStartMaterials } from "../agent-start-materials.ts";
6
- import type { AcpHostDescription } from "./description.ts";
6
+ import { acpModelId, type AcpHostDescription } from "./description.ts";
7
7
 
8
8
  /** ACP v1 surface used by the generic ACP adapter. Protocol details stay in this module. */
9
9
  export interface AcpConnection {
@@ -64,6 +64,12 @@ export type AcpRoleTurnHostConfig = Readonly<{
64
64
  sessionIdentity: AcpSessionIdentityAuthority;
65
65
  /** Whether a bound resume reuses the native session or mints a fresh one. */
66
66
  boundResume: AcpHostDescription["boundResume"];
67
+ /**
68
+ * How the seat model reaches the agent: "set_model" sends an ACP
69
+ * `session/set_model` RPC with modelId `provider:model` once the session
70
+ * exists (new or loaded); "argv" leaves it to the connect argv (--model).
71
+ */
72
+ modelPassing: AcpHostDescription["modelPassing"];
67
73
  connect(request: RoleTurnRequest): Promise<AcpConnection>;
68
74
  prepare(request: RoleTurnRequest): Promise<AcpPreparedTurn>;
69
75
  }>;
@@ -201,6 +207,7 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
201
207
  const execution = serial.then(async (): Promise<RoleTurnResult> => {
202
208
  const continuation = request.continuation;
203
209
  const prepared = await config.prepare(request);
210
+ const systemPromptOverride = renderAcpSystemPromptOverride(prepared.systemPrompt);
204
211
  let connection: AcpConnection | undefined;
205
212
  let sessionId: string | undefined;
206
213
  let accepted = false;
@@ -220,7 +227,8 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
220
227
  const modelState = initializeMeta?.modelState;
221
228
  const availableModels = Array.isArray(modelState?.availableModels) ? modelState.availableModels : undefined;
222
229
  if (request.model !== undefined && availableModels !== undefined && !availableModels.some((entry) =>
223
- typeof entry === "object" && entry !== null && (entry as { modelId?: unknown }).modelId === request.model?.model)) {
230
+ typeof entry === "object" && entry !== null
231
+ && (entry as { modelId?: unknown }).modelId === acpModelId(config.modelPassing, request.model))) {
224
232
  return failure("activation", "AcpHostModelMismatch", "host-model-mismatch", {
225
233
  provider: request.model.provider,
226
234
  model: request.model.model,
@@ -233,32 +241,33 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
233
241
  continuation.kind === "resume"
234
242
  ? request.hostTransition?.priorNativePaths
235
243
  : undefined;
244
+ const loadConnection = connection;
245
+ // Shared session/new + session/load body (cwd/mcpServers/_meta).
246
+ const sessionBindParams = {
247
+ cwd: request.cwd,
248
+ mcpServers: prepared.mcpServers,
249
+ _meta: { systemPromptOverride, yoloMode: false },
250
+ };
251
+ const loadSession = async (bindSessionId: string): Promise<string> => {
252
+ const loaded = await loadConnection.request("session/load", {
253
+ sessionId: bindSessionId,
254
+ ...sessionBindParams,
255
+ });
256
+ return typeof loaded.sessionId === "string" && loaded.sessionId !== ""
257
+ ? loaded.sessionId
258
+ : bindSessionId;
259
+ };
236
260
  if (continuation.kind === "resume" && config.boundResume === "session/load") {
237
261
  // Same-host resume reuses the native ACP session via session/load.
238
262
  const boundSessionId = await config.sessionIdentity.load(request.principal);
239
263
  if (boundSessionId !== undefined && boundSessionId !== "") {
240
- const loaded = await connection.request("session/load", {
241
- sessionId: boundSessionId,
242
- cwd: request.cwd,
243
- mcpServers: prepared.mcpServers,
244
- _meta: { systemPromptOverride: renderAcpSystemPromptOverride(prepared.systemPrompt), yoloMode: false },
245
- });
246
- sessionId = typeof loaded.sessionId === "string" && loaded.sessionId !== ""
247
- ? loaded.sessionId
248
- : boundSessionId;
264
+ sessionId = await loadSession(boundSessionId);
249
265
  }
250
266
  }
251
267
  if (sessionId === undefined) {
252
268
  // Initial run, unbound resume (cross-host / lost binding), or a host
253
269
  // whose bound resume is session/new: mint the session and bind it.
254
- const session = await connection.request(
255
- "session/new",
256
- {
257
- cwd: request.cwd,
258
- mcpServers: prepared.mcpServers,
259
- _meta: { systemPromptOverride: renderAcpSystemPromptOverride(prepared.systemPrompt), yoloMode: false },
260
- },
261
- );
270
+ const session = await connection.request("session/new", sessionBindParams);
262
271
  sessionId = typeof session.sessionId === "string" ? session.sessionId : undefined;
263
272
  if (sessionId === undefined || sessionId === "") {
264
273
  return failure("session", "AcpSessionFailure", "session-id-missing");
@@ -266,6 +275,24 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
266
275
  await config.sessionIdentity.bind(request.principal, sessionId);
267
276
  }
268
277
 
278
+ // set_model hosts address the seat model by `provider:model` once the
279
+ // session exists; argv hosts never reach this RPC. Provider is the seat
280
+ // table value after owner host-alias projection (#778) — concatenated
281
+ // here, never dropped, never remapped in package code. set_model may
282
+ // rebuild the session agent and drop ACP-injected mcpServers, so re-bind
283
+ // via the same loadSession authority (return value is the live session id).
284
+ if (
285
+ config.modelPassing === "set_model"
286
+ && request.model !== undefined
287
+ && sessionId !== undefined
288
+ ) {
289
+ await connection.request("session/set_model", {
290
+ sessionId,
291
+ modelId: acpModelId(config.modelPassing, request.model),
292
+ });
293
+ sessionId = await loadSession(sessionId);
294
+ }
295
+
269
296
  let prompt =
270
297
  priorNativePaths !== undefined && priorNativePaths.length > 0
271
298
  ? `${prepared.prompt}\n${priorNativePaths.join("\n")}`
@@ -319,12 +346,9 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
319
346
  for (let attempt = 0; attempt < 8; attempt += 1) {
320
347
  let result: Readonly<Record<string, unknown>>;
321
348
  try {
322
- const promptParts: Array<Record<string, unknown>> = [
323
- { type: "text", text: prompt },
324
- ];
325
349
  result = await promptOrAbort({
326
350
  sessionId,
327
- prompt: promptParts,
351
+ prompt: [{ type: "text", text: prompt }],
328
352
  });
329
353
  } catch (error) {
330
354
  // Envelope abort (typed infra declaration): closeRound owns the failure record.