pig-claude-bridge 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +4 -7
  2. package/UPSTREAM.md +11 -6
  3. package/docs/compatibility.md +16 -13
  4. package/extensions/pig-claude-bridge/extension.go +16 -3
  5. package/extensions/pig-claude-bridge/internal/bridge/askclaude.go +6 -6
  6. package/extensions/pig-claude-bridge/internal/bridge/consume.go +78 -59
  7. package/extensions/pig-claude-bridge/internal/bridge/hooks.go +39 -28
  8. package/extensions/pig-claude-bridge/internal/bridge/host.go +13 -5
  9. package/extensions/pig-claude-bridge/internal/bridge/isolated.go +5 -1
  10. package/extensions/pig-claude-bridge/internal/bridge/provider.go +258 -100
  11. package/extensions/pig-claude-bridge/internal/bridge/query.go +71 -11
  12. package/extensions/pig-claude-bridge/internal/bridge/ratelimit.go +13 -2
  13. package/extensions/pig-claude-bridge/internal/bridge/recording.go +672 -0
  14. package/extensions/pig-claude-bridge/internal/bridge/recordtap.go +211 -0
  15. package/extensions/pig-claude-bridge/internal/bridge/sessionstore.go +183 -0
  16. package/extensions/pig-claude-bridge/internal/bridge/state.go +130 -103
  17. package/extensions/pig-claude-bridge/internal/bridge/stream.go +38 -14
  18. package/extensions/pig-claude-bridge/internal/bridge/sync.go +192 -150
  19. package/extensions/pig-claude-bridge/internal/ccproto/args.go +28 -3
  20. package/extensions/pig-claude-bridge/internal/ccsession/records.go +8 -0
  21. package/extensions/pig-claude-bridge/internal/ccsession/repair.go +22 -4
  22. package/extensions/pig-claude-bridge/internal/ccsession/session.go +26 -5
  23. package/extensions/pig-claude-bridge/internal/ccsession/transcript.go +287 -0
  24. package/extensions/pig-claude-bridge/internal/config/config.go +12 -16
  25. package/extensions/pig-claude-bridge/internal/convert/convert.go +53 -84
  26. package/extensions/pig-claude-bridge/internal/digest/digest.go +300 -0
  27. package/package.json +1 -1
package/README.md CHANGED
@@ -52,12 +52,9 @@ The plan shows from session start, fast mode as soon as `/claude-fast` changes i
52
52
 
53
53
  ## Configuration
54
54
 
