pi-orchestrator-config 4.3.3 → 4.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Single extension that provides:
15
15
  | **Subagent tool** | Delegate tasks to specialist agents (single, parallel, chain, async modes); optional `model` param for model override |
16
16
  | **`list_models`** | List available models and providers for subagent model override |
17
17
  | **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`, `/async-kill`). On **acpx** parents, optional async is coerced to sync; dream/cron need `internal_operations_provider` + `internal_operations_model`. `cli-*` providers support async natively (see `dev-docs/cli-provider.md`) |
18
- | **CLI / ACPX providers** | Optional `cli_agents` / `acpx_agents` register `cli-*` / `acpx-*` via native `createProvider` (pi ≥ 0.84): `/login cli-<agent>` or `/login acpx-<agent>`, model refresh, filter when unavailable. Missing context window / maxTokens / cost filled from models.dev (`~/.pi/pi-config/models.dev.json`, 1-day refresh). Cold-start default restore (#753): gates `startup`\|`new`, empty `enabledModels`, trusted project merge — see `dev-docs/cli-provider.md` |
18
+ | **CLI / ACPX providers** | Optional `cli_agents` / `acpx_agents` register `cli-*` / `acpx-*` via native `createProvider` (pi ≥ 0.84): `/login cli-<agent>` or `/login acpx-<agent>`, model refresh, filter when unavailable. Missing context window / maxTokens / cost filled from models.dev (`~/.pi/pi-config/models.dev.json`, 1-day refresh). Cold-start default restore (#753): gates `startup`\|`new`, empty `enabledModels`, trusted project merge — see `dev-docs/cli-provider.md`. Headless Cursor `--approve-mcps` when `CLI_APPROVE_MCPS` is set (`false` opts out) or the process is a sidecar (`SIDECAR_PORT`; `startSidecar()` stamps it while running and restores it on `close()`). Executable sidecar consumers should call `bindSidecarListenExit()` so bind/`stopped` fatals exit 1. Headless Gemini defaults `GEMINI_CLI_TRUST_WORKSPACE=true` and preserves an explicit parent value (including `false`). |
19
19
  | **`/btw` command** | Quick side questions without polluting conversation history — ephemeral overlay |
20
20
  | **`/async-status` command** | Show status of background agents — select one for live output streaming |
21
21
  | **`/async-kill` command** | Kill async agents (overlay picker or by name/id) |
@@ -452,28 +452,34 @@ variables. Pi-config settings (image_model, acpx_agents, vertex_claude_1m, etc.)
452
452
  go in `.pi/pi-config-settings.json` — not here:
453
453
 
454
454
  ```env
455
- # Timezone (host timezone for correct timestamps)
456
- TZ=Asia/Jerusalem
455
+ # Timezone (host timezone for correct timestamps) — plain value, no inline comment:
456
+ # `docker run --env-file` does not strip trailing comments.
457
+ TZ=America/New_York
457
458
 
458
459
  # Host username (creates /home/<user> -> container home symlink so host paths resolve)
459
- PI_HOST_USER=myakove
460
+ PI_HOST_USER=youruser
461
+
462
+ # Host UID/GID belong on the docker alias (see below), not hardcoded here:
463
+ # -e PI_HOST_UID="$(id -u)" -e PI_HOST_GID="$(id -g)"
464
+ # The entrypoint remaps user `node` to those ids so bind-mounted 0600/0700
465
+ # files (~/.npmrc, ~/.ssh) are readable. IPA/LDAP users are not UID 1000.
460
466
 
461
467
  # Google Cloud / Vertex AI
462
468
  GOOGLE_CLOUD_PROJECT=your-project-id
463
469
  GOOGLE_CLOUD_LOCATION=us-east5
464
- GOOGLE_APPLICATION_CREDENTIALS=/home/myakove/.config/gcloud/application_default_credentials.json
470
+ GOOGLE_APPLICATION_CREDENTIALS=/home/youruser/.config/gcloud/application_default_credentials.json
465
471
 
466
472
  # GitHub
467
473
  GITHUB_TOKEN=ghp_xxx
468
474
  GITHUB_API_TOKEN=ghp_xxx
469
- GH_CONFIG_DIR=/home/myakove/.config/gh
475
+ GH_CONFIG_DIR=/home/youruser/.config/gh
470
476
 
471
477
  # Gemini (optional — required for image generation)
472
478
  GEMINI_API_KEY=xxx
473
479
 
474
480
  # mcpl (MCP Launchpad) config path inside the container (must match mount target)
475
481
  # Use your actual home path — it resolves inside the container via the PI_HOST_USER symlink
