Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a3cddd9
feat(init): project creation writes no Claude hooks; re-init strips o…
Oct 14, 2026
662409b
fix(im): one bad event can no longer stop reply delivery for every se…
Oct 14, 2026
287ecb2
fix(init): pin Telegram off via --settings; never fail create on a ba…
Oct 14, 2026
656dd44
fix(daemon): open mcp.sock only after the web /mcp listener is bound
Oct 14, 2026
0e5abf0
fix(im): /interrupt ends a body left over from a restart and resumes …
Oct 14, 2026
e965098
fix(harness): a failed local claude --resume is surfaced, never "fall…
Oct 14, 2026
b76dd80
perf: costs that grew with daemon and session lifetime stay bounded
Oct 14, 2026
3db45ea
fix(im): no single long-lived daemon task dies globally on one panic
Oct 14, 2026
73b347f
fix(im): bound the wait behind a body left over from a restart
Oct 14, 2026
5aa0fac
fix(im): outbox replay gives up instead of re-sending forever; logs -…
Oct 14, 2026
c93b93f
fix(im): contain each scheduled fire and sweep row; back off restarts
Oct 14, 2026
87692bc
test(init): pin that a stream-json spawn writes no project settings
Oct 14, 2026
3c6e9c3
fix(im): restart recovery keeps its promises (checker findings A-D)
Oct 14, 2026
3486e9e
fix(daemon): release the mcp.sock gate on every web-task exit; pin th…
Oct 14, 2026
f6faed9
Merge branch 'fix/gateway-consumer-resilience' into integrate/2026-10-14
Oct 14, 2026
5082841
Merge branch 'feat/no-claude-hooks' into integrate/2026-10-14
Oct 14, 2026
a83c759
Merge branch 'fix/flow-run-test' into integrate/2026-10-14
Oct 14, 2026
235db1d
fix(im): a body ended to keep its session leaves its work running, an…
Oct 14, 2026
9e92aa4
fix(im): a detached body's end has one owner, and its failures are no…
Oct 14, 2026
0faf997
fix(im): orphan reports are complete, attributable and secret-free
Oct 14, 2026
c6cc869
fix: racy meta stamps, replay attempt accounting, compaction headroom…
Oct 14, 2026
25e33fd
fix(im): a body end survives its caller; redaction covers quoted and …
Oct 14, 2026
1affc41
Merge branch 'perf/long-running' into integrate/2026-10-14
Oct 14, 2026
61afb08
Merge branch 'fix/restart-recovery' into integrate/2026-10-14
Oct 14, 2026
a3485b4
fix(im): a withdrawn task's parent is answered through the completion…
Oct 14, 2026
85122b7
Merge branch 'fix/restart-recovery' into integrate/2026-10-14
Oct 14, 2026
a4745a1
fix(web): embed SPA assets in debug binaries
Oct 16, 2026
466407e
fix(web): give tenants the same gateway control face as the admin
Oct 19, 2026
6ffc5b1
feat(web): two-layer step view — thinking text and full tool output
Oct 19, 2026
8cd8dd3
fix(a2a): one completion per parent per turn; a silent turn settles i…
Oct 20, 2026
e9b0e73
chore(im): rename deprecated AtomicUsize::fetch_update to try_update
firstintent Oct 20, 2026
9f363c7
feat(context): expose what a session's context window is made of
firstintent Oct 20, 2026
ffbc214
feat(web): context panel — a docked right sidebar showing what a sess…
firstintent Oct 20, 2026
ee29bcc
docs(usage): the session context sidebar
firstintent Oct 20, 2026
d988bf9
release: v0.13.0 — the session context sidebar, the two-layer step view
firstintent Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ members = [
]

