-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.rs
More file actions
6265 lines (5980 loc) · 250 KB
/
Copy pathmain.rs
File metadata and controls
6265 lines (5980 loc) · 250 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
// catalyst-code-core: stdio JSON-RPC server. The TUI spawns this binary,
// writes commands to stdin, and reads newline-delimited events from stdout.
//
// Several core functions (stream_turn, run_turn, dispatch_*) intentionally
// carry many positional args (the seam between the request loop and the tool
// layer); refactoring each into a context struct is a larger change, so allow
// the lint here rather than obscure the call sites.
#![allow(clippy::too_many_arguments)]
mod advisor;
mod agent;
mod audit;
mod browser;
mod change_coupling;
mod checkpoint;
mod codebase_index;
mod collections;
mod commands;
mod config;
mod context_pack;
mod coverage_ledger;
mod dap;
mod deferred_tools;
mod embed;
mod episodes;
mod failure_atlas;
mod fetch_tool;
mod file_snapshot;
mod harness_docs;
mod hashline;
mod fsutil;
mod git_ctx;
mod goal;
mod goal_ceo;
mod hub;
mod inline_images;
mod intercom;
mod knowledge_tool;
mod learning_activations;
mod learning_proposals;
mod learning_retrieval;
mod learning_store;
mod logging;
mod mcp;
mod memory;
#[cfg(test)]
mod memory_eval;
mod memory_hygiene;
mod memory_recall;
mod memory_staleness;
mod message;
mod models_dev;
mod oauth;
mod pattern_log;
mod plugin_trust;
mod plugins;
mod preferences;
mod presence;
mod project_guidance;
mod project_identity;
mod prompt_cache;
mod protocol;
mod provider;
mod providers;
mod read_summary;
mod rejected_approaches;
#[cfg(test)]
mod research_evidence;
mod runtime;
mod sandbox;
mod search_tool;
mod session;
mod skill_marketplace;
mod skill_metrics;
mod skills;
mod staging;
mod subagent;
mod task_fingerprint;
mod test_env;
mod tool_cache;
mod tooling;
mod tools;
mod vision;
mod workspace;
mod worktree;
use config::{Approval, Config, ResolvedProvider};
use git_ctx::{git_context_injection, read_git_context};
use intercom::IntercomBus;
use logging::{
estimate_message_tokens, estimate_messages_tokens, grounded_estimate, Logger, TurnMetrics,
TurnTimer,
};
use memory::{memory_injection, relevant_memories_tail};
#[allow(unused_imports)]
use message::{ContentPart, FunctionCall, ImageUrl, Message, ToolCall};
use plugins::{PluginManager, PLUGIN_DOCS};
use protocol::{emit, emit_aborted_done, emit_turn_rejected, Command, Event, ModelInfo};
use runtime::{CancellationReason, ResourceKind, RunContext, RuntimeCoordinator};
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{Mutex, Notify, RwLock};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use vision::VisionConfig;
use futures_util::FutureExt;
use std::panic::AssertUnwindSafe;
#[derive(Clone)]
pub struct QueuedPrompt {
prompt: String,
model: String,
/// Owning provider the user explicitly picked for `model` (from `/models`);
/// wins routing over the active-provider tie-break. None for older clients.
provider: Option<String>,
effort: String,
/// Attachments from the original send. Drain used to pass `None`, so a
/// queued "what is this screenshot?" arrived text-only.
images: Option<Vec<String>>,
}
const SYSTEM_PROMPT_BASE: &str = r#"You are a coding agent in Catalyst Code. Tools are workspace-confined.
RFC 2119: MUST / SHOULD / NEVER. NEVER open files hoping.
Understand before you change:
- MUST use grep/glob/`knowledge`/`lsp` first, then `read_file` on exact ranges (`path:12-40`).
- Bare `read_file` of parseable source returns a structural summary (bodies elided). Re-read ONLY the footer ranges. NEVER guess `…` content.
- `read_file`/`read` also resolve directories, ZIP entries (`archive.zip!/path` or `archive.zip:inner`), HTTP(S) URLs, and internal URIs (`skill://name`, `skill://name/path`, `memory://id`, `artifact://run_id`, `local://name`, `rule://`, `agent://id`, `history://id`, `issue://N`, `pr://N`, `catcode://`, `omp://`, `omp://tools/<name>.md`). `:conflicts` lists unresolved merge markers.
- Symbol work (definition, callers, hover, rename) MUST use core `lsp` with 1-indexed `line` + `symbol`. Do not guess from grep.
- Reuse the existing pattern. A second convention beside the existing one is prohibited.
Change the smallest correct thing:
- After a numbered read, prefer `edit` with hashline `input` for line-anchored changes; its `[path#TAG]` guard rejects stale files. Search/replace remains supported. Prefer `ast_edit` for structural rewrites.
- MUST use native tools over bash: `grep` not rg; `read_file` not sed/cat; `list_dir`/`glob` not ls/find; `git_*` for status/diff/log/show; `eval` not python/node -c; `write`/`write_file` not tee. Bash that 1:1 shadows a native tool is blocked.
- Paths are workspace-relative. `read_file` accepts `path:50-80` / `path:12+20` / `path:raw`. `eval` cells have `display`/`read`/`write`/`env` helpers.
Finish and verify:
- Complete the request end-to-end. No stubs or "next step" for in-scope work.
- Verify with the project's real check. Do not claim done without evidence.
- Proceed on safe, reversible, already-requested work. Ask only when blocked or destructive.
- Cite file:line. Ground claims in what you read or ran.
Self-learning:
- Persist durable facts with `memory` (workspace default; `scope:global` for cross-repo). Prefer `append`. Always pass a one-line `description`.
- Standing prompt carries a capped MEMORY CATALOG. Use `memory` get for full text.
- Prefer `knowledge` (context/search/symbol/related) before re-deriving project facts.
- Skills: `.catalyst-code/skills/<name>/SKILL.md` and `.omp/skills/<name>/SKILL.md`. Read with `skill://name` or `skill://name/path`. Project AGENTS.md / CLAUDE.md / .omp/RULES.md (injected below) are binding."#;
/// Compact orchestrator stub — enough to use `subagent`/`task` without injecting the
/// full pi-subagents skill body on every turn. Parent-only (`with_skill`).
const SUBAGENT_ORCHESTRATOR_STUB: &str = r#"# Subagents
Delegate via `task` (OMP-shaped) or `subagent` (core — no load_tools). Builtins: scout, researcher, librarian, planner, worker, reviewer, designer, sonic, security-reviewer, context-builder, oracle, delegate, task.
`task` accepts a shared `context` string plus `tasks[]` (`agent` defaults to delegate). Fire independent work in one batch.
Coordinate with `hub` (send/inbox/list/jobs/wait + process start/ps/logs/stop). Track multi-step work with `todo` ops (init/start/done/view), not just todo_write.
Children escalate with `contact_supervisor` — answer `need_decision` promptly. Manage runs with peek / steer / interrupt / resume / status.
Long work: pass `async:true` so the parent turn continues; poll with action=status/peek. Independent recon tools in one message run as a parallel wave.
Unknown multi-area work: scout (knowledge + grep + lsp) before worker edits. Verify with reviewer after writers. Do not serial-solo a large map.
Full playbook: `/skill:pi-subagents` or `skill://ultrawork`."#;
/// How to add a model provider — config (API-key) vs plugin (OAuth). Always
/// injected (like PLUGIN_DOCS) so the agent recognizes "add provider X" as a
/// supported task in any workspace, even without the opt-in skills present.
/// Full schemas/edge cases live in the `add-key-provider` and `plugin-authoring`
/// skills; this is the actionable minimum.
/// Guidance for choosing structural edits over textual edits. Kept concise so
/// every turn gets the preference without embedding the full IDE manual.
const STRUCTURED_EDIT_GUIDE: &str = r#"## Editing preference
After a numbered read, prefer `edit` with hashline `input` for precise line-anchored edits guarded by `[path#TAG]`; use search/replace `path` + `edits` when exact text is the better fit. Prefer `ast_edit` for structural code changes: renames, call/signature changes, syntax-aware refactors, and repeated AST-pattern rewrites. `ast_edit` is a core tool; use `apply:false` to inspect its diff before applying."#;
const PROVIDER_GUIDE: &str = r#"## Adding model providers
"Add/connect provider X" → two no-recompile paths, pick by auth type:
1. **API-key auth, OpenAI/Anthropic-compatible** → CONFIG. Built-in first-party presets include `umans`, `opencode-go`, `openrouter`, and `deepseek` (DeepSeek uses `https://api.deepseek.com` + `DEEPSEEK_API_KEY`). Add a `providers` entry to `~/.config/catalyst-code/config.json` when a preset is not suitable:
`{"providers":[{"name":"x","kind":"openai","base_url":"https://api.x.com/v1","api_key_env":"X_API_KEY"}],"activeProvider":"x"}`
`kind` sets wire+auth (`openai`→/chat/completions+Bearer; `anthropic`→/v1/messages. Official Anthropic uses `x-api-key`; generic compatible hosts use Bearer; OpenCode Go/Zen and Umans keep `x-api-key`) + discovery. `api_key_env` (env var NAME, preferred) or `api_key` (literal). Models auto-discover via /models; non-standard discovery (custom fields/404) needs a code branch in `core/src/provider.rs` — skill `add-key-provider` has the config-vs-code decision tree.
2. **OAuth/subscription login** (browser/device-code, no plain key — e.g. Grok, ChatGPT/Codex) → PLUGIN. A plugin's `plugin.json` declares an `oauth` block (`provider_id`, `kind`, `base_url`, `token_path`, `script` handling login/complete/token/clear actions, JSON in/out). The harness resolves the bearer token at turn time and lists the provider in `/login`; device-code plugins may return `flow: "poll"` so the harness completes them without `/oauth-code`. The staged `codex` bundle also imports file-backed Codex CLI auth. Skill `plugin-authoring` has the full schema + script contract; `docs/examples/plugins/grok-oauth/` is a device-code example.
Rule: plain API key → config; login flow → plugin."#;
/// Deferred load_tools groups — always injected so the agent knows secondary
/// capabilities exist without an opt-in skill. Keep lean: groups + when-to-load;
/// full tool schemas arrive only after load_tools. When adding a new deferred
/// group (e.g. browser), list it here AND in handle_load_tools / load_tools schema.
const DEFERRED_TOOLS_GUIDE: &str = r#"## Deferred tools
Call `load_tools` when needed: `git` (add/commit/push/pull/branch; status/diff/log/show are core), `web`, `bulk`, `ide` (snapshot_edit/lsp_rename), `debug`, `mcp`, `browser` (only if this build has a browser backend), `process`, or `all`. Also loadable: spawn, test_env. `diagnostics`, `workspace_activity`, `lsp`, `ast_edit`, `eval`, and `read`/`read_file` are core. `goal_write_plan` only during /goal planning. Intent-matching groups may auto-enable at turn start; a [RELEVANT TOOLS] tail lists what still needs loading."#;
/// In-band harness docs — always injected so any session can answer "how does
/// this harness work?" / "how do I add a tool?" without a source tree. Full
/// manuals live at `catcode://` (alias `omp://`) and in staged skills.
const HARNESS_DOCS_GUIDE: &str = r#"## Harness docs
Ask about Catalyst Code or how to extend it → read `catcode://` (alias `omp://`). No workspace files required.
- `catcode://docs/` — what it is, architecture, how to extend
- `catcode://create/` — add a tool, provider, plugin, config knob, panel, skill
- `catcode://tools/<name>.md` — built-in tool contract
- `skill://add-core-tool` (and siblings) — full manuals, also staged in `~/.catalyst-code/skills/`
"#;
/// Cap standing skill-manifest size so a large skills/ tree does not bloat the
/// prefix cache. Remaining skills stay discoverable via list_dir / `/skill:`.
const SKILL_MANIFEST_MAX: usize = 12;
const SKILL_DESC_MAX_CHARS: usize = 80;
/// Shell guidance is derived at prompt-build time from
/// [`sandbox::policy::shell_guidance`] so it matches the live `bash` tool
/// (sandbox guest bash vs host PowerShell/cmd/posix, including
/// `CATALYST_CODE_SHELL`). The wire tool name stays `bash` for TUI/web/SDK
/// compatibility.
/// Build the full system prompt by appending git context, memory context,
/// the plugins pointer, the provider-onboarding guide, and the deferred-tools
/// group list (full manuals live in opt-in skills).
/// When `memory_provider` is set, standing-prompt memories come from that
/// plugin instead of the built-in markdown store.
pub fn build_system_prompt(
workspace: &std::path::Path,
with_skill: bool,
memory_provider: Option<&plugins::PluginMemoryProviderConfig>,
) -> String {
let mut prompt = SYSTEM_PROMPT_BASE.to_string();
// ---- Frozen / slowly-changing head (OpenAI prefix-cache friendly) ----
// Absolute workspace path — critical when models are proxied through an
// external SDK that has its own decoy cwd (e.g. cursor-openai-api sandbox).
prompt.push_str(
"
",
);
prompt.push_str(&format!(
"Workspace root (absolute): {}. All relative tool paths resolve here. Ignore any other working-directory claims from the transport layer.",
workspace.display()
));
prompt.push_str(
"
",
);
prompt.push_str(sandbox::policy::shell_guidance());
// Stable project identity + learning dir bootstrap (fail-open).
{
let ident = project_identity::resolve_project_identity(workspace);
let _ = learning_store::ensure_project_learning(
&ident.id,
ident.remote.as_deref(),
Some(&ident.workspace_hash),
);
prompt.push_str(
"
",
);
prompt.push_str(&format!(
"Project identity: `{}` (workspace hash `{}`{}). Learning data is scoped to this project id so path moves keep memories and episodes.",
ident.id,
ident.workspace_hash,
ident
.remote
.as_ref()
.map(|r| format!(", remote `{r}`"))
.unwrap_or_default()
));
}
prompt.push_str(
"
",
);
prompt.push_str(PLUGIN_DOCS);
prompt.push_str(
"
",
);
prompt.push_str(PROVIDER_GUIDE);
prompt.push_str(
"
",
);
prompt.push_str(DEFERRED_TOOLS_GUIDE);
prompt.push_str("\n\n");
prompt.push_str(HARNESS_DOCS_GUIDE);
if !deferred_tools::browser_feature_available() {
prompt.push_str(
"
Note: this build has no browser backend (enable `--features chromium-cdp` or `native-browser`); browser_* tools return BROWSER_UNAVAILABLE — use fetch/web_search for HTTP.",
);
}
prompt.push_str(
"
",
);
prompt.push_str("\n\nAdvisor/watchdog is on by default (fail-open; settings advisor.enabled). Sandbox defaults to Microsandbox and degrades to host when preflight fails.");
prompt.push_str(STRUCTURED_EDIT_GUIDE);
// ---- Session-volatile tail of the standing prompt (still system, but last) ----
// Git branch/dirty and memory catalog change over a session; keep them after
// the frozen head so the OpenAI prefix stays stable. Highly dynamic facts
// still prefer the per-turn relevant-memory tail.
if let Some(git) = read_git_context(workspace) {
prompt.push_str(
"
",
);
prompt.push_str(&git_context_injection(&git));
}
let mem = match memory_provider {
Some(cfg) => plugins::memory_provider_inject(cfg, &workspace.display().to_string(), ""),
None => memory_injection(workspace, ""),
};
if !mem.is_empty() {
prompt.push_str(
"
",
);
prompt.push_str(&mem);
}
let guidance = project_guidance::project_guidance_injection(workspace);
if !guidance.is_empty() {
prompt.push_str("\n\n");
prompt.push_str(&guidance);
}
// Parent-only: stub + capped skill manifest. Subagents never receive these
// (they'd wrongly think they are the orchestrator).
if with_skill {
prompt.push_str("\n\n");
prompt.push_str(SUBAGENT_ORCHESTRATOR_STUB);
let manifest = skill_manifest_injection(workspace);
if !manifest.is_empty() {
prompt.push_str("\n\n");
prompt.push_str(&manifest);
}
}
prompt
}
/// Build the MAIN agent's system prompt: the base prompt (git context +
/// memory + plugins pointer + orchestrator stub + skill manifest) PLUS any
/// text plugins inject via their `system_prompt` manifest field. Plugin
/// injection is empty (so the prompt + its prefix cache are untouched) when no
/// enabled plugin declares one — mirroring how `build_system_prompt` stays
/// cheap in the common case. Subagents do NOT get plugin injection (they use
/// the built-in tool set only), matching the plugin-tools-are-main-agent-scoped
/// design.
fn build_main_system_prompt(
workspace: &std::path::Path,
pm: &plugins::PluginManager,
auto_reflect: bool,
) -> String {
let mp = pm.memory_provider();
let mut prompt = build_system_prompt(workspace, true, mp.as_ref());
let inj = pm.system_prompt_injection();
if !inj.is_empty() {
prompt.push_str(&inj);
}
// Main-agent-only: the `ask` tool is dispatchable only in the orchestrator
// loop (subagents escalate via contact_supervisor instead), so the ask-when
// -under-specified guidance lives here, not in the shared base prompt.
prompt.push_str(
"\n\nAsk the user when it matters:\n\
- Use `ask` whenever the request is under-specified and guessing could waste work or cause damage: ambiguous scope, multiple valid approaches with real trade-offs, missing required info you cannot find in the workspace, or an irreversible/destructive choice.\n\
- Do NOT ask about things you can determine yourself by reading the code or running a command — check first, ask only what you can't resolve.\n\
- One round of focused questions beats many; batch related questions in one `ask` call. If the user skips, proceed with best judgment and state your assumptions.",
);
// When auto-reflect is on, defer the completion summary until AFTER the
// reflection step so the summary is the last message the user reads.
// Supersedes the "summarize when done" line in SYSTEM_PROMPT_BASE (kept
// for subagents + the auto_reflect-off case).
if auto_reflect {
prompt.push_str(
"\n\nCompletion flow (auto-reflect on): call `finish` when work is verified — do not summarize first. \
After the harness reflection step, write the summary as your final message, then `finish`. \
A visible completion summary (≥ a short paragraph) is required before the turn ends; \
bare `finish` / tool-only reflection is not enough. This supersedes \
\"summarize when done\" above.",
);
}
prompt
}
/// One-line manifest of opt-in skills (name + description) discovered under
/// `.catalyst-code/skills/` (project then user scope). Spliced into the
/// orchestrator's stable system prompt so available skills are visible without a
/// `list_dir` round-trip. Excludes `pi-subagents` (covered by the stub above),
/// caps at `SKILL_MANIFEST_MAX` entries with truncated descriptions, and
/// deduplicates by name (project wins). Returns empty when no opt-in skills
/// exist so a fresh install's prompt — and its provider prefix cache — is
/// left untouched.
fn skill_manifest_injection(workspace: &std::path::Path) -> String {
let skills = subagent::discover_skills(workspace);
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut lines: Vec<String> = Vec::new();
let mut omitted = 0usize;
for (name, desc, loc) in &skills {
if name.as_str() == "pi-subagents" {
continue;
}
// Use the skill DIRECTORY name (parsed from the SKILL.md path) as the
// identifier, so `/skill:<name>` / path hints resolve — frontmatter
// `name` can drift from the dirname.
let n = std::path::Path::new(loc)
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| name.trim());
if n.is_empty() || !seen.insert(n) {
continue;
}
if lines.len() >= SKILL_MANIFEST_MAX {
omitted += 1;
continue;
}
let d = desc.trim();
if d.is_empty() {
lines.push(format!("- {n}"));
} else if d.chars().count() > SKILL_DESC_MAX_CHARS {
let truncated: String = d.chars().take(SKILL_DESC_MAX_CHARS).collect();
lines.push(format!("- {n}: {truncated}…"));
} else {
lines.push(format!("- {n}: {d}"));
}
}
if lines.is_empty() {
return String::new();
}
let mut out = format!(
"Available opt-in skills — apply with `/skill:<name>` (or read the matching \
.catalyst-code/skills/<name>/SKILL.md when it is in the workspace):\n{}",
lines.join("\n")
);
if omitted > 0 {
out.push_str(&format!(
"\n- …and {omitted} more (list_dir .catalyst-code/skills/ or /skill:<name>)"
));
}
out
}
/// Build and emit a `skills` event listing every discoverable skill (project
/// then user scope) with its name, description, location, and parsed body
/// content. The TUI/web use name+description for the `/skill:<name>`
/// autocomplete; `apply_skill` re-reads the body from disk at invocation time,
/// so the body here lets a frontend optionally inline content without a second
/// round-trip. Called on `init` and `list_skills`.
fn emit_skills_event(workspace: &std::path::Path) {
let skills = subagent::discover_skills_full(workspace);
let arr: Vec<Value> = skills
.iter()
.map(|s| {
json!({
"name": s.name,
"description": s.description,
"location": s.location,
"content": s.body,
})
})
.collect();
emit(&Event::new("skills").with("skills", json!(arr)));
}
/// Publish discoverable subagents (builtin + user + project) for the web/TUI
/// agent pickers. Called on `init` and `list_agents`.
fn emit_agents_event(workspace: &std::path::Path, cfg: &config::Config) {
use subagent::AgentSource;
let agents = subagent::discover_agents(workspace, &cfg.subagents);
let arr: Vec<Value> = agents
.iter()
.map(|a| {
let source = match a.source {
AgentSource::Builtin => "builtin",
AgentSource::User => "user",
AgentSource::Project => "project",
};
json!({
"name": a.name,
"description": a.description,
"source": source,
})
})
.collect();
emit(&Event::new("agents").with("agents", json!(arr)));
}
/// Build the JSON array of first-party provider presets for the `ready` and
/// `provider_presets` events. Each entry tells the client whether a key is
/// already stored from a prior explicit `/login` in this app — so a picker can
/// show "log in" vs "log out". Env vars are never treated as signed-in.
/// Subscription OAuth is plugin-only (appended below from `pm.oauth_configs()`).
fn provider_presets_json(cfg: &Config, pm: Option<&plugins::PluginManager>) -> Vec<Value> {
let mut out: Vec<Value> = config::PROVIDER_PRESETS
.iter()
.map(|p| {
let configured = cfg.find_provider(p.id).is_some();
// Auth available = literal key already in config. Do not treat env
// vars as signed-in — the user must paste a key via /login.
let has_key = cfg
.find_provider(p.id)
.and_then(|pc| pc.api_key.clone().filter(|s| !s.is_empty()))
.is_some();
// Keyless local presets (empty api_key_env) count as logged-in once
// configured — Ollama / LM Studio need no API key.
let logged_in = configured && (has_key || p.api_key_env.is_empty());
json!({
"id": p.id,
"label": p.label,
"kind": p.kind.as_str(),
"base_url": p.base_url,
"envVar": p.api_key_env,
"altEnvs": p.alt_envs,
"description": p.description,
"hasKey": has_key || (p.api_key_env.is_empty() && configured),
"configured": configured,
"loggedIn": logged_in,
"supportsOauth": false,
})
})
.collect();
// Append plugin-declared OAuth providers so they appear in the /login picker
// (built-in presets win on a colliding id).
if let Some(pm) = pm {
for c in pm.oauth_configs() {
if config::PROVIDER_PRESETS
.iter()
.any(|p| p.id == c.provider_id)
{
continue;
}
let configured = cfg.find_provider(&c.provider_id).is_some();
let has_key = pm.has_oauth_creds(&c.provider_id);
out.push(json!({
"id": c.provider_id,
"label": c.label,
"kind": c.kind.as_str(),
"base_url": c.base_url,
"envVar": null,
"altEnvs": [],
"description": c.description,
"hasKey": has_key,
"configured": configured,
"loggedIn": configured && has_key,
"supportsOauth": true,
}));
}
}
out
}
/// A pending approval request the TUI must answer before the tool runs.
#[allow(dead_code)]
pub struct PendingApproval {
request_id: String,
session_id: String,
run_id: String,
coordinator_bound: bool,
cancellation: CancellationToken,
tool_call_id: String,
tool: String,
risk: &'static str,
created_at_ms: u64,
args: Value,
notify: Arc<Notify>,
granted: Mutex<Option<bool>>, // Some(true)=approved, Some(false)=denied, None=awaiting
escalated: Mutex<bool>, // "always" was chosen → upgrade session mode
/// "allow_session" — add a session-scoped allow rule for this tool+args pattern.
allow_session: Mutex<bool>,
/// "allow_pattern" — optional rule_content (e.g. path glob) to persist as allow.
allow_pattern: Mutex<Option<String>>,
}
/// A pending `ask` tool call the user must answer before the model continues.
/// Mirrors PendingApproval but carries arbitrary structured answers back.
#[allow(dead_code)]
pub struct PendingAsk {
request_id: String,
/// The validated questions array sent to the TUI in the `ask_request`
/// event (and used to format the model-facing result).
questions: Value,
notify: Arc<Notify>,
/// None = awaiting. Some(obj) = answered (obj maps question id → answer).
/// Some(Value::Null) = the user skipped the whole prompt.
answers: Mutex<Option<Value>>,
}
/// A pending sudo approval: the agent wants to run a bash command that invokes
/// `sudo`. The user must approve (supplying a password) or decline (Esc). The
/// password is used once to feed `sudo -S` on stdin and is never stored.
#[allow(dead_code)]
pub struct PendingSudo {
request_id: String,
/// The full command string, shown to the user so they know what they're
/// approving.
command: String,
notify: Arc<Notify>,
/// None = awaiting. Some(Some(pw)) = approved with password.
/// Some(None) = declined (Esc). The outer Option is the "resolved" flag.
result: Mutex<Option<Option<String>>>,
}
pub struct State {
pub cfg: RwLock<Config>,
/// The shared HTTP client. Held on State so per-turn resolution can do
/// async OAuth token refresh (Gemini gcloud ADC / Claude CLI creds) without
/// threading the client through every call site.
pub client: reqwest::Client,
/// Per-provider runtime API keys (set via `set_key {provider,api_key}`).
/// Keyed by provider name; the active provider's key (if present) wins over
/// config literals/env vars during resolution. The "default" slot holds the
/// legacy single key when no providers are configured.
pub api_keys: RwLock<HashMap<String, String>>,
/// Runtime override of the active provider name (set via `set_provider`).
/// Wins over `cfg.active_provider`; None => use config's active provider.
pub active_provider: RwLock<Option<String>>,
pub conversation: Mutex<Vec<Message>>,
pub models: RwLock<Vec<ModelInfo>>,
/// Runtime identity/cancellation authority. `current` is retained as the
/// async-facing active-turn slot while migration proceeds; both carry the
/// same RunContext and stale finishers may clear it only by matching id.
pub runtime: Arc<RuntimeCoordinator>,
pub current: Mutex<Option<RunContext>>,
pub handle: Mutex<Option<JoinHandle<()>>>,
/// Pending approval requests keyed by their unique approval id (see
/// APPROVAL_SEQ) so parallel subagents can't clobber each other's request.
pub pending: Mutex<std::collections::HashMap<String, Arc<PendingApproval>>>,
/// Pending `ask` tool calls keyed by their unique ask id (see ASK_SEQ).
pub pending_asks: Mutex<std::collections::HashMap<String, Arc<PendingAsk>>>,
/// Pending sudo approval requests keyed by their unique sudo id (see
/// SUDO_SEQ). A bash command that invokes `sudo` blocks here until the user
/// approves (with password) or declines (Esc).
pub pending_sudos: Mutex<std::collections::HashMap<String, Arc<PendingSudo>>>,
pub logger: Logger,
/// Token counts accumulated across the session (for the status bar).
pub tokens_in: Mutex<u64>,
pub tokens_out: Mutex<u64>,
/// Cumulative prefix-cache hits across the session (from
/// usage.prompt_tokens_details.cached_tokens). Surfaces whether the
/// stable-prefix strategy is actually landing cache hits.
pub cached_tokens: Mutex<u64>,
/// Tool kinds ("destructive"/"readonly") the user said "always" to,
/// so subsequent calls of that kind skip the gate without escalating all.
pub escalated_kinds: Mutex<std::collections::HashSet<&'static str>>,
/// Prompt queued while a turn was running (one-deep buffer).
pub queued: Mutex<Option<QueuedPrompt>>,
/// User-bash (`!cmd`) context messages deferred while a turn is in flight.
/// Flushed after the turn ends so we never insert a user message between
/// an assistant `tool_calls` message and its `tool` results (providers
/// reject that ordering). PI does the same with `_pendingBashMessages`.
pub pending_bash: Mutex<Vec<Message>>,
/// Plugin manager — scans, loads, and executes hooks.
pub plugin_manager: PluginManager,
/// Vision-handoff config (curated vision models + preferred target), persisted
/// to .catalyst-code/vision.json; merged into the pre_turn hook context.
pub vision: RwLock<VisionConfig>,
/// Last time a turn completed (for idle compaction).
pub last_turn_time: Mutex<std::time::Instant>,
/// Incrementally maintained token estimate for the main conversation,
/// updated on every push + recalculated after compaction.
pub estimated_tokens: Mutex<u64>,
/// Real `prompt_tokens` from the endpoint's most recent `usage` chunk — the
/// authoritative count of the conversation exactly as the model tokenized it
/// (system prompt + messages + tool-call framing the char/4 heuristic
/// cannot see). Anchors `grounded_estimate` so the compaction trigger and the
/// footer percentage reflect reality instead of a whole-history char/4 guess.
/// `None` until the first request that reports usage, and reset whenever
/// history is rewritten (compaction/digest/reset/undo/load/refresh) so the
/// baseline never describes stale content.
pub last_real_prompt_tokens: Mutex<Option<u64>>,
/// Conversation length (message count) captured when
/// `last_real_prompt_tokens` was recorded. `grounded_estimate` only
/// char/4-estimates the messages added since this index, keeping the real
/// baseline accurate across tool-use loop iterations.
pub conv_len_at_last_real: Mutex<usize>,
/// The model id the user last sent a turn with. Used by the manual `/compact`
/// command to pick the right context window (instead of a hardcoded 200k)
/// and to size the reclaim budget. `None` until the first turn.
pub last_model: Mutex<Option<String>>,
/// Metrics from the most recently completed turn (tokens, TTFT, TPS, cache
/// hits). Surfaced to `session_stop` lifecycle hooks so a telemetry plugin
/// can aggregate per-turn signal out-of-the-box (without the debug log).
/// `None` until the first turn completes.
pub last_turn_metrics: Mutex<Option<TurnMetrics>>,
/// Rolling, KV-cache-aware work-state summary (goal / done / in-progress /
/// next / recent files). Maintained incrementally from conversation signals
/// and injected as a TRANSIENT tail system message before every request —
/// never persisted — so it never invalidates the cached conversation prefix.
/// See the `WorkState` block comment for the full cache strategy.
pub work_state: Mutex<WorkState>,
/// Concern/blocker recommendations persist as a compact transient tail on
/// subsequent requests until a later mutation touches their target path.
pub open_advisories: Mutex<Vec<crate::advisor::OpenAdvisory>>,
/// First-class goal mode (plan → deploy subagents). See `goal.rs`.
pub goal: Mutex<goal::GoalMode>,
/// Cancel token for an in-flight goal deploy task (separate from the
/// planning turn's token so cancel_goal can stop deploy without racing
/// the turn join handle).
pub goal_deploy_cancel: Mutex<Option<CancellationToken>>,
/// True while the post-deploy synthesizing wrap-up turn is the live turn.
/// Lets turn teardown finalize the goal even on abort/error paths without
/// racing the planning turn's drain against a fast deploy.
pub goal_wrapup_active: std::sync::atomic::AtomicBool,
/// Intercom bus: in-process mailboxes for subagent ↔ orchestrator and
/// subagent ↔ subagent coordination.
pub intercom: IntercomBus,
/// Tracked subagent runs for status/interrupt/resume (keyed by run id).
pub subagent_runs: Mutex<std::collections::HashMap<String, subagent::SubagentRun>>,
/// Pending no-browser OAuth login state (PKCE verifier + redirect_uri),
/// set when `/login` picks the manual flow (SSH/headless) and consumed by
/// the `oauth_code` command when the user pastes the code.
pub pending_oauth: Mutex<Option<oauth::PendingOauth>>,
/// Cached live peer sessions in this workspace, refreshed every heartbeat
/// (~8s) by the presence task. Kept in-memory so the anomaly nudge in
/// `run_turn` can check for concurrent activity WITHOUT a filesystem read
/// on every tool result (the hot path). Empty when alone. See `presence`.
pub peers: Mutex<Vec<presence::PresenceRecord>>,
/// Last time the concurrency anomaly note was emitted, for per-session
/// rate-limiting so a pathological tool-call loop can't nag every result.
pub last_concurrency_note: Mutex<Option<std::time::Instant>>,
/// Digested / ingress-capped tool outputs, keyed by tool+args hash, so an
/// identical re-call of a read-only tool can restore full content without
/// re-executing (bash is never restored). Cleared on workspace mutations.
pub tool_output_cache: Mutex<tool_cache::ToolOutputCache>,
/// Deferred tool names enabled for this session via `load_tools`. Core tools
pub enabled_deferred_tools: Mutex<std::collections::HashSet<String>>,
/// Successfully evaluated snippets retained per language for the deferred
/// session-scoped eval tool. Each invocation replays this history.
pub eval_history: Mutex<std::collections::HashMap<String, Vec<String>>>,
/// Session-scoped `/undo` count for telemetry (`human_corrections`).
pub undo_count: std::sync::atomic::AtomicU64,
/// True after an auto filesystem checkpoint has been taken for the current
/// turn (so we don't snapshot before every destructive tool in a wave).
pub auto_checkpoint_taken: std::sync::atomic::AtomicBool,
/// Session-scoped count of `read_file` hits on `SKILL.md` (skill utilization).
pub skill_read_count: std::sync::atomic::AtomicU64,
/// Full conversation compactions completed in this logical session.
pub compaction_count: std::sync::atomic::AtomicU64,
/// Bumped on compaction / system-prefix rewrites so `prompt_cache_key`
/// rotates and does not thrash a dead prefix on OpenAI.
pub prompt_cache_generation: std::sync::atomic::AtomicU64,
/// Session-pinned reasoning effort (first non-empty value wins) so mid-session
/// effort flips do not silently bust the OpenAI prefix cache.
pub pinned_reasoning_effort: Mutex<Option<String>>,
/// Snapshot of the MEMORY CATALOG / skill manifest taken at session start so
/// standing-prompt refreshes do not rewrite the cached system prefix.
pub frozen_system_suffix: Mutex<Option<String>>,
/// True after the first `session_start` lifecycle hook of this core process
/// session — subsequent turns only fire `turn_start` (not session_start).
pub session_start_fired: std::sync::atomic::AtomicBool,
}
/// Cancel any in-flight turn and drop the one-deep follow-up queue. Shared by
/// `/abort`, `/new`, `/clear`, `/reset`, and `load_session` so conversation
/// boundaries never leave a prior turn streaming into the new context.
async fn cancel_in_flight_turn(state: &State, reason: CancellationReason, replace_session: bool) {
let cancellation_started = std::time::Instant::now();
*state.queued.lock().await = None;
let cancelled = state.runtime.cancel_current(reason);
let replaced_session_id = replace_session.then(|| state.runtime.session_id().to_string());
if replace_session {
state.runtime.replace_session(reason);
}
if let Some(run) = state.current.lock().await.take() {
run.cancellation().cancel();
}
if let Some(goal) = state.goal_deploy_cancel.lock().await.take() {
goal.cancel();
}
{
fn cancel_run_tree(run: &subagent::SubagentRun) {
if let Some(cancel) = &run.cancel {
cancel.cancel();
}
for child in &run.children {
cancel_run_tree(child);
}
}
let runs = state.subagent_runs.lock().await;
for run in runs.values() {
cancel_run_tree(run);
}
}
state.intercom.reset();
if let Some(session_id) = replaced_session_id {
crate::dap::shutdown_session(&session_id).await;
}
// Reap named `process start` children so /abort does not leave host
// servers running after the turn that started them.
if let Ok(supervisor) =
crate::runtime::NamedProcessSupervisor::new(&state.cfg.read().await.workspace)
{
let _ = supervisor.stop_all();
}
// Wake every interactive waiter. Their run cancellation is authoritative,
// but explicit notification bounds cleanup even if a waiter is between
// registering itself and entering its cancellation select.
for pending in state.pending.lock().await.values() {
*pending.granted.lock().await = Some(false);
pending.notify.notify_waiters();
}
for pending in state.pending_asks.lock().await.values() {
*pending.answers.lock().await = Some(Value::Null);
pending.notify.notify_waiters();
}
for pending in state.pending_sudos.lock().await.values() {
*pending.result.lock().await = Some(None);
pending.notify.notify_waiters();
}
// A session boundary must not race state replacement against a still-live
// turn. Most turns stop immediately through their cancellation token; abort
// the Rust task after a bounded grace period as a final containment step.
let mut forced_abort = false;
let mut cleanup_failures = 0_u64;
if let Some(mut handle) = state.handle.lock().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(2), &mut handle).await {
Ok(Ok(())) => {}
Ok(Err(_)) => cleanup_failures = cleanup_failures.saturating_add(1),
Err(_) => {
forced_abort = true;
handle.abort();
if handle.await.is_err() {
// A forced task abort returns JoinError::cancelled. Record the
// forced containment separately, not as a cleanup failure.
}
}
}
}
state.pending.lock().await.clear();
state.pending_asks.lock().await.clear();
state.pending_sudos.lock().await.clear();
let duration_ms = cancellation_started.elapsed().as_millis() as u64;
let remaining_uncancelled_resources = state
.runtime
.snapshot()
.resources
.into_iter()
.filter(|resource| !resource.cancelled)
.count() as u64;
cleanup_failures = cleanup_failures.saturating_add(remaining_uncancelled_resources);
if let Some(cancelled) = cancelled {
state.logger.log(
"cancellation",
json!({
"session_id": &cancelled.session_id,
"run_id": &cancelled.run_id,
"reason": cancelled.reason.as_str(),
"duration_ms": duration_ms,
"status": if cleanup_failures == 0 { "completed" } else { "cleanup_failed" },
"forced_abort": forced_abort,
"cleanup_failures": cleanup_failures,
}),
);
emit(
&Event::new("run_cancelled")
.with("session_id", json!(cancelled.session_id))
.with("run_id", json!(cancelled.run_id))
.with("reason", json!(cancelled.reason.as_str()))
.with("duration_ms", json!(duration_ms))
.with("forced_abort", json!(forced_abort))
.with("cleanup_failures", json!(cleanup_failures)),
);
}
}
/// Shared tail of `login_oauth` (web flow) and `oauth_code` (manual flow):
/// ensure the provider is configured (no api_key — the token is resolved +
/// refreshed at turn time by enrich_oauth), set it active, persist, emit the
/// success + provider_changed events, and refresh the model list.
/// Uses the free `protocol::emit` so this is safe to call from a `tokio::spawn`
/// task (no non-Send `&dyn Fn` borrow).
async fn finalize_oauth(state: &State, client: &reqwest::Client, preset: &str, label: &str) {
{
let mut cfg = state.cfg.write().await;
if cfg.find_provider(preset).is_none() {
if let Some(p) = config::find_preset(preset) {
// OAuth-created configs need the same provider-specific
// transport headers as API-key configs (Copilot and Kimi are
// validated against their official client identities).
cfg.providers
.extend(config::preset_provider_configs(p, None));
} else if let Some(p) = state.plugin_manager.oauth_provider_config(preset) {
// A plugin-declared OAuth provider (no built-in preset): build
// the config from the plugin's declared base_url/kind/headers.
cfg.providers.push(p);
}
}
}
*state.active_provider.write().await = Some(preset.to_string());
{
let cfg = state.cfg.read().await;
let _ = config::save_providers_config(&cfg.providers, Some(preset));
}
state
.logger
.log("login_oauth", json!({ "provider": preset }));
emit(&Event::new("info").with(
"message",
json!(format!(
"logged into {label} via OAuth — you're signed in. Pick a model with /models if needed."
)),
));
// TUI gates prompt send on `authed`; API-key login emits this, OAuth must too.
emit(&Event::new("authed").with("ok", json!(true)));
let rp = state.resolved_provider_enriched().await;
// Always report has_key=true after a successful OAuth exchange — even if
// a transient enrich glitch can't re-read the token yet (it's on disk).
emit(
&Event::new("provider_changed")
.with("provider", json!(rp.name))
.with("kind", json!(rp.kind.as_str()))
.with("base_url", json!(rp.base_url))
.with("has_key", json!(true)),
);
state.refresh_models(client).await;
// Confirm models landed so the user isn't left staring at an empty list.
let n = state.models.read().await.len();
let mine = state
.models
.read()
.await
.iter()
.filter(|m| m.provider == preset)
.count();
emit(&Event::new("info").with(
"message",
json!(format!(
"OAuth ready: {mine} {label} model(s) available ({n} total across providers)."
)),
));
}
/// Pick which provider should serve a model id given its owner providers (in
/// aggregated-list order) and the effective active provider name. The ACTIVE
/// provider is authoritative: when it owns the model it always wins the tie,
/// and when it does NOT own the model we return `None` (no deterministic pick)
/// so the caller sends the id to the ACTIVE provider anyway — it will answer
/// with its own "unknown model" error rather than silently routing the turn to
/// a DIFFERENT provider's endpoint. There is deliberately NO cross-provider
/// failover: a down/unknown model must surface as an error on the provider the
/// user selected, never silently use another provider that happens to serve the
/// same model id. A single sole owner is served by that owner (unambiguous
/// routing; the active provider is a different provider that doesn't list the
/// id). Empty owners -> None (caller falls back to the active/legacy provider,
/// matching the pre-provider-tag behavior).
fn pick_provider_for_model<'a>(owners: &'a [String], active: Option<&str>) -> Option<&'a str> {
match owners.len() {
0 => None,
1 => owners.first().map(|s| s.as_str()),
_ => active
.and_then(|a| owners.iter().find(|o| o.as_str() == a))
.map(|s| s.as_str()),
}
}
/// Like [`pick_provider_for_model`], but the caller may carry an EXPLICIT
/// provider pick (the provider the user selected alongside the model in the
/// `/models` picker). A pick that is a verified owner of the model id WINS
/// outright — this is the fix for "can't use the other provider's copy of the
/// same model id": selecting a model uses its owning provider, no `/login` +
/// provider switch needed. A pick that is NOT an owner (stale model list,
/// hand-written command) is ignored and routing falls back to the legacy
/// active-provider tie-break, preserving the no-silent-cross-provider-failover
/// rule.
fn pick_provider_for_model_with<'a>(
owners: &'a [String],
active: Option<&str>,
pick: Option<&str>,
) -> Option<&'a str> {
if let Some(p) = pick {
if let Some(o) = owners.iter().find(|o| o.as_str() == p) {
return Some(o.as_str());
}
}
pick_provider_for_model(owners, active)
}
impl State {
/// Resolve the active provider for an API call: kind, base URL, effective
/// API key (runtime override -> config literal -> config env var -> global
/// env), and extra headers. Combines the config snapshot with the runtime
/// active-provider override and per-provider keys. This is the single
/// source of truth every provider call site uses, so switching providers
/// (or setting a key) takes effect on the next call with no other wiring.
///
/// Note: does **not** inject OAuth subscription tokens — use
/// [`Self::resolved_provider_enriched`] for that (turns, ready/authed).
pub async fn resolved_provider(&self) -> ResolvedProvider {