476
- MCPL_CONFIG_FILES=/home/myakove/.config/mcpl/mcp.json
482
+ MCPL_CONFIG_FILES=/home/youruser/.config/mcpl/mcp.json
477
483
  ```
478
484
 
479
485
  Pass via `--env-file /path/to/.env` in the docker run command.
@@ -645,6 +651,7 @@ PI_PIDIFF_ENABLE=false pi
645
651
  | `kubectl` / `oc` | Kubernetes and OpenShift CLI |
646
652
  | `agent-browser` | Browser automation CLI (navigate, click, screenshot, forms) |
647
653
  | `procps` | Process utilities (ps, top, pgrep, pkill) |
654
+ | `passwd` | `usermod`/`groupmod` — init remaps user `node` to `PI_HOST_UID`/`PI_HOST_GID` |
648
655
  | `docker` / `podman` | Container CLIs (used via `docker-safe` read-only wrapper) |
649
656
  | `docker-safe` | Restricted Docker/Podman wrapper — container only (ps, logs, inspect, top, stats) |
650
657
  | `jq` | JSON processing |
@@ -701,6 +708,8 @@ alias pi-docker='docker pull ghcr.io/myk-org/pi-config:latest && \
701
708
  --name "pi-config-$(basename $PWD)-$(date +%s)" \
702
709
  --network host \
703
710
  --env-file "$HOME/.pi/.env" \
711
+ -e PI_HOST_UID="$(id -u)" \
712
+ -e PI_HOST_GID="$(id -g)" \
704
713
  -v "$PWD":"$PWD":rw \
705
714
  -v "$HOME/.pi":"$HOME/.pi":rw \
706
715
  -v "$HOME/.gitconfig":"$HOME/.gitconfig":ro \
@@ -722,11 +731,15 @@ alias pi-docker='docker pull ghcr.io/myk-org/pi-config:latest && \
722
731
 
723
732
  Then just run `pi-docker` from any project directory.
724
733
 
725
- > **Startup note:** The container runs as non-root user `node` (UID 1000).
734
+ > **Startup note:** The container runs as non-root user `node`. At start the
735
+ > entrypoint remaps `node` to `PI_HOST_UID`/`PI_HOST_GID` (pass `$(id -u)` /
736
+ > `$(id -g)` on the alias) so bind-mounted secrets match the host user.
737
+ > Without those env vars, `node` stays UID 1000 (breaks IPA/LDAP hosts).
726
738
  > `pi install` runs on each start.
727
739
  > A `WARNING` on stderr is normal when the package is already cached in `~/.pi`.
728
740
  > If pi misbehaves or the warning persists, verify network connectivity
729
- > and run `pi install git:github.com/myk-org/pi-config` manually.
741
+ > and run `pi install git:github.com/myk-org/pi-config` and
742
+ > `pi install git:github.com/myk-org/pi-config/packages/pi-vertex-claude` manually.
730
743
 
731
744
  ## Discord Bot
732
745
 
@@ -54,11 +54,35 @@ function discoverCursorModels(): DiscoveredCliModel[] {
54
54
  return models;
55
55
  }
56
56
 
57
+ /**
58
+ * Headless Cursor MCP: `--approve-mcps` is opt-in, not unconditional.
59
+ * `CLI_APPROVE_MCPS` wins; otherwise sidecar processes (`SIDECAR_PORT`, stamped
60
+ * by `startSidecar()` while running) get it because they have no TTY.
61
+ * Interactive pi omits the flag.
62
+ */
63
+ export function shouldApproveCursorMcps(
64
+ env: NodeJS.ProcessEnv = process.env,
65
+ ): boolean {
66
+ const explicit = env.CLI_APPROVE_MCPS;
67
+ if (typeof explicit === "string" && explicit.length > 0) {
68
+ const on = ["1", "true", "yes", "on"].includes(explicit.toLowerCase());
69
+ log.debug(`cursor --approve-mcps CLI_APPROVE_MCPS=${explicit} -> ${on}`);
70
+ return on;
71
+ }
72
+ const sidecar = typeof env.SIDECAR_PORT === "string" && env.SIDECAR_PORT.length > 0;
73
+ log.debug(`cursor --approve-mcps SIDECAR_PORT set=${sidecar}`);
74
+ return sidecar;
75
+ }
76
+
57
77
  export const cursorProvider: CliProviderDef = {
58
78
  name: "cursor",
59
79
  binary: "agent",
60
80
  buildBaseArgs: (model, cwd) => {
61
- // --trust: workspace; --force: auto-approve; stream-partial-output: live tokens
81
+ // --trust: workspace; --force: auto-approve tools
82
+ const approveMcps = shouldApproveCursorMcps();
83
+ log.debug(
84
+ `cursor buildBaseArgs approveMcps=${approveMcps} modelOverride=${Boolean(model && model !== "default")}`,
85
+ );
62
86
  const args = [
63
87
  "--print",
64
88
  "--trust",
@@ -69,6 +93,9 @@ export const cursorProvider: CliProviderDef = {
69
93
  "--workspace",
70
94
  cwd,
71
95
  ];
96
+ if (approveMcps) {
97
+ args.push("--approve-mcps");
98
+ }
72
99
  if (model && model !== "default") {
73
100
  args.unshift("--model", model);
74
101
  }
@@ -81,7 +81,8 @@ export const geminiProvider: CliProviderDef = {
81
81
  name: "gemini",
82
82
  binary: "gemini",
83
83
  buildBaseArgs: (model) => {
84
- // --skip-trust: workspace; --yolo: auto-approve all tool actions
84
+ // --skip-trust: skip TTY trust dialog (MCP still needs GEMINI_CLI_TRUST_WORKSPACE)
85
+ // --yolo: auto-approve all tool actions
85
86
  const args = [
86
87
  "--skip-trust",
87
88
  "--yolo",
@@ -15,6 +15,7 @@ import { geminiProvider } from "./agents/gemini.js";
15
15
  * |---------|--------|--------|--------|
16
16
  * | Workspace trust | `--trust` | skipped by `-p` | `--skip-trust` |
17
17
  * | Tool/command approve | `--force` (`--yolo` alias) | `--dangerously-skip-permissions` | `--yolo` |
18
+ * | Project MCP | `--approve-mcps` if `CLI_APPROVE_MCPS` or `SIDECAR_PORT` (`startSidecar()` stamps it while running) | n/a | `GEMINI_CLI_TRUST_WORKSPACE=true` unless parent already set |
18
19
  *
19
20
  * Trust alone is NOT enough: without auto-approve, tool calls that need
20
21
  * confirmation hang or fail (no interactive user).
@@ -3,8 +3,11 @@
3
3
  */
4
4
 
5
5
  import { spawn } from "node:child_process";
6
+ import { createLogger } from "../shared/logger.js";
6
7
  import type { CliAgentName } from "./providers.js";
7
8
  import { buildCliCommand } from "./providers.js";
9
+
10
+ const log = createLogger("cli_provider");
8
11
  import {
9
12
  parseCliOutput,
10
13
  StreamJsonAccumulator,
@@ -54,9 +57,23 @@ const STRIP_ENV = [
54
57
  "CURSOR_TRACE_ID",
55
58
  ];
56
59
 
57
- function childEnv(): NodeJS.ProcessEnv {
60
+ function childEnv(agent: CliAgentName): NodeJS.ProcessEnv {
58
61
  const env = { ...process.env };
59
62
  for (const k of STRIP_ENV) delete env[k];
63
+ // --skip-trust skips the TTY dialog; Gemini still treats /tmp (and other
64
+ // unsigned folders) as untrusted and will not connect project MCP unless
65
+ // this env is set (same class of headless gap as Cursor --approve-mcps).
66
+ // Default true for headless MCP; preserve an explicit parent value (e.g. false).
67
+ if (agent === "gemini") {
68
+ if (env.GEMINI_CLI_TRUST_WORKSPACE === undefined) {
69
+ env.GEMINI_CLI_TRUST_WORKSPACE = "true";
70
+ log.debug("gemini spawn: defaulting GEMINI_CLI_TRUST_WORKSPACE=true for project MCP");
71
+ } else {
72
+ log.debug(
73
+ `gemini spawn: preserving parent GEMINI_CLI_TRUST_WORKSPACE=${env.GEMINI_CLI_TRUST_WORKSPACE}`,
74
+ );
75
+ }
76
+ }
60
77
  return env;
61
78
  }
62
79
 
@@ -80,9 +97,12 @@ export function runCliAgent(opts: CliRunOptions): Promise<CliRunResult> {
80
97
  return;
81
98
  }
82
99
 
100
+ log.debug(
101
+ `runCliAgent spawn agent=${opts.agent} stream=${stream} timeoutMs=${timeoutMs ?? "none"} cwdBound=${Boolean(opts.cwd)}`,
102
+ );
83
103
  const child = spawn(binary, argv, {
84
104
  cwd: opts.cwd,
85
- env: childEnv(),
105
+ env: childEnv(opts.agent),
86
106
  stdio: ["pipe", "pipe", "pipe"],
87
107
  windowsHide: true,
88
108
  });
@@ -919,15 +919,31 @@ export function registerAsyncAgents(
919
919
  }), { mode: 0o600 });
920
920
 
921
921
  // Find the runner script
922
- const runnerPath = path.join(path.dirname(new URL(import.meta.url).pathname), "async-runner.ts");
922
+ const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "async-runner.ts");
923
923
 
924
924
  // Find jiti for TypeScript execution
925
925
  let jitiCliPath: string | undefined;
926
926
  // Strategy 1: resolve from pi's own require context
927
+ // NOTE: newer pi SDK "exports" maps hide ./package.json — fall back to
928
+ // resolving the main entry and walking up to its package root.
927
929
  try {
928
- const piPkgDir = path.dirname(require.resolve("@earendil-works/pi-coding-agent/package.json"));
929
- const candidate = path.join(piPkgDir, "node_modules/jiti/lib/jiti-cli.mjs");
930
- if (fs.existsSync(candidate)) jitiCliPath = candidate;
930
+ const req = createRequire(import.meta.url);
931
+ let piPkgJsonPath: string | undefined;
932
+ try {
933
+ piPkgJsonPath = req.resolve("@earendil-works/pi-coding-agent/package.json");
934
+ } catch {
935
+ const entry = req.resolve("@earendil-works/pi-coding-agent");
936
+ let dir = path.dirname(entry);
937
+ while (dir !== path.dirname(dir)) {
938
+ const pj = path.join(dir, "package.json");
939
+ if (fs.existsSync(pj) && JSON.parse(fs.readFileSync(pj, "utf8"))?.name === "@earendil-works/pi-coding-agent") { piPkgJsonPath = pj; break; }
940
+ dir = path.dirname(dir);
941
+ }
942
+ }
943
+ if (piPkgJsonPath) {
944
+ const candidate = path.join(path.dirname(piPkgJsonPath), "node_modules/jiti/lib/jiti-cli.mjs");
945
+ if (fs.existsSync(candidate)) jitiCliPath = candidate;
946
+ }
931
947
  } catch (e: any) {
932
948
  log.debug(`jiti strategy 1 (require.resolve) failed: ${e?.message || e}`);
933
949
  }
@@ -947,6 +963,28 @@ export function registerAsyncAgents(
947
963
  }
948
964
  }
949
965
 
966
+ // Strategy 3: this package's own node_modules — git/stow installs ship
967
+ // jiti themselves (npm consumers are covered by strategies 1-2).
968
+ if (!jitiCliPath) {
969
+ try {
970
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
971
+ const candidate = path.join(pkgRoot, "node_modules/jiti/lib/jiti-cli.mjs");
972
+ if (fs.existsSync(candidate)) jitiCliPath = candidate;
973
+ } catch (e: any) {
974
+ log.debug(`jiti strategy 3 (package root) failed: ${e?.message || e}`);
975
+ }
976
+ }
977
+
978
+ // Fail fast with a self-explanatory error instead of spawning a child that
979
+ // cannot load TypeScript (bare node dies on ".js"→".ts" specifiers).
980
+ if (!jitiCliPath) {
981
+ log.error(`async-spawn: ${id} — no jiti-cli.mjs found (strategies 1-3 failed); refusing to spawn`);
982
+ // Clean up the artifacts we already created — nothing will consume them.
983
+ try { fs.rmSync(configPath, { force: true }); } catch {}
984
+ try { fs.rmSync(workerDir, { recursive: true, force: true }); } catch {}
985
+ return { id, error: "jiti-cli.mjs not found — cannot execute async agent (see orchestrator logs)" };
986
+ }
987
+
950
988
  const spawnArgs = jitiCliPath
951
989
  ? [jitiCliPath, runnerPath, configPath]
952
990
  : [runnerPath, configPath];
@@ -957,6 +995,10 @@ export function registerAsyncAgents(
957
995
  PI_SUBAGENT_CHILD: "1",
958
996
  PI_AGENT_NAME: agentName,
959
997
  PI_PRIMARY_MODEL: process.env.PI_PRIMARY_MODEL || process.env.PI_MODEL || "",
998
+ // Isolate children from the shared jiti FS cache — other sessions running
999
+ // different node/jiti versions can poison it and kill children at boot.
1000
+ JITI_CACHE: "false",
1001
+ JITI_FS_CACHE: "false",
960
1002
  __PI_CONFIG_SESSION_ID: (globalThis as any).__piConfigSessionId || "",
961
1003
  __PI_PARENT_SESSION_ID: (globalThis as any).__piConfigSessionId || "",
962
1004
  };
@@ -1004,14 +1046,21 @@ export function registerAsyncAgents(
1004
1046
  };
1005
1047
  asyncState.jobs.set(id, job);
1006
1048
 
1049
+ const stderrLogPath = path.join(workerDir, "child-stderr.log");
1050
+ const stderrLog = fs.createWriteStream(stderrLogPath, { flags: "w" });
1051
+ stderrLog.on("error", () => {}); // never let a log write failure crash the session
1052
+ log.debug(`async-spawn: ${job.id} stderr → ${stderrLogPath}`);
1053
+
1007
1054
  const proc = spawn(process.execPath, spawnArgs, {
1008
1055
  cwd,
1009
- stdio: "ignore",
1056
+ stdio: ["ignore", "ignore", "pipe"],
1010
1057
  windowsHide: true,
1011
1058
  env: spawnEnv,
1012
1059
  });
1060
+ proc.stderr?.pipe(stderrLog);
1013
1061
 
1014
1062
  proc.once("error", (err) => {
1063
+ try { stderrLog.destroy(); } catch {}
1015
1064
  log.info(`spawn-error: ${job.id} — ${err.message}`);
1016
1065
  if (job.status === "complete" || job.status === "failed") return;
1017
1066
  job.status = "failed";
@@ -1037,8 +1086,27 @@ export function registerAsyncAgents(
1037
1086
  setTimeout(() => {
1038
1087
  if (job.status === "complete" || job.status === "failed") return;
1039
1088
  log.info(`spawn-exit: ${job.id} — code=${code} signal=${signal}`);
1089
+ let stderrTail = "";
1090
+ try {
1091
+ if (fs.existsSync(stderrLogPath)) {
1092
+ // Tail-read only — the log can be arbitrarily large on noisy failures.
1093
+ const size = fs.statSync(stderrLogPath).size;
1094
+ const start = Math.max(0, size - 4096);
1095
+ let raw = "";
1096
+ if (size > 0) {
1097
+ const fd = fs.openSync(stderrLogPath, "r");
1098
+ try {
1099
+ const buf = Buffer.alloc(Math.min(size - start, 4096));
1100
+ fs.readSync(fd, buf, 0, buf.length, start);
1101
+ raw = buf.toString("utf8");
1102
+ } finally { fs.closeSync(fd); }
1103
+ }
1104
+ stderrTail = raw.trim().slice(-1200);
1105
+ }
1106
+ } catch {}
1107
+ log.warn(`async-child failed: ${job.id} code=${code}${stderrTail ? " (see child-stderr.log)" : " (no stderr captured)"}`);
1040
1108
  job.status = "failed";
1041
- job.output = `Process exited with code ${code} signal ${signal}`;
1109
+ job.output = `Process exited with code ${code} signal ${signal}${stderrTail ? `\n--- child stderr (tail) ---\n${stderrTail}` : ""}`;
1042
1110
  job.durationMs = Date.now() - job.startedAt;
1043
1111
  job.updatedAt = Date.now();
1044
1112
  job.sideEffectsApplied = true;
@@ -152,18 +152,28 @@ export const MIN_PI_VERSION = "0.84.0";
152
152
 
153
153
  /** Get the installed pi version from its package.json. */
154
154
  export function getPiVersion(): string | null {
155
+ // 1) Resolve from the running binary — walk up until we find the package
156
+ // root (works for any layout: dist/cli.js, dist/bundle/cli.js, src/…).
155
157
  try {
156
- // Resolve from pi's own install location (process.argv[1] → dist/cli.js → package root)
157
158
  const piScript = process.argv[1];
158
159
  if (piScript) {
159
- const realPath = fs.realpathSync(piScript);
160
- const piPkgPath = path.join(path.dirname(path.dirname(realPath)), "package.json");
161
- if (fs.existsSync(piPkgPath)) {
162
- const pkg = JSON.parse(fs.readFileSync(piPkgPath, "utf-8"));
163
- if (pkg.name === "@earendil-works/pi-coding-agent" && pkg.version) return pkg.version;
160
+ let dir = path.dirname(fs.realpathSync(piScript));
161
+ while (dir !== path.dirname(dir)) {
162
+ const pkgPath = path.join(dir, "package.json");
163
+ if (fs.existsSync(pkgPath)) {
164
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
165
+ if (pkg.name === "@earendil-works/pi-coding-agent" && pkg.version) return pkg.version;
166
+ }
167
+ dir = path.dirname(dir);
164
168
  }
165
169
  }
166
170
  } catch {}
171
+ // 2) Ask the CLI the user actually runs — authoritative for wrapper/bundle layouts.
172
+ try {
173
+ const out = execFileSync("pi", ["--version"], { timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
174
+ const m = out.match(/\d+\.\d+\.\d+(?:-[\w.]+)?/);
175
+ if (m) return m[0];
176
+ } catch {}
167
177
  return null;
168
178
  }
169
179
 
@@ -35,6 +35,7 @@ import { loadAcpxRuntime, type AcpxRuntimeModule } from "../acpx-provider/load-r
35
35
  import { modelIdToDisplayName } from "../acpx-provider/runtime-models.js";
36
36
  import { buildExternalSystemPrompt } from "../shared/build-system-prompt.js";
37
37
  import { fileLog } from "../shared/file-logger.js";
38
+ import { resolveAdapterCwd, adapterMemoryKey } from "../shared/session-cwd.js";
38
39
  import { resolveBinary } from "../shared/resolve-binary.js";
39
40
 
40
41
  const LOG_DOMAIN = "acpx-driver";
@@ -73,34 +74,42 @@ type AcpRuntimeHandle = Awaited<ReturnType<AcpxRuntime["ensureSession"]>>;
73
74
  // Adapter
74
75
  // ---------------------------------------------------------------------------
75
76
 
76
- function createAcpxAdapter(
77
+ export function createAcpxAdapter(
77
78
  config: AcpxConfig,
78
79
  cwd: string,
79
80
  runtime: AcpxRuntime,
80
- cwdSlug: string,
81
81
  initialHandle?: AcpRuntimeHandle,
82
82
  ): ProviderAdapterShape {
83
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
84
+ `createAcpxAdapter bootCwdBound=${Boolean(cwd)} agent=${config.agent}`);
83
85
  const handles = new Map<string, AcpRuntimeHandle>();
84
86
  const prevCumulative = new Map<string, { inputTokens?: number; outputTokens?: number; totalTokens?: number }>();
85
- // Seed with the discovery handle so the first turn reuses it
87
+ // Seed with the discovery handle so the first turn reuses it when cwd matches boot
86
88
  if (initialHandle) {
87
- handles.set("default", initialHandle);
89
+ handles.set(adapterMemoryKey("default", cwd), initialHandle);
88
90
  }
89
91
  const pendingHandles = new Map<string, Promise<AcpRuntimeHandle>>();
90
92
  const systemPromptSent = new Set<string>();
93
+ const knownSessionIds = new Set<string>();
91
94
 
92
- function sessionKey(modelId?: string): string {
95
+ function handleMapKey(modelId: string | undefined, turnCwd: string): string {
96
+ return adapterMemoryKey(modelId, turnCwd);
97
+ }
98
+
99
+ function sessionKey(modelId: string | undefined, turnCwd: string): string {
100
+ const slug = createHash("sha256").update(turnCwd).digest("hex").slice(0, 12);
93
101
  const model = modelId && modelId !== "default"
94
102
  ? `-${modelId.replace(/[^a-zA-Z0-9.-]/g, "_")}`
95
103
  : "";
96
- return `pi-${config.agent}${model}-${cwdSlug}`;
104
+ return `pi-${config.agent}${model}-${slug}`;
97
105
  }
98
106
 
99
107
  async function ensureHandle(
100
108
  acpxModelId: string | undefined,
101
- systemPrompt?: string,
109
+ systemPrompt: string | undefined,
110
+ turnCwd: string,
102
111
  ): Promise<AcpRuntimeHandle> {
103
- const key = acpxModelId || "default";
112
+ const key = handleMapKey(acpxModelId, turnCwd);
104
113
 
105
114
  const existing = handles.get(key);
106
115
  if (existing) return existing;
@@ -118,11 +127,13 @@ function createAcpxAdapter(
118
127
  sessionOpts.systemPrompt = systemPrompt;
119
128
  }
120
129
 
130
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
131
+ `ensureSession agent=${config.agent} cwd=${turnCwd} boot=${cwd} model=${acpxModelId || "default"}`);
121
132
  const handle = await runtime.ensureSession({
122
- sessionKey: sessionKey(acpxModelId),
133
+ sessionKey: sessionKey(acpxModelId, turnCwd),
123
134
  agent: config.agent,
124
135
  mode: "persistent",
125
- cwd,
136
+ cwd: turnCwd,
126
137
  ...(Object.keys(sessionOpts).length > 0
127
138
  ? { sessionOptions: sessionOpts }
128
139
  : {}),
@@ -142,13 +153,17 @@ function createAcpxAdapter(
142
153
  return {
143
154
  startSession: async (opts: SessionStartOptions): Promise<SessionHandle> => {
144
155
  const model = opts.model || "default";
156
+ const turnCwd = resolveAdapterCwd(opts, cwd);
145
157
  const systemPrompt = opts.systemPrompt
146
158
  ? opts.systemPrompt
147
- : buildExternalSystemPrompt({ systemPrompt: undefined }, cwd);
148
- const handle = await ensureHandle(model, systemPrompt);
159
+ : buildExternalSystemPrompt({ systemPrompt: undefined }, turnCwd);
160
+ await ensureHandle(model, systemPrompt, turnCwd);
161
+ const sessionId = sessionKey(model, turnCwd);
162
+ knownSessionIds.add(sessionId);
149
163
  return {
150
- sessionId: sessionKey(model),
164
+ sessionId,
151
165
  model,
166
+ cwd: turnCwd,
152
167
  };
153
168
  },
154
169
 
@@ -157,13 +172,14 @@ function createAcpxAdapter(
157
172
  prompt: string,
158
173
  opts?: TurnOptions,
159
174
  ): Promise<TurnResult> => {
160
- const handleKey = handle.model || "default";
175
+ const turnCwd = resolveAdapterCwd(handle, cwd);
176
+ const handleKey = handleMapKey(handle.model, turnCwd);
161
177
  const needsSystemPrompt = !systemPromptSent.has(handleKey);
162
178
  const systemPrompt = needsSystemPrompt
163
- ? buildExternalSystemPrompt({ systemPrompt: undefined }, cwd)
179
+ ? buildExternalSystemPrompt({ systemPrompt: undefined }, turnCwd)
164
180
  : undefined;
165
181
 
166
- const acpxHandle = await ensureHandle(handle.model, systemPrompt);
182
+ const acpxHandle = await ensureHandle(handle.model, systemPrompt, turnCwd);
167
183
  if (needsSystemPrompt) {
168
184
  systemPromptSent.add(handleKey);
169
185
  }
@@ -259,7 +275,7 @@ function createAcpxAdapter(
259
275
  const su = status.usage;
260
276
  if (su?.cumulative) {
261
277
  const c = su.cumulative;
262
- const key = handle.model || "default";
278
+ const key = handleMapKey(handle.model, turnCwd);
263
279
  const prev = prevCumulative.get(key) || { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
264
280
  const deltaInput = (c.inputTokens ?? 0) - (prev.inputTokens ?? 0);
265
281
  const deltaOutput = (c.outputTokens ?? 0) - (prev.outputTokens ?? 0);
@@ -287,7 +303,8 @@ function createAcpxAdapter(
287
303
  },
288
304
 
289
305
  stopSession: async (handle: SessionHandle): Promise<void> => {
290
- const key = handle.model || "default";
306
+ const turnCwd = resolveAdapterCwd(handle, cwd);
307
+ const key = handleMapKey(handle.model, turnCwd);
291
308
  const acpxHandle = handles.get(key);
292
309
  if (acpxHandle) {
293
310
  await runtime.close({ handle: acpxHandle, reason: "session stop" }).catch((err: unknown) => {
@@ -298,6 +315,7 @@ function createAcpxAdapter(
298
315
  }
299
316
  systemPromptSent.delete(key);
300
317
  prevCumulative.delete(key);
318
+ knownSessionIds.delete(sessionKey(handle.model, turnCwd));
301
319
  },
302
320
 
303
321
  stopAll: async (): Promise<void> => {
@@ -315,15 +333,11 @@ function createAcpxAdapter(
315
333
  pendingHandles.clear();
316
334
  systemPromptSent.clear();
317
335
  prevCumulative.clear();
336
+ knownSessionIds.clear();
318
337
  },
319
338
 
320
339
  hasSession: (sessionId: string): boolean => {
321
- for (const key of handles.keys()) {
322
- if (sessionKey(key === "default" ? undefined : key) === sessionId) {
323
- return true;
324
- }
325
- }
326
- return false;
340
+ return knownSessionIds.has(sessionId);
327
341
  },
328
342
  };
329
343
  }
@@ -467,7 +481,7 @@ export const AcpxDriver: ProviderDriver<AcpxConfig> = {
467
481
  });
468
482
 
469
483
  // Create adapter
470
- const adapter = createAcpxAdapter(config, cwd, runtime, cwdSlug, initialHandle);
484
+ const adapter = createAcpxAdapter(config, cwd, runtime, initialHandle);
471
485
 
472
486
  return {
473
487
  instanceId,
@@ -46,6 +46,7 @@ import {
46
46
  } from "../cli-provider/sessions.js";
47
47
  import { buildExternalSystemPrompt } from "../shared/build-system-prompt.js";
48
48
  import { fileLog } from "../shared/file-logger.js";
49
+ import { resolveAdapterCwd, adapterMemoryKey, deleteKeysForCwd } from "../shared/session-cwd.js";
49
50
 
50
51
  const LOG_DOMAIN = "claude-driver";
51
52
  const DRIVER_KIND = "claude-cli";
@@ -82,14 +83,14 @@ function createClaudeAdapter(
82
83
  instanceId: string,
83
84
  ): ProviderAdapterShape & {
84
85
  bindPiSessionId: (sid: string) => void;
85
- handleSessionStart: (reason: string, sid: string | null) => void;
86
+ handleSessionStart: (reason: string, sid: string | null, sessionCwd?: string) => void;
86
87
  } {
87
88
  let piSessionId = createProvisionalPiSessionId();
88
89
  const systemPromptSent = new Set<string>();
89
90
  const storedSystemPrompts = new Map<string, string>();
90
91
 
91
- function sessionKeyFor(model: string): CliSessionKey {
92
- return { cwd, agent: "claude", model, piSessionId };
92
+ function sessionKeyFor(model: string, turnCwd: string = cwd): CliSessionKey {
93
+ return { cwd: turnCwd, agent: "claude", model, piSessionId };
93
94
  }
94
95
 
95
96
  return {
@@ -97,21 +98,27 @@ function createClaudeAdapter(
97
98
  if (sid) piSessionId = sid;
98
99
  },
99
100
 
100
- handleSessionStart: (reason: string, _sid: string | null) => {
101
+ handleSessionStart: (reason: string, _sid: string | null, sessionCwd?: string) => {
101
102
  if (reason === "new" || reason === "resume") {
102
- systemPromptSent.clear();
103
- storedSystemPrompts.clear();
103
+ const turnCwd = sessionCwd || cwd;
104
+ deleteKeysForCwd(systemPromptSent, turnCwd);
105
+ deleteKeysForCwd(storedSystemPrompts, turnCwd);
104
106
  }
105
107
  },
106
108
 
107
109
  startSession: async (opts: SessionStartOptions): Promise<SessionHandle> => {
108
110
  const model = opts.model || "default";
109
- if (opts.systemPrompt) storedSystemPrompts.set(model, opts.systemPrompt);
110
- const key = sessionKeyFor(model);
111
+ const turnCwd = resolveAdapterCwd(opts, cwd);
112
+ const memKey = adapterMemoryKey(model, turnCwd);
113
+ if (opts.systemPrompt) storedSystemPrompts.set(memKey, opts.systemPrompt);
114
+ const key = sessionKeyFor(model, turnCwd);
111
115
  const existingId = loadCliSessionId(key);
116
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
117
+ `startSession model=${model} cwd=${turnCwd} boot=${cwd}`);
112
118
  return {
113
119
  sessionId: existingId || `claude-${instanceId}-${model}`,
114
120
  model,
121
+ cwd: turnCwd,
115
122
  };
116
123
  },
117
124
 
@@ -120,9 +127,10 @@ function createClaudeAdapter(
120
127
  prompt: string,
121
128
  opts?: TurnOptions,
122
129
  ): Promise<TurnResult> => {
123
- const key = sessionKeyFor(handle.model);
130
+ const turnCwd = resolveAdapterCwd(handle, cwd);
131
+ const key = sessionKeyFor(handle.model, turnCwd);
124
132
  const sessionId = loadCliSessionId(key);
125
- const handleKey = handle.model || "default";
133
+ const handleKey = adapterMemoryKey(handle.model, turnCwd);
126
134
  const needsSystemPrompt = !systemPromptSent.has(handleKey);
127
135
 
128
136
  // Prefer the system prompt stored at startSession over rebuilding
@@ -130,7 +138,7 @@ function createClaudeAdapter(
130
138
  if (needsSystemPrompt) {
131
139
  systemPrompt =
132
140
  storedSystemPrompts.get(handleKey) ||
133
- buildExternalSystemPrompt({ systemPrompt: undefined }, cwd);
141
+ buildExternalSystemPrompt({ systemPrompt: undefined }, turnCwd);
134
142
  }
135
143
 
136
144
  // Apply system prompt to the prompt text
@@ -157,7 +165,7 @@ function createClaudeAdapter(
157
165
  runCliAgent({
158
166
  agent: "claude",
159
167
  model: handle.model === "default" ? "default" : handle.model,
160
- cwd,
168
+ cwd: turnCwd,
161
169
  prompt: p,
162
170
  sessionId: sid,
163
171
  signal: opts?.signal,
@@ -215,9 +223,10 @@ function createClaudeAdapter(
215
223
  },
216
224
 
217
225
  stopSession: async (handle: SessionHandle): Promise<void> => {
218
- const key = sessionKeyFor(handle.model);
226
+ const turnCwd = resolveAdapterCwd(handle, cwd);
227
+ const key = sessionKeyFor(handle.model, turnCwd);
219
228
  clearCliSessionId(key);
220
- const handleKey = handle.model || "default";
229
+ const handleKey = adapterMemoryKey(handle.model, turnCwd);
221
230
  systemPromptSent.delete(handleKey);
222
231
  storedSystemPrompts.delete(handleKey);
223
232
  },
@@ -35,6 +35,7 @@ import { loadAcpxRuntime, type AcpxRuntimeModule } from "../acpx-provider/load-r
35
35
  import { modelIdToDisplayName } from "../acpx-provider/runtime-models.js";
36
36
  import { buildExternalSystemPrompt } from "../shared/build-system-prompt.js";
37
37
  import { fileLog } from "../shared/file-logger.js";
38
+ import { resolveAdapterCwd, adapterMemoryKey } from "../shared/session-cwd.js";
38
39
 
39
40
  const LOG_DOMAIN = "cursor-acpx-driver";
40
41
  const DRIVER_KIND = "cursor-acpx";
@@ -76,24 +77,30 @@ function createCursorAcpxAdapter(
76
77
  config: CursorAcpxConfig,
77
78
  cwd: string,
78
79
  runtime: AcpxRuntime,
79
- cwdSlug: string,
80
80
  ): ProviderAdapterShape {
81
81
  const handles = new Map<string, AcpRuntimeHandle>();
82
82
  const pendingHandles = new Map<string, Promise<AcpRuntimeHandle>>();
83
83
  const systemPromptSent = new Set<string>();
84
+ const knownSessionIds = new Set<string>();
84
85
 
85
- function sessionKey(modelId?: string): string {
86
+ function handleMapKey(modelId: string | undefined, turnCwd: string): string {
87
+ return adapterMemoryKey(modelId, turnCwd);
88
+ }
89
+
90
+ function sessionKey(modelId: string | undefined, turnCwd: string): string {
91
+ const slug = createHash("sha256").update(turnCwd).digest("hex").slice(0, 12);
86
92
  const model = modelId && modelId !== "default"
87
93
  ? `-${modelId.replace(/[^a-zA-Z0-9.-]/g, "_")}`
88
94
  : "";
89
- return `pi-${config.agent}${model}-${cwdSlug}`;
95
+ return `pi-${config.agent}${model}-${slug}`;
90
96
  }
91
97
 
92
98
  async function ensureHandle(
93
99
  acpxModelId: string | undefined,
94
- systemPrompt?: string,
100
+ systemPrompt: string | undefined,
101
+ turnCwd: string,
95
102
  ): Promise<AcpRuntimeHandle> {
96
- const key = acpxModelId || "default";
103
+ const key = handleMapKey(acpxModelId, turnCwd);
97
104
 
98
105
  const existing = handles.get(key);
99
106
  if (existing) return existing;
@@ -111,11 +118,13 @@ function createCursorAcpxAdapter(
111
118
  sessionOpts.systemPrompt = systemPrompt;
112
119
  }
113
120
 
121
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
122
+ `ensureSession agent=${config.agent} cwd=${turnCwd} boot=${cwd} model=${acpxModelId || "default"}`);
114
123
  const handle = await runtime.ensureSession({
115
- sessionKey: sessionKey(acpxModelId),
124
+ sessionKey: sessionKey(acpxModelId, turnCwd),
116
125
  agent: config.agent,
117
126
  mode: "persistent",
118
- cwd,
127
+ cwd: turnCwd,
119
128
  ...(Object.keys(sessionOpts).length > 0
120
129
  ? { sessionOptions: sessionOpts }
121
130
  : {}),
@@ -135,13 +144,17 @@ function createCursorAcpxAdapter(
135
144
  return {
136
145
  startSession: async (opts: SessionStartOptions): Promise<SessionHandle> => {
137
146
  const model = opts.model || "default";
147
+ const turnCwd = resolveAdapterCwd(opts, cwd);
138
148
  const systemPrompt = opts.systemPrompt
139
149
  ? opts.systemPrompt
140
- : buildExternalSystemPrompt({ systemPrompt: undefined }, cwd);
141
- const handle = await ensureHandle(model, systemPrompt);
150
+ : buildExternalSystemPrompt({ systemPrompt: undefined }, turnCwd);
151
+ await ensureHandle(model, systemPrompt, turnCwd);
152
+ const sessionId = sessionKey(model, turnCwd);
153
+ knownSessionIds.add(sessionId);
142
154
  return {
143
- sessionId: sessionKey(model),
155
+ sessionId,
144
156
  model,
157
+ cwd: turnCwd,
145
158
  };
146
159
  },
147
160
 
@@ -150,13 +163,14 @@ function createCursorAcpxAdapter(
150
163
  prompt: string,
151
164
  opts?: TurnOptions,
152
165
  ): Promise<TurnResult> => {
153
- const handleKey = handle.model || "default";
166
+ const turnCwd = resolveAdapterCwd(handle, cwd);
167
+ const handleKey = handleMapKey(handle.model, turnCwd);
154
168
  const needsSystemPrompt = !systemPromptSent.has(handleKey);
155
169
  const systemPrompt = needsSystemPrompt
156
- ? buildExternalSystemPrompt({ systemPrompt: undefined }, cwd)
170
+ ? buildExternalSystemPrompt({ systemPrompt: undefined }, turnCwd)
157
171
  : undefined;
158
172
 
159
- const acpxHandle = await ensureHandle(handle.model, systemPrompt);
173
+ const acpxHandle = await ensureHandle(handle.model, systemPrompt, turnCwd);
160
174
  if (needsSystemPrompt) {
161
175
  systemPromptSent.add(handleKey);
162
176
  }
@@ -207,7 +221,8 @@ function createCursorAcpxAdapter(
207
221
  },
208
222
 
209
223
  stopSession: async (handle: SessionHandle): Promise<void> => {
210
- const key = handle.model || "default";
224
+ const turnCwd = resolveAdapterCwd(handle, cwd);
225
+ const key = handleMapKey(handle.model, turnCwd);
211
226
  const acpxHandle = handles.get(key);
212
227
  if (acpxHandle) {
213
228
  await runtime.close({ handle: acpxHandle, reason: "session stop" }).catch((err: unknown) => {
@@ -217,6 +232,7 @@ function createCursorAcpxAdapter(
217
232
  handles.delete(key);
218
233
  }
219
234
  systemPromptSent.delete(key);
235
+ knownSessionIds.delete(sessionKey(handle.model, turnCwd));
220
236
  },
221
237
 
222
238
  stopAll: async (): Promise<void> => {
@@ -233,15 +249,11 @@ function createCursorAcpxAdapter(
233
249
  handles.clear();
234
250
  pendingHandles.clear();
235
251
  systemPromptSent.clear();
252
+ knownSessionIds.clear();
236
253
  },
237
254
 
238
255
  hasSession: (sessionId: string): boolean => {
239
- for (const key of handles.keys()) {
240
- if (sessionKey(key === "default" ? undefined : key) === sessionId) {
241
- return true;
242
- }
243
- }
244
- return false;
256
+ return knownSessionIds.has(sessionId);
245
257
  },
246
258
  };
247
259
  }
@@ -376,7 +388,7 @@ export const CursorAcpxDriver: ProviderDriver<CursorAcpxConfig> = {
376
388
  });
377
389
 
378
390
  // Create adapter
379
- const adapter = createCursorAcpxAdapter(config, cwd, runtime, cwdSlug);
391
+ const adapter = createCursorAcpxAdapter(config, cwd, runtime);
380
392
 
381
393
  return {
382
394
  instanceId,
@@ -53,10 +53,28 @@ import {
53
53
  } from "../cli-provider/sessions.js";
54
54
  import { buildExternalSystemPrompt } from "../shared/build-system-prompt.js";
55
55
  import { fileLog } from "../shared/file-logger.js";
56
+ import { resolveAdapterCwd, adapterMemoryKey, deleteKeysForCwd } from "../shared/session-cwd.js";
56
57
 
57
58
  const LOG_DOMAIN = "cursor-cli-driver";
58
59
  const DRIVER_KIND = "cursor-cli";
59
60
 
61
+ /**
62
+ * Per-turn history reseed. Cwd-scoped `session_start` must not force a fresh
63
+ * CLI session on a different workspace. `forceHistorySeedGlobal` is only
64
+ * `setForceHistorySeed()` (explicit adapter-wide override).
65
+ */
66
+ export function cursorTurnNeedsHistorySeed(
67
+ turnCwd: string,
68
+ forceHistorySeedCwds: ReadonlySet<string>,
69
+ forceHistorySeedGlobal: boolean,
70
+ ): boolean {
71
+ const cwdListed = forceHistorySeedCwds.has(turnCwd);
72
+ const needed = forceHistorySeedGlobal || cwdListed;
73
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
74
+ `cursorTurnNeedsHistorySeed cwd=${turnCwd} global=${forceHistorySeedGlobal} cwdListed=${cwdListed} needed=${needed}`);
75
+ return needed;
76
+ }
77
+
60
78
  // ---------------------------------------------------------------------------
61
79
  // Config
62
80
  // ---------------------------------------------------------------------------
@@ -147,7 +165,7 @@ function buildPromptWithHistory(
147
165
  // Adapter
148
166
  // ---------------------------------------------------------------------------
149
167
 
150
- function createCursorCliAdapter(
168
+ export function createCursorCliAdapter(
151
169
  config: CursorCliConfig,
152
170
  cwd: string,
153
171
  instanceId: string,
@@ -155,28 +173,45 @@ function createCursorCliAdapter(
155
173
  /** Bind the real pi session UUID (from sessionManager). */
156
174
  bindPiSessionId: (sid: string) => void;
157
175
  /** Handle session_start event (reseed, marker cleanup). */
158
- handleSessionStart: (reason: string, sid: string | null) => void;
176
+ handleSessionStart: (reason: string, sid: string | null, sessionCwd?: string) => void;
159
177
  /** Force history re-seed on next turn. */
160
178
  setForceHistorySeed: (force: boolean) => void;
161
179
  } {
180
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
181
+ `createCursorCliAdapter bootCwdBound=${Boolean(cwd)} instance=${instanceId}`);
162
182
  let activePiSessionId: string | null = null;
163
183
  const provisionalPiSessionId = createProvisionalPiSessionId();
164
184
  let forceHistorySeed = false;
185
+ const forceHistorySeedCwds = new Set<string>();
165
186
  const systemPromptSent = new Set<string>();
166
187
  const storedSystemPrompts = new Map<string, string>();
167
188
  const sessionKeys = new Map<string, CliSessionKey>();
168
189
 
190
+ function memoryKey(model: string | undefined, turnCwd: string): string {
191
+ return adapterMemoryKey(model, turnCwd);
192
+ }
193
+
194
+ function markerCwds(): string[] {
195
+ const found = new Set<string>([cwd]);
196
+ for (const key of sessionKeys.values()) {
197
+ if (key.cwd) found.add(key.cwd);
198
+ }
199
+ return [...found];
200
+ }
201
+
169
202
  function resolvedPiSessionId(): string {
170
203
  return activePiSessionId || provisionalPiSessionId;
171
204
  }
172
205
 
173
- function sessionKeyFor(model: string): CliSessionKey {
174
- return { cwd, agent: "cursor", model, piSessionId: resolvedPiSessionId() };
206
+ function sessionKeyFor(model: string, turnCwd: string = cwd): CliSessionKey {
207
+ return { cwd: turnCwd, agent: "cursor", model, piSessionId: resolvedPiSessionId() };
175
208
  }
176
209
 
177
210
  function migrateMarkersToRealPiSessionId(sid: string): void {
178
211
  if (!sid || sid === provisionalPiSessionId) return;
179
- migrateAllCliSessionMarkers(cwd, provisionalPiSessionId, sid);
212
+ for (const markerCwd of markerCwds()) {
213
+ migrateAllCliSessionMarkers(markerCwd, provisionalPiSessionId, sid);
214
+ }
180
215
  for (const [handleKey, prevKey] of sessionKeys) {
181
216
  if (
182
217
  !prevKey.piSessionId ||
@@ -202,9 +237,10 @@ function createCursorCliAdapter(
202
237
  migrateMarkersToRealPiSessionId(sid);
203
238
  },
204
239
 
205
- handleSessionStart: (reason: string, sid: string | null) => {
240
+ handleSessionStart: (reason: string, sid: string | null, sessionCwd?: string) => {
206
241
  const prevSid = activePiSessionId;
207
242
  const resolvedSid = sid || activePiSessionId;
243
+ const turnCwd = sessionCwd || cwd;
208
244
  if (sid) {
209
245
  activePiSessionId = sid;
210
246
  migrateMarkersToRealPiSessionId(sid);
@@ -215,22 +251,27 @@ function createCursorCliAdapter(
215
251
  prevPiSessionId: prevSid,
216
252
  nextPiSessionId: resolvedSid,
217
253
  });
218
- forceHistorySeed = decision.forceHistorySeed;
254
+ if (decision.forceHistorySeed) {
255
+ forceHistorySeedCwds.add(turnCwd);
256
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
257
+ `handleSessionStart reseed cwd=${turnCwd} (not adapter-global)`);
258
+ }
219
259
 
220
260
  if (decision.action === "keep") {
221
261
  return;
222
262
  }
223
263
  if (decision.action === "reseed" && resolvedSid) {
224
264
  try {
225
- clearCliSessionsForPiSession(cwd, resolvedSid, {
265
+ clearCliSessionsForPiSession(turnCwd, resolvedSid, {
226
266
  includeLegacyDefault: prevSid == null || prevSid === "" || prevSid === "default",
227
267
  });
228
268
  } catch (err) {
229
269
  fileLog(LOG_DOMAIN, "warn", LOG_DOMAIN,
230
270
  `session_start marker cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
