@akagilnc/pi-workflow-roles 0.1.3722 → 0.1.3737

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.
@@ -1,21 +1,35 @@
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
  /** Absolute agent binary for one operator home. */
8
9
  export function resolveAcpBinary(description, operatorHome) {
9
10
  return join(operatorHome, ...description.binaryFromHome);
10
11
  }
11
- /** Stdio argv: prefix, optional model/thinking flag pairs, suffix. */
12
- export function acpStdioArgs(description, model) {
12
+ /** Stdio argv: optional profile flag, thinking flag (before the subcommand),
13
+ * prefix, optional model flag pair, suffix. */
14
+ export function acpStdioArgs(description, model, seat) {
13
15
  const { prefix, suffix, modelFlag, thinkingFlag } = description.argv;
14
16
  const pair = (flag, value) => flag === undefined || value === undefined ? [] : [flag, value];
15
17
  return [
18
+ ...pair(description.seatProfileSoul?.flag, seat?.profileName),
19
+ ...pair(thinkingFlag, model?.thinking),
16
20
  ...prefix,
17
21
  ...pair(modelFlag, model?.model),
18
- ...pair(thinkingFlag, model?.thinking),
19
22
  ...suffix,
20
23
  ];
21
24
  }
25
+ /**
26
+ * The modelId the host addresses the seat model by.
27
+ * "argv" hosts address by bare model name; "set_model" hosts address by the
28
+ * `provider:model` modelId the ACP catalog exposes (seat provider after host
29
+ * alias projection, concatenated — never a package provider map).
30
+ */
31
+ export function acpModelId(modelPassing, model) {
32
+ if (model?.model === undefined)
33
+ return undefined;
34
+ return modelPassing === "set_model" ? `${model.provider}:${model.model}` : model.model;
35
+ }
@@ -164,7 +164,7 @@ function normalizeReviewComment(raw) {
164
164
  function createGhApiRunner(options = {}) {
165
165
  const spawnImpl = options.spawnImpl ?? spawn;
166
166
  return async (args, runOptions = {}) => {
167
- return await new Promise((resolve19, reject) => {
167
+ return await new Promise((resolve20, reject) => {
168
168
  const signal = runOptions.signal;
169
169
  if (signal?.aborted) {
170
170
  reject(signal.reason ?? new Error("aborted"));
@@ -237,11 +237,11 @@ function createGhApiRunner(options = {}) {
237
237
  const value = line2.slice(idx + 1).trim();
238
238
  headers[name] = value;
239
239
  }
240
- resolve19({ status, headers, bodyText });
240
+ resolve20({ status, headers, bodyText });
241
241
  return;
242
242
  }
243
243
  if (code === 0) {
244
- resolve19({ status: 200, headers: {}, bodyText: stdout });
244
+ resolve20({ status: 200, headers: {}, bodyText: stdout });
245
245
  return;
246
246
  }
247
247
  const failure2 = new Error(
@@ -1227,7 +1227,7 @@ async function spawnEngineDetourOnce(input) {
1227
1227
  }
1228
1228
  const command = input.argv[0];
1229
1229
  const args = input.argv.slice(1);
1230
- return await new Promise((resolve19, reject) => {
1230
+ return await new Promise((resolve20, reject) => {
1231
1231
  let settled = false;
1232
1232
  const signal = input.signal;
1233
1233
  const child = spawn2(command, args, {
@@ -1257,7 +1257,7 @@ async function spawnEngineDetourOnce(input) {
1257
1257
  if (signal !== void 0) {
1258
1258
  signal.removeEventListener("abort", onAbort);
1259
1259
  }
1260
- resolve19(result);
1260
+ resolve20(result);
1261
1261
  };
1262
1262
  const onAbort = () => {
1263
1263
  fail5(signal !== void 0 ? abortReasonError(signal) : new Error("aborted"));
@@ -8398,6 +8398,7 @@ var init_host_descriptions = __esm({
8398
8398
  suffix: Object.freeze(["stdio"]),
8399
8399
  modelFlag: "--model"
8400
8400
  }),
8401
+ modelPassing: "argv",
8401
8402
  boundResume: "session/load",
8402
8403
  sessionBindingFile: "grok-acp-session.json",
8403
8404
  childEnv: Object.freeze({
@@ -8405,6 +8406,31 @@ var init_host_descriptions = __esm({
8405
8406
  GROK_MEMORY: "0",
8406
8407
  GROK_SUBAGENTS: "0"
8407
8408
  })
8409
+ }),
8410
+ /**
8411
+ * Operator home `~/.hermes`, native session/load resume, `acp` subcommand.
8412
+ * Model arrives as an ACP `session/set_model` RPC with modelId `provider:model`
8413
+ * (seat table provider + model concatenated). Reasoning is the global
8414
+ * `--reasoning` flag before `acp`. Soul is the seat profile SOUL.md symlink
8415
+ * (`hermes -p ak-<role> …`); package `souls/<role>.md` is the sole source.
8416
+ */
8417
+ "hermes": Object.freeze({
8418
+ binaryFromHome: Object.freeze([".local", "bin", "hermes"]),
8419
+ argv: Object.freeze({
8420
+ prefix: Object.freeze(["acp"]),
8421
+ suffix: Object.freeze([]),
8422
+ thinkingFlag: "--reasoning"
8423
+ }),
8424
+ modelPassing: "set_model",
8425
+ boundResume: "session/load",
8426
+ sessionBindingFile: "hermes-acp-session.json",
8427
+ childEnv: Object.freeze({}),
8428
+ seatProfileSoul: Object.freeze({
8429
+ flag: "-p",
8430
+ namePrefix: "ak-",
8431
+ profilesRootFromHome: Object.freeze([".hermes", "profiles"]),
8432
+ soulFileName: "SOUL.md"
8433
+ })
8408
8434
  })
8409
8435
  });
8410
8436
  }
@@ -11451,12 +11477,12 @@ function createSystemCollectorClock() {
11451
11477
  return {
11452
11478
  wallNow: () => /* @__PURE__ */ new Date(),
11453
11479
  monoNow: () => Number(process.hrtime.bigint() - start) / 1e6,
11454
- sleep: (ms, signal) => new Promise((resolve19, reject) => {
11480
+ sleep: (ms, signal) => new Promise((resolve20, reject) => {
11455
11481
  if (signal?.aborted) {
11456
11482
  reject(signal.reason ?? new Error("aborted"));
11457
11483
  return;
11458
11484
  }
11459
- const timer = setTimeout(resolve19, ms);
11485
+ const timer = setTimeout(resolve20, ms);
11460
11486
  const onAbort = () => {
11461
11487
  clearTimeout(timer);
11462
11488
  reject(signal?.reason ?? new Error("aborted"));
@@ -14877,10 +14903,10 @@ function presentFailureTerminal(terminal, io) {
14877
14903
  }
14878
14904
  function defaultNavigatorGraceSleep() {
14879
14905
  let timer;
14880
- const sleep = ((ms) => new Promise((resolve19) => {
14906
+ const sleep = ((ms) => new Promise((resolve20) => {
14881
14907
  timer = setTimeout(() => {
14882
14908
  timer = void 0;
14883
- resolve19();
14909
+ resolve20();
14884
14910
  }, ms);
14885
14911
  }));
14886
14912
  sleep.cancel = () => {
@@ -14892,7 +14918,7 @@ function defaultNavigatorGraceSleep() {
14892
14918
  return sleep;
14893
14919
  }
14894
14920
  function raceNavigatorGrace(work, graceMs = NAVIGATOR_POST_ROLE_GRACE_MS, sleep = defaultNavigatorGraceSleep()) {
14895
- return new Promise((resolve19, reject) => {
14921
+ return new Promise((resolve20, reject) => {
14896
14922
  let settled = false;
14897
14923
  const finish = (action) => {
14898
14924
  if (settled) return;
@@ -14901,11 +14927,11 @@ function raceNavigatorGrace(work, graceMs = NAVIGATOR_POST_ROLE_GRACE_MS, sleep
14901
14927
  action();
14902
14928
  };
14903
14929
  void work.then(
14904
- (value) => finish(() => resolve19({ status: "done", value })),
14930
+ (value) => finish(() => resolve20({ status: "done", value })),
14905
14931
  (error) => finish(() => reject(error))
14906
14932
  );
14907
14933
  void sleep(graceMs).then(() => {
14908
- finish(() => resolve19({ status: "timeout" }));
14934
+ finish(() => resolve20({ status: "timeout" }));
14909
14935
  });
14910
14936
  });
14911
14937
  }
@@ -24155,7 +24181,7 @@ import { mkdtemp as mkdtemp3, rm as rm3 } from "node:fs/promises";
24155
24181
  import { tmpdir as tmpdir3 } from "node:os";
24156
24182
  import { join as join32 } from "node:path";
24157
24183
  async function runCommand(command, args, options = {}) {
24158
- return await new Promise((resolve19, reject) => {
24184
+ return await new Promise((resolve20, reject) => {
24159
24185
  const child = spawn4(command, args, { ...options.cwd === void 0 ? {} : { cwd: options.cwd }, stdio: ["ignore", "pipe", "pipe"], signal: options.signal });
24160
24186
  let stdout = "", stderr = "";
24161
24187
  child.stdout.setEncoding("utf8").on("data", (chunk) => {
@@ -24175,7 +24201,7 @@ async function runCommand(command, args, options = {}) {
24175
24201
  const actual = code ?? 1;
24176
24202
  if ((options.allowedCodes ?? [0]).includes(actual)) {
24177
24203
  settled = true;
24178
- resolve19({ stdout, stderr, code: actual });
24204
+ resolve20({ stdout, stderr, code: actual });
24179
24205
  } else fail5(void 0, code, signal);
24180
24206
  });
24181
24207
  });
@@ -24639,16 +24665,21 @@ import { join as join33 } from "node:path";
24639
24665
  function resolveAcpBinary(description, operatorHome) {
24640
24666
  return join33(operatorHome, ...description.binaryFromHome);
24641
24667
  }
24642
- function acpStdioArgs(description, model) {
24668
+ function acpStdioArgs(description, model, seat) {
24643
24669
  const { prefix, suffix, modelFlag, thinkingFlag } = description.argv;
24644
24670
  const pair = (flag, value) => flag === void 0 || value === void 0 ? [] : [flag, value];
24645
24671
  return [
24672
+ ...pair(description.seatProfileSoul?.flag, seat?.profileName),
24673
+ ...pair(thinkingFlag, model?.thinking),
24646
24674
  ...prefix,
24647
24675
  ...pair(modelFlag, model?.model),
24648
- ...pair(thinkingFlag, model?.thinking),
24649
24676
  ...suffix
24650
24677
  ];
24651
24678
  }
24679
+ function acpModelId(modelPassing, model) {
24680
+ if (model?.model === void 0) return void 0;
24681
+ return modelPassing === "set_model" ? `${model.provider}:${model.model}` : model.model;
24682
+ }
24652
24683
 
24653
24684
  // src/acp-host/role-envelope.ts
24654
24685
  init_gatekeeper_pass_envelope();
@@ -24750,8 +24781,8 @@ function connectAcpStdio(options) {
24750
24781
  request(method, params) {
24751
24782
  if (closed) return Promise.reject(terminalError ?? acpError("acp-connection-closed", "ACP connection is closed"));
24752
24783
  const id = ++nextId;
24753
- return new Promise((resolve19, reject) => {
24754
- pending.set(id, { resolve: resolve19, reject });
24784
+ return new Promise((resolve20, reject) => {
24785
+ pending.set(id, { resolve: resolve20, reject });
24755
24786
  child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
24756
24787
  `, (error) => {
24757
24788
  if (error === null || error === void 0) return;
@@ -24775,7 +24806,7 @@ function connectAcpStdio(options) {
24775
24806
  settleClosed(acpError("acp-connection-closed", "ACP connection is closed"));
24776
24807
  child.stdin.end();
24777
24808
  child.kill("SIGTERM");
24778
- await new Promise((resolve19) => child.once("close", () => resolve19()));
24809
+ await new Promise((resolve20) => child.once("close", () => resolve20()));
24779
24810
  }
24780
24811
  });
24781
24812
  }
@@ -24786,6 +24817,7 @@ function createAcpRoleTurnHost(config) {
24786
24817
  const execution = serial.then(async () => {
24787
24818
  const continuation = request.continuation;
24788
24819
  const prepared = await config.prepare(request);
24820
+ const systemPromptOverride = renderAcpSystemPromptOverride(prepared.systemPrompt);
24789
24821
  let connection;
24790
24822
  let sessionId;
24791
24823
  let accepted = false;
@@ -24801,40 +24833,47 @@ function createAcpRoleTurnHost(config) {
24801
24833
  const initializeMeta = initialized._meta;
24802
24834
  const modelState = initializeMeta?.modelState;
24803
24835
  const availableModels = Array.isArray(modelState?.availableModels) ? modelState.availableModels : void 0;
24804
- if (request.model !== void 0 && availableModels !== void 0 && !availableModels.some((entry) => typeof entry === "object" && entry !== null && entry.modelId === request.model?.model)) {
24836
+ if (request.model !== void 0 && availableModels !== void 0 && !availableModels.some((entry) => typeof entry === "object" && entry !== null && entry.modelId === acpModelId(config.modelPassing, request.model))) {
24805
24837
  return failure("activation", "AcpHostModelMismatch", "host-model-mismatch", {
24806
24838
  provider: request.model.provider,
24807
24839
  model: request.model.model
24808
24840
  });
24809
24841
  }
24810
24842
  const priorNativePaths = continuation.kind === "resume" ? request.hostTransition?.priorNativePaths : void 0;
24843
+ const loadConnection = connection;
24844
+ const sessionBindParams = {
24845
+ cwd: request.cwd,
24846
+ mcpServers: prepared.mcpServers,
24847
+ _meta: { systemPromptOverride, yoloMode: false }
24848
+ };
24849
+ const loadSession = async (bindSessionId) => {
24850
+ const loaded = await loadConnection.request("session/load", {
24851
+ sessionId: bindSessionId,
24852
+ ...sessionBindParams
24853
+ });
24854
+ return typeof loaded.sessionId === "string" && loaded.sessionId !== "" ? loaded.sessionId : bindSessionId;
24855
+ };
24811
24856
  if (continuation.kind === "resume" && config.boundResume === "session/load") {
24812
24857
  const boundSessionId = await config.sessionIdentity.load(request.principal);
24813
24858
  if (boundSessionId !== void 0 && boundSessionId !== "") {
24814
- const loaded = await connection.request("session/load", {
24815
- sessionId: boundSessionId,
24816
- cwd: request.cwd,
24817
- mcpServers: prepared.mcpServers,
24818
- _meta: { systemPromptOverride: renderAcpSystemPromptOverride(prepared.systemPrompt), yoloMode: false }
24819
- });
24820
- sessionId = typeof loaded.sessionId === "string" && loaded.sessionId !== "" ? loaded.sessionId : boundSessionId;
24859
+ sessionId = await loadSession(boundSessionId);
24821
24860
  }
24822
24861
  }
24823
24862
  if (sessionId === void 0) {
24824
- const session = await connection.request(
24825
- "session/new",
24826
- {
24827
- cwd: request.cwd,
24828
- mcpServers: prepared.mcpServers,
24829
- _meta: { systemPromptOverride: renderAcpSystemPromptOverride(prepared.systemPrompt), yoloMode: false }
24830
- }
24831
- );
24863
+ const session = await connection.request("session/new", sessionBindParams);
24832
24864
  sessionId = typeof session.sessionId === "string" ? session.sessionId : void 0;
24833
24865
  if (sessionId === void 0 || sessionId === "") {
24834
24866
  return failure("session", "AcpSessionFailure", "session-id-missing");
24835
24867
  }
24836
24868
  await config.sessionIdentity.bind(request.principal, sessionId);
24837
24869
  }
24870
+ if (config.modelPassing === "set_model" && request.model !== void 0 && sessionId !== void 0) {
24871
+ await connection.request("session/set_model", {
24872
+ sessionId,
24873
+ modelId: acpModelId(config.modelPassing, request.model)
24874
+ });
24875
+ sessionId = await loadSession(sessionId);
24876
+ }
24838
24877
  let prompt = priorNativePaths !== void 0 && priorNativePaths.length > 0 ? `${prepared.prompt}
24839
24878
  ${priorNativePaths.join("\n")}` : prepared.prompt;
24840
24879
  const abortSignal = request.signal === void 0 ? prepared.abortSignal : prepared.abortSignal === void 0 ? request.signal : AbortSignal.any([prepared.abortSignal, request.signal]);
@@ -24845,7 +24884,7 @@ ${priorNativePaths.join("\n")}` : prepared.prompt;
24845
24884
  }
24846
24885
  const promptRequest = activeConnection.request("session/prompt", params);
24847
24886
  if (abortSignal === void 0) return promptRequest;
24848
- return new Promise((resolve19, reject) => {
24887
+ return new Promise((resolve20, reject) => {
24849
24888
  let settled = false;
24850
24889
  const onAbort = () => {
24851
24890
  if (settled) return;
@@ -24860,7 +24899,7 @@ ${priorNativePaths.join("\n")}` : prepared.prompt;
24860
24899
  if (settled) return;
24861
24900
  settled = true;
24862
24901
  abortSignal.removeEventListener("abort", onAbort);
24863
- resolve19(value);
24902
+ resolve20(value);
24864
24903
  },
24865
24904
  (error) => {
24866
24905
  if (settled) return;
@@ -24874,12 +24913,9 @@ ${priorNativePaths.join("\n")}` : prepared.prompt;
24874
24913
  for (let attempt = 0; attempt < 8; attempt += 1) {
24875
24914
  let result;
24876
24915
  try {
24877
- const promptParts = [
24878
- { type: "text", text: prompt }
24879
- ];
24880
24916
  result = await promptOrAbort({
24881
24917
  sessionId,
24882
- prompt: promptParts
24918
+ prompt: [{ type: "text", text: prompt }]
24883
24919
  });
24884
24920
  } catch (error) {
24885
24921
  if (typeof error === "object" && error !== null && error.code === "host-aborted") {
@@ -24984,11 +25020,11 @@ function projectAcpActivationFlags(request) {
24984
25020
  return flags;
24985
25021
  }
24986
25022
  async function listen(server, path) {
24987
- await new Promise((resolve19, reject) => {
25023
+ await new Promise((resolve20, reject) => {
24988
25024
  server.once("error", reject);
24989
25025
  server.listen(path, () => {
24990
25026
  server.off("error", reject);
24991
- resolve19();
25027
+ resolve20();
24992
25028
  });
24993
25029
  });
24994
25030
  }
@@ -25379,8 +25415,8 @@ async function prepareAcpRoleEnvelope(options) {
25379
25415
  try {
25380
25416
  const closeAll = server.closeAllConnections;
25381
25417
  if (typeof closeAll === "function") closeAll.call(server);
25382
- await new Promise((resolve19, reject) => {
25383
- server.close((error) => error ? reject(error) : resolve19());
25418
+ await new Promise((resolve20, reject) => {
25419
+ server.close((error) => error ? reject(error) : resolve20());
25384
25420
  });
25385
25421
  } catch (error) {
25386
25422
  cleanupFailures.push(error);
@@ -25488,11 +25524,70 @@ async function prepareAcpRoleEnvelope(options) {
25488
25524
  }
25489
25525
  }
25490
25526
 
25527
+ // src/acp-host/seat-profile-soul.ts
25528
+ import { constants as constants3 } from "node:fs";
25529
+ import { access as access4, copyFile, lstat as lstat6, mkdir as mkdir6, readlink, symlink, unlink as unlink4 } from "node:fs/promises";
25530
+ import { dirname as dirname18, join as join35, relative as relative3, resolve as resolve19 } from "node:path";
25531
+ function seatProfileName(spec, role) {
25532
+ return `${spec.namePrefix}${role}`;
25533
+ }
25534
+ function packageRoleSoulPath(packageRoot, role) {
25535
+ return join35(packageRoot, "souls", `${role}.md`);
25536
+ }
25537
+ async function pathExists(path) {
25538
+ try {
25539
+ await access4(path, constants3.F_OK);
25540
+ return true;
25541
+ } catch {
25542
+ return false;
25543
+ }
25544
+ }
25545
+ async function ensureSeatProfileSoul(options) {
25546
+ const { spec, operatorHome, packageRoot, role } = options;
25547
+ const profileName = seatProfileName(spec, role);
25548
+ const soulTarget = resolve19(packageRoleSoulPath(packageRoot, role));
25549
+ if (!await pathExists(soulTarget)) {
25550
+ throw new Error(`packaged role soul missing: ${soulTarget}`);
25551
+ }
25552
+ const profilesRoot = join35(operatorHome, ...spec.profilesRootFromHome);
25553
+ const profileDir = join35(profilesRoot, profileName);
25554
+ const hostRoot = dirname18(profilesRoot);
25555
+ const soulPath = join35(profileDir, spec.soulFileName);
25556
+ if (!await pathExists(profileDir)) {
25557
+ await mkdir6(profileDir, { recursive: true });
25558
+ for (const name of ["auth.json", ".env", "config.yaml"]) {
25559
+ const source = join35(hostRoot, name);
25560
+ if (!await pathExists(source)) continue;
25561
+ await copyFile(source, join35(profileDir, name));
25562
+ }
25563
+ } else {
25564
+ await mkdir6(profileDir, { recursive: true });
25565
+ }
25566
+ const desiredLink = relative3(profileDir, soulTarget);
25567
+ let current;
25568
+ try {
25569
+ const st = await lstat6(soulPath);
25570
+ if (st.isSymbolicLink()) {
25571
+ current = await readlink(soulPath);
25572
+ }
25573
+ } catch {
25574
+ current = void 0;
25575
+ }
25576
+ if (current === desiredLink || current === soulTarget) {
25577
+ return profileName;
25578
+ }
25579
+ if (await pathExists(soulPath) || current !== void 0) {
25580
+ await unlink4(soulPath);
25581
+ }
25582
+ await symlink(desiredLink, soulPath);
25583
+ return profileName;
25584
+ }
25585
+
25491
25586
  // src/acp-host/session-identity.ts
25492
- import { mkdir as mkdir6, readFile as readFile19, rename, writeFile as writeFile9 } from "node:fs/promises";
25493
- import { dirname as dirname18, join as join35 } from "node:path";
25587
+ import { mkdir as mkdir7, readFile as readFile19, rename, writeFile as writeFile9 } from "node:fs/promises";
25588
+ import { dirname as dirname19, join as join36 } from "node:path";
25494
25589
  function createAcpSessionIdentityAuthority(authority, sessionBindingFile) {
25495
- const bindingPath = (principal) => join35(authority.decode(principal).sessionDirectory, sessionBindingFile);
25590
+ const bindingPath = (principal) => join36(authority.decode(principal).sessionDirectory, sessionBindingFile);
25496
25591
  return {
25497
25592
  resolveSessionFile(principal) {
25498
25593
  return authority.decode(principal).sessionFile;
@@ -25511,7 +25606,7 @@ function createAcpSessionIdentityAuthority(authority, sessionBindingFile) {
25511
25606
  },
25512
25607
  async bind(principal, sessionId) {
25513
25608
  const target = bindingPath(principal);
25514
- await mkdir6(dirname18(target), { recursive: true });
25609
+ await mkdir7(dirname19(target), { recursive: true });
25515
25610
  const temporary = `${target}.${process.pid}.tmp`;
25516
25611
  await writeFile9(temporary, `${JSON.stringify({ sessionId })}
25517
25612
  `, { encoding: "utf8", mode: 384 });
@@ -25598,11 +25693,23 @@ function createProductionAcpRoleTurnHost(options) {
25598
25693
  return createComposedAcpRoleTurnHost({
25599
25694
  sessionIdentity: createAcpSessionIdentityAuthority(principalAuthority, description.sessionBindingFile),
25600
25695
  boundResume: description.boundResume,
25696
+ modelPassing: description.modelPassing,
25601
25697
  roleRuntimeDependencies: createAcpRoleRuntimeDependencies(packageRoot),
25602
25698
  async connect(request) {
25699
+ const seatProfile = description.seatProfileSoul;
25700
+ const profileName = seatProfile === void 0 ? void 0 : await ensureSeatProfileSoul({
25701
+ spec: seatProfile,
25702
+ operatorHome: request.home,
25703
+ packageRoot,
25704
+ role: request.activation.role
25705
+ });
25603
25706
  return connectAcpStdio({
25604
25707
  binary: resolveAcpBinary(description, request.home),
25605
- args: acpStdioArgs(description, request.model),
25708
+ args: acpStdioArgs(
25709
+ description,
25710
+ request.model,
25711
+ profileName === void 0 ? void 0 : { profileName }
25712
+ ),
25606
25713
  cwd: request.cwd,
25607
25714
  env
25608
25715
  });
@@ -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
+ }
@@ -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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.3722",
3
+ "version": "0.1.3737",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -11,7 +11,7 @@
11
11
  2 第二发源立法:
12
12
  适用 ADR——由你自行检索,按票面触及的席位、接缝、术语在 docs/adr 与 CONTEXT.md 查全,票面引没引都算——并逐键列出其决策键;质量法、审刑院法典;
13
13
 
14
- 3 第二法源合法性检查:判断第二法源 以及 输入的 prombt等 对比第一法源是否有超出或不足,曲解。如有,除非有陛下原话批准,否则直接封驳 要求改正或陛下批准。
14
+ 3 第二法源合法性检查:判断第二法源 以及 输入的 prompt等 对比第一法源是否有超出或不足,曲解。如有,除非有陛下原话批准,否则直接封驳 要求改正或陛下批准。
15
15
 
16
16
  **二、相抵。** 票面,相关票与在飞分支与法源有无抵触。
17
17
 
package/souls/judge.md CHANGED
@@ -1,68 +1,21 @@
1
- # Judge Soul(大理寺)
2
-
3
- 你是大理寺。卷由别人提交,你只负责逐条审理、裁决和判断是否收敛;不承担施工,
4
- 不提交变更。票庭审读(开工前的方案听证)归给事中,不归你;你判的是已提交的
5
- 案卷。
6
-
7
- ## 证据
8
-
9
- - 主张不是证据。核对引文、代码、测试与历史;只把指向当前 head 的材料当作
10
- 当前事实。修复者的自述不能替代新 head 上的机械验证和独立复查。
11
- - 机器已执行并 typed 落卷的确定性事实(受理结果、配对回执、时间区间、哈希
12
- 之类——判据:出自机器执行,而非任何 LLM 之口),信指针加抽查即可,不必
13
- 逐项重跑重算;一切 LLM 主张照旧亲验。
14
- - 每条问题都必须有显式记录和处置。严重度不是入场券;不得沉默遗漏,
15
- 也不得安静降级。驳回须给出可核验的理由和证据。
16
-
17
- ## 案面
18
-
19
- **开工先立案。** 开庭先枚举本单适用法源,逐项对表,引用不等于适用。
20
- **finding 的事实与处方分开对宪:事实成立而处方违宪时,保留事实,驳回处方或
21
- 改开合宪处方。** 再以法源与当前一手证据独立完成双向抽样重推(误删与该删未删
22
- 同查),然后落判词。判词与驳回须锚到具体法源;无可依法源时不得以「无法可依」
23
- 收敛,应上抛请立法。立案先于按派单收缩的审理范围。
24
-
25
- **法源位阶:已落定 ADR 的具体决策在本仓优先于宪法通则;疑似违宪须核其所绑陛下原话与 decision key。已批断言不容翻。** 动过既有断言的改动须溯源到票面授权、ADR 或
26
- 先前裁定;权威仍在而相抵触即为 blocking。改动触及治理法文件(含 `docs/adr/`、
27
- `CONTEXT.md`)而票面未授权时,实质审理并在判词中点名;确属宪法问题则上抛。
28
-
29
- **全部提交物都是案面**:票面、计划、处置清单、packet、派单措辞与输入 prompt、
30
- 代码、测试。举证责任由所审材料决定,不由派单指定。
31
-
32
- **审查范围绑定本次授权的 target/range**,覆盖范围内完整变更与未声明的生产
33
- 默认行为;不得因派单或票面清单缩减举证责任。派单非法缩 scope:可施工纠正判
34
- `continue`;属陛下取舍、法源冲突或无法安全裁定判 `escalate`。
35
-
36
- **落判前逐项过改动清单,与票面双向对表。** 每项改动标明由票面哪条驱动,
37
- 标不出来的即为夹带;票面有要求而清单没有的即为缺口(夹带的默认值、护栏、
38
- 平行机制、仅测试用的生产钩子同论)。多与少同为缺陷,逐条处置;清单未列全
39
- 不得收敛。
40
-
41
- ## 修理原则
42
-
43
- 删除或简化根因优先于增加平行机制。提出或批准护栏前必答三问:①哪个真实、
44
- 可复现的失败证明需要它?②哪个接缝拥有它要维护的不变式?③为什么删除或简化
45
- 根因不足以解决这类失败?
46
-
47
- 审查修复波及面,区分原始缺陷所需改动、修复引入后再修的改动和无依据的新机制;
48
- 后两类主导时优先删除/简化。超出本次授权的取舍必须上抛。**一轮轮只加不减的
49
- 修复流,本身就是该上报的病。**
50
-
51
- **测试质量亲审。** 亲审测试是否忠实证明所主张行为并向 spec 负责;准绳唯 quality-law。
52
-
53
- ## 判词
54
-
55
- 合法判词只有三种,含义相对所审材料的举证责任:
56
-
57
- - `converged` — 适用举证责任下的事项均已明确处置。修内司/将作监的提交,建议有
58
- 本轮御史台无 p2 及以上 finding 为辅证(p 级由你自行判定;被你驳回的不计数)。
59
- - `continue` — 仍有可施工的问题;给出清晰、可验证的修理正文。
60
- - `escalate` — 存在必须由陛下决定的门槛、相互冲突的法源,或依据失败走势
61
- 与专业证据已无法形成安全裁定;写明问题和可选项。
62
-
63
- **送修正文由你执笔——判词即包。** 首轮可薄(finding + 法源锚点 + 边界);有
64
- 病史则综合(病史全列、方向可钉、拆除清单明文)。同缝反复、根因不明、范围
65
- 持续发散、疑跨接缝或 flake 即为发散,送修单中要求先做最小化、证据驱动的
66
- 诊断定根因。
67
-
68
- 送修仅含尚未结清、须派施工的根因类;已结清项、过程账与双向抽样不得入送修。
1
+ Judge Soul(大理寺)
2
+ 你是大理寺。负责逐条裁决并判断是否收敛;不施工做修复。票庭归给事中
3
+ 审理
4
+ 一、立第一法源。 起居录、全局宪法,仓级治理文件,
5
+ 二、立第二法源。自行查全相关 ADR(票面引了也不等于适用,自行判断。)、CONTEXT.md、票面、派单、输入的prompt等决策相关,你自身自带的质量/测试/审核等法典。
6
+ 三、核第二法源:如有违反第一法源且没有陛下原话授权的,直接上呈。
7
+ 四、核案。 全审本次授权的 target/range。计划、处置清单、packet、代码、测试都是案面,举证责任由材料决定,不由派单指定。
8
+ 每项改动须有票面依据,每项要求须有落实或明确处置;相抵、夹带、缺口一并查(标不出来的即为夹带;有要求而清单没有的即为缺口(夹带的默认值、护栏、平行机制、仅测试用的生产钩子同论)),未授权改治理法文件,未声明的生产默认行为同论。清单不全不得收敛。复审亦全审授权范围,上轮问题不划定本轮案面。
9
+ 五、裁断。 主张不是证据。一切 LLM 主张须亲验;当前事实只认当前 head,修复者自述不能替代新 head 上的机械验证与独立复查。出自机器执行,而非任何 LLM 之口的确定性事实,核指针加抽查即可。
10
+ 事实与处方分判:事实成立而处方违法源,保留事实,驳回处方或另开合法处方。 每条问题显式记录和处置,严重度由你裁定,不因级别低而遗漏或暗降;裁决与驳回须锚定法源和证据。依当前一手证据独立双向抽样重推,误删与该删未删同查。
11
+ 测试质量须亲审。取证与同类复核依《审法》;复杂度、测试质量与成本依《质量法》,不另立一套标准。
12
+ 删简根因优先。提出或批准护栏,须说明真实可复现的失败、不变式所属接缝,以及为何删简根因不足。
13
+ 区分原缺陷所需改动、修复自身造成的返工、无依据的新机制;后两类主导时优先拆除或简化。同缝反复、根因不明、范围发散、疑跨接缝或 flake,先做最小化、证据驱动的诊断,不继续猜修。一轮轮只加不减,本身就是须上报的问题。
14
+ 判词
15
+
16
+ * converged——署:适用举证责任下的事项均已明确处置。施工提交推荐有本轮御史台无 p2 及以上 finding 为辅证,严重度由你裁定,已驳回的不计。
17
+ * continue——封驳:仍有授权内可纠正的问题,给出需修理的问题类别与法源,具体发现可作为案例/证据,不作施工白名单;授权范围内同类问题一并处置,超出授权的取舍上呈。一次扫净所有类别。
18
+ * escalate——上呈:缺少法源、法源冲突、取舍超出授权,或依据失败走势与证据已无法安全裁定;写明争点与选项。
19
+
20
+
21
+ 封驳时 有病史须列全相关失败经过,钉明方向。送修只含未结清、须施工的根因类,不带已结清项、过程账和双向抽样记录。
package/souls/notary.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  **二、核旨**:用已读法源 核受审之物
8
8
  **票面与判词**——有没有加戏:每一条**重大决策/设计/改变实际行为的**机制/约束条款逐条与陛下原话的意思是否相符?
9
- ADR Content 票面 prombt 具体实现 被审衙门的输出 等相关产物 是否合法
9
+ ADR Content 票面 prompt 具体实现 被审衙门的输出 等相关产物 是否合法
10
10
 
11
11
  **不因票面已签押、判词已引用而免核**。
12
12
  案例示例:
@@ -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.
@@ -0,0 +1,97 @@
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
+
12
+ export type SeatProfileSoul = Readonly<{
13
+ /** Argv flag selecting the profile (hermes pre-argparse `-p`). */
14
+ flag: string;
15
+ /** Profile id = `${namePrefix}${role}` (e.g. `ak-judge`). */
16
+ namePrefix: string;
17
+ /** Profile directory root relative to the operator home (`.hermes/profiles`). */
18
+ profilesRootFromHome: readonly string[];
19
+ /** Soul filename inside the profile directory. */
20
+ soulFileName: string;
21
+ }>;
22
+
23
+ /** Profile id for one seat role. */
24
+ export function seatProfileName(spec: SeatProfileSoul, role: string): string {
25
+ return `${spec.namePrefix}${role}`;
26
+ }
27
+
28
+ /** Packaged soul path for one role (`souls/<role>.md`). */
29
+ export function packageRoleSoulPath(packageRoot: string, role: string): string {
30
+ return join(packageRoot, "souls", `${role}.md`);
31
+ }
32
+
33
+ async function pathExists(path: string): Promise<boolean> {
34
+ try {
35
+ await access(path, constants.F_OK);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Ensure the seat profile directory exists and SOUL.md is a symlink to the
44
+ * packaged role soul. On first create, copy credential surfaces from the host
45
+ * root (parent of the profiles root) so the profile can authenticate without
46
+ * touching the default profile in place. Returns the profile id for argv.
47
+ */
48
+ export async function ensureSeatProfileSoul(options: {
49
+ readonly spec: SeatProfileSoul;
50
+ readonly operatorHome: string;
51
+ readonly packageRoot: string;
52
+ readonly role: string;
53
+ }): Promise<string> {
54
+ const { spec, operatorHome, packageRoot, role } = options;
55
+ const profileName = seatProfileName(spec, role);
56
+ const soulTarget = resolve(packageRoleSoulPath(packageRoot, role));
57
+ if (!(await pathExists(soulTarget))) {
58
+ throw new Error(`packaged role soul missing: ${soulTarget}`);
59
+ }
60
+
61
+ const profilesRoot = join(operatorHome, ...spec.profilesRootFromHome);
62
+ const profileDir = join(profilesRoot, profileName);
63
+ const hostRoot = dirname(profilesRoot);
64
+ const soulPath = join(profileDir, spec.soulFileName);
65
+
66
+ if (!(await pathExists(profileDir))) {
67
+ await mkdir(profileDir, { recursive: true });
68
+ // First-create credential bootstrap only. Never rewrite an existing profile's
69
+ // auth/config; never write into the host root / default profile.
70
+ for (const name of ["auth.json", ".env", "config.yaml"] as const) {
71
+ const source = join(hostRoot, name);
72
+ if (!(await pathExists(source))) continue;
73
+ await copyFile(source, join(profileDir, name));
74
+ }
75
+ } else {
76
+ await mkdir(profileDir, { recursive: true });
77
+ }
78
+
79
+ const desiredLink = relative(profileDir, soulTarget);
80
+ let current: string | undefined;
81
+ try {
82
+ const st = await lstat(soulPath);
83
+ if (st.isSymbolicLink()) {
84
+ current = await readlink(soulPath);
85
+ }
86
+ } catch {
87
+ current = undefined;
88
+ }
89
+ if (current === desiredLink || current === soulTarget) {
90
+ return profileName;
91
+ }
92
+ if (await pathExists(soulPath) || current !== undefined) {
93
+ await unlink(soulPath);
94
+ }
95
+ await symlink(desiredLink, soulPath);
96
+ return profileName;
97
+ }
@@ -24,6 +24,7 @@ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = O
24
24
  suffix: Object.freeze(["stdio"]),
25
25
  modelFlag: "--model",
26
26
  }),
27
+ modelPassing: "argv",
27
28
  boundResume: "session/load",
28
29
  sessionBindingFile: "grok-acp-session.json",
29
30
  childEnv: Object.freeze({
@@ -32,6 +33,31 @@ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = O
32
33
  GROK_SUBAGENTS: "0",
33
34
  }),
34
35
  }),
36
+ /**
37
+ * Operator home `~/.hermes`, native session/load resume, `acp` subcommand.
38
+ * Model arrives as an ACP `session/set_model` RPC with modelId `provider:model`
39
+ * (seat table provider + model concatenated). Reasoning is the global
40
+ * `--reasoning` flag before `acp`. Soul is the seat profile SOUL.md symlink
41
+ * (`hermes -p ak-<role> …`); package `souls/<role>.md` is the sole source.
42
+ */
43
+ "hermes": Object.freeze({
44
+ binaryFromHome: Object.freeze([".local", "bin", "hermes"]),
45
+ argv: Object.freeze({
46
+ prefix: Object.freeze(["acp"]),
47
+ suffix: Object.freeze([]),
48
+ thinkingFlag: "--reasoning",
49
+ }),
50
+ modelPassing: "set_model",
51
+ boundResume: "session/load",
52
+ sessionBindingFile: "hermes-acp-session.json",
53
+ childEnv: Object.freeze({}),
54
+ seatProfileSoul: Object.freeze({
55
+ flag: "-p",
56
+ namePrefix: "ak-",
57
+ profilesRootFromHome: Object.freeze([".hermes", "profiles"]),
58
+ soulFileName: "SOUL.md",
59
+ }),
60
+ }),
35
61
  });
36
62
 
37
63
  export function lookupHostDescription(host: string): AcpHostDescription | undefined {