55
- Config file `claude-bridge.json`, in two places (the project file is merged over the global one, key by key):
55
+ Config file `claude-bridge.json` in PiG's agent directory, `~/.pig/agent` by default (`$PIG_HOME/agent`, or `PIG_CODING_AGENT_DIR`). With `PIG_USE_PI_DIRS=1` PiG shares Pi's tree and this is `~/.pi/agent` (or `PI_CODING_AGENT_DIR`). There is no project config (pi-claude-bridge #198): a cloned repo could otherwise point the bridge at its own Claude Code executable or turn on metered billing. For the same reason Claude Code loads only `~/.claude/settings.json` on the provider path, never a repo's `.claude/settings.json` or `settings.local.json`.
56
56
 
57
- - global: PiG's agent directory, `~/.pig/agent` by default (`$PIG_HOME/agent`, or `PIG_CODING_AGENT_DIR`). With `PIG_USE_PI_DIRS=1` PiG shares Pi's tree and this is `~/.pi/agent` (or `PI_CODING_AGENT_DIR`).
58
- - project: `<project>/.pig/claude-bridge.json`; under `PIG_USE_PI_DIRS=1`, `<project>/.pi/claude-bridge.json`.
59
-
60
- The extension asks PiG for both directories; nothing is hard-coded. A missing or unparseable file counts as empty; with `CLAUDE_BRIDGE_DEBUG` set, a parse failure is written to the debug log. A key with a value of the wrong type is ignored, except `askClaude.allowFullMode` and `askClaude.defaultMode`, which fail closed (full mode denied, mode `none`).
57
+ The extension asks PiG for the directory; nothing is hard-coded. A missing or unparseable file counts as empty; with `CLAUDE_BRIDGE_DEBUG` set, a parse failure is written to the debug log. A key with a value of the wrong type is ignored, except `askClaude.allowFullMode` and `askClaude.defaultMode`, which fail closed (full mode denied, mode `none`).
61
58
 
62
59
  ```json
63
60
  {
@@ -184,9 +181,9 @@ Claude Code sends a `rate_limit_event` when a usage window changes. The TypeScri
184
181
 
185
182
  ## Known issues
186
183
 
187
- **A session rebuild re-sends the whole conversation.** The bridge rewrites Claude Code's session from PiG's history whenever the two diverge (after an abort, `/compact`, tree navigation, an API error, or on returning to a session), and the next request usually misses the prompt cache for everything past the system prompt. Abort-heavy sessions cost noticeably more.
184
+ **Session sync.** The bridge compares PiG's history with what Claude Code's session file covers, message by message. An unchanged history resumes the session; a rewind, edit, abort, failed turn or restart forks or trims it at the last point both agree and imports only what follows; a full import happens only when the model, the system prompt or the working directory changed. The pointer from a PiG session to its Claude Code session lives under `<agent dir>/claude-bridge/sessions/` (user-only files), so a restart or `/reload` forks the earlier session instead of re-sending the conversation, and a `/fork` or clone continues from its parent's. Each import or trim still costs that turn a prompt-cache miss for what it rewrote.
188
185
 
189
- **Files Claude Code edits are not carried across a rebuild.** The edit itself survives in the history as a tool call and result; what is lost is the post-edit file snapshot. `@file` expansions *are* carried.
186
+ **Files Claude Code edits are not carried across an import.** The edit itself survives in the history as a tool call and result; what is lost is the post-edit file snapshot. `@file` expansions and tool results Claude Code saved to a file *are* carried.
190
187
 
191
188
  **System prompt changes mid-session may not reach the model.** Project context (AGENTS.md/CLAUDE.md), skills, and extension-written instructions are captured on the first request and reused on resume. This keeps the cached prefix stable, but later changes may not take effect until a rebuild or compaction. Start a new session if updated instructions must take effect immediately.
192
189
 
package/UPSTREAM.md CHANGED
@@ -6,8 +6,8 @@ This extension is a Go port of [pi-claude-bridge](https://github.com/elidickinso
6
6
 
7
7
  | what | pin |
8
8
  |---|---|
9
- | pi-claude-bridge | `6aa276a1498e5376363f5c1f8cf04bfcaee38c93`, plus these later commits ported on their own: f502f5d (silent-turn reminder, MCP description limit), 2edde93 (interrupted-turn resume), 5c4a268 (default output style), e93d51d (parent-session env markers), 0b096a8 (thinking off). The 0.10.0 session-sync rework (#222) and the AskClaude split (0.11.0) are not ported |
10
- | cc-session-io | 0.4.0 (MIT), the subset the bridge imports |
9
+ | pi-claude-bridge | `acbe45b` (0.11.0). The AskClaude package split and `src/cache-trace.ts` are not ported (docs/compatibility.md D8, D9); `src/index.ts` line ranges below are those of 6aa276a, where the code moved they name the 0.11.0 function instead |
10
+ | cc-session-io | 0.4.1 (MIT), the subset the bridge imports |
11
11
  | `@anthropic-ai/claude-agent-sdk` | 0.3.310 (Claude Code 2.1.310). The protocol client was written from the SDK's public type definitions and live probes ([protocol reference](docs/reference/agent-sdk-protocol-0.3.310.md)); the SDK itself is not vendored or depended on |
12
12
  | Claude Code | 2.1.310 (`ccproto.MinVersion`) |
13
13
  | PiG SDK | `github.com/MichaelKinsy/PiG/extensions/sdk` v0.7.0 (PiG 0.7.0); the [signatures](docs/reference/pig-sdk-signatures-v0.5.3.md) recorded at v0.5.3 still match every symbol the bridge uses |
@@ -26,7 +26,10 @@ Paths are relative to `extensions/pig-claude-bridge/`. TypeScript paths are `src
26
26
  | `internal/askclaude/tool.go` | `index.ts` 2662-2780 and the AskClaude call handler near 1750 | tool definition, remembered/isolated sessions, notices |
27
27
  | `internal/askclaude/ui.go` | `askclaude-ui.ts` | action summary and tool-name mapping, rendered as lines (D6) |
28
28
  | `internal/bridge/askclaude.go` | `index.ts` `promptAndWait` (2263) | AskClaude query path |
29
- | `internal/bridge/consume.go` | `index.ts` 1028-1100, 1486-1790 (`consumeQuery`, steering, tool-result delivery, abort, discard) | goroutines and channels instead of the event loop (D3) |
29
+ | `internal/bridge/consume.go` | `index.ts` 1028-1100, 1486-1790 (`consumeQuery`, steering, tool-result delivery, abort, discard; 0.11.0 `discardRewrittenQuery`, `dropParkedQuery`, `deliverToolResults`) | goroutines and channels instead of the event loop (D3) |
30
+ | `internal/bridge/recording.go` | `session-sync.ts` (`Recording`, `compareHistory`, `importHistory`, `LiveRecording`), `session-transcript.ts` (`resolveRecordingCut`, `recordedToolResults`) | mutex-guarded |
31
+ | `internal/bridge/recordtap.go` | 0.11.0 `index.ts` `tapRecording`, `incorporate`, `recordAssistantEmission`, `completeAssistantRecording` | |
32
+ | `internal/bridge/sessionstore.go` | `session-store.ts` | `""` stands for a null uuid in memory; the file keeps JSON null |
30
33
  | `internal/bridge/debug.go` | `index.ts` 36-150 (`debug`, `makeCliDebugOptions`, `diagDump`) | |
31
34
  | `internal/bridge/hooks.go` | `index.ts` 2463-2660 (event handlers) | `session_start`, `before_agent_start`, `agent_start`, `turn_start`, compaction/tree, shutdown |
32
35
  | `internal/bridge/host.go` | new (Go-only) | `Host` interface and `Deps`, so the PiG SDK stays confined to `extension.go` and the bridge is testable with a fake host |
@@ -37,12 +40,14 @@ Paths are relative to `extensions/pig-claude-bridge/`. TypeScript paths are `src
37
40
  | `internal/bridge/query.go` | `query-state.ts`, `prompt-stream.ts`, `index.ts` 480-494 (`newAssistantOutput`) | |
38
41
  | `internal/bridge/ratelimit.go` | `rate-limit.ts`, `index.ts` 510-550 | headers go to the debug log only (D1) |
39
42
  | `internal/bridge/sessioncwd.go` | `session-cwd.ts` | most-recent-pending summary slot (D7) |
40
- | `internal/bridge/state.go` | `index.ts` 187-340 (`SessionState`, `sessionKey`, `markRebuildForSession`, `armStaleContexts`) | `sponsorMarkRebuildForSession` not ported (D4) |
43
+ | `internal/bridge/state.go` | 0.11.0 `index.ts` "Session persistence" (`sessionStateFor`, `inheritSessionState`, `setSessionStateFor`, `markRebuildForSession`, `armStaleContexts`, `waitForSessionWriter`) | `sponsorMarkRebuildForSession` not ported (D4) |
41
44
  | `internal/bridge/stream.go` | `index.ts` 1101-1216, 1215-1485 (`processStreamEvent`, `processAssistantMessage`, finalisation) | |
42
- | `internal/bridge/sync.go` | `index.ts` 341-420, 600-720 (`syncSharedSession`, `turnStart`, `convertAndImportMessages`, `verifyWrittenSession`, `safeRealpath`), 219-237 (`readCarriedAttachments`) | |
45
+ | `internal/bridge/sync.go` | 0.11.0 `index.ts` `syncSharedSession`, `turnStart`, `verifyWrittenSession`, `safeRealpath`, `debugSessionPaths` | `importForAskClaude` is the port's (D8) |
43
46
  | `internal/bridge/toolargs.go` | `index.ts` 914-1018 (`piToolNameFor`, `mapToolArgs`) | |
44
47
  | `internal/bridge/usage.go` | `usage.ts`, `index.ts` `recordUsage` | |
45
- | `internal/ccproto/args.go` | `index.ts` 53-57 (`CC_CHILD_ENV`), 2030-2096 (query options); the SDK's argv builder (`sdk.mjs`) | argv pinned by `testdata/sdk-argv-snapshot.json` |
48
+ | `internal/ccproto/args.go` | `cc-child.ts` (`CC_CHILD_ENV`, `PARENT_SESSION_VARS`, `SESSION_STORAGE_VARS`, `ccChildEnv`), `index.ts` query options; the SDK's argv builder (`sdk.mjs`) | argv pinned by `testdata/sdk-argv-snapshot.json`; `CLAUDE_CODE_DISABLE_FEATURE_FLAGS` is the port's |
49
+ | `internal/ccsession/transcript.go` | `session-transcript.ts` (`readTranscript`, `findBoundary`, `resolveCut`, `savePrefixSession`, the tool-result index of `recordedToolResults`) | |
50
+ | `internal/digest/digest.go` | `message-digests.ts` | pinned by `tests/fixtures/message-digests.json` (`scripts/dev/gen-digests.mjs`) |
46
51
  | `internal/ccproto/control.go` | the SDK's control protocol (`sdk.mjs`), reference sections 2 to 4 | requests, responses, inbound `mcp_message`, interrupt |
47
52
  | `internal/ccproto/exec.go` | `index.ts` executable options; the SDK's executable handling | `claude` from `PATH` and version check (D2) |
48
53
  | `internal/ccproto/messages.go` | the SDK's `SDKUserMessage` type, reference section 4 | |
@@ -10,8 +10,8 @@ What this port is pinned to, and every place where it differs from the TypeScrip
10
10
  | PiG Go SDK | `github.com/MichaelKinsy/PiG/extensions/sdk` v0.7.0, Go 1.26 floor |
11
11
  | Claude Code | 2.1.310 minimum (the port warns when the binary is older); verified against 2.1.319 |
12
12
  | Agent SDK protocol | `@anthropic-ai/claude-agent-sdk` 0.3.310 (Claude Code 2.1.310); [reference](reference/agent-sdk-protocol-0.3.310.md). 0.3.308 to 0.3.310 changes nothing on the wire that the bridge uses; the one new control subtype, survey feedback, is never sent |
13
- | pi-claude-bridge | `6aa276a`, https://github.com/elidickinson/pi-claude-bridge, plus the later commits listed in [../UPSTREAM.md](../UPSTREAM.md) (see "Ported after 6aa276a" below) |
14
- | cc-session-io | 0.4.0 (MIT) |
13
+ | pi-claude-bridge | `acbe45b` (0.11.0), https://github.com/elidickinson/pi-claude-bridge; see "Caught up to 0.11.0" below for what is not ported |
14
+ | cc-session-io | 0.4.1 (MIT), the subset the bridge imports (`addToolResultBlocks` as `AddToolResultBlocks`; the 0.4.1 timestamp stamping is approximated by the port's monotonic clock) |
15
15
  | Pi compatibility surface | pi 1.4.0 (the Pi that PiG 0.5.3 tracks) |
16
16
  | `@modelcontextprotocol/sdk` | 1.31.0 (the TypeScript declares `^1.29.0`), only as the reference for the JSON-RPC responses the in-process MCP server produces |
17
17
  | Model catalog | `@earendil-works/pi-ai` 1.2.0, generated into `internal/models/catalog_gen.go` |
@@ -32,6 +32,12 @@ What this port is pinned to, and every place where it differs from the TypeScrip
32
32
 
33
33
  **D7. Summary attribution by the most recent pending compaction.** TypeScript: `session-cwd.ts` keys the pending summary session by the identity of the request's `AbortSignal`. Port: Go has no such identity, so a single most-recent-pending slot is set at `session_before_compact` / `session_before_tree` and the isolated summary query reads it; it is not cleared by the read (Pi makes several summary calls per operation, compaction makes two) and `forgetSummarySession` clears it only if no other session has been remembered since. With two sessions summarising at once in one process, the later one wins. A compaction that fails or is aborted never reaches `session_compact`, so the port also forgets the pending session at PiG's `session_compact_failed` (fired after a failed or aborted compaction, PiG `compaction.md`); without it the stale slot would hand that session's cwd to a later summary from another session. The TypeScript needs no such hook because a cancelled compaction's signal is never looked up again. A cancelled tree navigation has no failure event, so its slot stays until the next `session_before_compact` or `session_before_tree` replaces it.
34
34
 
35
+ **D8. AskClaude stays in this package.** TypeScript 0.11.0 moved the tool to `pi-claude-bridge-askclaude`, its own npm package with its own copy of the conversion and child env. Port: the tool stays in `internal/askclaude`, off unless `askClaude.enabled` is set, and its shared mode imports pi's history into a session of its own (`importForAskClaude`) without touching the provider's mirror. Reason: a second Go package means a second binary, release flow and repo for a tool that is already opt-in.
36
+
37
+ **D9. No cache trace.** TypeScript: `CLAUDE_BRIDGE_TRACE_DIR` writes a per-query trace (`src/cache-trace.ts`) that `diag/audit-cache.mjs` analyses. Port: not ported; `CLAUDE_BRIDGE_DEBUG` and the `syncResult:` decision lines remain. Reason: debug tooling with no user-facing behaviour.
38
+
39
+ **D10. Shutdown kills every live child.** TypeScript: `session_shutdown` closes only the shutting-down session's parked queries, since subagents share the process. Port: the session's own parked queries are closed first with their progress committed, then every Claude Code child this process started is killed (as before). Reason: PiG subagents are separate processes (D4), so another session's live query in the same process does not occur.
40
+
35
41
  ## Rulings that changed the plan
36
42
 
37
43
  - Work in place on branch `feat/native-port`, no worktree: the repo is new and single-purpose.
@@ -71,8 +77,10 @@ What this port is pinned to, and every place where it differs from the TypeScrip
71
77
 
72
78
  ### Session files and logs
73
79
 
74
- - `syncSharedSession` takes `host` and `modelID`.
75
- - The `APPEND` log line prints the advanced cursor (TypeScript prints the pre-advance cursor).
80
+ - `syncSharedSession` takes `host`, `modelID` and `promptDigest`; its `syncResult:` line carries the row and reason in one line where the TypeScript logs them across its trace.
81
+ - The persisted state file is the TypeScript's (`s-<sha256 of the UTF-16LE pi session id>.json`, version 1), under PiG's agent dir rather than Pi's, so the two bridges never read each other's pointers.
82
+ - A prefix write re-emits the retained records through a key-order-preserving field rewrite (`sessionId`, and `parentUuid` on the suffix's first record), where the TypeScript spreads the object.
83
+ - The fake Claude Code the unit tests drive echoes completed blocks before closing them and writes session records, as the real one does; the TypeScript tests stub the SDK instead.
76
84
  - The `[moduleInstanceId]` bracket is dropped from log lines (the TypeScript `diag/*.mjs` parsers expect it); the deadlock-check regex was adapted. The `imported roles:` debug line and the sanitized-id list are dropped.
77
85
  - Stderr tail counts runes where the SDK counts UTF-16 units.
78
86
  - An empty `CLAUDE_CONFIG_DIR` counts as unset (`??` in JavaScript would use it), and with `HOME` unset the Claude config dir falls back to the account's home directory instead of a relative `.claude`.
@@ -84,7 +92,7 @@ What this port is pinned to, and every place where it differs from the TypeScrip
84
92
  ### Provider stream and notices
85
93
 
86
94
  - `Details.executionTime` is an `int64`.
87
- - The "Claude Code returned an error result" replacement is unreachable, as in the TypeScript (the loop stops at the error result). An error result drops the mirror, while a process exit or panic marks rebuild (the TypeScript drops the mirror on exit too).
95
+ - The "Claude Code returned an error result" replacement is unreachable, as in the TypeScript (the loop stops at the error result). A failed query (error result, process exit, panic) commits its recording dirty, as the TypeScript does; nothing drops the mirror any more.
88
96
  - Abort lifetime is proven by the live smoke's tool round trip. Abort follows `ctx.Signal()`, PiG's run-scoped signal, where the plan said `ctx.Done()`: the per-request `Done()` is cancelled when each provider call ends, which would kill a query parked at a tool call between calls; `Done()` is used only when a context carries no signal.
89
97
  - Abort is honoured while Claude Code starts: the call's cancellation reaches the spawn until the `initialize` answer arrives (spike S2 saw about 4 s, inferred to be user `SessionStart` hooks), so an abort then ends the turn as aborted instead of waiting; after that, the stream's own abort wiring takes over, so a parked query still survives between calls. The isolated and AskClaude paths spawn under the call's context. `claude --version` is bounded by 10 s and runs once at a time; a probe that times out is logged and does not fail the query, and an abort during the probe ends the turn as aborted without caching a result. Only the leading `x.y.z` of the version output is read.
90
98
  - The cost of a turn leaves out pi-ai's `cost.tiers` and `cacheWrite1h`. The registered models carry zero rates and no tiers, so the cost is zero either way.
@@ -127,18 +135,13 @@ What this port is pinned to, and every place where it differs from the TypeScrip
127
135
  - The alternative provider for the AskClaude, image and session-resume tests must come from `CLAUDE_BRIDGE_TESTING_ALT_PROVIDER`, `CLAUDE_BRIDGE_TESTING_ALT_MODEL` and that provider's API-key variable, because every live run uses a fresh `PIG_HOME`/`PIG_CODING_AGENT_DIR` (the TypeScript suite used the real agent dir). Without them those checks skip.
128
136
  - The deadlock-check log pattern, the shutdown test (a third case for the stdin close) and the subagent test (our own agent definition, run through `@tintinweb/pi-subagents`) are adapted as listed in [verification.md](verification.md).
129
137
 
130
- ## Ported after 6aa276a
131
-
132
- Upstream pi-claude-bridge moved on to 0.11.0 (acbe45b, 100 commits) after the pin. These were ported on their own, each with the TypeScript commit; the rest is listed below.
138
+ ## Caught up to 0.11.0
133
139
 
134
- - **Child environment (f502f5d, 2edde93).** `CLAUDE_CODE_SILENT_TURN_REMINDER=0`, `CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH=16384` and `CLAUDE_CODE_RESUME_INTERRUPTED_TURN_MAX_AGE_MS=1` join `CCChildEnv`. The argv snapshot predates them, so `TestArgvMatchesNodeSdkSnapshot` sets them aside and `TestBuildEnv` pins their values.
135
- - **Parent-session markers (e93d51d, #198).** When `CLAUDECODE` is set in PiG's environment, the parent Claude Code session's markers are dropped from the child env. The TypeScript also moved `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` off its own `process.env`; the port never set it there.
136
- - **Default output style (5c4a268, #220).** The provider's `--settings` carries `outputStyle: "default"`. Pinned live by `TestOutputStyleDefaultOverridesAUserLevelOutputStyle`.
137
- - **Thinking off (0b096a8, #152).** No reasoning level means `--thinking=disabled` on the provider and isolated paths, for reasoning models whose `thinkingLevelMap` does not mark `off` as `null`.
140
+ The port followed pi-claude-bridge from 6aa276a to acbe45b (0.10.0 and 0.11.0, 100 commits). Ported: the content-based session sync (#222) with its live recording, cuts, prefix writes, forks and persisted state; the conversion changes (#227, #207, #242); the child environment (f502f5d, 2edde93, e93d51d), `settingSources: ["user"]` and the end of the project config (#198); the default output style (#220); thinking off (#152); provider env; the Extra Usage notice (#201, #213); same-session routing (#111); parked-query closing at shutdown (#210, as D10); the bash default timeout in the `tool_call` hook (#203); the fallback fix (87f67d1). The argv snapshot predates some of these, so `TestArgvMatchesNodeSdkSnapshot` sets `EnvAddedAfterSnapshot`, `--setting-sources=user`, `--session-id` and `outputStyle` aside, and the unit tests pin them instead.
138
141
 
139
142
  Port-only, with no TypeScript counterpart: **`CLAUDE_CODE_DISABLE_FEATURE_FLAGS=1`** in `CCChildEnv`. In Claude Code 2.1.319 feature-flag evaluation (`Ph()`) requires both no disable variable and telemetry on, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` counts as telemetry off, so the TypeScript already runs with flags off; the explicit variable also keeps the disk cache closed should `CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF` be exported, and `TestDisableFeatureFlagsTurnsFlagEvaluationOff` pins the behaviour.
140
143
 
141
- Not ported from 0.10.0 and 0.11.0: the content-digest session sync (#222: forks and trims instead of rebuilds, atomic session state, `/fork` and clone continuing the parent's Claude Code session), `CLAUDE_BRIDGE_TRACE_DIR`, provider `env` from `auth.json`, the Extra Usage billing notice, the empty-tool-result placeholder (#227), Claude Code's normalised tool args (#242), the `settingSources` change that stops loading a repo's `.claude/settings*.json` (#198), the AskClaude split into its own package, and the removal of the `provider.plan` notice (the port detects the plan instead). D1 to D7 above still hold.
144
+ Not ported: the AskClaude split (D8), `CLAUDE_BRIDGE_TRACE_DIR` and the `diag/` tooling (D9), the per-session scope of shutdown (D10), the removal of the `provider.plan` startup notice (the port detects the plan instead and never asked), and the `BRIDGE_VERSION`/SDK version log line at load. D1 to D7 above still hold.
142
145
 
143
146
  ## Pi-only tests that are not ported
144
147
 
@@ -129,8 +129,7 @@ func Extension() *sdk.Extension {
129
129
  // unread and the logs beside the cwd, and the extension still loads.
130
130
  agentDir, _ := sdk.AgentDir()
131
131
  configDirName, _ := sdk.ConfigDirName()
132
- cwd, _ := os.Getwd()
133
- cfg := config.Load(agentDir, cwd, configDirName)
132
+ cfg := config.Load(agentDir)
134
133
  // Detected before the provider registers, since the plan decides which models get 1M.
135
134
  // Skipped outside a host, so validate-only installs and tests never spawn claude.
136
135
  if agentDir != "" && cfg.Provider.Plan == nil {
@@ -196,7 +195,8 @@ func Extension() *sdk.Extension {
196
195
  ext.OnSessionStart(func(ctx sdk.Context, data map[string]any) (any, error) {
197
196
  defer func() { _ = recover() }()
198
197
  reason, _ := data["reason"].(string)
199
- b.OnSessionStart(sdkHost{ctx}, reason)
198
+ previous, _ := data["previousSessionFile"].(string)
199
+ b.OnSessionStart(sdkHost{ctx}, reason, previous)
200
200
  return nil, nil
201
201
  })
202
202
  forward := func(event string, fn func(h bridge.Host, data map[string]any)) {
@@ -218,6 +218,19 @@ func Extension() *sdk.Extension {
218
218
  forward("session_compact_failed", func(h bridge.Host, _ map[string]any) { b.OnSessionCompactFailed(h) })
219
219
  forward("session_tree", func(h bridge.Host, _ map[string]any) { b.OnSessionTree(h) })
220
220
  forward("session_shutdown", func(h bridge.Host, _ map[string]any) { b.OnSessionShutdown(h) })
221
+ // Claude Code models expect bash to time out (CC's default is 120s); PiG's bash has none.
222
+ // Set on the execution copy PiG hands tool_call handlers, not in the stored call, so PiG's
223
+ // history keeps the arguments CC sent and a rebuild matches CC's records (pi-claude-bridge
224
+ // #203). A `background: true` call (added by an extension) doesn't block the turn, so it
225
+ // gets no default.
226
+ ext.OnEvent("tool_call", func(ctx sdk.Context, data map[string]any) (any, error) {
227
+ defer func() { _ = recover() }()
228
+ input, _ := data["input"].(map[string]any)
229
+ if sdk.IsToolCallEventType("bash", data) && input != nil && input["timeout"] == nil && input["background"] != true && ctx.ModelProvider() == "claude-bridge" {
230
+ input["timeout"] = 120
231
+ }
232
+ return nil, nil
233
+ })
221
234
  return ext
222
235
  }
223
236
 
@@ -65,19 +65,19 @@ func (b *Bridge) PromptAndWait(ctx context.Context, host Host, r AskRequest) (te
65
65
  }
66
66
 
67
67
  // A non-isolated call continues the session the provider already keeps for this pi
68
- // session, or creates one from pi's context. It does not move the mirror's cursor,
69
- // so the next provider call sees the missed messages and rebuilds.
68
+ // session, or imports pi's context into one of its own. The provider's mirror is
69
+ // not touched (D8): its recording does not cover what AskClaude adds, so the next
70
+ // provider turn forks or rewrites from the content comparison as usual.
70
71
  resume := ""
71
72
  if !r.Isolated && len(r.Context) > 0 {
72
73
  if state := b.sessionStateFor(r.PiSessionID); state != nil {
73
- resume = state.SessionID
74
+ resume = state.CCSessionID
74
75
  } else {
75
76
  withPrompt := append(slices.Clone(r.Context), map[string]any{"role": "user", "content": r.Prompt, "timestamp": b.deps.Now().UnixMilli()})
76
- synced, err := b.syncSharedSession(host, r.PiSessionID, withPrompt, nil, cwd, cliModel)
77
- if err != nil {
77
+ var err error
78
+ if resume, err = b.importForAskClaude(host, withPrompt, cwd, cliModel); err != nil {
78
79
  return "", err
79
80
  }
80
- resume = synced.SessionID
81
81
  }
82
82
  }
83
83
 
@@ -67,13 +67,18 @@ func (b *Bridge) unregisterContext(q *QueryContext) {
67
67
  delete(b.contexts, q)
68
68
  }
69
69
 
70
- // contextForToolResults finds the live query a turn's results belong to, by the
71
- // tool call ids it parked, queued or emitted. nil when none matches.
72
- func (b *Bridge) contextForToolResults(results []convert.McpResult) *QueryContext {
70
+ // contextForToolResults finds the parked query of this pi session that a turn's
71
+ // results answer, by the tool call ids it parked, queued or emitted. nil when none
72
+ // matches. Tool-call ids repeat across sessions whenever one copies another's
73
+ // history (a fork, a worker seeded from its host), so an id alone could deliver one
74
+ // session's turn into another's Claude Code query (pi-claude-bridge #111).
75
+ func (b *Bridge) contextForToolResults(results []convert.McpResult, piSessionID string) *QueryContext {
73
76
  b.mu.Lock()
74
77
  live := make([]*QueryContext, 0, len(b.contexts))
75
78
  for c := range b.contexts {
76
- live = append(live, c)
79
+ if c.piSessionID == piSessionID {
80
+ live = append(live, c)
81
+ }
77
82
  }
78
83
  b.mu.Unlock()
79
84
  for _, r := range results {
@@ -195,6 +200,7 @@ func parseRateLimitInfo(m map[string]any) RateLimitInfo {
195
200
  OverageStatus: str(m["overageStatus"]), OverageResetsAt: num(m["overageResetsAt"]),
196
201
  Utilization: num(m["utilization"]), SurpassedThreshold: num(m["surpassedThreshold"]),
197
202
  }
203
+ info.IsUsingOverage, _ = m["isUsingOverage"].(bool)
198
204
  if windows := obj(m["unifiedWindows"]); windows != nil {
199
205
  info.UnifiedWindows = map[string]*UsageWindow{}
200
206
  for name, raw := range windows {
@@ -253,7 +259,14 @@ loop:
253
259
  b.debugf("consumeQuery: skipping malformed frame: %v", err)
254
260
  continue
255
261
  }
262
+ if q.isAbandoned() {
263
+ break
264
+ }
265
+ b.tapRecording(q, frame)
256
266
  b.reportHeldRateLimit(q)
267
+ if ctx.Err() != nil || q.isAbandoned() {
268
+ break
269
+ }
257
270
  if b.consumeFrame(host, q, frame, &res) {
258
271
  break
259
272
  }
@@ -300,17 +313,10 @@ func (b *Bridge) consumeFrame(host Host, q *QueryContext, frame map[string]any,
300
313
  typ := str(frame["type"])
301
314
 
302
315
  // Lifecycle frames come before the currentPiStream gate: everything below it is
303
- // content, which has nowhere to go once a turn ended on a tool call.
304
- //
305
- // A replay acknowledgement clears a queued steer even after its turn's stream
306
- // ended. For a fresh steer uuid, replay marks incorporation, not completion:
307
- // its MCP calls still need stdin until the following result.
308
- if parent, present := frame["parent_tool_use_id"]; typ == "user" && present && parent == nil && frame["isReplay"] == true {
309
- q.mu.Lock()
310
- delete(q.pendingSteerUUIDs, str(frame["uuid"]))
311
- q.mu.Unlock()
312
- }
313
-
316
+ // content, which has nowhere to go once a turn ended on a tool call. A replay
317
+ // acknowledgement (tapRecording) clears a queued steer even after its turn's
318
+ // stream ended; it marks incorporation, not completion: the steer's MCP calls
319
+ // still need stdin until the following result.
314
320
  switch typ {
315
321
  case "result":
316
322
  // The failure a result carries is the only record the turn failed at all; it
@@ -373,7 +379,15 @@ func (b *Bridge) consumeFrame(host Host, q *QueryContext, frame map[string]any,
373
379
  case "stream_event":
374
380
  b.processStreamEvent(q, frame)
375
381
  case "assistant":
376
- b.processAssistantMessage(q, frame)
382
+ // A completed echo can trail tool-result delivery. tapRecording recorded it for
383
+ // its original step; it is never rendered again into the new pi stream.
384
+ q.mu.Lock()
385
+ step := q.assistantSteps[str(obj(frame["message"])["id"])]
386
+ emitted := step != nil && step.emitted != nil
387
+ q.mu.Unlock()
388
+ if !emitted {
389
+ b.processAssistantMessage(q, frame)
390
+ }
377
391
  case "result":
378
392
  // The success path: push the result text when no stream event delivered it.
379
393
  func() {
@@ -478,12 +492,10 @@ func (b *Bridge) turnField(q *QueryContext, key string) any {
478
492
  return q.turnOutput[key]
479
493
  }
480
494
 
481
- // finishAborted ends an aborted query. The killed subprocess may flush a late
482
- // record into the session JSONL, so its mirror rebuilds on a fresh id. A turn whose
483
- // stream already ended was returned to pi and is left alone.
495
+ // finishAborted ends an aborted query; its final snapshot was committed by the abort
496
+ // itself. A turn whose stream already ended was returned to pi and is left alone.
484
497
  func (b *Bridge) finishAborted(q *QueryContext) {
485
- b.markSessionNeedsRebuild(q.piSessionID, true)
486
- b.debugf("provider: abort detected, marked session needsRebuild + forceRotate")
498
+ b.debugf("provider: abort detected")
487
499
  q.mu.Lock()
488
500
  defer q.mu.Unlock()
489
501
  q.heldRateLimit = nil
@@ -495,17 +507,16 @@ func (b *Bridge) finishAborted(q *QueryContext) {
495
507
  q.endStreamLocked(map[string]any{"type": "error", "reason": "aborted", "error": deepCopy(q.turnOutput)})
496
508
  }
497
509
 
498
- // failQuery ends a failed query with exactly one error event on the open stream.
499
- // A turn whose stream already ended is left unchanged (issue #181).
510
+ // failQuery ends a failed query with exactly one error event on the open stream,
511
+ // after committing its progress dirty: the next turn compares content and keeps what
512
+ // still matches. A turn whose stream already ended is left unchanged (issue #181).
500
513
  func (b *Bridge) failQuery(q *QueryContext, client ccproto.Client, failure error) {
501
514
  b.debugf("provider: query error, error=%v", failure)
502
- var reported *resultFailure
503
- if errors.As(failure, &reported) {
504
- // The conversation is in an unknown state: drop the mirror, like the SDK's
505
- // throw after an error result did.
506
- b.setSessionStateFor(q.piSessionID, nil)
507
- } else {
508
- b.markSessionNeedsRebuild(q.piSessionID, false)
515
+ q.mu.Lock()
516
+ commit := q.commitRecording
517
+ q.mu.Unlock()
518
+ if commit != nil {
519
+ commit(true)
509
520
  }
510
521
  q.mu.Lock()
511
522
  ps := q.promptStream
@@ -638,16 +649,23 @@ func (b *Bridge) drainForAbort(q *QueryContext) {
638
649
  }
639
650
 
640
651
  // discardRewrittenQuery drops a Claude Code query parked at a tool boundary whose
641
- // conversation pi has since rewritten (/compact, tree navigation; issue #101).
642
- // Delivering the tool result into it would hand Claude Code the context pi just
643
- // shrank. The caller takes the fresh-query path, where REBUILD imports pi's
644
- // rewritten history, this tool result included. reason is what the parked handlers
645
- // are answered with.
652
+ // conversation pi has since rewritten or replaced (/compact, tree navigation, a
653
+ // history edit, a fresh prompt from the same session; issue #101). Its progress is
654
+ // committed first, dirty, so the next sync keeps whatever still matches. Delivering
655
+ // the tool result into it would hand Claude Code the context pi just shrank; the
656
+ // caller takes the fresh-query path instead, where the sync imports pi's current
657
+ // history, this tool result included. reason is what the parked handlers are answered with.
646
658
  func (b *Bridge) discardRewrittenQuery(q *QueryContext, reason string) {
659
+ q.mu.Lock()
660
+ commit := q.commitRecording
661
+ q.mu.Unlock()
662
+ if commit != nil {
663
+ commit(true)
664
+ }
647
665
  q.mu.Lock()
648
666
  discarded := q.activeQuery
649
667
  q.activeQuery = nil
650
- q.abandoned = q.abandoned || discarded != nil
668
+ q.abandoned = true
651
669
  q.turnToolCallIDs = []string{}
652
670
  ps := q.promptStream
653
671
  q.promptStream = nil
@@ -668,21 +686,14 @@ func (b *Bridge) discardRewrittenQuery(q *QueryContext, reason string) {
668
686
  // subprocess never settles.
669
687
  q.releasePendingToolCalls(reason)
670
688
  b.killClient(discarded)
671
- // The CLI may still flush a record into the session JSONL and the rebuild is
672
- // next, so rotate rather than race it. Only this session's mirror.
673
- b.markSessionNeedsRebuild(q.piSessionID, true)
674
- b.debugf("provider: history rewritten under a parked query — discarded it, rebuilding from current history")
675
- }
676
-
677
- // steerMissedSession records a steer that never made it into Claude Code's
678
- // session. The cursor has already counted it, so count-based sync would skip it
679
- // forever: rebuild instead, which re-imports the message from pi's context.
680
- func (b *Bridge) steerMissedSession(q *QueryContext, text string) {
681
689
  q.mu.Lock()
682
- q.missedSteer = true
690
+ if q.currentPiStream != nil && q.turnOutput != nil {
691
+ q.turnOutput["stopReason"] = "error"
692
+ q.turnOutput["errorMessage"] = "Conversation replaced"
693
+ q.endStreamLocked(map[string]any{"type": "error", "reason": "error", "error": deepCopy(q.turnOutput)})
694
+ }
683
695
  q.mu.Unlock()
684
- b.markSessionNeedsRebuild(q.piSessionID, false)
685
- b.debugf("provider: steer never reached CC, marked query for rebuild: %s", truncateRunes(text, 60))
696
+ b.debugf("provider: discarded parked query; its recorded prefix remains available for a fork")
686
697
  }
687
698
 
688
699
  func newUUID() string {
@@ -705,9 +716,15 @@ func newUUID() string {
705
716
  // the FIFO ordering are Claude Code internals.
706
717
  //
707
718
  // Results pair with handlers by tool use id. host may be nil (no stuck-handler notice).
708
- func (b *Bridge) deliverToolResults(ctx context.Context, host Host, q *QueryContext, results []convert.McpResult, steer []any, contextLength int) {
719
+ // history is pi's conversation without system messages; it registers the results
720
+ // and any steer with the recording.
721
+ func (b *Bridge) deliverToolResults(ctx context.Context, host Host, q *QueryContext, results []convert.McpResult, steer []any, history []map[string]any) {
722
+ contextLength := len(history)
723
+ if err := q.recording.Results(history); err != nil {
724
+ b.debugf("recording: tool results could not be digested: %v", err)
725
+ }
709
726
  if len(steer) > 0 {
710
- b.pushSteer(ctx, q, steer)
727
+ b.pushSteer(ctx, q, steer, history)
711
728
  }
712
729
 
713
730
  type release struct {
@@ -757,8 +774,9 @@ func (b *Bridge) deliverToolResults(ctx context.Context, host Host, q *QueryCont
757
774
  }
758
775
 
759
776
  // pushSteer writes a steer to Claude Code's stdin and waits for the write. A steer
760
- // that cannot be written is recorded as missed; the tool results are delivered anyway.
761
- func (b *Bridge) pushSteer(ctx context.Context, q *QueryContext, steer []any) {
777
+ // that cannot be written leaves a coverage gap (no replay acknowledgement ever
778
+ // comes, so the next sync imports it from pi); the tool results are delivered anyway.
779
+ func (b *Bridge) pushSteer(ctx context.Context, q *QueryContext, steer []any, history []map[string]any) {
762
780
  parts := make([]string, len(steer))
763
781
  for i, raw := range steer {
764
782
  if blk := obj(raw); blk["type"] == "text" {
@@ -775,24 +793,25 @@ func (b *Bridge) pushSteer(ctx context.Context, q *QueryContext, steer []any) {
775
793
  if ps != nil {
776
794
  uuid = newUUID()
777
795
  // Registered before the write: a result can arrive during it.
778
- q.pendingSteerUUIDs[uuid] = struct{}{}
796
+ q.addPendingSteer(uuid)
779
797
  }
780
798
  q.mu.Unlock()
781
799
  if ps == nil {
782
800
  b.debugf("WARNING: steer with no prompt stream, dropping: %s", truncateRunes(text, 60))
783
- b.steerMissedSession(q, text)
784
801
  return
785
802
  }
803
+ if err := q.recording.Users(history, turnStart(history), uuid, false, true); err != nil {
804
+ b.debugf("recording: steer could not be digested: %v", err)
805
+ }
786
806
  msg := userMessage(steer, "next")
787
807
  msg.UUID = uuid
788
808
  if err := ps.Push(ctx, msg); err != nil {
789
- // The query is ending: further input would wedge tool-result delivery. The
790
- // steer is still in pi's context but the cursor is past it, so force a rebuild.
809
+ // The query is ending: further input would wedge tool-result delivery. No replay
810
+ // acknowledgement means no coverage slot; the next sync imports the steer from pi.
791
811
  q.mu.Lock()
792
- delete(q.pendingSteerUUIDs, uuid)
812
+ q.removePendingSteer(uuid)
793
813
  q.mu.Unlock()
794
814
  b.debugf("provider: steer push rejected, delivering tool result anyway: %v", err)
795
- b.steerMissedSession(q, text)
796
815
  return
797
816
  }
798
817
  b.debugf("provider: steer written to CC stdin before tool result: %s", truncateRunes(text, 60))
@@ -1,7 +1,6 @@
1
1
  package bridge
2
2
 
3
3
  import (
4
- "fmt"
5
4
  "runtime/debug"
6
5
  "slices"
7
6
  "sort"
@@ -117,15 +116,22 @@ func (b *Bridge) hostSessionID(host Host, event string) string {
117
116
  return id
118
117
  }
119
118
 
120
- // OnSessionStart remembers the session's cwd, and drops every mirror when the
121
- // session is a fresh conversation.
122
- func (b *Bridge) OnSessionStart(host Host, reason string) {
119
+ // OnSessionStart remembers the session's cwd. Mirrors stay: each is keyed by its own
120
+ // pi session id, so switching sessions erases none of them. A fork or clone starts
121
+ // from its parent's mirror, so its first turn forks the parent's Claude Code session.
122
+ func (b *Bridge) OnSessionStart(host Host, reason, previousSessionFile string) {
123
123
  defer b.recoverHandler("session_start")
124
124
  b.showStatus(host)
125
- b.rememberSessionCwd(b.hostSessionID(host, "session_start"), host.Cwd())
126
- switch reason {
127
- case "new", "resume", "fork":
128
- b.clearSessions("session_start:" + reason)
125
+ id := b.hostSessionID(host, "session_start")
126
+ b.rememberSessionCwd(id, host.Cwd())
127
+ b.mu.Lock()
128
+ n := len(b.sessions)
129
+ b.mu.Unlock()
130
+ b.debugf("session_start:%s: retaining %d shared session(s)", reason, n)
131
+ if reason == "fork" && previousSessionFile != "" && id != "" {
132
+ if err := b.inheritSessionState(previousSessionFile, id); err != nil {
133
+ b.debugf("session_start:fork: %v", err)
134
+ }
129
135
  }
130
136
  }
131
137
 
@@ -207,18 +213,39 @@ func (b *Bridge) historyRewrittenBy(host Host, event string) {
207
213
  b.forgetSummarySession(id)
208
214
  }
209
215
 
210
- // OnSessionShutdown ends a pi session: its cwd, its sync lock and every mirror go,
211
- // and every Claude Code child this process started is killed, even when the host fails.
216
+ // OnSessionShutdown ends a pi session: its cwd and sync lock go, its parked queries
217
+ // are closed with their progress kept for a later resume (a tool that returns
218
+ // terminate: true ends pi's run with Claude Code still parked on its result, issue
219
+ // #210), and then every Claude Code child this process started is killed (D10),
220
+ // even when the host fails. Mirrors stay; they are keyed per pi session and persisted.
212
221
  func (b *Bridge) OnSessionShutdown(host Host) {
213
222
  defer b.recoverHandler("session_shutdown")
214
223
  defer b.killLiveQueries()
215
224
  id := b.hostSessionID(host, "session_shutdown")
216
225
  b.forgetSessionCwd(id)
217
226
  b.mu.Lock()
218
- delete(b.syncLocks, sessionKey(id))
227
+ delete(b.syncLocks, id)
219
228
  b.mu.Unlock()
229
+ b.closeSessionQueries(id)
220
230
  b.reportLeaks("session_shutdown")
221
- b.clearSessions("session_shutdown")
231
+ }
232
+
233
+ // closeSessionQueries closes every query the shutting-down pi session still holds.
234
+ func (b *Bridge) closeSessionQueries(piSessionID string) {
235
+ for _, q := range b.liveContexts() {
236
+ if q.piSessionID != piSessionID {
237
+ continue
238
+ }
239
+ q.mu.Lock()
240
+ active, pending, ps := q.activeQuery != nil, len(q.pendingToolCalls), q.promptStream != nil
241
+ q.mu.Unlock()
242
+ if active {
243
+ b.debugf("session_shutdown: closing parked query — pendingToolCalls=%d promptStream=%t", pending, ps)
244
+ } else {
245
+ b.debugf("WARNING: session_shutdown left a context with no query — pendingToolCalls=%d promptStream=%t", pending, ps)
246
+ }
247
+ b.discardRewrittenQuery(q, "Session shut down; this query was closed.")
248
+ }
222
249
  }
223
250
 
224
251
  // killLiveQueries aborts every live query, then kills every Claude Code child still
@@ -235,19 +262,3 @@ func (b *Bridge) killLiveQueries() {
235
262
  }
236
263
  ccproto.Shutdown()
237
264
  }
238
-
239
- // clearSessions drops every mirror: children never emit session_shutdown (only
240
- // runtime teardown does), so there is no per-entry removal, and a top-level
241
- // transition takes every mirror with it.
242
- func (b *Bridge) clearSessions(event string) {
243
- b.mu.Lock()
244
- n := len(b.sessions)
245
- b.sessions = map[string]SessionState{}
246
- b.historyRewritten = map[string]struct{}{}
247
- b.mu.Unlock()
248
- plural := "s"
249
- if n == 1 {
250
- plural = ""
251
- }
252
- b.debugf("%s", fmt.Sprintf("%s: clearing %d shared session%s", event, n, plural))
253
- }
@@ -46,11 +46,16 @@ type Bridge struct {
46
46
  deps Deps
47
47
 
48
48
  mu sync.Mutex
49
- // sessions holds one CC-conversation mirror per pi session, keyed by sessionKey.
50
- sessions map[string]SessionState
51
- // syncLocks serialise syncSharedSession per session key.
49
+ // sessions holds one CC-conversation mirror per pi session id (never "").
50
+ sessions map[string]*SessionState
51
+ // loaded are the pi session ids whose persisted state has been read once; memory
52
+ // is authoritative after. known are the ids this process read or wrote state for.
53
+ loaded, known map[string]bool
54
+ // writers maps a CC session id to the exit of the child last writing it.
55
+ writers map[string]<-chan struct{}
56
+ // syncLocks serialise syncSharedSession per pi session id.
52
57
  syncLocks map[string]*sync.Mutex
53
- // historyRewritten holds real pi session ids only, never the "(none)" key.
58
+ // historyRewritten holds the pi session ids pi rewrote (compact, tree) since their last query.
54
59
  historyRewritten map[string]struct{}
55
60
  // contexts are the live queries armStaleContexts marks.
56
61
  contexts map[*QueryContext]struct{}
@@ -94,7 +99,10 @@ func New(d Deps) *Bridge {
94
99
  }
95
100
  b := &Bridge{
96
101
  deps: d,
97
- sessions: map[string]SessionState{},
102
+ sessions: map[string]*SessionState{},
103
+ loaded: map[string]bool{},
104
+ known: map[string]bool{},
105
+ writers: map[string]<-chan struct{}{},
98
106
  syncLocks: map[string]*sync.Mutex{},
99
107
  historyRewritten: map[string]struct{}{},
100
108
  contexts: map[*QueryContext]struct{}{},
@@ -106,9 +106,13 @@ func (b *Bridge) runIsolatedSummary(ctx context.Context, host Host, model, piCon
106
106
  if tools == nil {
107
107
  tools = []string{}
108
108
  }
109
+ env, err := providerEnv(options)
110
+ if err != nil {
111
+ return fail(err.Error())
112
+ }
109
113
  debugFile, onStderr := b.cliDebugOptions(tag)
110
114
  opts := ccproto.Options{
111
- Executable: exe, Cwd: cwd, Model: cliModel, Effort: effort, ThinkingDisabled: thinkingOff, MaxTurns: 1,
115
+ Executable: exe, Cwd: cwd, Env: env, Model: cliModel, Effort: effort, ThinkingDisabled: thinkingOff, MaxTurns: 1,
112
116
  Tools: tools, ToolsSet: true, AllowedTools: allowedTools,
113
117
  SettingSourcesSet: true, Skills: []string{},
114
118
  SystemPrompt: str(piContext["systemPrompt"]), SystemPromptSet: true,