231
271
  }
232
- systemPromptSent.clear();
233
- storedSystemPrompts.clear();
272
+ deleteKeysForCwd(systemPromptSent, turnCwd);
273
+ deleteKeysForCwd(storedSystemPrompts, turnCwd);
274
+ deleteKeysForCwd(sessionKeys, turnCwd);
234
275
  }
235
276
  },
236
277
 
@@ -240,13 +281,18 @@ function createCursorCliAdapter(
240
281
 
241
282
  startSession: async (opts: SessionStartOptions): Promise<SessionHandle> => {
242
283
  const model = opts.model || "default";
243
- if (opts.systemPrompt) storedSystemPrompts.set(model, opts.systemPrompt);
244
- const key = sessionKeyFor(model);
245
- sessionKeys.set(model, key);
284
+ const turnCwd = resolveAdapterCwd(opts, cwd);
285
+ const memKey = memoryKey(model, turnCwd);
286
+ if (opts.systemPrompt) storedSystemPrompts.set(memKey, opts.systemPrompt);
287
+ const key = sessionKeyFor(model, turnCwd);
288
+ sessionKeys.set(memKey, key);
246
289
  const existingId = loadCliSessionId(key);
290
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
291
+ `startSession model=${model} cwd=${turnCwd} boot=${cwd}`);
247
292
  return {
248
293
  sessionId: existingId || `cursor-${instanceId}-${model}`,
249
294
  model,
295
+ cwd: turnCwd,
250
296
  };
251
297
  },
