-
Notifications
You must be signed in to change notification settings - Fork 358
Expand file tree
/
Copy pathclaude.rs
More file actions
3063 lines (2928 loc) · 140 KB
/
Copy pathclaude.rs
File metadata and controls
3063 lines (2928 loc) · 140 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Claude Code harness.
//!
//! Chat: one *resident* `claude --print --input-format stream-json` child per
//! chat session (`local::claude::ClaudeHost`), reused across turns — each turn
//! sends one user message and folds the child's stream-json output, from the
//! `--replay-user-messages` echo of that message (the turn's start boundary —
//! see `belongs_to_current_turn`) until a `result` event. The child persists
//! (stable `session_id`, stdin held open), collapsing the old spawn-per-turn
//! overhead; a config change (permission mode
//! / effort / bridge), interrupt, or crash respawns it with `--resume`. The
//! playbook rides `--append-system-prompt-file`; the permission mode is
//! `--permission-mode` from the session's setting (`auto`/`bypassPermissions` — see
//! `options`). AskUserQuestion / ExitPlanMode surface as interactive cards: the
//! turn ends on them and the user's answer resumes the session — except in plan
//! mode, where the mcp-gate bridge holds both open mid-turn and the answer
//! continues the same turn.
//!
//! Detection: `claude auth status --json` is the readiness source of truth.
//! `~/.claude.json` contributes display metadata only after that live check;
//! `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` remain credential fallbacks.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use async_trait::async_trait;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use super::detect::{
bin_version, nonempty_str, parse_version, read_json, HarnessAuthState, HarnessInfo, ModelInfo,
};
use super::options::{
HarnessOptions, OptionChoice, PermissionMode, PlanActivation, REASONING_DEFAULT_ID,
};
use super::{
Harness, ResumeAction, TurnFailure, TurnOutcome, TurnResult, Waited, ORX_MAX_ATTEMPTS,
};
use crate::error::{anyhow, Result};
use crate::local::chat::{
find_part_mut, prepare_env, ContextUsage, DeliveryState, PromptAnswer, ResumeCtx, TurnCtx,
WirePart, WirePrompt, WireQuestionOption, WireToolState,
};
use crate::local::claude::{SpawnConfig, SpawnSpec, TurnEvent};
use crate::local::opencode::ensure_playbook;
use crate::local::shell_env::{self, find_on_path};
/// FALLBACK model list, used only when the `list_models` control request fails
/// (a CLI too old to answer it, or a spawn/timeout failure). The primary source
/// is [`claude_list_models`]: the same catalog the CLI's own `/model` menu
/// renders, with per-model `supportedEffortLevels`.
const CLAUDE_MODELS: [&str; 4] = [
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-haiku-4-5",
];
/// FALLBACK effort tiers, paired with `CLAUDE_MODELS` above — the base five
/// every supported CLI accepts. The primary source is per-model
/// `supportedEffortLevels` from `list_models`.
const CLAUDE_EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
/// `ultracode` — the session mode that selects `xhigh` effort plus standing
/// dynamic-workflow orchestration. NOT the same as `ultrathink` (a prompt
/// keyword) or Codex's `ultra`. The CLI models it as a *mode*, not an effort
/// level: `list_models` never includes it in `supportedEffortLevels`, even on
/// versions whose `--effort` accepts it — which is why support is detected by
/// [`claude_accepts_ultracode`] rather than read from the catalog.
const CLAUDE_ULTRACODE: &str = "ultracode";
/// Includes Anthropic's multi-process refresh-token and sleep/wake fixes.
const MIN_CLAUDE_VERSION: (u64, u64, u64) = (2, 1, 211);
const AUTH_STATUS_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AuthProbe {
state: HarnessAuthState,
method: Option<&'static str>,
}
fn parse_auth_status(success: bool, stdout: &[u8]) -> AuthProbe {
let value = serde_json::from_slice::<Value>(stdout).ok();
let logged_in = value
.as_ref()
.and_then(|value| value.get("loggedIn"))
.and_then(Value::as_bool);
let reported_method = value
.as_ref()
.and_then(|value| value.get("authMethod"))
.and_then(Value::as_str)
.map(|method| method.to_ascii_lowercase());
let method = reported_method.as_deref().and_then(|method| {
if method.contains("api") || method.contains("token") {
Some("apiKey")
} else if method.contains("oauth") || method.contains("claude") {
Some("oauth")
} else {
None
}
});
let state = match (success, logged_in) {
(true, Some(true)) => HarnessAuthState::Ready,
(_, Some(false)) => HarnessAuthState::NeedsLogin,
_ => HarnessAuthState::Unknown,
};
AuthProbe { state, method }
}
async fn probe_auth(bin: &Path) -> AuthProbe {
let mut cmd = Command::new(bin);
cmd.args(["auth", "status", "--json"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
prepare_env(&mut cmd);
match tokio::time::timeout(AUTH_STATUS_TIMEOUT, cmd.output()).await {
Ok(Ok(out)) => parse_auth_status(out.status.success(), &out.stdout),
_ => AuthProbe {
state: HarnessAuthState::Unknown,
method: None,
},
}
}
async fn effective_auth_probe(bin: &Path) -> AuthProbe {
let mut probe = probe_auth(bin).await;
// Headless Claude gives ANTHROPIC_* credentials precedence over a saved
// subscription login. If status still reports OAuth in that environment,
// it has only verified leftover OAuth metadata, not the credential the
// worker will actually send.
if has_api_credential() && probe.method != Some("apiKey") {
probe.state = HarnessAuthState::Unknown;
probe.method = None;
} else if probe.state == HarnessAuthState::Ready && probe.method.is_none() {
probe.method = Some("oauth");
}
probe
}
fn gate_oauth_version(mut probe: AuthProbe, version: Option<&str>) -> AuthProbe {
if probe.state == HarnessAuthState::Ready && probe.method == Some("oauth") {
probe.state = match version.and_then(parse_version) {
Some(version) if version >= MIN_CLAUDE_VERSION => HarnessAuthState::Ready,
Some(_) => HarnessAuthState::Unsupported,
None => HarnessAuthState::Unknown,
};
}
probe
}
pub(crate) async fn current_auth_state() -> HarnessAuthState {
match find_claude() {
Some(bin) => {
let version = bin_version(&bin).await;
gate_oauth_version(effective_auth_probe(&bin).await, version.as_deref()).state
}
None => HarnessAuthState::Unknown,
}
}
pub(crate) fn auth_recovery_note() -> &'static str {
if has_api_credential() {
"Claude Code rejected the configured `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN`. Replace or unset it, then re-check this harness."
} else {
"Sign in with `claude auth login`, then re-check this harness."
}
}
/// Ask the installed CLI's own argument parser whether it accepts
/// `--effort ultracode`. `--version` still runs the parser, which prints
/// `Warning: Unknown --effort value …` for a value it doesn't know and exits
/// without touching the network (~0.2s); absence of the warning is acceptance.
///
/// The parser is the only truthful surface. Every enumeration the CLI offers
/// lies about this value: `--help` lists five tiers on versions that accept
/// six; the warning's own "Valid values:" list omits `ultracode` on versions
/// that accept it; and `list_models` never advertises it (see
/// [`CLAUDE_ULTRACODE`]). Probing the parser replaces a hard-coded version
/// gate — the boundary (2.1.202 rejects / 2.1.203 accepts, bisected across
/// every published version in between) is now discovered per install instead
/// of pinned.
///
/// Any failure reports unsupported: a missing choice is a smaller harm than a
/// choice that silently runs at the default effort.
async fn claude_accepts_ultracode(bin: &Path) -> bool {
let mut cmd = Command::new(bin);
cmd.args(["--effort", CLAUDE_ULTRACODE, "--version"])
.stdin(Stdio::null());
prepare_env(&mut cmd);
let fut = cmd.output();
match tokio::time::timeout(Duration::from_secs(10), fut).await {
Ok(Ok(out)) => {
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
out.status.success() && !text.contains("Unknown --effort value")
}
_ => false,
}
}
/// Query the CLI's own model catalog — the `list_models` control request over
/// `--print` stream-json, the same data its `/model` menu renders: every model
/// with its `supportedEffortLevels`. This is the Claude analogue of codex's
/// `model/list` and opencode's `models --verbose`; a curated table here shipped
/// effort tiers on Haiku, which the catalog says supports none.
///
/// One shot: spawn, write the control request, read until its
/// `control_response` (skipping stream noise), kill the child. Any failure —
/// spawn, timeout, a CLI too old for the subtype — returns `None` and the
/// caller falls back to the static table.
async fn claude_list_models(bin: &Path, ultracode: bool) -> Option<Vec<ModelInfo>> {
let fut = async {
let mut cmd = Command::new(bin);
cmd.args([
"--print",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
]);
prepare_env(&mut cmd);
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.ok()?;
let mut stdin = child.stdin.take()?;
let mut lines = BufReader::new(child.stdout.take()?).lines();
use tokio::io::AsyncWriteExt;
let req = serde_json::json!({
"type": "control_request",
"request_id": "orx_list_models",
"request": { "subtype": "list_models" },
});
let mut line = req.to_string();
line.push('\n');
stdin.write_all(line.as_bytes()).await.ok()?;
while let Ok(Some(line)) = lines.next_line().await {
let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue;
};
if v.get("type").and_then(Value::as_str) != Some("control_response") {
continue;
}
let resp = v.get("response")?;
if resp.get("request_id").and_then(Value::as_str) != Some("orx_list_models") {
continue;
}
// An `error` subtype has no inner response — `?` falls through to
// the static fallback.
let models = parse_claude_model_list(resp.get("response")?, ultracode);
return (!models.is_empty()).then_some(models);
}
None
};
tokio::time::timeout(Duration::from_secs(15), fut)
.await
.ok()
.flatten()
}
/// `list_models` response → per-model `ModelInfo`. Split from the transport
/// for testability.
///
/// * `value` is the id the CLI's own picker submits (aliases like `sonnet`,
/// `opus[1m]`), so it's what we store and pass back as `--model`.
/// * The `default` entry is skipped — the composer's "Default model" row (a
/// null model) already means "let the CLI pick".
/// * A model without `supportedEffortLevels` (Haiku) gets an empty list, which
/// hides the reasoning picker — same absent-vs-empty contract as opencode.
/// * `ultracode` is appended where the CLI accepts it (see
/// [`claude_accepts_ultracode`]) and the model reaches `xhigh`, since the
/// mode is documented as `xhigh` + dynamic workflows.
fn parse_claude_model_list(result: &Value, ultracode: bool) -> Vec<ModelInfo> {
let Some(models) = result.get("models").and_then(Value::as_array) else {
return Vec::new();
};
models
.iter()
.filter_map(|m| {
let value = m.get("value").and_then(Value::as_str)?;
if value == "default" {
return None;
}
let mut efforts: Vec<&str> = m
.get("supportedEffortLevels")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if ultracode && efforts.contains(&"xhigh") {
efforts.push(CLAUDE_ULTRACODE);
}
// The catalog's `displayName` is unversioned ("Opus"); the version
// lives in the description's first `·` segment ("Opus 4.8 with 1M
// context · Best for everyday, complex tasks"). Promote that
// segment to the display name and keep the rest as the blurb, so
// the picker leads with the resolved version.
let (name, blurb) = match m.get("description").and_then(Value::as_str) {
Some(desc) => match desc.split_once('·') {
Some((head, tail)) => (Some(head.trim()), Some(tail.trim())),
None => (m.get("displayName").and_then(Value::as_str), Some(desc)),
},
None => (m.get("displayName").and_then(Value::as_str), None),
};
let mut info = ModelInfo::new(value)
.with_reasoning(&efforts)
.with_label(name, blurb);
// Claude reports no default *tier* because its unset default isn't
// one: with adaptive thinking, the CLI scales effort per request.
// Name the sentinel row for what actually runs — preselecting a
// fixed tier here would pin behavior the user never asked for.
if m.get("supportsAdaptiveThinking") == Some(&Value::Bool(true)) {
if let Some(choices) = info.reasoning_levels.as_mut() {
if let Some(sentinel) = choices.first_mut() {
sentinel.label = "Adaptive".to_string();
}
}
}
Some(info)
})
.collect()
}
/// The FALLBACK effort ids (see `CLAUDE_EFFORT_LEVELS`), plus `ultracode` when
/// the parser probe accepted it.
fn claude_effort_ids(ultracode: bool) -> Vec<&'static str> {
let mut ids: Vec<&'static str> = CLAUDE_EFFORT_LEVELS.to_vec();
if ultracode {
ids.push(CLAUDE_ULTRACODE);
}
ids
}
pub struct ClaudeCode;
/// Either credential Claude Code accepts. `ANTHROPIC_AUTH_TOKEN` is the one a
/// custom `ANTHROPIC_BASE_URL` gateway uses, so detecting only the api key
/// reports those working setups as signed out.
const CLAUDE_CREDENTIAL_VARS: [&str; 2] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
fn has_api_credential() -> bool {
CLAUDE_CREDENTIAL_VARS
.iter()
.any(|key| super::detect::api_key(key).is_some())
}
/// One-shot session title from the first user message: a throwaway
/// `claude -p` child pinned to Haiku, mirroring how Claude Code titles its own
/// conversations with a cheap background model. Deliberately *not* the session's
/// resident child — a title request there would pollute the real conversation
/// history.
///
/// `--model haiku` is a CLI model alias of the kind we already pass through from
/// the catalog; a CLI too old to know it exits non-zero, which lands on `None`
/// and leaves the placeholder title in place. Every other failure (spawn,
/// timeout, garbage output) degrades the same silent way.
async fn claude_generate_title(bin: &Path, first_message: &str) -> Option<String> {
let mut cmd = Command::new(bin);
cmd.args([
"-p",
&super::title::title_prompt(first_message),
"--model",
"haiku",
"--max-turns",
"1",
// Naming a chat needs no tools and no MCP: booting the user's servers
// for a one-line request would cost far more than the request itself.
// With no tools to call, `--max-turns 1` can't be spent on a tool use.
// The empty list is the documented "disable all tools" form; an older
// CLI that rejects it exits non-zero, so the placeholder is kept.
"--strict-mcp-config",
"--tools",
"",
// Replace the agent system prompt: the default hauls ~8.5k tokens of
// Claude Code scaffolding into a request that ignores it (measured
// 8.5k → 1.9k input). Latency is unchanged — the ~3s is node boot plus
// one API round trip — but every title gets ~78% cheaper.
"--system-prompt",
"You generate short chat titles.",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
// Hermetic: run outside any repo so the child doesn't ingest the server
// cwd's CLAUDE.md / settings into a request that only needs one sentence.
.current_dir(std::env::temp_dir());
prepare_env(&mut cmd);
// Plain text only — an ANSI-colorizing CLI (or a synced FORCE_COLOR) would
// otherwise write escape codes straight into the title column.
cmd.env("NO_COLOR", "1");
let fut = cmd.output();
let out = tokio::time::timeout(super::title::TITLE_TIMEOUT, fut)
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
}
super::title::sanitize_title(&String::from_utf8_lossy(&out.stdout))
}
/// `claude` on PATH, else the common install drop locations.
pub(crate) fn find_claude() -> Option<PathBuf> {
find_on_path("claude").or_else(|| {
let home = dirs::home_dir()?;
[".claude/local/claude", ".local/bin/claude"]
.iter()
.map(|rel| home.join(rel))
.find(|c| c.is_file())
})
}
#[derive(Debug, PartialEq, Eq)]
struct ClaudeConfigPaths {
root: PathBuf,
metadata: PathBuf,
}
fn resolve_config_paths(
config_dir: Option<PathBuf>,
home: Option<PathBuf>,
) -> Option<ClaudeConfigPaths> {
if let Some(root) = config_dir.filter(|path| !path.as_os_str().is_empty()) {
return Some(ClaudeConfigPaths {
metadata: root.join(".claude.json"),
root,
});
}
let home = home?;
Some(ClaudeConfigPaths {
root: home.join(".claude"),
metadata: home.join(".claude.json"),
})
}
fn config_paths() -> Option<ClaudeConfigPaths> {
resolve_config_paths(
shell_env::var("CLAUDE_CONFIG_DIR").map(PathBuf::from),
dirs::home_dir(),
)
}
#[async_trait]
impl Harness for ClaudeCode {
fn id(&self) -> &'static str {
"claude-code"
}
fn name(&self) -> &'static str {
"Claude Code"
}
fn supports_chat(&self) -> bool {
true
}
/// The resident child holds stdin open, so a second stream-json user
/// message reaches the turn already running.
fn supports_steering(&self) -> bool {
true
}
async fn detect(&self) -> Option<HarnessInfo> {
let mut info = HarnessInfo::new(self.id(), self.name());
if let Some(bin) = find_claude() {
info.installed = true;
info.version = bin_version(&bin).await;
info.bin_path = Some(bin.to_string_lossy().into_owned());
}
// The CLI owns OAuth and Keychain refresh. Its live status, including
// the effective auth method, decides whether this harness can run.
if info.installed {
let bin = info.bin_path.as_deref().map(Path::new);
let probe = match bin {
Some(bin) => {
gate_oauth_version(effective_auth_probe(bin).await, info.version.as_deref())
}
None => AuthProbe {
state: HarnessAuthState::Unknown,
method: None,
},
};
info.auth_state = probe.state;
info.auth_method = probe.method;
if info.auth_state == HarnessAuthState::Ready {
info.authenticated = true;
if probe.method == Some("oauth") {
if let Some(acct) = config_paths()
.and_then(|paths| read_json(paths.metadata))
.and_then(|cfg| cfg.get("oauthAccount").cloned())
{
info.account = nonempty_str(&acct, "emailAddress");
info.org = nonempty_str(&acct, "organizationName");
info.plan = match nonempty_str(&acct, "billingType").as_deref() {
Some("stripe_subscription") => Some("Subscription".to_string()),
Some(other) => Some(other.to_string()),
None => None,
};
}
}
}
}
info.agent_ready = info.installed && info.authenticated;
if info.agent_ready {
// The resident child is only spawnable once the CLI is ready.
info.supports_steering = true;
// Ask the installed CLI for its own catalog: `list_models` for the
// models and their per-model effort tiers, and the parser probe for
// `ultracode` (a session mode the catalog never advertises — see
// `claude_accepts_ultracode`). The static table only covers a CLI
// too old to answer.
let bin = info.bin_path.as_deref().map(Path::new);
let (ultracode, models) = match bin {
Some(bin) => {
let ultracode = claude_accepts_ultracode(bin).await;
(ultracode, claude_list_models(bin, ultracode).await)
}
None => (false, None),
};
info = info.with_models(models.unwrap_or_else(|| {
let ids = claude_effort_ids(ultracode);
CLAUDE_MODELS
.iter()
.map(|id| ModelInfo::new(*id).with_reasoning(&ids))
.collect()
}));
} else if info.auth_state == HarnessAuthState::Unsupported {
info.agent_note = Some(
"Update Claude Code to 2.1.211 or newer, then re-check this harness.".to_string(),
);
} else if info.installed {
info.agent_note = Some(match info.auth_state {
HarnessAuthState::Unknown if has_api_credential() =>
"Claude Code could not verify the effective `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN`. Fix or unset it, then re-check this harness.".to_string(),
HarnessAuthState::Unknown =>
"Open a terminal and run `claude auth status`, then re-check this harness.".to_string(),
_ => auth_recovery_note().to_string(),
});
} else {
info.agent_note = Some(
"Install Claude Code (claude.com/download), then sign in with `claude auth login`."
.to_string(),
);
}
Some(info)
}
async fn run_turn(&self, ctx: &mut TurnCtx) -> TurnResult {
run_turn(ctx)
.await
.map(|()| TurnOutcome::Completed)
.map_err(|error| TurnFailure::adapter(error, ctx.delivery_state()))
}
async fn generate_title(&self, first_message: &str) -> Option<String> {
claude_generate_title(&find_claude()?, first_message).await
}
fn options(&self) -> HarnessOptions {
// Claude owns planning as one of its five native permission modes. The
// permission bridge makes Manual and Accept edits actionable in
// headless mode instead of letting their prompts die unseen.
HarnessOptions::none()
.with_permission_choices(
vec![
OptionChoice::described("manual", "Manual", "Always ask before making changes"),
OptionChoice::described(
"acceptEdits",
"Accept edits",
"Automatically accept all file edits",
),
OptionChoice::described("plan", "Plan", "Create a plan before making changes"),
OptionChoice::described("auto", "Auto", "Claude handles permission decisions"),
OptionChoice::described(
"bypassPermissions",
"Bypass permissions",
"Accepts all permissions",
),
],
"auto",
PlanActivation::Permission,
)
// Harness-wide fallback only, and deliberately the conservative
// five: `options()` is static and can't see the detected CLI
// version, so `ultracode` is added per-model in `detect` where the
// version IS known. The default is `Default` (no `--effort` at all),
// so the CLI's own configured effort survives (issue #123).
.with_reasoning_levels(&CLAUDE_EFFORT_LEVELS)
}
/// Two resume paths. A card the permission bridge surfaced mid-turn
/// (`native_id` set) settles the held bridge request — the still-running
/// turn unblocks in place ([`ResumeAction::Handled`]), except plan
/// approval, which interrupts the paused plan turn and resumes via a new
/// message under the approved mode. An end-turn card (no `native_id`)
/// resumes by sending a *new user message* under `--resume` (see
/// `run_turn`); a denied permission is the one case with no resume.
async fn resume_from_prompt(
&self,
ctx: &ResumeCtx,
prompt: &WirePrompt,
answer: &PromptAnswer,
) -> Result<ResumeAction> {
if let Some(native_id) = &prompt.native_id {
// The bridge request lives inside a running turn; once that turn is
// gone the card is stale. Normally `PendingGuard` resolves it at
// turn teardown, but a process crash/restart skips that — leaving
// a zombie card that renders actionable and swallows every answer
// forever. Collapse it store-side before reporting the miss.
if !ctx.is_busy().await {
ctx.host
.resolve_zombie_prompt(&ctx.session_id, &answer.prompt_id);
return Err(anyhow!("this approval is no longer pending"));
}
let note = answer.note.as_deref().filter(|s| !s.trim().is_empty());
return match (prompt.kind.as_str(), answer.approve) {
// Mid-turn tool approval: answer the held request; the turn
// keeps streaming. The CLI requires updatedInput on an allow —
// echo the card's recorded input.
("permission", true) => {
ctx.host.settle_permission(
native_id,
crate::local::chat::PermissionDecision::Allow {
updated_input: prompt.tool_input.clone(),
},
)?;
Ok(ResumeAction::Handled { plan_mode: None })
}
("permission", false) => {
let message = match note {
Some(note) => format!(
"The user denied this action: {note}. Do not retry it; adjust course."
),
None => "The user denied this action. Do not retry it; adjust course."
.to_string(),
};
ctx.host.settle_permission(
native_id,
crate::local::chat::PermissionDecision::Deny { message },
)?;
Ok(ResumeAction::Handled { plan_mode: None })
}
// Deny the held ExitPlanMode. With a note it's a revision
// request — the model revises the plan in the same turn. With
// no note it's a plain REJECTION (the strip's Reject button):
// tell the model to stop, not to improvise a revision (or a
// "what should change?" question card). The wording is
// `synthesize_resume`'s plan-deny arm verbatim — one source
// for both delivery shapes.
("plan", false) => {
let (message, _) = synthesize_resume("plan", answer);
ctx.host.settle_permission(
native_id,
crate::local::chat::PermissionDecision::Deny { message },
)?;
Ok(ResumeAction::Handled { plan_mode: None })
}
// Plan approval: don't settle the held request — the paused
// plan turn gets interrupted (respond()'s SendMessage arm) and
// replaced by a fresh implementation turn under the approved
// mode, reusing the proven --resume machinery. The drained
// bridge request is denied into the dying child, harmlessly.
("plan", true) => {
let (text, mode) = synthesize_resume("plan", answer);
Ok(ResumeAction::SendMessage {
text,
mode,
plan_mode: None,
})
}
// Mid-turn question (a bridge-held AskUserQuestion): the held
// tool call is denied with the user's answer as the message —
// the model reads the answer from the denial and continues the
// same turn. (Allowing the tool instead would run it headless,
// which returns no answer — the model would guess and move on
// rather than block; that's the bug this arm exists to avoid.)
("question", _) => {
let (text, _) = synthesize_resume("question", answer);
if text.trim().is_empty() {
return Err(anyhow!("select an option (or add a note) to answer"));
}
ctx.host.settle_permission(
native_id,
crate::local::chat::PermissionDecision::Deny {
message: format!(
"The user answered: {text}. Treat this as their answer and \
continue — do not ask this question again. (Only the first \
question of the call was shown; ask any others separately.)"
),
},
)?;
Ok(ResumeAction::Handled { plan_mode: None })
}
_ => Err(anyhow!("unsupported prompt kind for a bridge card")),
};
}
// A denied permission closes the card without resuming; every other
// answer continues the session.
if prompt.kind == "permission" && !answer.approve {
return Ok(ResumeAction::Nothing);
}
// Likewise a note-less plan REJECTION on an end-turn card: the turn is
// already over — resuming just to say "stop" would end in fresh text
// that `should_synthesize_plan` turns into ANOTHER card, so Reject
// could never dismiss the strip. Close the card with no resume.
if prompt.kind == "plan"
&& !answer.approve
&& answer.note.as_deref().is_none_or(|s| s.trim().is_empty())
{
return Ok(ResumeAction::Nothing);
}
let (text, mode) = synthesize_resume(&prompt.kind, answer);
// Reject an empty resume (e.g. a question answered with no selection and
// no note) so `respond` leaves the card actionable.
if text.trim().is_empty() {
return Err(anyhow!("no answer provided"));
}
Ok(ResumeAction::SendMessage {
text,
mode,
plan_mode: None,
})
}
fn config_home(&self) -> Option<PathBuf> {
config_paths().map(|paths| paths.root)
}
fn skill_target(&self) -> Option<PathBuf> {
Some(
self.config_home()?
.join("skills")
.join("orx")
.join("SKILL.md"),
)
}
fn skill_shim(&self) -> Option<&'static str> {
Some(super::CLAUDE_SKILL)
}
fn session_skills_dir(&self) -> Option<&'static str> {
Some(".claude/skills")
}
}
/// Internal policy → Claude Code `--permission-mode` value. Each provider-owned
/// choice already uses the CLI spelling. `Auto` is the default when the session
/// hasn't picked one.
pub(crate) fn claude_permission_mode(mode: Option<PermissionMode>) -> &'static str {
match mode.unwrap_or(PermissionMode::Auto) {
PermissionMode::Ask => "manual",
PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::Plan => "plan",
PermissionMode::Auto => "auto",
PermissionMode::Bypass => "bypassPermissions",
}
}
pub(crate) fn uses_permission_bridge(mode: Option<PermissionMode>) -> bool {
matches!(
mode,
Some(PermissionMode::Ask | PermissionMode::AcceptEdits | PermissionMode::Plan)
)
}
/// Path (relative to the worktree) of the plan-mode settings file we write and
/// pass via `--settings`. Lives under the same agent dir as the playbook, which
/// is already git-excluded.
const PLAN_SETTINGS_REL: &str = ".openresearch/agent/claude-plan-settings.json";
/// Path (relative to the worktree) of the plan-mode MCP config wiring the
/// `orx mcp-gate` permission bridge. Same git-excluded agent dir.
const MCP_CONFIG_REL: &str = ".openresearch/agent/claude-mcp.json";
/// Write the plan-mode `--settings` file into `repo` and return its path. The
/// file registers `PreToolUse` hooks running `orx plan-gate` (this same
/// binary): on `Bash` it allows read-only inspection through plan mode's gate,
/// and on `ExitPlanMode` it forces an `ask` — headless plan mode otherwise
/// SELF-approves the call ("User has approved exiting plan mode", nobody
/// asked; verified on claude 2.1.197) and starts editing. The `ask` routes
/// plan approval to the permission bridge card. See `plan_gate`.
///
/// The hook command is this executable's absolute path, so it resolves without
/// depending on `orx` being on Claude's `PATH`.
pub(crate) fn write_plan_settings(repo: &std::path::Path) -> Result<PathBuf> {
let orx = std::env::current_exe()
.map_err(|e| anyhow!("cannot resolve orx binary path for plan-mode hook: {e}"))?;
let hook = serde_json::json!([{
"type": "command",
"command": format!(
"{} plan-gate",
crate::jobs::ssh::sh_quote(&orx.to_string_lossy())
),
}]);
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": hook },
{ "matcher": "ExitPlanMode", "hooks": hook },
],
}
});
let path = repo.join(PLAN_SETTINGS_REL);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow!("cannot create {}: {e}", parent.display()))?;
}
std::fs::write(&path, serde_json::to_vec_pretty(&settings).unwrap())
.map_err(|e| anyhow!("cannot write {}: {e}", path.display()))?;
Ok(path)
}
/// Write the per-spawn `--mcp-config` file pointing Claude at `orx mcp-gate`
/// (this same binary) and return its path. The bridge's env block carries the
/// `orx up` port, the session id, and a fresh per-child token minted at spawn —
/// everything the resident bridge needs to relay permission requests back to
/// the running server for the child's whole life.
pub(crate) fn write_mcp_config(
repo: &std::path::Path,
up_port: u16,
session_id: &str,
token: &str,
) -> Result<PathBuf> {
let orx = std::env::current_exe()
.map_err(|e| anyhow!("cannot resolve orx binary path for the mcp bridge: {e}"))?;
let config = serde_json::json!({
"mcpServers": {
"orx": {
"type": "stdio",
"command": orx.to_string_lossy(),
"args": ["mcp-gate"],
"env": {
"ORX_UP_PORT": up_port.to_string(),
"ORX_SESSION_ID": session_id,
"ORX_GATE_TOKEN": token,
},
},
}
});
let path = repo.join(MCP_CONFIG_REL);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow!("cannot create {}: {e}", parent.display()))?;
}
std::fs::write(&path, serde_json::to_vec_pretty(&config).unwrap())
.map_err(|e| anyhow!("cannot write {}: {e}", path.display()))?;
Ok(path)
}
/// Session reasoning id → Claude's `--effort` value.
///
/// Only the `default` sentinel (and an absent level) send nothing; every other
/// value is forwarded. The composer only offers what `list_models` reported
/// for the selected model (plus a probe-verified `ultracode`), so an allowlist
/// here would drop tiers a future catalog genuinely advertises — the same
/// policy as `codex_reasoning` for catalog models and `opencode_variant`.
/// Claude is also the gentlest harness to forward into: an unknown value warns
/// on stderr and runs at the default effort rather than failing the turn.
fn claude_effort(level: Option<&str>) -> Option<&str> {
level.filter(|l| *l != REASONING_DEFAULT_ID)
}
/// The follow-up message + resume mode for an answered Claude prompt — Claude's
/// resume strategy: a prompt ends the turn and the answer becomes a *new user
/// message* that continues via `--resume`. `ChatHost` validates `resume_mode`
/// against Claude's advertised choices before this helper parses it; an absent
/// id falls through to the per-kind default. The question arm is
/// also reused as a plain text builder by the bridge's mid-turn question
/// resume (the denial message that carries the answer).
pub(crate) fn synthesize_resume(
kind: &str,
req: &PromptAnswer,
) -> (String, Option<PermissionMode>) {
let note = req.note.as_deref().filter(|s| !s.trim().is_empty());
let chosen = req.resume_mode.as_deref().and_then(PermissionMode::from_id);
match kind {
"plan" if req.approve => {
let mut text = "The user approved the plan. Proceed with implementing it.".to_string();
if let Some(note) = note {
text.push_str(&format!("\n\nAdditional guidance: {note}"));
}
// Approving a plan means leaving plan mode; default to `auto`.
(text, chosen.or(Some(PermissionMode::Auto)))
}
"plan" => {
// Stay in plan mode. With a note it's a revision request; without
// one it's a plain rejection — stop, don't guess at revisions.
let text = note
.map(|n| format!("Keep refining the plan: {n}"))
.unwrap_or_else(|| {
"The user rejected this plan. Stop planning and wait for \
further instructions."
.to_string()
});
(text, Some(PermissionMode::Plan))
}
"permission" => {
// Approving a blocked tool must resume under a mode that actually
// *grants* it. Claude's `--permission-mode` is coarse: `acceptEdits`
// only auto-approves file edits, so it leaves a Bash (or any
// non-edit) denial in place — the tool is denied again and the card
// re-appears in a loop. `bypassPermissions` is the only mode that lets the
// previously-blocked tool through, so that's the default for an
// approval (a caller can still override via `resume_mode`). Verified
// against the CLI: acceptEdits re-denies Bash, bypassPermissions clears it.
let text = "The user approved that action. Continue.".to_string();
(text, chosen.or(Some(PermissionMode::Bypass)))
}
// question (or anything else): feed the selection back as the user's reply.
_ => (req.contextualized_answer(req.plain_answer_text()), None),
}
}
/// Whether a finished plan-mode turn needs a synthesized plan card: the model
/// presented its plan as plain text without calling ExitPlanMode (and without
/// asking a question), and the turn didn't error. Without a card the user is
/// stranded — only a plan-card answer switches the resume mode, so a plain
/// chat reply would resume still in plan mode. A trivial Q&A turn in plan mode
/// also gets a card: in plan mode the only exit *is* a plan answer, so the
/// card is always the recourse.
pub(crate) fn should_synthesize_plan(
plan_mode: bool,
saw_prompt: bool,
errored: bool,
final_text: &str,
) -> bool {
plan_mode && !saw_prompt && !errored && !final_text.trim().is_empty()
}
/// ExitPlanMode → a `plan` prompt (its `input.plan` is the proposed markdown).
fn plan_prompt(name: &str, input: Option<&Value>) -> Option<WirePrompt> {
if name != "ExitPlanMode" {
return None;
}
let plan = input
.and_then(|i| i.get("plan"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Some(WirePrompt {
kind: "plan".into(),
plan: Some(plan),
..Default::default()
})
}
/// AskUserQuestion → a `question` prompt. Claude's schema is
/// `{questions: [{question, header, options: [{label, description}], multiSelect}]}`;
/// we surface the first question (the composer answers one at a time). Also
/// used by the plan-mode bridge (`ChatHost::request_permission`, via the
/// harness re-export) to build the held mid-turn question card.
pub(crate) fn question_prompt(name: &str, input: Option<&Value>) -> Option<WirePrompt> {
if name != "AskUserQuestion" {
return None;
}
let q = input
.and_then(|i| i.get("questions"))
.and_then(Value::as_array)
.and_then(|qs| qs.first())?;
let options = q
.get("options")
.and_then(Value::as_array)
.map(|opts| {
opts.iter()
.filter_map(|o| {
Some(WireQuestionOption {
label: o.get("label").and_then(Value::as_str)?.to_string(),
description: o
.get("description")
.and_then(Value::as_str)
.map(str::to_string),
})
})
.collect()
})
.unwrap_or_default();
Some(WirePrompt {
kind: "question".into(),
question: q
.get("question")
.and_then(Value::as_str)
.map(str::to_string),
header: q.get("header").and_then(Value::as_str).map(str::to_string),
options,