[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2021"
rust-version = "1.85"
license = "MIT"
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ The installer runs the daemon; `ccteam status` reprints your link (`http://<lan-
</p>

- six formation playbooks (commander & crews, driver & advisor, cross review, bake-off, research triangulation, cost pyramid) that prefill the launcher with a vendor lineup
- a Chat tab per session (plus a byte-faithful terminal where applicable), including a clock on the composer to queue delayed user turns above the input
- a Chat tab per session (plus a byte-faithful terminal where applicable), including a clock on the composer to queue delayed user turns above the input; a turn's folded activity row opens into its steps, and each step into its full thinking text or tool input and output
- a context sidebar per session: what its context window is made of — system prompt, tools, each memory file by path, skills, MCP tools, agents, the conversation split by tool and attachment, last-request cache use — taken from the harness's own accounting, refreshed when a turn ends (Claude sessions)
- a Team page: the live delegation topology — vendor, the model and reasoning effort each session is actually running, cost, every row a real link so a parent and its delegate open side by side — plus a division-of-labor charter (the per-project `routing.md` agents read via `status`) edited in place, and a Runs tab listing every ccteam Flow run with the sessions it hired
- a DSH page that opens DeepSeek Harness Web inside ccteam: the daemon authenticates the request, starts or attaches the right local DSH web instance, and gives each logged-in user a separate DSH home
- a cost pill with daily budget caps
Expand Down Expand Up @@ -232,7 +233,7 @@ Every spawn surface takes `model` and `effort` for every vendor and forwards bot
ccteam adds a team to your repo without taking it over:

- **Roleless by default** — the brain reads *your* `CLAUDE.md` / `AGENTS.md` through the vendor's own mechanism; ccteam never rewrites project knowledge.
- **Small footprint** — exactly `.ccteam/` (state), `.claude/agents/` (personas you install), and ccteam's own section of `.claude/settings.local.json` — never your `settings.json`.
- **Small footprint** — exactly `.ccteam/` (state), `.claude/agents/` (personas you install), and in `.claude/settings.local.json` only the keys you ask for (a hub plugin you enable, an `/effort` you pin) — never your `settings.json`, and no hooks (unless you pick the frozen terminal protocol, which installs its own).
- **Durable sessions** — ids (`s1`, `s2`, …) survive daemon restarts and cold-resume from disk; state is plain files in your repo. A session you are not talking to releases its harness process after an idle window (default one hour, matching the prompt-cache TTL) and comes straight back on your next message, on the same id and the same conversation — so a fleet of thirty sessions costs thirty transcripts, not thirty resident processes. One session, one process: a restart never kills an agent mid-turn and never starts a second one beside it — the daemon lets the process finish, queues what you send it meanwhile, recovers the answer it gave from the vendor's own record, and resumes the session by id.

## Extras
Expand Down Expand Up @@ -267,7 +268,7 @@ curl -sSL https://raw.githubusercontent.com/firstintent/ccteam/main/install.sh |
rm -rf ~/.ccteam # state, secrets, hub cache — keep it if you may return
```

Per project, delete `.ccteam/` and ccteam's section of `.claude/settings.local.json`.
Per project, delete `.ccteam/` — or, before uninstalling, run `ccteam project rm --purge <slug>`, which also strips any hook entries or plugin pin an older ccteam left in `.claude/settings.local.json`.

## Support

Expand Down
173 changes: 157 additions & 16 deletions crates/ccteam-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ pub fn run_init(paths: &CcteamPaths, opts: InitOptions) -> Result<String> {
target.join(".claude").join("agents").display(),
project_report.agents_action,
));
if let Some(note) = &project_report.hooks_note {
out.push_str(&format!(
" claude settings {} ({note})\n",
target.join(".claude").join("settings.local.json").display(),
));
}
out.push_str(&format!(
" config.yaml {} (upserted)\n",
ccteam_core::ccteam_config_path(&paths.root).display(),
Expand Down Expand Up @@ -315,6 +321,10 @@ struct ProjectInstallReport {
state_action: &'static str,
workflow_action: &'static str,
agents_action: &'static str,
/// What happened to old ccteam hook entries in
/// `.claude/settings.local.json` (docs-local issue #212); `None` when
/// there was nothing to report.
hooks_note: Option<String>,
}

impl ProjectInstallReport {
Expand Down Expand Up @@ -426,6 +436,19 @@ fn install_project_at(
};
}

// docs-local issue #212 — re-running `ccteam init` is how an existing
// project sheds the Claude Code hooks earlier ccteam versions wrote (a
// fresh install already ran the same scrub inside
// `bootstrap_project_at_dir`, so here it only reports). Best-effort: a
// user file that is not valid JSON is left alone and noted, never fatal.
let hooks_note = match ccteam_core::remove_ccteam_hooks(
&target.join(".claude").join("settings.local.json"),
false,
) {
Ok(report) => scrub_note(&report),
Err(err) => Some(format!("left untouched: {err:#}")),
};

Ok(ProjectInstallReport {
slug: slug.to_string(),
team: final_team,
Expand All @@ -434,9 +457,24 @@ fn install_project_at(
state_action,
workflow_action,
agents_action,
hooks_note,
})
}

/// One receipt note for an init-time [`ccteam_core::remove_ccteam_hooks`] run.
fn scrub_note(report: &ccteam_core::CcteamHookScrubReport) -> Option<String> {
use ccteam_core::CcteamHookScrubAction as A;
match &report.action {
A::Removed { entries } | A::RemovedNowEmpty { entries } => Some(format!(
"stripped {entries} retired ccteam entr{}",
if *entries == 1 { "y" } else { "ies" }
)),
A::Unreadable { error } => Some(format!("left untouched: cannot read it — {error}")),
A::Unparseable { error } => Some(format!("left untouched: not valid JSON — {error}")),
A::NotFound | A::NoChangeNeeded | A::WouldRemove { .. } => None,
}
}

/// Normalize a raw `--owner` value (`ccteam init --owner`) into the
/// `ProjectState.owner` convention. A value already containing `:` is taken
/// verbatim (`user:u123`, `telegram:456`); a bare value is scoped to the
Expand Down Expand Up @@ -1785,6 +1823,7 @@ pub fn run_web(opts: WebOptions) -> Result<()> {
// Standalone `ccteam internal web` shares the home but is not the
// daemon; publishing would point `daemon status` at this process.
publish_endpoint: false,
bound: None,
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
Expand Down Expand Up @@ -3664,16 +3703,16 @@ fn purge_imd_registry_for_slug(
/// Helper for `run_remove --purge` — deletes exactly ccteam's own
/// footprint inside `<project>/` (or, under `--dry-run`, just records
/// the planned step). A project's on-disk ccteam footprint is `.ccteam/`
/// (state.json + workflow.yaml) plus ccteam's hook section inside
/// (state.json + workflow.yaml) plus any ccteam hook entries an earlier
/// version (or a terminal-protocol session) left inside
/// `.claude/settings.local.json`. (ccteam seeds NO role, so there is
/// no ccteam-owned persona to purge.)
///
/// **DELETE** (ccteam-managed only):
/// - `<project>/.ccteam/` — state.json + workflow.yaml live here (W2).
/// - ccteam's chat-progress + AskUserQuestion hook entries inside
/// `<project>/.claude/settings.local.json` (surgically, via
/// [`ccteam_core::remove_chat_hooks`]). If stripping them leaves the
/// file an empty object, the now-vestigial file is deleted.
/// - ccteam's own hook entries inside `<project>/.claude/settings.local.json`
/// (surgically, via [`ccteam_core::remove_ccteam_hooks`]). If stripping
/// them leaves the file an empty object, the now-vestigial file is deleted.
///
/// **KEEP (never touched):**
/// - ALL of `<project>/.claude/agents/*.md` — every role file is a user
Expand Down Expand Up @@ -3715,10 +3754,24 @@ fn purge_project_managed_paths(
// removed surgically so any operator-authored keys/hooks survive.
// If the strip empties the file, delete the vestigial object.
let settings_local = project_dir.join(".claude").join("settings.local.json");
let scrub = ccteam_core::remove_chat_hooks(&settings_local, dry_run)?;
use ccteam_core::ChatHookScrubAction as A;
let scrub = ccteam_core::remove_ccteam_hooks(&settings_local, dry_run)?;
use ccteam_core::CcteamHookScrubAction as A;
match scrub.action {
A::NotFound | A::NoChangeNeeded => {}
// Purge is explicit cleanup: refuse rather than silently skip a file
// it could not inspect.
A::Unreadable { error } => {
anyhow::bail!(
"cannot purge ccteam entries from {}: cannot read it ({error})",
settings_local.display()
);
}
A::Unparseable { error } => {
anyhow::bail!(
"cannot purge ccteam entries from {}: not valid JSON ({error}); fix or delete it",
settings_local.display()
);
}
A::WouldRemove { entries } => {
report.steps.push(format!(
"would strip {entries} ccteam hook entr{} from .claude/settings.local.json ({})",
Expand All @@ -3734,9 +3787,8 @@ fn purge_project_managed_paths(
));
}
A::RemovedNowEmpty { entries } => {
// The file existed only to carry ccteam's hooks — delete it.
std::fs::remove_file(&settings_local)
.with_context(|| format!("rm {}", settings_local.display()))?;
// The file existed only to carry ccteam's hooks — the scrub
// deleted it.
report.steps.push(format!(
"stripped {entries} ccteam hook entr{} and removed now-empty \
.claude/settings.local.json ({})",
Expand Down Expand Up @@ -4638,13 +4690,10 @@ mod tests {
!body.contains("mode: agent-team"),
"default workflow.yaml must NOT declare agent-team mode",
);
// No F94 hooks in the default (managed) settings layer. v0.8.6
// W2b — ccteam writes to settings.local.json, not settings.json.
let settings_body =
std::fs::read_to_string(target.join(".claude").join("settings.local.json")).unwrap();
// docs-local issue #212 — init writes no Claude settings at all.
assert!(
!settings_body.contains("TeammateIdle"),
"default settings.local.json must NOT include team_* hooks",
!target.join(".claude").join("settings.local.json").exists(),
"init must NOT create .claude/settings.local.json",
);
}

Expand Down Expand Up @@ -4693,6 +4742,98 @@ mod tests {
);
}

/// docs-local issue #212 — re-running `ccteam init` on a project an
/// earlier version initialized strips the Claude Code hooks it wrote into
/// `.claude/settings.local.json`, keeping every other key; a file left
/// holding nothing is removed.
#[test]
fn run_init_refresh_strips_old_ccteam_hooks() {
ensure_isolation();
let tmp = TempDir::new().unwrap();
let paths = fresh_paths(&tmp);
let target = tmp.path().join("old-hooks");
let opts = || InitOptions {
install_in: Some(target.clone()),
..InitOptions::default()
};
run_init(&paths, opts()).unwrap();
let local = target.join(".claude").join("settings.local.json");
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
let hook_sh = paths.hooks_script();
let hook_sh = hook_sh.display();
std::fs::write(
&local,
format!(
r#"{{"env":{{"CCTEAM_HOME":"/x"}},"hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"{hook_sh} progress-append Stop","async":true}}]}}]}}}}"#
),
)
.unwrap();

run_init(&paths, opts()).unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&local).unwrap()).unwrap();
assert_eq!(v, serde_json::json!({"env": {"CCTEAM_HOME": "/x"}}));

// Only ccteam hooks left → the file goes away.
std::fs::write(
&local,
format!(
r#"{{"hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"{hook_sh} progress-append Stop"}}]}}]}}}}"#
),
)
.unwrap();
run_init(&paths, opts()).unwrap();
assert!(!local.exists(), "hooks-only settings.local.json removed");
}

/// docs-local issue #212 — an empty or invalid settings.local.json never
/// fails `ccteam init` (fresh or re-run); the file is left as it was and
/// the project is fully initialized.
#[test]
fn run_init_tolerates_unparseable_settings_local() {
ensure_isolation();
for (name, body) in [("empty", ""), ("comma", "{\"model\": \"opus\",}")] {
let tmp = TempDir::new().unwrap();
let paths = fresh_paths(&tmp);
let target = tmp.path().join(name);
let local = target.join(".claude").join("settings.local.json");
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(&local, body).unwrap();
let opts = || InitOptions {
install_in: Some(target.clone()),
..InitOptions::default()
};
run_init(&paths, opts()).unwrap_or_else(|e| panic!("{name}: fresh init: {e:#}"));
assert!(target.join(".ccteam").join("workflow.yaml").exists());
run_init(&paths, opts()).unwrap_or_else(|e| panic!("{name}: re-init: {e:#}"));
assert_eq!(std::fs::read_to_string(&local).unwrap(), body, "{name}");
}
}

/// The init receipt names a read failure as such, not as invalid JSON.
#[test]
fn scrub_note_distinguishes_unreadable_from_unparseable() {
use ccteam_core::{CcteamHookScrubAction as A, CcteamHookScrubReport};
let note = |action| {
scrub_note(&CcteamHookScrubReport {
target: "x".into(),
action,
})
.unwrap()
};
let read = note(A::Unreadable {
error: "Is a directory (os error 21)".into(),
});
assert!(
read.contains("cannot read") && !read.contains("JSON"),
"{read}"
);
let parse = note(A::Unparseable {
error: "EOF while parsing".into(),
});
assert!(parse.contains("not valid JSON"), "{parse}");
}

/// `--force` re-runs overwrite user files. workflow.yaml lives in
/// `.ccteam/`, not the root.
#[test]
Expand Down
Loading
Loading