252
298
 
@@ -255,8 +301,9 @@ function createCursorCliAdapter(
255
301
  prompt: string,
256
302
  opts?: TurnOptions,
257
303
  ): Promise<TurnResult> => {
258
- const handleKey = handle.model || "default";
259
- const key = sessionKeyFor(handle.model);
304
+ const turnCwd = resolveAdapterCwd(handle, cwd);
305
+ const handleKey = memoryKey(handle.model, turnCwd);
306
+ const key = sessionKeyFor(handle.model, turnCwd);
260
307
  // Read previous key BEFORE updating so legacy-marker adoption can compare categories
261
308
  const prevKey = sessionKeys.get(handleKey);
262
309
  sessionKeys.set(handleKey, key);
@@ -268,23 +315,30 @@ function createCursorCliAdapter(
268
315
  sessionId = loadCliSessionId(key);
269
316
  }
270
317
 
271
- const needsSystemPrompt = !systemPromptSent.has(handleKey) || forceHistorySeed;
318
+ const reseedThisCwd = cursorTurnNeedsHistorySeed(
319
+ turnCwd,
320
+ forceHistorySeedCwds,
321
+ forceHistorySeed,
322
+ );
323
+ const needsSystemPrompt = !systemPromptSent.has(handleKey) || reseedThisCwd;
324
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
325
+ `sendTurn model=${handle.model} cwd=${turnCwd} boot=${cwd} reseed=${reseedThisCwd}`);
272
326
 
