Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,13 @@ debug = false
lto = true
codegen-units = 1
strip = true

# `make install` builds this, not `release`: the published binary's fat LTO over
# one codegen unit runs single-threaded at the very end of the build, which on a
# dev box is most of the wall time. Thin LTO across parallel codegen units keeps
# the binary optimized and is several times faster to produce; `release` stays
# the shipped artifact's profile.
[profile.local-release]
inherits = "release"
lto = "thin"
codegen-units = 16
16 changes: 9 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ccteam Makefile — thin convenience wrappers around cargo / npm / the CLI.
#
# make gate # full pre-push gate: fmt + clippy + tests + SPA
# make install # THE install: build release + copy to $(BIN_DIR)/ccteam
# make install # THE install: build optimized (thin LTO) + copy to $(BIN_DIR)/ccteam
# # + `ccteam daemon restart` (self-managed setsid daemon;
# # migrates any legacy systemd/launchd unit) + next steps
# make start # run the daemon in the FOREGROUND (dev / one-off)
Expand Down Expand Up @@ -34,7 +34,7 @@ BIN_LINK := $(BIN_DIR)/$(BIN_NAME)
# every build lands below this checkout. The first arm avoids invoking
# `cargo metadata` when the caller already supplied the environment variable.
CARGO_TARGET_DIR_RESOLVED = $(or $(strip $(CARGO_TARGET_DIR)),$(shell cargo metadata --no-deps --format-version 1 2>/dev/null | sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p'))
RELEASE_BIN = $(abspath $(CARGO_TARGET_DIR_RESOLVED))/release/$(BIN_NAME)
INSTALL_BIN = $(abspath $(CARGO_TARGET_DIR_RESOLVED))/local-release/$(BIN_NAME)
CCTEAM_HOME ?= $(HOME)/.ccteam
WEB_DIR := $(CURDIR)/crates/ccteam-web/web
WEB_PORT ?= 7331
Expand Down Expand Up @@ -78,7 +78,7 @@ help:
@printf ' \033[1mmake gate\033[0m full pre-push gate (fmt+clippy+test+test-web+web-check)\n'
@printf ' make clean cargo clean + rm SPA dist\n\n'
@printf '\033[1mInstall\033[0m\n'
@printf ' \033[1mmake install\033[0m build release + atomic copy + `ccteam daemon restart` (self-managed daemon)\n'
@printf ' \033[1mmake install\033[0m optimized build (thin LTO) + atomic copy + `ccteam daemon restart` (self-managed daemon)\n'
@printf ' make uninstall stop the daemon + remove the executable (state untouched)\n'
@printf ' make reinstall uninstall + install\n\n'
@printf '\033[1mRun foreground (daemon = IM gateway + web UI + MCP, one process)\033[0m\n'
Expand Down Expand Up @@ -203,7 +203,8 @@ gate: fmt-check clippy test test-web web-check

# --- Install / uninstall -----------------------------------------------------
#
# `make install` is THE product install: release build → atomic executable
# `make install` is THE product install: optimized build (Cargo profile
# `local-release` = release with thin LTO, see Cargo.toml) → atomic executable
# copy → `ccteam daemon restart` (self-managed setsid daemon). The installed
# executable is deliberately independent of Cargo's build tree: shared target
# cleanup or a redirected CARGO_TARGET_DIR must never break the live daemon.
Expand All @@ -218,11 +219,12 @@ gate: fmt-check clippy test test-web web-check
# schema is owned by `ccteam_core::install_channel::InstallMarker`; unknown
# fields are ignored, and `tag` is absent here on purpose (no release tag).

install-binary: release
install-binary: require-node
cargo build --profile local-release
@set -eu; \
_release_bin="$(RELEASE_BIN)"; \
_release_bin="$(INSTALL_BIN)"; \
if [ ! -x "$$_release_bin" ]; then \
printf '\033[31merror:\033[0m Cargo release binary not found: %s\n' "$$_release_bin" >&2; \
printf '\033[31merror:\033[0m Cargo local-release binary not found: %s\n' "$$_release_bin" >&2; \
exit 1; \
fi; \
mkdir -p "$(BIN_DIR)"; \
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ ccteam is the connective tissue they lack — identity, routing, delivery guaran

**1 · Remote control from Telegram / Lark / Slack**

Paste a bot token once (Settings → Access) and the chat becomes a full console — completion notifications, HITL `[approve] [deny]` buttons, and shipped files all land in the same thread. Dispatch at midnight, close the laptop, find the result at breakfast. On Slack every session gets **its own thread**: a top-level message starts a session, a reply in its thread talks only to it, so parallel sessions never interleave (commands start with `!` there — `!status`, `!model` — since Slack keeps `/` for itself; Settings → Access creates the Slack app for you in one click):
Paste a bot token once (Settings → Access) and the chat becomes a full console — completion notifications, HITL `[approve] [deny]` buttons, and shipped files all land in the same thread. Dispatch at midnight, close the laptop, find the result at breakfast. In a group or channel the bot answers every message from allowed members — or, with one switch per IM, only when @-mentioned. On Slack every session gets **its own thread**: a top-level message starts a session, a reply in its thread talks only to it, so parallel sessions never interleave (commands start with `!` there — `!status`, `!model` — since Slack keeps `/` for itself; Settings → Access creates the Slack app for you in one click):

```text
/cd demo # pick a project; your next message talks to it
Expand Down Expand Up @@ -190,7 +190,7 @@ After you reboot your computer, run `ccteam start` again to bring ccteam back.

**Configure in the browser** — open the printed link (also shown by `ccteam status`), create a project, and just type; the session is born on your first message. Then:

- **Settings → Access** — everything that connects to ccteam, on one page: the copy-paste MCP config for external agents (a credential scoped to one project, rendered as the real config each vendor expects, or as plain text for plugin-backed flows such as DSH, listed and revocable afterwards — the secret is shown once, never again), satellite join tokens for new machines, your own Telegram/Lark bot (a numbered two-step card per platform — save the credential, then bind who the bot answers, with sender capture starting on its own), and per-user login links
- **Settings → Access** — everything that connects to ccteam, on one page: the copy-paste MCP config for external agents (a credential scoped to one project, rendered as the real config each vendor expects, or as plain text for plugin-backed flows such as DSH, listed and revocable afterwards — the secret is shown once, never again), satellite join tokens for new machines, your own Telegram/Lark/Slack bot (a numbered two-step card per platform — save the credential, then bind who the bot answers, with sender capture starting on its own), and per-user login links
- **Settings → Hosts** — each machine's vendor panel (installed / version / readiness) and one-click registration of the ccteam MCP tools into the vendor CLIs with writable config (Claude Code, Codex, Grok, OpenCode, Kimi), so even hand-started sessions can hire the team. DSH's one-click on the same page registers ccteam's plugin into your own `~/.dsh` web profile instead (a DSH session of yours can also orchestrate after pasting an Access credential); Pi gets the team tools through a ccteam-owned bridge loaded into the sessions ccteam spawns, so a `pi` you start by hand in a shell is left completely untouched
- **Workflow → Marketplace** — install skills (into your user-level library `~/.ccteam/skills`; the skills tab comes first) and personas (into the project), checksum-verified; attach library skills to any message from the composer
- **DSH** — open native DSH Web as a first-class console page. Each identity runs one DSH runtime and ccteam is its second client: DSH sessions hired anywhere in ccteam are created inside that same runtime, appear live in this page's sidebar under the project's workspace, and can be opened mid-task to watch or interject — the agent's next dispatch continues the same conversation. The owner sees the real `~/.dsh` space (ccteam attaches to a DSH Web already on `127.0.0.1:3080` when present); each regular user gets an isolated `$CCTEAM_HOME/runtime/dsh/web/<user>/` space with the ccteam client plugin preloaded. It works out of the box by following this machine's DSH login until the user changes DSH Settings → Models; the whole identity — menu sessions and hires alike — runs on that one config. User-installed DSH plugins are preserved.
Expand Down
53 changes: 50 additions & 3 deletions crates/ccteam-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2010,7 +2010,10 @@ pub fn run_config_set_im_token(token: &str) -> Result<String> {
let creds_path = ccteam_im::credentials::default_path();
let mut creds = ccteam_im::credentials::load(Some(&creds_path))
.context("load existing IM credentials before merge")?;
creds.telegram = Some(result.creds);
let mut telegram = result.creds;
// Re-running setup must not reset the group-reply switch.
telegram.require_mention = creds.telegram.as_ref().is_some_and(|t| t.require_mention);
creds.telegram = Some(telegram);
ccteam_im::credentials::save(&creds_path, &creds).context("persist IM credentials")?;

// Best-effort in-place reload of a running daemon's IM listeners (no
Expand Down Expand Up @@ -2107,7 +2110,10 @@ pub fn run_config_set_lark_creds_with_base(
} else {
"Lark (intl, open.larksuite.com)"
};
creds.lark = Some(result.creds);
let mut lark = result.creds;
// Re-running setup must not reset the group-reply switch.
lark.require_mention = creds.lark.as_ref().is_some_and(|l| l.require_mention);
creds.lark = Some(lark);
ccteam_im::credentials::save(&creds_path, &creds).context("persist IM credentials")?;

// Best-effort in-place reload of a running daemon's IM listeners (no
Expand Down Expand Up @@ -2196,7 +2202,10 @@ pub fn run_config_set_slack_creds_with_base(
let mut creds = ccteam_im::credentials::load(Some(&creds_path))
.context("load existing IM credentials before merge")?;
let allow_count = result.creds.allowed_user_ids.len();
creds.slack = Some(result.creds);
let mut slack = result.creds;
// Re-running setup must not reset the group-reply switch.
slack.require_mention = creds.slack.as_ref().is_some_and(|s| s.require_mention);
creds.slack = Some(slack);
ccteam_im::credentials::save(&creds_path, &creds).context("persist IM credentials")?;

// Best-effort live reload; skipped on the test seam (see the Lark twin).
Expand Down Expand Up @@ -5100,6 +5109,7 @@ mod tests {
telegram: Some(ccteam_im::credentials::TelegramCreds {
bot_token: "tg-seed-token".into(),
allowed_chat_ids: vec!["111".into()],
require_mention: false,
}),
..Default::default()
};
Expand Down Expand Up @@ -5307,6 +5317,7 @@ mod tests {
app_secret: "seed_secret".into(),
allowed_user_ids: vec!["ou_seed".into()],
use_feishu: true,
require_mention: false,
}),
..Default::default()
};
Expand Down Expand Up @@ -5355,6 +5366,42 @@ mod tests {
assert_eq!(lark.app_id, "cli_seed");
}

/// Re-running `config set slack` (a rotated token) must not reset the
/// group-reply switch the owner turned on in the web console.
#[test]
fn re_running_slack_setup_keeps_the_require_mention_switch() {
let tmp = TempDir::new().unwrap();
let creds_path = tmp.path().join("im/credentials.json");
let seed = ccteam_im::credentials::Credentials {
slack: Some(ccteam_im::credentials::SlackCreds {
bot_token: "xoxb-old".into(),
app_token: "xapp-old".into(),
allowed_user_ids: vec!["U0ALICE".into()],
require_mention: true,
}),
..Default::default()
};
ccteam_im::credentials::save(&creds_path, &seed).unwrap();
let base = spawn_slack_http(&[
("/auth.test", SLACK_AUTH_OK),
("/apps.connections.open", SLACK_SOCKET_OK),
]);
run_config_set_slack_creds_with_base(
"xoxb-new",
"xapp-new",
vec!["U0ALICE".into()],
&base,
Some(&creds_path),
)
.expect("re-run succeeds");
let slack = ccteam_im::credentials::load(Some(&creds_path))
.unwrap()
.slack
.unwrap();
assert_eq!(slack.bot_token, "xoxb-new");
assert!(slack.require_mention, "the switch survives a token change");
}

#[test]
fn slack_creds_empty_allowlist_warns_fail_closed() {
let tmp = TempDir::new().unwrap();
Expand Down
33 changes: 20 additions & 13 deletions crates/ccteam-cli/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -697,13 +697,13 @@ fn is_executable_file(path: &Path) -> bool {
std::fs::metadata(path).is_ok_and(|m| m.is_file() && is_executable(&m))
}

/// `<…>/target/{debug,release}` is a build output, not an install
/// location: `cargo clean` there would take the daemon's binary with it.
/// `<…>/target/<profile>` is a build output, not an install location:
/// `cargo clean` there would take the daemon's binary with it.
fn is_cargo_build_tree(dir: &Path) -> bool {
matches!(
dir.file_name().and_then(|n| n.to_str()),
Some("debug") | Some("release")
) && dir.parent().and_then(|p| p.file_name()) == Some(std::ffi::OsStr::new("target"))
dir.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| ccteam_core::install_channel::CARGO_BUILD_PROFILES.contains(&n))
&& dir.parent().and_then(|p| p.file_name()) == Some(std::ffi::OsStr::new("target"))
}

fn is_writable_dir(dir: &Path) -> bool {
Expand Down Expand Up @@ -1273,6 +1273,7 @@ mod tests {
| Some("/home/u/.local/bin/ccteam")
| Some("/ro/ccteam")
| Some("/src/target/debug/ccteam")
| Some("/src/target/local-release/ccteam")
)
};
let writable = |d: &Path| d != Path::new("/ro");
Expand Down Expand Up @@ -1304,12 +1305,15 @@ mod tests {
ladder(None, Some("/nope:/opt/first:/home/u/.local/bin")),
Path::new("/opt/first")
);
// Rung 2 skips a cargo build tree (`cargo clean` would delete it)…
assert_eq!(
ladder(None, Some("/src/target/debug:/home/u/.local/bin")),
Path::new("/home/u/.local/bin"),
"a build tree is not an install location"
);
// Rung 2 skips a cargo build tree (`cargo clean` would delete it),
// whichever profile built it — `make install` builds `local-release`…
for tree in ["/src/target/debug", "/src/target/local-release"] {
assert_eq!(
ladder(None, Some(&format!("{tree}:/home/u/.local/bin"))),
Path::new("/home/u/.local/bin"),
"a build tree is not an install location: {tree}"
);
}
// …and a directory it cannot write.
assert_eq!(
ladder(None, Some("/ro")),
Expand Down Expand Up @@ -1344,7 +1348,10 @@ mod tests {
("1: explicit override", "CCTEAM_INSTALL_DIR"),
("2: PATH lookup", "command -v ccteam"),
("2: symlink resolution", "canonical_bin"),
("2: build-tree exclusion", "*/target/release|*/target/debug"),
(
"2: build-tree exclusion",
"*/target/release|*/target/local-release|*/target/debug",
),
("2: writability", "-w \"$_dir\""),
("3: default", "$HOME/.local/bin"),
] {
Expand Down
1 change: 1 addition & 0 deletions crates/ccteam-cli/src/web_chat_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ fn to_im_message(message: WebChannelMessage) -> ChannelMessage {
attachments: Vec::new(),
// v0.8.5 D3: a chip click carries its opaque "{token}:{idx}".
selection: message.selection.map(|data| ChoiceReply { data }),
ambient: false,
}
}

Expand Down
30 changes: 14 additions & 16 deletions crates/ccteam-cli/tests/no_silent_todo_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
//! the `chat-handle` and `listbots-cache` anchors (the AgentSpec /
//! BotRegistration / build_handle_map schema landed) and the
//! dynamic-workflow rebuild of `ccteam-flow` deleted
//! `human-approval-adapter` with the old orchestrator, so the count is
//! now three.
//! `human-approval-adapter` with the old orchestrator, and Slack over
//! Socket Mode (#19) closed the last three (`im-providers`,
//! `slack-inbound`, `slack-socket-mode`), so none survive.
//! Sister-finding sites owned by F173 / F169 / F170 cover the
//! remaining markers — see `docs/dev-coupling-audit.md` for the index.
//!
Expand Down Expand Up @@ -114,14 +115,14 @@ fn no_silent_todo_in_production_src() {
);
}

/// Cross-check that the surviving `TODO(V0.7-<anchor>)` tag set
/// matches expectations — guards against accidental tag removal or
/// duplication. F168 originally delivered six anchors; V0.6.8 retired
/// `chat-handle` and `listbots-cache`, and the dynamic-workflow rebuild
/// of `ccteam-flow` deleted `human-approval-adapter` along with the old
/// decision-engine orchestrator, so the count is now three.
/// Every `TODO(V0.7-<anchor>)` F168 deferred is closed: V0.6.8 retired
/// `chat-handle` and `listbots-cache`, the dynamic-workflow rebuild of
/// `ccteam-flow` deleted `human-approval-adapter`, and Slack over Socket
/// Mode (#19) delivered `im-providers`, `slack-inbound` and
/// `slack-socket-mode`. V0.7 is long past, so a new anchor naming it would
/// be a stale promise — defer to a version still ahead instead.
#[test]
fn f168_v07_deferred_tag_count_is_three() {
fn f168_v07_deferred_anchors_are_all_closed() {
let workspace_root = workspace_root();
let mut hits: Vec<String> = Vec::new();
for root in SCAN_ROOTS {
Expand All @@ -143,13 +144,10 @@ fn f168_v07_deferred_tag_count_is_three() {
}
});
}
assert_eq!(
hits.len(),
3,
"exactly 3 V0.7-deferred TODO anchors survive (F168 minus \
chat-handle and listbots-cache, closed in V0.6.8, minus \
human-approval-adapter, deleted with the old ccteam-flow \
orchestrator); found {}:\n{}",
assert!(
hits.is_empty(),
"no V0.7-deferred TODO anchor may survive (all six F168 anchors are \
closed); found {}:\n{}",
hits.len(),
hits.join("\n")
);
Expand Down
Loading
Loading