@team-agent/installer 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +33 -1
- package/crates/team-agent/src/cli/status_port.rs +119 -66
- package/crates/team-agent/src/cli/tests/status_send.rs +74 -48
- package/crates/team-agent/src/coordinator/tick.rs +2 -21
- package/crates/team-agent/src/leader/helpers.rs +1 -22
- package/crates/team-agent/src/lifecycle/launch.rs +1 -11
- package/crates/team-agent/src/lifecycle/profile_launch.rs +25 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +20 -26
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +30 -8
- package/crates/team-agent/src/lifecycle/restart/selection.rs +67 -16
- package/crates/team-agent/src/lifecycle/restart.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +99 -0
- package/crates/team-agent/src/messaging/delivery.rs +113 -0
- package/crates/team-agent/src/provider/adapter.rs +18 -372
- package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
- package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
- package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
- package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
- package/crates/team-agent/src/provider/mod.rs +6 -0
- package/crates/team-agent/src/provider/session/capture.rs +131 -1
- package/crates/team-agent/src/provider/wire.rs +139 -0
- package/crates/team-agent/src/state/paths.rs +8 -4
- package/crates/team-agent/src/state/persist.rs +17 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +6 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
//! Copilot provider-local command builders + permission helpers.
|
|
2
|
+
//!
|
|
3
|
+
//! Extracted from `provider/adapter.rs` (0.4.x decoupling step 2). Pure
|
|
4
|
+
//! extraction — byte-identical to the original inline forms. Scope kept
|
|
5
|
+
//! small: base command + resume + permission flags + MCP type→transport
|
|
6
|
+
//! translation. Auth hint (`copilot_auth_hint`) and session store scan
|
|
7
|
+
//! (`scan_copilot_session_store`, `copilot_candidate`) stay in
|
|
8
|
+
//! `adapter.rs` because they depend on `command_on_path` /
|
|
9
|
+
//! `CaptureSessionContext` / sqlite session-store helpers. Step 3 of the
|
|
10
|
+
//! decoupling plan can move those into provider session store hooks.
|
|
11
|
+
|
|
12
|
+
use crate::model::enums::AuthMode;
|
|
13
|
+
use crate::provider::McpConfig;
|
|
14
|
+
|
|
15
|
+
pub(crate) fn copilot_base_command(
|
|
16
|
+
auth_mode: AuthMode,
|
|
17
|
+
mcp_config: Option<&McpConfig>,
|
|
18
|
+
system_prompt: Option<&str>,
|
|
19
|
+
model: Option<&str>,
|
|
20
|
+
tools: &[&str],
|
|
21
|
+
) -> Vec<String> {
|
|
22
|
+
let _ = (auth_mode, system_prompt);
|
|
23
|
+
let mut argv = vec![
|
|
24
|
+
"copilot".to_string(),
|
|
25
|
+
// Noise control trio + disable remote control (防 GitHub web 远控 worker)
|
|
26
|
+
"--no-color".to_string(),
|
|
27
|
+
"--no-auto-update".to_string(),
|
|
28
|
+
"--no-remote".to_string(),
|
|
29
|
+
// P0: disable built-in github-mcp-server. Residual risk covered by
|
|
30
|
+
// spawn-time `copilot mcp list` scan + per-name --disable-mcp-server.
|
|
31
|
+
"--disable-builtin-mcps".to_string(),
|
|
32
|
+
];
|
|
33
|
+
if copilot_dangerous_auto_approve(tools) {
|
|
34
|
+
argv.push("--allow-all".to_string());
|
|
35
|
+
} else {
|
|
36
|
+
for flag in copilot_permission_flags(tools) {
|
|
37
|
+
argv.push(flag);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// mcp_team ∈ canonical → approval-free (whole-server pattern).
|
|
41
|
+
argv.push("--allow-tool".to_string());
|
|
42
|
+
argv.push("team_orchestrator".to_string());
|
|
43
|
+
if let Some(model) = model {
|
|
44
|
+
argv.push("--model".to_string());
|
|
45
|
+
argv.push(model.to_string());
|
|
46
|
+
}
|
|
47
|
+
if let Some(config) = mcp_config {
|
|
48
|
+
// Copilot mcp config schema field is `transport` (stdio|http|sse),
|
|
49
|
+
// not the canonical `type`. McpConfig.raw is canonical; only copilot
|
|
50
|
+
// translates type→transport for --additional-mcp-config.
|
|
51
|
+
argv.push("--additional-mcp-config".to_string());
|
|
52
|
+
argv.push(copilot_translate_mcp_config(&config.raw).to_string());
|
|
53
|
+
}
|
|
54
|
+
argv
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Translate `McpConfig.raw` canonical schema (`type`) into the copilot
|
|
58
|
+
/// `mcp add`/`--additional-mcp-config` expected `transport` field
|
|
59
|
+
/// (stdio|http|sse). Only the copilot adapter walks this translation —
|
|
60
|
+
/// claude/codex paths leave canonical schema untouched.
|
|
61
|
+
pub(crate) fn copilot_translate_mcp_config(raw: &serde_json::Value) -> serde_json::Value {
|
|
62
|
+
let Some(servers) = raw.as_object() else {
|
|
63
|
+
return raw.clone();
|
|
64
|
+
};
|
|
65
|
+
let mut translated = serde_json::Map::new();
|
|
66
|
+
for (name, server) in servers {
|
|
67
|
+
let Some(obj) = server.as_object() else {
|
|
68
|
+
translated.insert(name.clone(), server.clone());
|
|
69
|
+
continue;
|
|
70
|
+
};
|
|
71
|
+
let mut out = serde_json::Map::new();
|
|
72
|
+
for (key, value) in obj {
|
|
73
|
+
if key == "type" {
|
|
74
|
+
out.insert("transport".to_string(), value.clone());
|
|
75
|
+
} else {
|
|
76
|
+
out.insert(key.clone(), value.clone());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
translated.insert(name.clone(), serde_json::Value::Object(out));
|
|
80
|
+
}
|
|
81
|
+
serde_json::Value::Object(translated)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Resume path = base + `--resume <sid>` (drop --session-id); kept separate
|
|
85
|
+
/// to avoid the plan accidentally emitting --session-id and --resume in the
|
|
86
|
+
/// same frame.
|
|
87
|
+
pub(crate) fn copilot_base_command_resume(
|
|
88
|
+
auth_mode: AuthMode,
|
|
89
|
+
mcp_config: Option<&McpConfig>,
|
|
90
|
+
system_prompt: Option<&str>,
|
|
91
|
+
model: Option<&str>,
|
|
92
|
+
tools: &[&str],
|
|
93
|
+
) -> Vec<String> {
|
|
94
|
+
copilot_base_command(auth_mode, mcp_config, system_prompt, model, tools)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub(crate) fn copilot_dangerous_auto_approve(tools: &[&str]) -> bool {
|
|
98
|
+
tools.contains(&"dangerous_auto_approve")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Granular deny mapping (canonical tool → copilot flag, all via
|
|
102
|
+
/// `--deny-tool <kind>`; help-permissions Tool Permissions has four kinds:
|
|
103
|
+
/// shell/write/mcp/url):
|
|
104
|
+
/// execute_bash ∉ allowed → `--deny-tool 'shell'`
|
|
105
|
+
/// fs_write ∉ allowed → `--deny-tool 'write'`
|
|
106
|
+
/// network ∉ allowed → `--deny-tool 'url'`
|
|
107
|
+
/// fs_read/fs_list have no copilot deny kind (honestly prompt_only).
|
|
108
|
+
pub(crate) fn copilot_permission_flags(tools: &[&str]) -> Vec<String> {
|
|
109
|
+
let mut flags = Vec::new();
|
|
110
|
+
if !tools.contains(&"execute_bash") {
|
|
111
|
+
flags.push("--deny-tool".to_string());
|
|
112
|
+
flags.push("shell".to_string());
|
|
113
|
+
}
|
|
114
|
+
if !tools.contains(&"fs_write") {
|
|
115
|
+
flags.push("--deny-tool".to_string());
|
|
116
|
+
flags.push("write".to_string());
|
|
117
|
+
}
|
|
118
|
+
if !tools.contains(&"network") {
|
|
119
|
+
// `--deny-tool 'url'` (omit domain → match all URLs).
|
|
120
|
+
flags.push("--deny-tool".to_string());
|
|
121
|
+
flags.push("url".to_string());
|
|
122
|
+
}
|
|
123
|
+
flags
|
|
124
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//! Fake provider — scripted worker built into the team-agent CLI itself.
|
|
2
|
+
//! Extracted from `provider/adapter.rs` (0.4.x decoupling step 2).
|
|
3
|
+
|
|
4
|
+
pub(crate) fn fake_worker_command() -> Vec<String> {
|
|
5
|
+
let exe = std::env::current_exe()
|
|
6
|
+
.ok()
|
|
7
|
+
.and_then(|p| p.into_os_string().into_string().ok())
|
|
8
|
+
.unwrap_or_else(|| "team-agent".to_string());
|
|
9
|
+
vec![
|
|
10
|
+
exe,
|
|
11
|
+
"fake-worker".to_string(),
|
|
12
|
+
"--workspace".to_string(),
|
|
13
|
+
"{workspace}".to_string(),
|
|
14
|
+
"--agent-id".to_string(),
|
|
15
|
+
"{agent_id}".to_string(),
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//! Provider-local adapter implementations. Split from the monolithic
|
|
2
|
+
//! `provider/adapter.rs` as 0.4.x decoupling step 2. Each file owns its
|
|
3
|
+
//! provider's command builders, permission/sandbox/auth helpers, and
|
|
4
|
+
//! anything else that varies by provider but doesn't need shared
|
|
5
|
+
//! capture/scan utilities. The `ProviderAdapter` trait and the
|
|
6
|
+
//! `BasicProviderAdapter` registry stay in `adapter.rs`; the trait impl
|
|
7
|
+
//! dispatches into these helpers exactly as the inline forms did.
|
|
8
|
+
//!
|
|
9
|
+
//! Per-file scope:
|
|
10
|
+
//! * `claude` — Claude/ClaudeCode argv, dangerous-skip flag, disallowed
|
|
11
|
+
//! tools mapping, auth hint, model passthrough.
|
|
12
|
+
//! * `codex` — Codex argv, profile/sandbox flags, MCP `-c` injection
|
|
13
|
+
//! with 600s tool_timeout, developer-instructions escaping.
|
|
14
|
+
//! * `copilot` — Copilot argv (no-color/no-remote/disable-builtin-mcps),
|
|
15
|
+
//! allow/deny flag matrix, MCP `type→transport` translation, resume
|
|
16
|
+
//! base, weak auth hint.
|
|
17
|
+
//! * `fake` — Built-in scripted worker exec path.
|
|
18
|
+
//!
|
|
19
|
+
//! Behavior is byte-identical to pre-split. Future steps may move
|
|
20
|
+
//! `pre_spawn_adjust_plan`, `profile_env`, `session_backing_probe`, and
|
|
21
|
+
//! `session_candidates` into provider-owned hooks following the same
|
|
22
|
+
//! per-file layout.
|
|
23
|
+
|
|
24
|
+
pub(crate) mod claude;
|
|
25
|
+
pub(crate) mod codex;
|
|
26
|
+
pub(crate) mod copilot;
|
|
27
|
+
pub(crate) mod fake;
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
pub use crate::model::enums::{AuthMode, Provider};
|
|
33
33
|
|
|
34
34
|
pub mod adapter;
|
|
35
|
+
/// 0.4.x decoupling step 2: per-provider command builders + permission/auth
|
|
36
|
+
/// helpers split out of `adapter.rs`. Pure extraction — behavior unchanged.
|
|
37
|
+
pub(crate) mod adapters;
|
|
35
38
|
pub mod approvals;
|
|
36
39
|
pub mod classify;
|
|
37
40
|
pub mod command;
|
|
@@ -40,6 +43,9 @@ pub mod session;
|
|
|
40
43
|
pub mod session_scan;
|
|
41
44
|
pub mod startup_prompt;
|
|
42
45
|
pub mod types;
|
|
46
|
+
/// 0.4.x: provider wire-format helpers — single source of truth for
|
|
47
|
+
/// `Provider` ↔ string conversions (replaces 7 hand-rolled match copies).
|
|
48
|
+
pub(crate) mod wire;
|
|
43
49
|
// helpers 全部原为模块私有(JSONL 解析 / 正则编译),非根可见 → 私有 mod,不参与 re-export。
|
|
44
50
|
mod helpers;
|
|
45
51
|
|
|
@@ -296,7 +296,24 @@ where
|
|
|
296
296
|
.get("rollout_path")
|
|
297
297
|
.and_then(Value::as_str)
|
|
298
298
|
.is_some_and(|s| !s.is_empty());
|
|
299
|
-
|
|
299
|
+
// S1-CAPTURE-001 (0.4.8): a pending_session_id that differs from the
|
|
300
|
+
// on-row session_id means a fresh respawn is in flight — the prior
|
|
301
|
+
// worker's session_id may still be on the row (concurrent capture race,
|
|
302
|
+
// delayed persist, or persist-backfill replay) but the NEW worker has
|
|
303
|
+
// not yet been bound. Stamping capture_state=captured here would
|
|
304
|
+
// falsely declare the OLD session authoritative for the NEW worker
|
|
305
|
+
// pane — the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001
|
|
306
|
+
// gate evidence. Only stamp `captured` when session_id agrees with
|
|
307
|
+
// _pending_session_id (or _pending is absent, meaning no rebind in
|
|
308
|
+
// flight).
|
|
309
|
+
let pending_mismatch = match (
|
|
310
|
+
agent_obj.get("_pending_session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
|
|
311
|
+
agent_obj.get("session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
|
|
312
|
+
) {
|
|
313
|
+
(Some(pending), Some(current)) => pending != current,
|
|
314
|
+
_ => false,
|
|
315
|
+
};
|
|
316
|
+
if has_session && !pending_mismatch {
|
|
300
317
|
// Captured already — fix state field if drifted.
|
|
301
318
|
if agent_obj
|
|
302
319
|
.get("capture_state")
|
|
@@ -684,6 +701,29 @@ fn agent_session_complete(agent: &Value) -> bool {
|
|
|
684
701
|
if !session_id_ok {
|
|
685
702
|
return false;
|
|
686
703
|
}
|
|
704
|
+
// S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): pending mismatch guard.
|
|
705
|
+
// When fresh-start has written a new `_pending_session_id` but the old
|
|
706
|
+
// authoritative `session_id` is still present and differs, the tuple is
|
|
707
|
+
// STALE — the new process has a different session that has not been
|
|
708
|
+
// captured yet. Returning true here would let capture skip this agent
|
|
709
|
+
// and keep treating the old transcript as authoritative (the gate's
|
|
710
|
+
// observed mis-attribution). Force re-capture by reporting incomplete.
|
|
711
|
+
// The fresh-tuple clear in mark_agent_started (lifecycle/restart/agent.rs)
|
|
712
|
+
// is the primary fix; this guard handles historical poison state where
|
|
713
|
+
// both fields already coexist from a prior version.
|
|
714
|
+
let pending = agent
|
|
715
|
+
.get("_pending_session_id")
|
|
716
|
+
.and_then(Value::as_str)
|
|
717
|
+
.filter(|s| !s.is_empty());
|
|
718
|
+
let current = agent
|
|
719
|
+
.get("session_id")
|
|
720
|
+
.and_then(Value::as_str)
|
|
721
|
+
.filter(|s| !s.is_empty());
|
|
722
|
+
if let (Some(pending), Some(current)) = (pending, current) {
|
|
723
|
+
if pending != current {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
687
727
|
let rollout_path = match agent
|
|
688
728
|
.get("rollout_path")
|
|
689
729
|
.and_then(Value::as_str)
|
|
@@ -1002,6 +1042,16 @@ fn apply_captured_session(
|
|
|
1002
1042
|
serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
|
|
1003
1043
|
);
|
|
1004
1044
|
agent_obj.remove("attribution_ambiguous");
|
|
1045
|
+
// S1-CAPTURE-001 (0.4.8): after writing the authoritative tuple, the
|
|
1046
|
+
// `_pending_session_id` placeholder is no longer needed — remove it so
|
|
1047
|
+
// future reads don't trip the pending-mismatch guard in
|
|
1048
|
+
// agent_session_complete. Also stamp capture_state="captured" for
|
|
1049
|
+
// diagnose/status observability.
|
|
1050
|
+
agent_obj.remove("_pending_session_id");
|
|
1051
|
+
agent_obj.insert(
|
|
1052
|
+
"capture_state".to_string(),
|
|
1053
|
+
serde_json::json!("captured"),
|
|
1054
|
+
);
|
|
1005
1055
|
true
|
|
1006
1056
|
}
|
|
1007
1057
|
|
|
@@ -1784,4 +1834,84 @@ mod u1_tests {
|
|
|
1784
1834
|
let _ = std::fs::remove_dir_all(workspace.join(".team"));
|
|
1785
1835
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
1786
1836
|
}
|
|
1837
|
+
|
|
1838
|
+
/// S1-CAPTURE-001 (0.4.8): pending mismatch guard. When fresh-start has
|
|
1839
|
+
/// written a new `_pending_session_id` but the old authoritative
|
|
1840
|
+
/// `session_id` is still present and differs, `agent_session_complete`
|
|
1841
|
+
/// must return false so capture re-runs and re-binds to the new process.
|
|
1842
|
+
/// Without this, the historical poison state (old + new coexist) lets
|
|
1843
|
+
/// the stale rollout look authoritative.
|
|
1844
|
+
#[test]
|
|
1845
|
+
fn agent_session_complete_returns_false_on_pending_mismatch() {
|
|
1846
|
+
let dir = std::env::temp_dir().join(format!(
|
|
1847
|
+
"ta-pending-mismatch-{}",
|
|
1848
|
+
std::process::id()
|
|
1849
|
+
));
|
|
1850
|
+
let _ = std::fs::create_dir_all(&dir);
|
|
1851
|
+
let rollout = dir.join("old.jsonl");
|
|
1852
|
+
std::fs::write(&rollout, "{\"type\":\"assistant\"}\n").unwrap();
|
|
1853
|
+
|
|
1854
|
+
let agent = serde_json::json!({
|
|
1855
|
+
"provider": "claude",
|
|
1856
|
+
"session_id": "old",
|
|
1857
|
+
"rollout_path": rollout.to_string_lossy(),
|
|
1858
|
+
"captured_at": "2026-01-01T00:00:00+00:00",
|
|
1859
|
+
"captured_via": "fs_watch",
|
|
1860
|
+
"_pending_session_id": "new",
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
assert!(
|
|
1864
|
+
!super::agent_session_complete(&agent),
|
|
1865
|
+
"S1-CAPTURE-001: pending != session_id must force incomplete \
|
|
1866
|
+
(capture must re-attribute to new session)"
|
|
1867
|
+
);
|
|
1868
|
+
|
|
1869
|
+
// Sanity: when they match, complete is true.
|
|
1870
|
+
let agent_match = serde_json::json!({
|
|
1871
|
+
"provider": "claude",
|
|
1872
|
+
"session_id": "new",
|
|
1873
|
+
"rollout_path": rollout.to_string_lossy(),
|
|
1874
|
+
"captured_at": "2026-01-01T00:00:00+00:00",
|
|
1875
|
+
"captured_via": "fs_watch",
|
|
1876
|
+
"_pending_session_id": "new",
|
|
1877
|
+
});
|
|
1878
|
+
assert!(
|
|
1879
|
+
super::agent_session_complete(&agent_match),
|
|
1880
|
+
"pending == session_id (in-progress capture confirmed): must be complete"
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
let _ = std::fs::remove_file(&rollout);
|
|
1884
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/// S1-CAPTURE-001 (0.4.8): apply_captured_session clears
|
|
1888
|
+
/// `_pending_session_id` and stamps `capture_state="captured"` so the
|
|
1889
|
+
/// pending-mismatch guard doesn't fire on subsequent reads.
|
|
1890
|
+
#[test]
|
|
1891
|
+
fn apply_captured_session_clears_pending_and_stamps_capture_state() {
|
|
1892
|
+
let mut agent = serde_json::Map::new();
|
|
1893
|
+
agent.insert(
|
|
1894
|
+
"_pending_session_id".to_string(),
|
|
1895
|
+
serde_json::json!("new-sess"),
|
|
1896
|
+
);
|
|
1897
|
+
let captured = CapturedSession {
|
|
1898
|
+
session_id: Some(SessionId::new("new-sess")),
|
|
1899
|
+
rollout_path: Some(RolloutPath::new(PathBuf::from("/tmp/new.jsonl"))),
|
|
1900
|
+
captured_via: CaptureVia::FsWatch,
|
|
1901
|
+
attribution_confidence: Confidence::High,
|
|
1902
|
+
spawn_cwd: PathBuf::from("/tmp/cwd"),
|
|
1903
|
+
};
|
|
1904
|
+
let written = super::apply_captured_session(&mut agent, &captured);
|
|
1905
|
+
assert!(written, "apply must return true for valid captured");
|
|
1906
|
+
assert!(
|
|
1907
|
+
agent.get("_pending_session_id").is_none(),
|
|
1908
|
+
"S1-CAPTURE-001: _pending_session_id must be removed after capture \
|
|
1909
|
+
(agent={agent:?})"
|
|
1910
|
+
);
|
|
1911
|
+
assert_eq!(
|
|
1912
|
+
agent.get("capture_state").and_then(serde_json::Value::as_str),
|
|
1913
|
+
Some("captured"),
|
|
1914
|
+
"S1-CAPTURE-001: capture_state must be stamped 'captured'"
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1787
1917
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//! Provider wire-format helpers — the single source of truth for converting
|
|
2
|
+
//! `Provider` ↔ string forms used across state JSON, CLI args, log fields,
|
|
3
|
+
//! and the workers we shell out to.
|
|
4
|
+
//!
|
|
5
|
+
//! This module replaces 7 hand-rolled copies of these match arms that lived
|
|
6
|
+
//! in `leader/helpers.rs`, `coordinator/tick.rs` (×2), `lifecycle/restart/common.rs`,
|
|
7
|
+
//! `lifecycle/profile_launch.rs`, `lifecycle/launch.rs`, `provider/adapter.rs`,
|
|
8
|
+
//! and `lifecycle/restart/rebuild.rs`. Centralising them removes the kind of
|
|
9
|
+
//! drift that left `restart/rebuild.rs` with a `claude-code` alias the other
|
|
10
|
+
//! sites silently dropped, and shrinks the surface area when a new provider
|
|
11
|
+
//! is added.
|
|
12
|
+
//!
|
|
13
|
+
//! Wire-format invariants (do not change without a coordinated migration):
|
|
14
|
+
//! * `provider_wire(Provider)` returns the canonical snake_case string used
|
|
15
|
+
//! in state JSON. These values are persisted on disk and read by older
|
|
16
|
+
//! runtimes — changing them is a breaking change.
|
|
17
|
+
//! * `parse_provider(&str)` accepts the canonical form AND every historical
|
|
18
|
+
//! alias we have ever written. New aliases go in `aliases()` and become
|
|
19
|
+
//! a parse target automatically.
|
|
20
|
+
//! * `command_name(Provider)` returns the binary name we exec for that
|
|
21
|
+
//! provider. `Claude` and `ClaudeCode` both shell out to `claude` —
|
|
22
|
+
//! they are distinct state values but share an executable.
|
|
23
|
+
|
|
24
|
+
use crate::model::enums::Provider;
|
|
25
|
+
|
|
26
|
+
/// Canonical snake_case wire string for `Provider`. Used for state JSON,
|
|
27
|
+
/// log fields, and any cross-process serialization. Stable: do not change
|
|
28
|
+
/// existing values.
|
|
29
|
+
pub(crate) fn provider_wire(provider: Provider) -> &'static str {
|
|
30
|
+
match provider {
|
|
31
|
+
Provider::Claude => "claude",
|
|
32
|
+
Provider::ClaudeCode => "claude_code",
|
|
33
|
+
Provider::Codex => "codex",
|
|
34
|
+
Provider::Copilot => "copilot",
|
|
35
|
+
Provider::GeminiCli => "gemini_cli",
|
|
36
|
+
Provider::Fake => "fake",
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Parse a wire string back to `Provider`. Accepts the canonical
|
|
41
|
+
/// `provider_wire` output AND every historical alias listed by `aliases()`.
|
|
42
|
+
/// Returns `None` for unknown strings.
|
|
43
|
+
pub(crate) fn parse_provider(raw: &str) -> Option<Provider> {
|
|
44
|
+
for &provider in ALL_PROVIDERS {
|
|
45
|
+
for alias in aliases(provider) {
|
|
46
|
+
if *alias == raw {
|
|
47
|
+
return Some(provider);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
None
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// All aliases (including the canonical wire form) that parse to `provider`.
|
|
55
|
+
/// The canonical wire form is always the first entry, so callers that need
|
|
56
|
+
/// the "primary" string can take `aliases(p)[0]` — though most code should
|
|
57
|
+
/// use `provider_wire(p)` directly for clarity.
|
|
58
|
+
pub(crate) fn aliases(provider: Provider) -> &'static [&'static str] {
|
|
59
|
+
match provider {
|
|
60
|
+
Provider::Claude => &["claude"],
|
|
61
|
+
// `claude-code` (kebab) is a historical alias that appeared in
|
|
62
|
+
// restart/rebuild.rs:1186-1194 only. Keeping it parseable means
|
|
63
|
+
// legacy state files still load.
|
|
64
|
+
Provider::ClaudeCode => &["claude_code", "claude-code"],
|
|
65
|
+
Provider::Codex => &["codex"],
|
|
66
|
+
Provider::Copilot => &["copilot"],
|
|
67
|
+
Provider::GeminiCli => &["gemini_cli"],
|
|
68
|
+
Provider::Fake => &["fake"],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Executable name we exec for the worker. `Claude` and `ClaudeCode` both
|
|
73
|
+
/// resolve to `claude` — the distinction is in state semantics
|
|
74
|
+
/// (`ClaudeCode` is the canonical CLI; `Claude` is a legacy alias kept for
|
|
75
|
+
/// older specs).
|
|
76
|
+
pub(crate) fn command_name(provider: Provider) -> &'static str {
|
|
77
|
+
match provider {
|
|
78
|
+
Provider::Claude | Provider::ClaudeCode => "claude",
|
|
79
|
+
Provider::Codex => "codex",
|
|
80
|
+
Provider::Copilot => "copilot",
|
|
81
|
+
Provider::GeminiCli => "gemini",
|
|
82
|
+
Provider::Fake => "team-agent",
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ALL_PROVIDERS: &[Provider] = &[
|
|
87
|
+
Provider::Claude,
|
|
88
|
+
Provider::ClaudeCode,
|
|
89
|
+
Provider::Codex,
|
|
90
|
+
Provider::Copilot,
|
|
91
|
+
Provider::GeminiCli,
|
|
92
|
+
Provider::Fake,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
#[cfg(test)]
|
|
96
|
+
mod tests {
|
|
97
|
+
use super::*;
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn wire_round_trip_for_every_provider() {
|
|
101
|
+
for &p in ALL_PROVIDERS {
|
|
102
|
+
let wire = provider_wire(p);
|
|
103
|
+
assert_eq!(parse_provider(wire), Some(p), "round-trip {p:?}");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[test]
|
|
108
|
+
fn claude_code_kebab_alias_parses() {
|
|
109
|
+
// Historical alias preserved from restart/rebuild.rs:1186-1194.
|
|
110
|
+
assert_eq!(parse_provider("claude-code"), Some(Provider::ClaudeCode));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[test]
|
|
114
|
+
fn unknown_string_returns_none() {
|
|
115
|
+
assert_eq!(parse_provider(""), None);
|
|
116
|
+
assert_eq!(parse_provider("openai"), None);
|
|
117
|
+
assert_eq!(parse_provider("CLAUDE"), None); // case-sensitive
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[test]
|
|
121
|
+
fn aliases_first_entry_is_canonical_wire() {
|
|
122
|
+
for &p in ALL_PROVIDERS {
|
|
123
|
+
assert_eq!(aliases(p)[0], provider_wire(p), "canonical first for {p:?}");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#[test]
|
|
128
|
+
fn command_name_claude_and_claude_code_both_resolve_to_claude_binary() {
|
|
129
|
+
assert_eq!(command_name(Provider::Claude), "claude");
|
|
130
|
+
assert_eq!(command_name(Provider::ClaudeCode), "claude");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn command_name_covers_every_provider() {
|
|
135
|
+
for &p in ALL_PROVIDERS {
|
|
136
|
+
assert!(!command_name(p).is_empty(), "{p:?} has no command name");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -160,10 +160,14 @@ pub enum CommandScope {
|
|
|
160
160
|
/// Caller passed `--team X` explicitly (or there's only one alive team
|
|
161
161
|
/// and we resolved it). Carries the canonical team_key.
|
|
162
162
|
Resolved(String),
|
|
163
|
-
/// No `--team` and multiple alive teams. Destructive commands
|
|
164
|
-
///
|
|
165
|
-
///
|
|
166
|
-
///
|
|
163
|
+
/// No `--team` and multiple alive teams. Destructive commands AND
|
|
164
|
+
/// selected-team commands (status, etc.) MUST refuse with this list
|
|
165
|
+
/// of candidates so the operator chooses explicitly. Only true
|
|
166
|
+
/// all-team aggregation commands may proceed without --team.
|
|
167
|
+
/// (S4QR-001 0.4.8: status was previously documented as a read-only
|
|
168
|
+
/// command that could proceed; in fact it projects a single team's
|
|
169
|
+
/// view and silently defaulting to the active team caused gate
|
|
170
|
+
/// failures when multiple alive teams existed.)
|
|
167
171
|
Ambiguous(Vec<String>),
|
|
168
172
|
/// No `--team` and no teams alive yet (fresh workspace) — bare
|
|
169
173
|
/// commands fall through to legacy single-team behaviour.
|
|
@@ -560,6 +560,23 @@ fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
|
|
|
560
560
|
if !latest_complete {
|
|
561
561
|
return;
|
|
562
562
|
}
|
|
563
|
+
// S1-CAPTURE-001 (0.4.8) Rule 5: a fresh restart marker (`_pending_session_id`
|
|
564
|
+
// present on incoming) signals the worker just respawned and the prior
|
|
565
|
+
// tuple was intentionally cleared. The capture scanner is the only path
|
|
566
|
+
// permitted to promote `_pending_session_id` into the authoritative tuple
|
|
567
|
+
// after it confirms backing. Persist-side backfill MUST NOT revive the
|
|
568
|
+
// latest tuple in this state — doing so resurrects the previous worker's
|
|
569
|
+
// session/rollout pair and delivered tokens land in the OLD transcript
|
|
570
|
+
// (the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001 gate
|
|
571
|
+
// evidence). Refuse backfill regardless of whether incoming session_id
|
|
572
|
+
// is null vs latest's value: the pending marker is authoritative intent.
|
|
573
|
+
let incoming_pending = incoming_row
|
|
574
|
+
.get("_pending_session_id")
|
|
575
|
+
.and_then(Value::as_str)
|
|
576
|
+
.filter(|s| !s.is_empty());
|
|
577
|
+
if incoming_pending.is_some() {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
563
580
|
// Rule 2: incoming carries a DIFFERENT non-null session_id → do not mix.
|
|
564
581
|
let incoming_session = incoming_row.get("session_id").and_then(Value::as_str);
|
|
565
582
|
let latest_session = latest_agent.get("session_id").and_then(Value::as_str);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.4.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.4.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.4.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.4.8",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.4.8",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.4.8"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|
|
@@ -18,6 +18,12 @@ team-agent claude
|
|
|
18
18
|
|
|
19
19
|
Pass provider flags after the provider name, for example `team-agent codex --dangerously-bypass-approvals-and-sandbox`. Existing tmux layouts are valid too, including Finder/Ghostty launchers, as long as `team-agent quick-start` is invoked from the leader's current tmux pane. Do not start a real team from a naked terminal that Team Agent cannot address through tmux.
|
|
20
20
|
|
|
21
|
+
## Leader Role
|
|
22
|
+
|
|
23
|
+
Invoking this skill turns the current agent into the team leader. The leader **orchestrates**: read reports, set direction, decompose work, dispatch tasks to teammates, review results, and decide. The leader does **not** execute hands-on work — no `cargo test`, no product-code edits, no `git push`, no build/verify cycles. Those belong to teammates. If the leader catches themselves running tests, editing source files, or pushing commits, they have stepped out of role; stop and re-dispatch.
|
|
24
|
+
|
|
25
|
+
When the user has been communicating in Chinese throughout the conversation, all leader↔teammate messaging (`send`, `report_result`, MCP messages, task descriptions) must also be in Chinese. The leader dispatches in Chinese, the worker reports back in Chinese. Switch back to the user's language only at the user-facing boundary.
|
|
26
|
+
|
|
21
27
|
## Minimal Copy-Paste Team
|
|
22
28
|
|
|
23
29
|
```bash
|