273
327
  // Prefer the system prompt stored at startSession over rebuilding
274
328
  let systemPrompt: string | undefined;
275
329
  if (needsSystemPrompt) {
276
330
  systemPrompt =
277
331
  storedSystemPrompts.get(handleKey) ||
278
- buildExternalSystemPrompt({ systemPrompt: undefined }, cwd);
332
+ buildExternalSystemPrompt({ systemPrompt: undefined }, turnCwd);
279
333
  }
280
334
 
281
335
  // Resolve history seed plan
282
336
  const seedPlan = resolveCliHistorySeed({
283
337
  hasCliSession: !!sessionId,
284
- forceHistorySeed,
338
+ forceHistorySeed: reseedThisCwd,
285
339
  });
286
340
  // Stale marker after /resume: drop it so we open a fresh CLI chat
287
- if (forceHistorySeed && sessionId) {
341
+ if (reseedThisCwd && sessionId) {
288
342
  clearCliSessionId(key);
289
343
  }
290
344
  const effectiveSessionId = seedPlan.useCliSession ? sessionId : null;
@@ -315,7 +369,7 @@ function createCursorCliAdapter(
315
369
  runCliAgent({
316
370
  agent: "cursor",
317
371
  model: handle.model === "default" ? "default" : handle.model,
318
- cwd,
372
+ cwd: turnCwd,
319
373
  prompt: p,
320
374
  sessionId: sid,
321
375
  signal: opts?.signal,
@@ -335,7 +389,8 @@ function createCursorCliAdapter(
335
389
  if (needsSystemPrompt) {
336
390
  systemPromptSent.add(handleKey);
337
391
  }
338
- if (forceHistorySeed) {
392
+ forceHistorySeedCwds.delete(turnCwd);
393
+ if (forceHistorySeed && reseedThisCwd) {
339
394
  forceHistorySeed = false;
340
395
  }
341
396
 
@@ -366,7 +421,8 @@ function createCursorCliAdapter(
366
421
  if (needsSystemPrompt) {
367
422
  systemPromptSent.add(handleKey);
368
423
  }
369
- if (forceHistorySeed) {
424
+ forceHistorySeedCwds.delete(turnCwd);
425
+ if (forceHistorySeed && reseedThisCwd) {
370
426
  forceHistorySeed = false;
371
427
  }
372
428
  return {
@@ -382,12 +438,14 @@ function createCursorCliAdapter(
382
438
  },
383
439
 
384
440
  stopSession: async (handle: SessionHandle): Promise<void> => {
385
- const key = sessionKeyFor(handle.model);
441
+ const turnCwd = resolveAdapterCwd(handle, cwd);
442
+ const memKey = memoryKey(handle.model, turnCwd);
443
+ const key = sessionKeyFor(handle.model, turnCwd);
386
444
  clearCliSessionId(key);
387
- const handleKey = handle.model || "default";
388
- sessionKeys.delete(handleKey);
389
- systemPromptSent.delete(handleKey);
390
- storedSystemPrompts.delete(handleKey);
445
+ sessionKeys.delete(memKey);
446
+ systemPromptSent.delete(memKey);
447
+ storedSystemPrompts.delete(memKey);
448
+ forceHistorySeedCwds.delete(turnCwd);
391
449
  },
392
450
 
393
451
  stopAll: async (): Promise<void> => {
@@ -42,6 +42,7 @@ import {
42
42
  } from "../cli-provider/sessions.js";
43
43
  import { buildExternalSystemPrompt } from "../shared/build-system-prompt.js";
44
44
  import { fileLog } from "../shared/file-logger.js";
45
+ import { resolveAdapterCwd, adapterMemoryKey, deleteKeysForCwd } from "../shared/session-cwd.js";
45
46
 
46
47
  const LOG_DOMAIN = "gemini-driver";
47
48
  const DRIVER_KIND = "gemini-cli";
@@ -78,14 +79,14 @@ function createGeminiAdapter(
78
79
  instanceId: string,
79
80
  ): ProviderAdapterShape & {
80
81
  bindPiSessionId: (sid: string) => void;
81
- handleSessionStart: (reason: string, sid: string | null) => void;
82
+ handleSessionStart: (reason: string, sid: string | null, sessionCwd?: string) => void;
82
83
  } {
83
84
  let piSessionId = createProvisionalPiSessionId();
84
85
  const systemPromptSent = new Set<string>();
85
86
  const storedSystemPrompts = new Map<string, string>();
86
87
 
87
- function sessionKeyFor(model: string): CliSessionKey {
88
- return { cwd, agent: "gemini", model, piSessionId };
88
+ function sessionKeyFor(model: string, turnCwd: string = cwd): CliSessionKey {
89
+ return { cwd: turnCwd, agent: "gemini", model, piSessionId };
89
90
  }
90
91
 
91
92
  return {
@@ -93,21 +94,27 @@ function createGeminiAdapter(
93
94
  if (sid) piSessionId = sid;
94
95
  },
95
96
 
96
- handleSessionStart: (reason: string, _sid: string | null) => {
97
+ handleSessionStart: (reason: string, _sid: string | null, sessionCwd?: string) => {
97
98
  if (reason === "new" || reason === "resume") {
98
- systemPromptSent.clear();
99
- storedSystemPrompts.clear();
99
+ const turnCwd = sessionCwd || cwd;
100
+ deleteKeysForCwd(systemPromptSent, turnCwd);
101
+ deleteKeysForCwd(storedSystemPrompts, turnCwd);
100
102
  }
101
103
  },
102
104
 
103
105
  startSession: async (opts: SessionStartOptions): Promise<SessionHandle> => {
104
106
  const model = opts.model || "default";
105
- if (opts.systemPrompt) storedSystemPrompts.set(model, opts.systemPrompt);
106
- const key = sessionKeyFor(model);
107
+ const turnCwd = resolveAdapterCwd(opts, cwd);
108
+ const memKey = adapterMemoryKey(model, turnCwd);
109
+ if (opts.systemPrompt) storedSystemPrompts.set(memKey, opts.systemPrompt);
110
+ const key = sessionKeyFor(model, turnCwd);
107
111
  const existingId = loadCliSessionId(key);
112
+ fileLog(LOG_DOMAIN, "debug", LOG_DOMAIN,
113
+ `startSession model=${model} cwd=${turnCwd} boot=${cwd}`);
108
114
  return {
109
115
  sessionId: existingId || `gemini-${instanceId}-${model}`,
110
116
  model,
117
+ cwd: turnCwd,
111
118
  };
112
119
  },
113
120
 
@@ -116,16 +123,17 @@ function createGeminiAdapter(
116
123
  prompt: string,
117
124
  opts?: TurnOptions,
118
125
  ): Promise<TurnResult> => {
119
- const key = sessionKeyFor(handle.model);
126
+ const turnCwd = resolveAdapterCwd(handle, cwd);
127
+ const key = sessionKeyFor(handle.model, turnCwd);
120
128
  const sessionId = loadCliSessionId(key);
121
- const handleKey = handle.model || "default";
129
+ const handleKey = adapterMemoryKey(handle.model, turnCwd);
122
130
  const needsSystemPrompt = !systemPromptSent.has(handleKey);
123
131
 
124
132
  let systemPrompt: string | undefined;
125
133
  if (needsSystemPrompt) {
126
134
  systemPrompt =
127
135
  storedSystemPrompts.get(handleKey) ||
128
- buildExternalSystemPrompt({ systemPrompt: undefined }, cwd);
136
+ buildExternalSystemPrompt({ systemPrompt: undefined }, turnCwd);
129
137
  }
130
138
 
131
139
  let finalPrompt = prompt;
@@ -150,7 +158,7 @@ function createGeminiAdapter(
150
158
  runCliAgent({
151
159
  agent: "gemini",
152
160
  model: handle.model === "default" ? "default" : handle.model,
153
- cwd,
161
+ cwd: turnCwd,
154
162
  prompt: p,
155
163
  sessionId: sid,
156
164
  signal: opts?.signal,
@@ -202,9 +210,10 @@ function createGeminiAdapter(
202
210
  },
203
211
 
204
212
  stopSession: async (handle: SessionHandle): Promise<void> => {
205
- const key = sessionKeyFor(handle.model);
213
+ const turnCwd = resolveAdapterCwd(handle, cwd);
214
+ const key = sessionKeyFor(handle.model, turnCwd);
206
215
  clearCliSessionId(key);
207
- const handleKey = handle.model || "default";
216
+ const handleKey = adapterMemoryKey(handle.model, turnCwd);
208
217
  systemPromptSent.delete(handleKey);
209
218
  storedSystemPrompts.delete(handleKey);
210
219
  },
@@ -59,6 +59,10 @@ import {
59
59
  readPiSessionIdFromManager,
60
60
  } from "../cli-provider/sessions.js";
61
61
  import { fileLog } from "../shared/file-logger.js";
62
+ import {
63
+ enterSessionCwd,
64
+ resolveProviderStreamCwd,
65
+ } from "../shared/session-cwd.js";
62
66
  import { isCliAgentName } from "../cli-provider/providers.js";
63
67
  import {
64
68
  isProvidersInitialized,
@@ -152,14 +156,19 @@ function makeStreamFunction(
152
156
  const colonIdx = model.id.indexOf(":");
153
157
  const driverModelId = colonIdx >= 0 ? model.id.substring(colonIdx + 1) : "default";
154
158
 
159
+ const turnCwd = resolveProviderStreamCwd(projectCwd);
160
+ log.debug(
161
+ `${kind}-${agent} stream cwd=${turnCwd} boot=${projectCwd} model=${driverModelId}`,
162
+ );
163
+
155
164
  // Build system prompt
156
- const systemPrompt = buildExternalSystemPrompt(context, projectCwd);
165
+ const systemPrompt = buildExternalSystemPrompt(context, turnCwd);
157
166
 
158
167
  // Ensure session
159
168
  const handle = await instance.adapter.startSession({
160
169
  model: driverModelId,
161
170
  systemPrompt,
162
- cwd: projectCwd,
171
+ cwd: turnCwd,
163
172
  });
164
173
 
165
174
  // Extract latest user message
@@ -283,10 +292,22 @@ async function registerAcpxAgent(
283
292
  const log = createLogger("providers");
284
293
  log.debug("providers module loaded");
285
294
 
295
+ const cwdHookBound = new WeakSet<object>();
296
+
286
297
  export default async function (pi: ExtensionAPI) {
287
298
  // pi --help / --version — skip discovery
288
299
  if (isPiMetaInvocation()) return;
289
300
 
301
+ // Always bind this AgentSession's cwd, even when the factory early-returns.
302
+ // Sidecar user sessions load this extension after the internal registrar
303
+ // already marked initialized — without this hook they never stash cwd (#768).
304
+ if (!cwdHookBound.has(pi)) {
305
+ cwdHookBound.add(pi);
306
+ pi.on("before_agent_start", (_event, ctx) => {
307
+ enterSessionCwd(typeof ctx.cwd === "string" ? ctx.cwd : "");
308
+ });
309
+ }
310
+
290
311
  // Idempotency guard — shims re-export this default, so it may be called
291
312
  // multiple times (cli-provider shim, acpx-provider shim, providers dir).
292
313
  // Only run once per session; session_shutdown resets so /new|/resume|/fork
@@ -358,7 +379,7 @@ export default async function (pi: ExtensionAPI) {
358
379
  for (const inst of cliInstances.values()) {
359
380
  const adapter = inst.adapter as any;
360
381
  if (typeof adapter.handleSessionStart === "function") {
361
- adapter.handleSessionStart(reason, sid);
382
+ adapter.handleSessionStart(reason, sid, ctx.cwd);
362
383
  }
363
384
  }
364
385
  });
@@ -117,6 +117,8 @@ export interface SessionStartOptions {
117
117
  export interface SessionHandle {
118
118
  readonly sessionId: string;
119
119
  readonly model: string;
120
+ /** Session/project cwd for this turn's CLI/ACPX spawn (#768). */
121
+ readonly cwd?: string;
120
122
  }
121
123
 
122
124
  /** Options for sending a turn. */
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Per-turn session cwd for CLI/ACPX spawn (#768).
3
+ *
4
+ * Pi stream() has no cwd. Sidecar sessions and extension before_agent_start
5
+ * stash the folder here. Drivers read it instead of process.cwd() from boot.
6
+ *
7
+ * Store lives on globalThis via Symbol.for so jiti, tsx, and sidecar share
8
+ * one AsyncLocalStorage even when this file is loaded more than once.
9
+ */
10
+
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+ import { createLogger } from "./logger.js";
13
+
14
+ const log = createLogger("providers");
15
+
16
+ /** Must match packages/pi-sidecar/src/session-cwd.ts — do not change one copy. */
17
+ export const SESSION_CWD_ALS_ID = "pi-config.sessionCwdAls";
18
+ const ALS_KEY = Symbol.for(SESSION_CWD_ALS_ID);
19
+
20
+ /** Unit separator — model ids and paths cannot contain this. */
21
+ const MEMORY_SEP = "\x1f";
22
+
23
+ function getAls(): AsyncLocalStorage<string> {
24
+ const g = globalThis as unknown as Record<
25
+ symbol,
26
+ AsyncLocalStorage<string> | undefined
27
+ >;
28
+ let als = g[ALS_KEY];
29
+ if (!als) {
30
+ als = new AsyncLocalStorage<string>();
31
+ g[ALS_KEY] = als;
32
+ log.debug("session cwd ALS created on globalThis");
33
+ }
34
+ return als;
35
+ }
36
+
37
+ /** Cwd bound for the current async turn, if any. */
38
+ export function getSessionCwd(): string | undefined {
39
+ const value = getAls().getStore();
40
+ const cwd = typeof value === "string" && value.length > 0 ? value : undefined;
41
+ log.debug(`getSessionCwd bound=${cwd !== undefined}`);
42
+ return cwd;
43
+ }
44
+
45
+ /** Bind cwd for the rest of this async resource (before_agent_start). */
46
+ export function enterSessionCwd(cwd: string): void {
47
+ if (typeof cwd !== "string" || cwd.length === 0) {
48
+ log.warn("enterSessionCwd skipped: empty cwd");
49
+ return;
50
+ }
51
+ if (getSessionCwd()) {
52
+ log.debug(`enterSessionCwd skipped: ALS already bound cwd=${getSessionCwd()} incoming=${cwd}`);
53
+ return;
54
+ }
55
+ getAls().enterWith(cwd);
56
+ log.debug(`enterSessionCwd cwd=${cwd}`);
57
+ }
58
+
59
+ /** Run fn with cwd bound for the full async tree (sidecar prompt()). */
60
+ export function runWithSessionCwd<T>(cwd: string, fn: () => T): T {
61
+ if (typeof cwd !== "string" || cwd.length === 0) {
62
+ log.warn("runWithSessionCwd empty cwd; running unbound");
63
+ return fn();
64
+ }
65
+ log.debug(`runWithSessionCwd cwd=${cwd}`);
66
+ return getAls().run(cwd, fn);
67
+ }
68
+
69
+ /** Stream path: ALS session cwd, else boot projectCwd. */
70
+ export function resolveProviderStreamCwd(bootCwd: string): string {
71
+ const fromAls = getSessionCwd();
72
+ if (fromAls) {
73
+ log.debug(`stream cwd from ALS=${fromAls} boot=${bootCwd}`);
74
+ return fromAls;
75
+ }
76
+ log.warn(`stream cwd fallback boot=${bootCwd} (ALS empty)`);
77
+ return bootCwd;
78
+ }
79
+
80
+ /** Spawn path: handle.cwd from startSession, else adapter create-time cwd. */
81
+ export function resolveAdapterCwd(
82
+ handle: { cwd?: string },
83
+ fallback: string,
84
+ ): string {
85
+ if (typeof handle.cwd === "string" && handle.cwd.length > 0) {
86
+ return handle.cwd;
87
+ }
88
+ return fallback;
89
+ }
90
+
91
+ /** In-memory adapter maps: one entry per model+cwd so sidecar jobs stay isolated. */
92
+ export function adapterMemoryKey(model: string | undefined, turnCwd: string): string {
93
+ return `${model || "default"}${MEMORY_SEP}${turnCwd}`;
94
+ }
95
+
96
+ /** True when `key` was built for this cwd via adapterMemoryKey. */
97
+ export function adapterMemoryKeyMatchesCwd(key: string, turnCwd: string): boolean {
98
+ return key.endsWith(`${MEMORY_SEP}${turnCwd}`);
99
+ }
100
+
101
+ /** Drop in-memory adapter entries for one session cwd (leave other jobs alone). */
102
+ export function deleteKeysForCwd(
103
+ store: { keys(): IterableIterator<string>; delete(key: string): unknown },
104
+ turnCwd: string,
105
+ ): void {
106
+ for (const k of [...store.keys()]) {
107
+ if (adapterMemoryKeyMatchesCwd(k, turnCwd)) store.delete(k);
108
+ }
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.3.3",
3
+ "version": "4.3.5",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"