@khalilgharbaoui/opencode-claude-code-plugin 0.35.0 → 0.36.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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +286 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/skills/claude-code-plugin/SKILL.md +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Khalil Gharbaoui
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -345,6 +345,8 @@ One piece of Claude Code trivia is worth stating plainly, because it is the oppo
|
|
|
345
345
|
|
|
346
346
|
Like model and effort, the TTL is part of the Claude session key, so changing it respawns rather than sharing a process. Compaction is exempt.
|
|
347
347
|
|
|
348
|
+
Forking an opencode session normally throws that cache away: the fork arrives as a new session with the thread copied in and no Claude session behind it, so the whole conversation is re-rendered as text into the first message and paid for again as cache writes. Setting `forkSessions: true` in the provider options branches the parent's Claude conversation with `claude --resume <parent> --fork-session` instead. Measured on CLI 2.1.280 with haiku 4.5 over a ~13k-token thread, the forked turn wrote 814 cache tokens and read 39,710, against 22,355 written and 17,385 read for the replay: $0.0058 against $0.0467 for that one turn, with the parent's own transcript byte-identical afterwards. It is off by default because a resumed Claude conversation reuses the system prompt recorded on its first request, so a forked session answers under the parent's appended system prompt rather than the current turn's. Anything it cannot match exactly keeps today's replay: another account, an unknown or busy parent, a fork cut mid-conversation or taken mid tool call, a different cwd, model, agent, effort or cache TTL, compaction, and the interactive transport.
|
|
349
|
+
|
|
348
350
|
To force an **account** rather than a model, pin the full string. This only applies if you declared [`accounts`](#multiple-claude-code-accounts) in the first place; with the default single-account setup there is nothing to pin. Both halves are needed, because the provider selects the account's config dir and the `@account` marker is what the model was registered under for that provider:
|
|
349
351
|
|
|
350
352
|
```yaml
|
|
@@ -483,6 +485,7 @@ reaches the CLI, so there is nothing there to fall back from.
|
|
|
483
485
|
| `bridgeSkipNativeSkills` | boolean | `true` | Leave a skill unbridged when the Claude session already loads it from `$CLAUDE_CONFIG_DIR/skills`, the project's `.claude/skills`, or an installed plugin, so one skill does not reach the model twice. Matched by resolved path, by identical `SKILL.md`, or by name. `false` bridges everything and reinstates the duplicates. See [Skills Claude already has](#skills-claude-already-has). |
|
|
484
486
|
| `logging` | object | all defaults | The plugin's own logger, four independent fields: `file` (boolean, default `false`), `dir` (string, default `~/.local/share/opencode-claude-code/`), `mode` (`"silent"` \| `"debug"`, default `"silent"`) and `level` (`"debug"` \| `"info"` \| `"notice"` \| `"warn"` \| `"error"`, default `"info"`). Goes under `provider.claude-code.options` like every other row here. See [Logging](#logging). |
|
|
485
487
|
| `turnStats` | boolean | `false` | Append a one-line cost / duration / cache footer to each finished turn. See [Per-turn stats](#per-turn-stats). |
|
|
488
|
+
| `forkSessions` | boolean | `false` | Branch a forked opencode session off the parent's Claude conversation with `--fork-session` instead of replaying its history as text. See [The prompt cache an agent writes](#the-prompt-cache-an-agent-writes). |
|
|
486
489
|
| `interactive` | boolean | `false` | **Experimental.** Drive the interactive `claude` TUI (subscription billing) instead of headless `--print`. Requires opencode running under Bun with PTY support; silently falls back to headless otherwise. The tool proxy, `permissionMode` and `/btw` are all unavailable on it, so read [What it does not support](#what-it-does-not-support) before enabling. Env: `CLAUDE_CODE_INTERACTIVE_TRANSPORT=1`. |
|
|
487
490
|
| `interactiveBypass` | boolean | `false` | Deprecated/no-op with `interactive`: Claude Code's TUI shows a manual safety confirmation for `bypassPermissions`, so the plugin intentionally does not pass it. |
|
|
488
491
|
| `interactiveAllowTools` | string[] | `["Bash", "Edit", "Write", "Read", "WebFetch"]` | With `interactive`: built-in tools pre-allowed without prompting (replaces the default list). MCP server wildcards (`mcp__<server>__*`) are always added from the bridged config. |
|
|
@@ -976,6 +979,8 @@ Everything else, including every key the allowlist does not name, becomes `[reda
|
|
|
976
979
|
|
|
977
980
|
Never in a bundle: prompt or reply text, system prompts or the appended prompt file, tool inputs or outputs, file contents, environment values, bearer tokens, the proxy `authToken`, API keys, `Authorization` headers, MCP server env or headers, URL credentials or query strings, or the raw spawn argv. The argv is kept as option names with every value replaced, which is what a spawn bug report actually needs.
|
|
978
981
|
|
|
982
|
+
Kept on purpose, so read it before pasting: folder paths below your home directory (project and config folder names such as `~/.claude-<account>`) and your `accounts` names. A maintainer needs both to read a cwd or an account problem, and only you can tell whether a folder or account name is something you would rather not publish.
|
|
983
|
+
|
|
979
984
|
It is capped at 120 lines and 24,000 bytes, newest first, and says how many lines it left out. With file logging off it says so, tells you how to turn it on, and still returns the report:
|
|
980
985
|
|
|
981
986
|
```sh
|
package/dist/index.d.ts
CHANGED
|
@@ -420,6 +420,8 @@ interface ClaudeCodeConfig {
|
|
|
420
420
|
bridgeSkipNativeSkills?: boolean;
|
|
421
421
|
/** Append a one-line cost / duration / cache footer to each finished turn. */
|
|
422
422
|
turnStats?: boolean;
|
|
423
|
+
/** Branch a forked opencode session off the parent's Claude conversation. */
|
|
424
|
+
forkSessions?: boolean;
|
|
423
425
|
logging?: LoggingConfig;
|
|
424
426
|
}
|
|
425
427
|
interface LoggingConfig {
|
|
@@ -761,6 +763,37 @@ interface ClaudeCodeProviderSettings {
|
|
|
761
763
|
* `permission_denials` always reach `providerMetadata`.
|
|
762
764
|
*/
|
|
763
765
|
turnStats?: boolean;
|
|
766
|
+
/**
|
|
767
|
+
* When a new opencode session turns out to be a fork of one this provider
|
|
768
|
+
* already served, branch the parent's Claude conversation with
|
|
769
|
+
* `claude --resume <parent> --fork-session` instead of re-rendering the
|
|
770
|
+
* whole thread as text into the first message.
|
|
771
|
+
*
|
|
772
|
+
* Measured on Claude Code 2.1.280 with haiku 4.5 over a ~13k-token
|
|
773
|
+
* conversation: the forked turn wrote 814 cache tokens and read 39,710,
|
|
774
|
+
* against 22,355 written and 17,385 read for the replay of the same thread,
|
|
775
|
+
* which is $0.0058 against $0.0467 for that one turn. The parent's own
|
|
776
|
+
* transcript is byte-identical afterwards (checked by hash).
|
|
777
|
+
*
|
|
778
|
+
* Off by default, and the reason is measured rather than cautious: a
|
|
779
|
+
* resumed Claude conversation reuses the system prompt recorded on its
|
|
780
|
+
* FIRST request (`--system-prompt-snapshot`, default `on`), so a forked
|
|
781
|
+
* session is answered under the parent's appended system prompt, not this
|
|
782
|
+
* turn's. Probed directly: a parent seeded with "your codename is ZEBRA"
|
|
783
|
+
* and forked while passing "your codename is QUAIL" answered ZEBRA. The
|
|
784
|
+
* conversation the model sees is otherwise the real transcript rather than
|
|
785
|
+
* the replay's clipped rendering, which is strictly more faithful and
|
|
786
|
+
* strictly cheaper, so this is worth turning on for branch-heavy work.
|
|
787
|
+
*
|
|
788
|
+
* It is also conservative about when it fires. Everything below keeps
|
|
789
|
+
* today's replay, unchanged: a different account, a parent whose Claude
|
|
790
|
+
* session id is unknown or gone, a parent that is still busy, a fork cut
|
|
791
|
+
* mid-conversation or taken mid tool round trip, a different cwd, model,
|
|
792
|
+
* agent, effort or prompt-cache TTL, a compaction turn, the interactive
|
|
793
|
+
* transport, an account-failover switch, and a `claude` with no
|
|
794
|
+
* `--fork-session` flag.
|
|
795
|
+
*/
|
|
796
|
+
forkSessions?: boolean;
|
|
764
797
|
/**
|
|
765
798
|
* Routing for Claude's built-in `WebSearch` tool.
|
|
766
799
|
*
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import {
|
|
1
|
+
// src/ids.ts
|
|
2
|
+
import { randomInt } from "crypto";
|
|
3
|
+
var ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
4
|
+
var ID_LENGTH = 16;
|
|
5
|
+
function generateId() {
|
|
6
|
+
let id = "";
|
|
7
|
+
for (let index = 0; index < ID_LENGTH; index++) {
|
|
8
|
+
id += ID_ALPHABET[randomInt(ID_ALPHABET.length)];
|
|
9
|
+
}
|
|
10
|
+
return id;
|
|
11
|
+
}
|
|
3
12
|
|
|
4
13
|
// src/host-tools.ts
|
|
5
14
|
function defined(input) {
|
|
@@ -3922,6 +3931,190 @@ async function cancelBackgroundTask(input, options) {
|
|
|
3922
3931
|
};
|
|
3923
3932
|
}
|
|
3924
3933
|
|
|
3934
|
+
// src/session-fork.ts
|
|
3935
|
+
import { createHash as createHash3 } from "crypto";
|
|
3936
|
+
var fingerprints = /* @__PURE__ */ new Map();
|
|
3937
|
+
var fingerprintSeq = 0;
|
|
3938
|
+
var MAX_FORK_FINGERPRINTS = 64;
|
|
3939
|
+
function hashPart(hash, part) {
|
|
3940
|
+
const p = part;
|
|
3941
|
+
const type = typeof p?.type === "string" ? p.type : "unknown";
|
|
3942
|
+
switch (type) {
|
|
3943
|
+
case "text":
|
|
3944
|
+
hash.update("text\0");
|
|
3945
|
+
hash.update(String(p?.text ?? ""));
|
|
3946
|
+
break;
|
|
3947
|
+
case "tool-call":
|
|
3948
|
+
hash.update("call\0");
|
|
3949
|
+
hash.update(String(p?.toolName ?? ""));
|
|
3950
|
+
hash.update("\0");
|
|
3951
|
+
hash.update(stableJson(p?.input));
|
|
3952
|
+
break;
|
|
3953
|
+
case "tool-result":
|
|
3954
|
+
hash.update("result\0");
|
|
3955
|
+
hash.update(String(p?.toolName ?? ""));
|
|
3956
|
+
hash.update("\0");
|
|
3957
|
+
hash.update(stableJson(p?.output));
|
|
3958
|
+
break;
|
|
3959
|
+
case "file":
|
|
3960
|
+
case "image": {
|
|
3961
|
+
hash.update(type);
|
|
3962
|
+
hash.update("\0");
|
|
3963
|
+
hash.update(String(p?.mediaType ?? ""));
|
|
3964
|
+
hash.update("\0");
|
|
3965
|
+
const data = p?.data ?? p?.image;
|
|
3966
|
+
if (typeof data === "string") hash.update(data);
|
|
3967
|
+
else if (data instanceof Uint8Array) hash.update(data);
|
|
3968
|
+
else if (data instanceof URL) hash.update(data.href);
|
|
3969
|
+
else hash.update(stableJson(data));
|
|
3970
|
+
break;
|
|
3971
|
+
}
|
|
3972
|
+
default:
|
|
3973
|
+
hash.update("other\0");
|
|
3974
|
+
hash.update(type);
|
|
3975
|
+
break;
|
|
3976
|
+
}
|
|
3977
|
+
hash.update("");
|
|
3978
|
+
}
|
|
3979
|
+
function stableJson(value) {
|
|
3980
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
3981
|
+
const walk = (node) => {
|
|
3982
|
+
if (node === null || typeof node !== "object") return node;
|
|
3983
|
+
if (seen.has(node)) return "[circular]";
|
|
3984
|
+
seen.add(node);
|
|
3985
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
3986
|
+
const out = {};
|
|
3987
|
+
for (const key of Object.keys(node).sort()) {
|
|
3988
|
+
out[key] = walk(node[key]);
|
|
3989
|
+
}
|
|
3990
|
+
return out;
|
|
3991
|
+
};
|
|
3992
|
+
try {
|
|
3993
|
+
return JSON.stringify(walk(value)) ?? "null";
|
|
3994
|
+
} catch {
|
|
3995
|
+
return "[unserializable]";
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
function messageDigest(msg) {
|
|
3999
|
+
const hash = createHash3("sha256");
|
|
4000
|
+
hash.update(msg.role);
|
|
4001
|
+
hash.update("\0");
|
|
4002
|
+
if (typeof msg.content === "string") {
|
|
4003
|
+
hash.update("text\0");
|
|
4004
|
+
hash.update(msg.content);
|
|
4005
|
+
hash.update("");
|
|
4006
|
+
} else if (Array.isArray(msg.content)) {
|
|
4007
|
+
for (const part of msg.content) hashPart(hash, part);
|
|
4008
|
+
}
|
|
4009
|
+
return hash.digest("hex").slice(0, 32);
|
|
4010
|
+
}
|
|
4011
|
+
function conversationDigests(prompt) {
|
|
4012
|
+
const out = [];
|
|
4013
|
+
for (const msg of prompt) {
|
|
4014
|
+
if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "tool") {
|
|
4015
|
+
continue;
|
|
4016
|
+
}
|
|
4017
|
+
out.push({
|
|
4018
|
+
role: msg.role,
|
|
4019
|
+
digest: messageDigest(msg)
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
return out;
|
|
4023
|
+
}
|
|
4024
|
+
function recordForkFingerprint(sessionKey2, prompt, cliPath) {
|
|
4025
|
+
const entries = conversationDigests(prompt);
|
|
4026
|
+
if (entries.length === 0) return;
|
|
4027
|
+
fingerprints.delete(sessionKey2);
|
|
4028
|
+
fingerprints.set(sessionKey2, { entries, cliPath, seq: ++fingerprintSeq });
|
|
4029
|
+
while (fingerprints.size > MAX_FORK_FINGERPRINTS) {
|
|
4030
|
+
const oldest = fingerprints.keys().next();
|
|
4031
|
+
if (oldest.done) break;
|
|
4032
|
+
fingerprints.delete(oldest.value);
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
function forgetForkFingerprint(sessionKey2) {
|
|
4036
|
+
fingerprints.delete(sessionKey2);
|
|
4037
|
+
}
|
|
4038
|
+
function forkSiblingSignature(sessionKey2) {
|
|
4039
|
+
const parts = sessionKey2.split("::");
|
|
4040
|
+
if (parts.length < 5) return null;
|
|
4041
|
+
if (parts[2] === "compaction") return null;
|
|
4042
|
+
const blanked = [...parts];
|
|
4043
|
+
blanked[3] = "*";
|
|
4044
|
+
return blanked.join("::");
|
|
4045
|
+
}
|
|
4046
|
+
function splitForkHistory(prompt) {
|
|
4047
|
+
const messages = prompt;
|
|
4048
|
+
let lastAssistant = -1;
|
|
4049
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4050
|
+
if (messages[i].role === "assistant") {
|
|
4051
|
+
lastAssistant = i;
|
|
4052
|
+
break;
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
if (lastAssistant < 0) return { history: [], forkable: false };
|
|
4056
|
+
for (let i = lastAssistant + 1; i < messages.length; i++) {
|
|
4057
|
+
const msg = messages[i];
|
|
4058
|
+
if (msg.role === "tool") return { history: [], forkable: false };
|
|
4059
|
+
if (!Array.isArray(msg.content)) continue;
|
|
4060
|
+
for (const part of msg.content) {
|
|
4061
|
+
if (part?.type === "tool-result") return { history: [], forkable: false };
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
return {
|
|
4065
|
+
history: conversationDigests(messages.slice(0, lastAssistant + 1)),
|
|
4066
|
+
forkable: true
|
|
4067
|
+
};
|
|
4068
|
+
}
|
|
4069
|
+
function findForkParent(opts) {
|
|
4070
|
+
const signature = forkSiblingSignature(opts.sessionKey);
|
|
4071
|
+
if (!signature) return null;
|
|
4072
|
+
const { history, forkable } = splitForkHistory(opts.prompt);
|
|
4073
|
+
if (!forkable || history.length === 0) return null;
|
|
4074
|
+
let best = null;
|
|
4075
|
+
let bestLength = 0;
|
|
4076
|
+
let bestSeq = -1;
|
|
4077
|
+
for (const [key, fingerprint] of fingerprints) {
|
|
4078
|
+
if (key === opts.sessionKey) continue;
|
|
4079
|
+
if (fingerprint.cliPath !== opts.cliPath) continue;
|
|
4080
|
+
if (forkSiblingSignature(key) !== signature) continue;
|
|
4081
|
+
const recorded = fingerprint.entries;
|
|
4082
|
+
if (recorded.length === 0 || recorded.length > history.length) continue;
|
|
4083
|
+
if (!isPrefix(recorded, history)) continue;
|
|
4084
|
+
if (!tailIsReplyOnly(history, recorded.length)) continue;
|
|
4085
|
+
const claudeSessionId = opts.lookupClaudeSessionId(key);
|
|
4086
|
+
if (!claudeSessionId) continue;
|
|
4087
|
+
if (opts.isBusy(key)) continue;
|
|
4088
|
+
if (recorded.length > bestLength || recorded.length === bestLength && fingerprint.seq > bestSeq) {
|
|
4089
|
+
best = { parentKey: key, claudeSessionId, matched: recorded.length };
|
|
4090
|
+
bestLength = recorded.length;
|
|
4091
|
+
bestSeq = fingerprint.seq;
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
if (best) {
|
|
4095
|
+
log.info("found a fork parent for this conversation", {
|
|
4096
|
+
sessionKey: opts.sessionKey,
|
|
4097
|
+
parentKey: best.parentKey,
|
|
4098
|
+
matchedMessages: best.matched,
|
|
4099
|
+
historyMessages: history.length
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
4102
|
+
return best;
|
|
4103
|
+
}
|
|
4104
|
+
function isPrefix(recorded, history) {
|
|
4105
|
+
for (let i = 0; i < recorded.length; i++) {
|
|
4106
|
+
if (recorded[i].role !== history[i].role) return false;
|
|
4107
|
+
if (recorded[i].digest !== history[i].digest) return false;
|
|
4108
|
+
}
|
|
4109
|
+
return true;
|
|
4110
|
+
}
|
|
4111
|
+
function tailIsReplyOnly(history, from) {
|
|
4112
|
+
for (let i = from; i < history.length; i++) {
|
|
4113
|
+
if (history[i].role === "user") return false;
|
|
4114
|
+
}
|
|
4115
|
+
return true;
|
|
4116
|
+
}
|
|
4117
|
+
|
|
3925
4118
|
// src/permission-presets.ts
|
|
3926
4119
|
var READ_ONLY_DISALLOWED_CLI_TOOLS = [
|
|
3927
4120
|
"Bash",
|
|
@@ -4526,6 +4719,11 @@ function claudeSessionIsBusy(key) {
|
|
|
4526
4719
|
if (activeProcesses.has(key)) return true;
|
|
4527
4720
|
return getPendingProxyCalls(key).length > 0 || hasExitPlanModeQuestions(key);
|
|
4528
4721
|
}
|
|
4722
|
+
function claudeSessionIsWriting(key) {
|
|
4723
|
+
const active = activeProcesses.get(key);
|
|
4724
|
+
if (active && isTurnInFlight(active)) return true;
|
|
4725
|
+
return getPendingProxyCalls(key).length > 0 || hasExitPlanModeQuestions(key);
|
|
4726
|
+
}
|
|
4529
4727
|
function capClaudeSessions() {
|
|
4530
4728
|
for (const key of [...claudeSessions.keys()]) {
|
|
4531
4729
|
if (claudeSessions.size <= MAX_CLAUDE_SESSION_ENTRIES) return;
|
|
@@ -4549,6 +4747,7 @@ function deleteClaudeSessionId(key) {
|
|
|
4549
4747
|
const claudeSessionId = claudeSessions.get(key);
|
|
4550
4748
|
if (claudeSessionId) clearLedger(claudeSessionId);
|
|
4551
4749
|
claudeSessions.delete(key);
|
|
4750
|
+
forgetForkFingerprint(key);
|
|
4552
4751
|
}
|
|
4553
4752
|
function effortSessionKey(baseKey, effort) {
|
|
4554
4753
|
return effort ? `${baseKey}::effort=${effort}` : baseKey;
|
|
@@ -4685,6 +4884,25 @@ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcp
|
|
|
4685
4884
|
return ap;
|
|
4686
4885
|
}
|
|
4687
4886
|
function appendResumeIfNeeded(sessionKey2, cliArgs) {
|
|
4887
|
+
if (cliArgs.includes("--fork-session")) {
|
|
4888
|
+
const forkedSessionId = claudeSessions.get(sessionKey2);
|
|
4889
|
+
if (!forkedSessionId) return cliArgs;
|
|
4890
|
+
const resumed = [];
|
|
4891
|
+
for (let i = 0; i < cliArgs.length; i++) {
|
|
4892
|
+
if (cliArgs[i] === "--fork-session") continue;
|
|
4893
|
+
if (cliArgs[i] === "--resume") {
|
|
4894
|
+
resumed.push("--resume", forkedSessionId);
|
|
4895
|
+
i++;
|
|
4896
|
+
continue;
|
|
4897
|
+
}
|
|
4898
|
+
resumed.push(cliArgs[i]);
|
|
4899
|
+
}
|
|
4900
|
+
log.info("respawning a forked session on its own claude conversation", {
|
|
4901
|
+
sessionKey: sessionKey2,
|
|
4902
|
+
claudeSessionId: forkedSessionId
|
|
4903
|
+
});
|
|
4904
|
+
return resumed;
|
|
4905
|
+
}
|
|
4688
4906
|
if (cliArgs.includes("--resume") || cliArgs.includes("--session-id")) {
|
|
4689
4907
|
return cliArgs;
|
|
4690
4908
|
}
|
|
@@ -4725,6 +4943,7 @@ function buildCliArgs(opts) {
|
|
|
4725
4943
|
sessionKey: sessionKey2,
|
|
4726
4944
|
skipPermissions,
|
|
4727
4945
|
includeSessionId = true,
|
|
4946
|
+
forkFromClaudeSessionId,
|
|
4728
4947
|
model,
|
|
4729
4948
|
permissionMode,
|
|
4730
4949
|
mcpConfig,
|
|
@@ -4774,6 +4993,8 @@ function buildCliArgs(opts) {
|
|
|
4774
4993
|
const sessionId = claudeSessions.get(sessionKey2);
|
|
4775
4994
|
if (sessionId && !activeProcesses.has(sessionKey2)) {
|
|
4776
4995
|
args.push("--resume", sessionId);
|
|
4996
|
+
} else if (forkFromClaudeSessionId) {
|
|
4997
|
+
args.push("--resume", forkFromClaudeSessionId, "--fork-session");
|
|
4777
4998
|
}
|
|
4778
4999
|
}
|
|
4779
5000
|
if (mcpConfig) {
|
|
@@ -5129,7 +5350,7 @@ async function handleBtwCommand(client, input, options = {}) {
|
|
|
5129
5350
|
}
|
|
5130
5351
|
|
|
5131
5352
|
// src/diagnostic-bundle.ts
|
|
5132
|
-
import { createHash as
|
|
5353
|
+
import { createHash as createHash4, randomBytes as randomBytes2 } from "crypto";
|
|
5133
5354
|
import { openSync, readSync, closeSync, fstatSync, realpathSync } from "fs";
|
|
5134
5355
|
import { homedir as homedir4 } from "os";
|
|
5135
5356
|
|
|
@@ -5199,6 +5420,8 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
|
|
|
5199
5420
|
"falling back to the next model in the chain",
|
|
5200
5421
|
"fast mode is in cooldown after a rate limit; this turn runs at standard speed and is billed at standard Opus rates, not the fast price shown in the model picker.",
|
|
5201
5422
|
"file part without data, skipping",
|
|
5423
|
+
"forkSessions is on but this claude CLI has no --fork-session; replaying the conversation instead",
|
|
5424
|
+
"forking the parent conversation's claude session instead of replaying it",
|
|
5202
5425
|
"ignoring a failover reset time that is not in the future",
|
|
5203
5426
|
"ignoring unknown proxyOpencodeTools entries",
|
|
5204
5427
|
"ignoring unknown proxyTools entries",
|
|
@@ -5450,7 +5673,7 @@ function shape(value) {
|
|
|
5450
5673
|
return `[redacted, ${size} chars]`;
|
|
5451
5674
|
}
|
|
5452
5675
|
function createIdHasher(salt = randomBytes2(16).toString("hex")) {
|
|
5453
|
-
return (raw) =>
|
|
5676
|
+
return (raw) => createHash4("sha256").update(salt).update("\0").update(raw).digest("hex").slice(0, 8);
|
|
5454
5677
|
}
|
|
5455
5678
|
function createRedactionContext(options = {}) {
|
|
5456
5679
|
const home = options.home ?? homedir4();
|
|
@@ -5668,7 +5891,11 @@ var BUNDLE_PREAMBLE = [
|
|
|
5668
5891
|
"Never included: prompt or reply text, system prompts, tool inputs or outputs,",
|
|
5669
5892
|
"file contents, environment values, bearer tokens, API keys, `Authorization`",
|
|
5670
5893
|
"headers, MCP server env or headers, URL credentials or query strings, or the",
|
|
5671
|
-
"raw spawn argv."
|
|
5894
|
+
"raw spawn argv.",
|
|
5895
|
+
"",
|
|
5896
|
+
"Kept on purpose, so read it before pasting: folder paths below your home (your",
|
|
5897
|
+
"project and config folder names) and your `accounts` names, which a maintainer",
|
|
5898
|
+
"needs to read a cwd or an account problem."
|
|
5672
5899
|
].join("\n");
|
|
5673
5900
|
var BUNDLE_LOGGING_OFF = [
|
|
5674
5901
|
"File logging is **off**, so there is nothing to bundle. The report above still",
|
|
@@ -9378,7 +9605,6 @@ function toFinishReason(reason = "stop") {
|
|
|
9378
9605
|
}
|
|
9379
9606
|
|
|
9380
9607
|
// src/turn-state.ts
|
|
9381
|
-
import { generateId } from "@ai-sdk/provider-utils";
|
|
9382
9608
|
function resolveResultFallbackMs() {
|
|
9383
9609
|
const env = process.env.CLAUDE_CODE_RESULT_FALLBACK_MS;
|
|
9384
9610
|
const parsed = env ? Number.parseInt(env, 10) : NaN;
|
|
@@ -9532,9 +9758,6 @@ function watchTurnAbort(signal) {
|
|
|
9532
9758
|
};
|
|
9533
9759
|
}
|
|
9534
9760
|
|
|
9535
|
-
// src/stream-parser.ts
|
|
9536
|
-
import { generateId as generateId2 } from "@ai-sdk/provider-utils";
|
|
9537
|
-
|
|
9538
9761
|
// src/tool-mapping.ts
|
|
9539
9762
|
function isWebSearchTool(name) {
|
|
9540
9763
|
return name === "WebSearch" || name === "web_search";
|
|
@@ -10188,7 +10411,7 @@ function createLineHandler(state, ctx) {
|
|
|
10188
10411
|
const idx = msg.index;
|
|
10189
10412
|
if (block2.type === "thinking") {
|
|
10190
10413
|
noteReasoning(state);
|
|
10191
|
-
const reasoningId =
|
|
10414
|
+
const reasoningId = generateId();
|
|
10192
10415
|
state.reasoningIds.set(idx, reasoningId);
|
|
10193
10416
|
}
|
|
10194
10417
|
if (block2.type === "text") {
|
|
@@ -10434,7 +10657,7 @@ ${plan}
|
|
|
10434
10657
|
if (block2.thinking && block2.thinking.length > 0) {
|
|
10435
10658
|
noteReasoning(state);
|
|
10436
10659
|
state.hadThinkingTextFromStream = true;
|
|
10437
|
-
const thinkingId =
|
|
10660
|
+
const thinkingId = generateId();
|
|
10438
10661
|
state.controller.enqueue({
|
|
10439
10662
|
type: "reasoning-start",
|
|
10440
10663
|
id: thinkingId
|
|
@@ -10484,7 +10707,7 @@ ${plan}
|
|
|
10484
10707
|
}
|
|
10485
10708
|
if (block2.type === "thinking" && block2.thinking) {
|
|
10486
10709
|
noteReasoning(state);
|
|
10487
|
-
const thinkingId =
|
|
10710
|
+
const thinkingId = generateId();
|
|
10488
10711
|
state.controller.enqueue({
|
|
10489
10712
|
type: "reasoning-start",
|
|
10490
10713
|
id: thinkingId
|
|
@@ -11066,7 +11289,7 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11066
11289
|
usage,
|
|
11067
11290
|
request: result.request,
|
|
11068
11291
|
response: {
|
|
11069
|
-
id: typeof sessionId === "string" && sessionId ? sessionId :
|
|
11292
|
+
id: typeof sessionId === "string" && sessionId ? sessionId : generateId(),
|
|
11070
11293
|
timestamp: /* @__PURE__ */ new Date(),
|
|
11071
11294
|
modelId: this.modelId
|
|
11072
11295
|
},
|
|
@@ -11138,7 +11361,7 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11138
11361
|
const buffered = [];
|
|
11139
11362
|
let flushed = false;
|
|
11140
11363
|
const emitNote = (note) => {
|
|
11141
|
-
const id =
|
|
11364
|
+
const id = generateId();
|
|
11142
11365
|
controller.enqueue({ type: "text-start", id });
|
|
11143
11366
|
controller.enqueue({ type: "text-delta", id, delta: note });
|
|
11144
11367
|
controller.enqueue({ type: "text-end", id });
|
|
@@ -11290,7 +11513,7 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11290
11513
|
controller.enqueue({ type: "stream-start", warnings });
|
|
11291
11514
|
try {
|
|
11292
11515
|
const text = await buildDoctorReport(doctorOptions);
|
|
11293
|
-
const id =
|
|
11516
|
+
const id = generateId();
|
|
11294
11517
|
controller.enqueue({ type: "text-start", id });
|
|
11295
11518
|
controller.enqueue({ type: "text-delta", id, delta: text });
|
|
11296
11519
|
controller.enqueue({ type: "text-end", id });
|
|
@@ -11338,7 +11561,7 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11338
11561
|
controller.enqueue({ type: "stream-start", warnings });
|
|
11339
11562
|
try {
|
|
11340
11563
|
const answer = await answerAside();
|
|
11341
|
-
const id =
|
|
11564
|
+
const id = generateId();
|
|
11342
11565
|
controller.enqueue({ type: "text-start", id });
|
|
11343
11566
|
controller.enqueue({ type: "text-delta", id, delta: answer.response });
|
|
11344
11567
|
controller.enqueue({ type: "text-end", id });
|
|
@@ -11364,7 +11587,7 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11364
11587
|
providerOptionsKeys: options.providerOptions ? Object.keys(options.providerOptions) : []
|
|
11365
11588
|
});
|
|
11366
11589
|
const text = this.synthesizeTitle(options.prompt);
|
|
11367
|
-
const textId =
|
|
11590
|
+
const textId = generateId();
|
|
11368
11591
|
const stream2 = new ReadableStream({
|
|
11369
11592
|
start(controller) {
|
|
11370
11593
|
controller.enqueue({ type: "stream-start", warnings });
|
|
@@ -11433,7 +11656,7 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11433
11656
|
const stream2 = new ReadableStream({
|
|
11434
11657
|
start(controller) {
|
|
11435
11658
|
controller.enqueue({ type: "stream-start", warnings });
|
|
11436
|
-
const id =
|
|
11659
|
+
const id = generateId();
|
|
11437
11660
|
controller.enqueue({ type: "text-start", id });
|
|
11438
11661
|
controller.enqueue({ type: "text-delta", id, delta: note });
|
|
11439
11662
|
controller.enqueue({ type: "text-end", id });
|
|
@@ -11498,6 +11721,49 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11498
11721
|
if (failoverAnswer?.kind === "switch" && hasPriorConversation) {
|
|
11499
11722
|
includeHistoryContext = true;
|
|
11500
11723
|
}
|
|
11724
|
+
let forkFromClaudeSessionId;
|
|
11725
|
+
if (this.config.forkSessions === true && includeHistoryContext && !compactionMode && !useInteractive && failoverAnswer?.kind !== "switch") {
|
|
11726
|
+
const parent = findForkParent({
|
|
11727
|
+
sessionKey: sk,
|
|
11728
|
+
prompt: options.prompt,
|
|
11729
|
+
cliPath,
|
|
11730
|
+
lookupClaudeSessionId: getClaudeSessionId,
|
|
11731
|
+
// `claudeSessionIsWriting`, not `getActiveProcess`: this walks every
|
|
11732
|
+
// sibling key, and that one refreshes LRU order and cancels idle
|
|
11733
|
+
// timers on everything it touches. An idle live child is fine to
|
|
11734
|
+
// branch from; a transcript mid-write is not.
|
|
11735
|
+
isBusy: claudeSessionIsWriting
|
|
11736
|
+
});
|
|
11737
|
+
if (parent) {
|
|
11738
|
+
const supported = await Promise.race([
|
|
11739
|
+
detectCliSupportsFlag(cliPath, "--fork-session"),
|
|
11740
|
+
turnAbort.whenAborted.then(() => false)
|
|
11741
|
+
]);
|
|
11742
|
+
if (turnAbort.aborted) {
|
|
11743
|
+
return abortedBeforeWork("probing --fork-session support");
|
|
11744
|
+
}
|
|
11745
|
+
if (supported) {
|
|
11746
|
+
forkFromClaudeSessionId = parent.claudeSessionId;
|
|
11747
|
+
includeHistoryContext = false;
|
|
11748
|
+
log.notice(
|
|
11749
|
+
"forking the parent conversation's claude session instead of replaying it",
|
|
11750
|
+
{
|
|
11751
|
+
sessionKey: sk,
|
|
11752
|
+
parentKey: parent.parentKey,
|
|
11753
|
+
matchedMessages: parent.matched
|
|
11754
|
+
}
|
|
11755
|
+
);
|
|
11756
|
+
} else {
|
|
11757
|
+
log.warn(
|
|
11758
|
+
"forkSessions is on but this claude CLI has no --fork-session; replaying the conversation instead",
|
|
11759
|
+
{ cliPath }
|
|
11760
|
+
);
|
|
11761
|
+
}
|
|
11762
|
+
}
|
|
11763
|
+
}
|
|
11764
|
+
if (this.config.forkSessions === true && !compactionMode && !useInteractive) {
|
|
11765
|
+
recordForkFingerprint(sk, options.prompt, cliPath);
|
|
11766
|
+
}
|
|
11501
11767
|
const { model: spawnModelId, fast: fastMode } = parseModelId(failover.modelId);
|
|
11502
11768
|
const exitPlanModeQuestionResult = compactionMode ? null : consumeExitPlanModeQuestionResult(sk, options.prompt);
|
|
11503
11769
|
if (exitPlanModeQuestionResult) {
|
|
@@ -11972,6 +12238,7 @@ var ClaudeCodeLanguageModel = class {
|
|
|
11972
12238
|
state.cliArgs = buildCliArgs({
|
|
11973
12239
|
sessionKey: sk,
|
|
11974
12240
|
skipPermissions,
|
|
12241
|
+
forkFromClaudeSessionId,
|
|
11975
12242
|
model: spawnModelId,
|
|
11976
12243
|
permissionMode: self.config.permissionMode,
|
|
11977
12244
|
mcpConfig: mcp.paths,
|
|
@@ -13202,6 +13469,7 @@ function createClaudeCode(settings = {}) {
|
|
|
13202
13469
|
bridgeOpencodeSkills: settings.bridgeOpencodeSkills === true,
|
|
13203
13470
|
bridgeSkipNativeSkills: settings.bridgeSkipNativeSkills !== false,
|
|
13204
13471
|
turnStats: settings.turnStats === true,
|
|
13472
|
+
forkSessions: settings.forkSessions === true,
|
|
13205
13473
|
interactive: settings.interactive,
|
|
13206
13474
|
interactiveBypass: settings.interactiveBypass,
|
|
13207
13475
|
interactiveAllowTools: settings.interactiveAllowTools,
|