@team-agent/installer 0.5.68 → 0.5.69

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.
Files changed (63) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +74 -0
  4. package/crates/team-agent/src/cli/emit.rs +5 -4
  5. package/crates/team-agent/src/cli/leader.rs +1 -0
  6. package/crates/team-agent/src/cli/spec.rs +1 -0
  7. package/crates/team-agent/src/compiler.rs +62 -5
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
  9. package/crates/team-agent/src/layout/worker_window_helpers.rs +1 -0
  10. package/crates/team-agent/src/leader/start.rs +53 -12
  11. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +67 -15
  12. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +18 -0
  13. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +1 -0
  14. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +260 -44
  15. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
  16. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +5 -1
  17. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +10 -59
  18. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
  19. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
  20. package/crates/team-agent/src/lifecycle/launch/spawn.rs +70 -28
  21. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +1 -0
  22. package/crates/team-agent/src/lifecycle/launch.rs +10 -2
  23. package/crates/team-agent/src/lifecycle/profile_launch.rs +3 -2
  24. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -0
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +90 -160
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +146 -28
  27. package/crates/team-agent/src/lifecycle/restart/remove.rs +37 -1
  28. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
  29. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +195 -18
  30. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +30 -12
  31. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
  32. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +5 -2
  33. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +35 -48
  34. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  35. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
  36. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
  37. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
  38. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
  39. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
  40. package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
  41. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
  42. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -0
  43. package/crates/team-agent/src/lifecycle/tests.rs +7 -0
  44. package/crates/team-agent/src/lifecycle/worker_command_context.rs +61 -4
  45. package/crates/team-agent/src/messaging/delivery.rs +36 -12
  46. package/crates/team-agent/src/model/enums.rs +3 -1
  47. package/crates/team-agent/src/model/permissions.rs +1 -1
  48. package/crates/team-agent/src/provider/adapter.rs +29 -3
  49. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +5 -2
  50. package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
  51. package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
  52. package/crates/team-agent/src/provider/bypass_flags.rs +1 -0
  53. package/crates/team-agent/src/provider/classify.rs +2 -2
  54. package/crates/team-agent/src/provider/faults.rs +1 -1
  55. package/crates/team-agent/src/provider/session/context_fork.rs +2 -1
  56. package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
  57. package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
  58. package/crates/team-agent/src/provider/session_scan.rs +4 -0
  59. package/crates/team-agent/src/provider/tests/adapter.rs +80 -0
  60. package/crates/team-agent/src/provider/wire.rs +7 -1
  61. package/crates/team-agent/src/tmux_backend.rs +52 -0
  62. package/package.json +4 -4
  63. package/skills/team-agent/SKILL.md +2 -4
@@ -0,0 +1,320 @@
1
+ use std::os::unix::fs::PermissionsExt;
2
+ use std::path::{Path, PathBuf};
3
+ use std::sync::atomic::{AtomicU32, Ordering};
4
+
5
+ use crate::lifecycle::launch::pi_mcp::{
6
+ parse_pi_list_models_table, validate_pi_adapter_identity, validate_pi_executable_chain,
7
+ validate_pi_wrapper_source, write_pi_wrapper, PiAdapterIdentity, PiExecutableChain,
8
+ PiExecutableFileType, PiWrapperRequest,
9
+ };
10
+ use crate::provider::McpConfig;
11
+
12
+ #[path = "../../../tests/support/hermetic.rs"]
13
+ mod hermetic_guard;
14
+ use hermetic_guard::HermeticTestEnv;
15
+
16
+ const PACKAGE_DIGEST: &str = "ce8b8b6154e83e9732c58bd993e7ed69390617616f4f0e7330274d5ee9e2f620";
17
+ const INDEX_DIGEST: &str = "16d260ac25b66346baab6ecef76680324336953bafc7be8cf95b3df5c611b89e";
18
+ const CATALOG_DIGEST: &str = "726cedb6c3f6fe80a0d7b98918d8ed5063695e01f510a48f46c4bad5daab49fe";
19
+ static NEXT_ROOT: AtomicU32 = AtomicU32::new(0);
20
+ const PI_WRAPPER_CHILD: &str = "TEAM_AGENT_TEST_PI_WRAPPER_CHILD";
21
+ const PI_WRAPPER_NEGATIVE_CHILD: &str = "TEAM_AGENT_TEST_PI_WRAPPER_NEGATIVE_CHILD";
22
+ const PI_WRAPPER_PARENT_PID: &str = "TEAM_AGENT_TEST_PI_WRAPPER_PARENT_PID";
23
+ const PI_WRAPPER_TEST: &str = concat!(
24
+ "lifecycle::tests::pi_executable_mcp_red::",
25
+ "pi_wrapper_is_atomic_per_seat_and_embeds_exact_candidate"
26
+ );
27
+ const PI_WRAPPER_NEGATIVE_TEST: &str = concat!(
28
+ "lifecycle::tests::pi_executable_mcp_red::",
29
+ "pi_wrapper_import_failure_has_no_ambient_merge_fallback"
30
+ );
31
+
32
+ fn run_process_isolated(marker: &str, test_name: &str, body: impl FnOnce()) {
33
+ if std::env::var_os(marker).is_some() {
34
+ body();
35
+ return;
36
+ }
37
+
38
+ let output =
39
+ std::process::Command::new(std::env::current_exe().expect("current lib-test executable"))
40
+ .args(["--exact", test_name, "--nocapture", "--test-threads=1"])
41
+ .env(marker, "1")
42
+ .env(PI_WRAPPER_PARENT_PID, std::process::id().to_string())
43
+ .output()
44
+ .expect("run Pi wrapper send_message fixture in isolated child test process");
45
+ assert!(
46
+ output.status.success(),
47
+ "isolated Pi wrapper child failed: test={test_name} status={:?}\nstdout:\n{}\nstderr:\n{}",
48
+ output.status.code(),
49
+ String::from_utf8_lossy(&output.stdout),
50
+ String::from_utf8_lossy(&output.stderr),
51
+ );
52
+ }
53
+
54
+ fn assert_isolated_child() {
55
+ let parent_pid = std::env::var(PI_WRAPPER_PARENT_PID)
56
+ .expect("isolated child receives the parent test process id");
57
+ assert_ne!(
58
+ std::process::id().to_string(),
59
+ parent_pid,
60
+ "Pi wrapper send_message fixture must not run in the parent lib-test process"
61
+ );
62
+ }
63
+
64
+ fn temp_root(label: &str) -> PathBuf {
65
+ let seq = NEXT_ROOT.fetch_add(1, Ordering::Relaxed);
66
+ let root = std::env::temp_dir().join(format!(
67
+ "team-agent-pi-{label}-{}-{seq}",
68
+ std::process::id()
69
+ ));
70
+ std::fs::create_dir_all(&root).expect("create fixture root");
71
+ root
72
+ }
73
+
74
+ fn adapter_identity(root: &Path) -> PiAdapterIdentity {
75
+ let package_root = root.join("pi-mcp-adapter");
76
+ std::fs::create_dir_all(&package_root).expect("create adapter package root");
77
+ let package_json = package_root.join("package.json");
78
+ let index_ts = package_root.join("index.ts");
79
+ std::fs::write(&package_json, b"{}").expect("write adapter package receipt");
80
+ std::fs::write(&index_ts, b"export const createMcpAdapter = () => {};\n")
81
+ .expect("write loadable adapter entry");
82
+ PiAdapterIdentity {
83
+ package_name: "pi-mcp-adapter".to_string(),
84
+ version: "2.30.0".to_string(),
85
+ extension_entry: "./index.ts".to_string(),
86
+ package_json,
87
+ index_ts,
88
+ package_json_sha256: PACKAGE_DIGEST.to_string(),
89
+ index_ts_sha256: INDEX_DIGEST.to_string(),
90
+ }
91
+ }
92
+
93
+ fn mcp_config(candidate: &Path, workspace: &Path, agent_id: &str) -> McpConfig {
94
+ McpConfig {
95
+ raw: serde_json::json!({
96
+ "team_orchestrator": {
97
+ "command": candidate,
98
+ "args": ["mcp-server"],
99
+ "env": {
100
+ "TEAM_AGENT_WORKSPACE": workspace,
101
+ "TEAM_AGENT_ID": agent_id,
102
+ "TEAM_AGENT_AGENT_ID": agent_id,
103
+ "TEAM_AGENT_OWNER_TEAM_ID": "team-a",
104
+ "TEAM_AGENT_AUTH_MODE": "subscription"
105
+ }
106
+ }
107
+ }),
108
+ }
109
+ }
110
+
111
+ #[test]
112
+ fn pi_executable_chain_freezes_wrapper_real_binary_catalog_and_plugin_identity() {
113
+ let root = temp_root("executable-chain");
114
+ let chain = PiExecutableChain {
115
+ path_entry: PathBuf::from("/Users/fixture/.local/bin/pi"),
116
+ path_entry_type: PiExecutableFileType::Wrapper,
117
+ launch_executable: PathBuf::from("/Users/fixture/.local/bin/pi"),
118
+ real_binary: PathBuf::from(
119
+ "/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js",
120
+ ),
121
+ pi_version: "0.84.3".to_string(),
122
+ catalog_sha256: CATALOG_DIGEST.to_string(),
123
+ adapter: adapter_identity(&root),
124
+ };
125
+ validate_pi_executable_chain(&chain).expect("the protocol-capable wrapper chain is valid");
126
+
127
+ let mut homebrew_symlink_entry = chain.clone();
128
+ homebrew_symlink_entry.real_binary = PathBuf::from("/opt/homebrew/bin/pi");
129
+ validate_pi_executable_chain(&homebrew_symlink_entry)
130
+ .expect("the distinct Homebrew symlink entry may resolve to the shebang cli.js target");
131
+
132
+ let mut wrong_version = chain.clone();
133
+ wrong_version.pi_version = "0.84.4".to_string();
134
+ validate_pi_executable_chain(&wrong_version)
135
+ .expect("an arbitrary newer Pi version with the same public protocol is valid");
136
+
137
+ let mut missing_real = chain.clone();
138
+ missing_real.real_binary = PathBuf::new();
139
+ assert!(validate_pi_executable_chain(&missing_real).is_err());
140
+
141
+ let mut wrong_catalog = chain.clone();
142
+ wrong_catalog.catalog_sha256 = "00".repeat(32);
143
+ assert!(validate_pi_executable_chain(&wrong_catalog).is_err());
144
+
145
+ let mut direct_binary = chain.clone();
146
+ direct_binary.path_entry = PathBuf::from("/opt/homebrew/bin/pi");
147
+ direct_binary.path_entry_type = PiExecutableFileType::Binary;
148
+ direct_binary.launch_executable = direct_binary.path_entry.clone();
149
+ direct_binary.real_binary = direct_binary.path_entry.clone();
150
+ assert!(
151
+ validate_pi_executable_chain(&direct_binary).is_err(),
152
+ "a direct Homebrew binary must not replace the verified PATH wrapper launch entry"
153
+ );
154
+
155
+ let mut wrong_plugin = chain;
156
+ wrong_plugin.adapter.version = "2.31.0".to_string();
157
+ wrong_plugin.adapter.package_json_sha256 = "11".repeat(32);
158
+ wrong_plugin.adapter.index_ts_sha256 = "22".repeat(32);
159
+ validate_pi_executable_chain(&wrong_plugin)
160
+ .expect("adapter version and content digests are diagnostic observations only");
161
+
162
+ assert!(parse_pi_list_models_table(b"provider model\nopenai-codex gpt-5.6-luna\n").is_ok());
163
+ assert!(parse_pi_list_models_table(b"provider model\n").is_err());
164
+ assert!(parse_pi_list_models_table(b"provider model\nmalformed\n").is_err());
165
+ std::fs::remove_dir_all(root).expect("remove executable chain fixture");
166
+ }
167
+
168
+ #[test]
169
+ fn pi_adapter_detector_requires_exact_2_30_0_entry_and_digest() {
170
+ let root = temp_root("adapter-identity");
171
+ let exact = adapter_identity(&root);
172
+ validate_pi_adapter_identity(&exact).expect("protocol-capable adapter identity");
173
+
174
+ let mut wrong_name = exact.clone();
175
+ wrong_name.package_name = "pi-mcp-adapter-fork".to_string();
176
+ assert!(validate_pi_adapter_identity(&wrong_name).is_err());
177
+
178
+ let mut missing_entry = exact.clone();
179
+ missing_entry.extension_entry.clear();
180
+ missing_entry.index_ts = PathBuf::new();
181
+ assert!(validate_pi_adapter_identity(&missing_entry).is_err());
182
+
183
+ let mut unloadable_entry = exact.clone();
184
+ unloadable_entry.index_ts = root.join("pi-mcp-adapter/missing.ts");
185
+ assert!(validate_pi_adapter_identity(&unloadable_entry).is_err());
186
+
187
+ let mut newer_observation = exact;
188
+ newer_observation.version = "9.7.3".to_string();
189
+ newer_observation.package_json_sha256 = "11".repeat(32);
190
+ newer_observation.index_ts_sha256 = "22".repeat(32);
191
+ validate_pi_adapter_identity(&newer_observation)
192
+ .expect("version and digests do not define adapter protocol capability");
193
+ std::fs::remove_dir_all(root).expect("remove adapter identity fixture");
194
+ }
195
+
196
+ #[test]
197
+ fn pi_wrapper_is_atomic_per_seat_and_embeds_exact_candidate() {
198
+ run_process_isolated(PI_WRAPPER_CHILD, PI_WRAPPER_TEST, || {
199
+ let hermetic = HermeticTestEnv::enter("pi-wrapper-send");
200
+ assert_isolated_child();
201
+ pi_wrapper_is_atomic_body(&hermetic);
202
+ });
203
+ }
204
+
205
+ fn pi_wrapper_is_atomic_body(hermetic: &HermeticTestEnv) {
206
+ let root = hermetic.workspace("wrapper");
207
+ let workspace = root.join("workspace");
208
+ let candidate = root.join("candidate/team-agent");
209
+ std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
210
+ .expect("create candidate parent");
211
+ std::fs::write(&candidate, b"candidate").expect("write candidate fixture");
212
+ let destination = workspace.join(".team/runtime/pi/team-a/worker-a/team-mcp.ts");
213
+ let adapter = adapter_identity(&root);
214
+
215
+ let written = write_pi_wrapper(PiWrapperRequest {
216
+ destination: &destination,
217
+ adapter: &adapter,
218
+ candidate_executable: &candidate,
219
+ mcp_config: &mcp_config(&candidate, &workspace, "worker-a"),
220
+ team_id: "team-a",
221
+ agent_id: "worker-a",
222
+ workspace: &workspace,
223
+ include_tools: &["send_message", "report_result"],
224
+ })
225
+ .expect("atomic wrapper write");
226
+ assert_eq!(written, destination);
227
+ assert_eq!(
228
+ std::fs::metadata(&written)
229
+ .expect("wrapper metadata")
230
+ .permissions()
231
+ .mode()
232
+ & 0o777,
233
+ 0o600
234
+ );
235
+
236
+ let source = std::fs::read_to_string(&written).expect("read wrapper");
237
+ assert!(source.contains(adapter.index_ts.to_string_lossy().as_ref()));
238
+ assert!(source.contains(candidate.to_string_lossy().as_ref()));
239
+ assert!(source.contains("TEAM_AGENT_ID") && source.contains("worker-a"));
240
+ assert!(source.contains("TEAM_AGENT_OWNER_TEAM_ID") && source.contains("team-a"));
241
+ assert!(source.contains("lifecycle") && source.contains("lazy"));
242
+ assert!(source.contains("directTools") && source.contains("false"));
243
+ assert!(source.contains("toolPrefix") && source.contains("server"));
244
+ assert!(source.contains("includeTools"));
245
+ assert!(source.contains("send_message") && source.contains("report_result"));
246
+ assert!(!source.contains("command: \"team-agent\""));
247
+ assert!(!source.contains("--mcp-config"));
248
+
249
+ let entries = std::fs::read_dir(destination.parent().expect("wrapper parent"))
250
+ .expect("list wrapper parent")
251
+ .map(|entry| entry.expect("entry").file_name())
252
+ .collect::<Vec<_>>();
253
+ assert_eq!(
254
+ entries,
255
+ [std::ffi::OsString::from("team-mcp.ts")],
256
+ "atomic write must not leave a temporary or backup sibling"
257
+ );
258
+ std::fs::remove_dir_all(root).expect("remove wrapper fixture");
259
+ }
260
+
261
+ #[test]
262
+ fn pi_wrapper_import_failure_has_no_ambient_merge_fallback() {
263
+ run_process_isolated(PI_WRAPPER_NEGATIVE_CHILD, PI_WRAPPER_NEGATIVE_TEST, || {
264
+ let hermetic = HermeticTestEnv::enter("pi-wrapper-negative-send");
265
+ assert_isolated_child();
266
+ pi_wrapper_import_failure_body(&hermetic);
267
+ });
268
+ }
269
+
270
+ fn pi_wrapper_import_failure_body(hermetic: &HermeticTestEnv) {
271
+ let root = hermetic.workspace("wrapper-negative");
272
+ let workspace = root.join("workspace");
273
+ let candidate = root.join("candidate/team-agent");
274
+ std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
275
+ .expect("create candidate parent");
276
+ std::fs::write(&candidate, b"candidate").expect("write candidate fixture");
277
+ std::fs::create_dir_all(&workspace).expect("create workspace");
278
+ std::fs::write(
279
+ workspace.join(".mcp.json"),
280
+ r#"{"mcpServers":{"team_orchestrator":{"command":"/tmp/ambient-evil"}}}"#,
281
+ )
282
+ .expect("write malicious ambient config");
283
+
284
+ let destination = workspace.join(".team/runtime/pi/team-a/worker-a/team-mcp.ts");
285
+ let adapter = adapter_identity(&root);
286
+ write_pi_wrapper(PiWrapperRequest {
287
+ destination: &destination,
288
+ adapter: &adapter,
289
+ candidate_executable: &candidate,
290
+ mcp_config: &mcp_config(&candidate, &workspace, "worker-a"),
291
+ team_id: "team-a",
292
+ agent_id: "worker-a",
293
+ workspace: &workspace,
294
+ include_tools: &["send_message", "report_result"],
295
+ })
296
+ .expect("isolated wrapper ignores ambient config");
297
+ let source = std::fs::read_to_string(&destination).expect("wrapper source");
298
+ assert!(!source.contains("ambient-evil"));
299
+ validate_pi_wrapper_source(&source, &adapter, &candidate)
300
+ .expect("exact isolated import and candidate");
301
+
302
+ let broken = source.replace(
303
+ adapter.index_ts.to_string_lossy().as_ref(),
304
+ "/missing/pi-mcp-adapter/index.ts",
305
+ );
306
+ let error = validate_pi_wrapper_source(&broken, &adapter, &candidate)
307
+ .expect_err("broken import must refuse");
308
+ let text = error.to_string();
309
+ assert!(
310
+ text.contains("import") || text.contains("adapter"),
311
+ "got {text}"
312
+ );
313
+ assert!(!broken.contains("--mcp-config"));
314
+
315
+ let missing_proxy_tools = source.replace("\"includeTools\"", "\"missingTools\"");
316
+ validate_pi_wrapper_source(&missing_proxy_tools, &adapter, &candidate)
317
+ .expect_err("wrapper without the MCP proxy tool allowlist must refuse");
318
+
319
+ std::fs::remove_dir_all(root).expect("remove wrapper fixture");
320
+ }
@@ -0,0 +1,98 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use crate::cli::diagnose::{pi_doctor_facts, PiBackingStatus, PiDoctorInput, PiMcpStatus};
4
+ use crate::lifecycle::launch::pi_mcp::{pi_cleanup_plan, pi_seat_paths, PiCleanupAction};
5
+ use crate::provider::{AuthHintStatus, ClassifySource, TurnState};
6
+
7
+ fn doctor_input() -> PiDoctorInput {
8
+ PiDoctorInput {
9
+ executable_chain_verified: true,
10
+ pi_version: Some("0.84.3".to_string()),
11
+ adapter_version: Some("2.30.0".to_string()),
12
+ catalog_sha256: Some(
13
+ "726cedb6c3f6fe80a0d7b98918d8ed5063695e01f510a48f46c4bad5daab49fe".to_string(),
14
+ ),
15
+ selected_model: Some("team-agent/qwen3.8-27b".to_string()),
16
+ candidate_executable: Some(PathBuf::from("/candidate/team-agent")),
17
+ wrapper: Some(PathBuf::from(
18
+ "/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts",
19
+ )),
20
+ mcp_roundtrip_at: None,
21
+ backing: PiBackingStatus::Pending,
22
+ activity: TurnState::Unknown,
23
+ activity_source: ClassifySource::SessionFile,
24
+ env_key_names: vec![
25
+ "TEAM_AGENT_ID".to_string(),
26
+ "TEAM_AGENT_OWNER_TEAM_ID".to_string(),
27
+ ],
28
+ }
29
+ }
30
+
31
+ #[test]
32
+ fn pi_stop_retains_backing_and_never_scans_by_process_name() {
33
+ let paths = pi_seat_paths(Path::new("/workspace"), "team-a", "worker-a");
34
+ let stop = pi_cleanup_plan(PiCleanupAction::Stop, &paths);
35
+ assert!(
36
+ stop.delete_paths.is_empty(),
37
+ "stop must retain both the Team-owned wrapper and session backing"
38
+ );
39
+ assert!(stop.retain_paths.contains(&paths.wrapper));
40
+ assert!(stop.retain_paths.contains(&paths.sessions));
41
+ assert_eq!(
42
+ stop.process_name_scan, None,
43
+ "Pi cleanup may use exact recorded pane/child receipts, never a process-name scan"
44
+ );
45
+
46
+ let remove = pi_cleanup_plan(PiCleanupAction::Remove, &paths);
47
+ assert_eq!(remove.delete_paths, [paths.wrapper.clone()]);
48
+ assert!(remove.retain_paths.contains(&paths.sessions));
49
+ assert_eq!(remove.process_name_scan, None);
50
+ assert!(
51
+ remove
52
+ .delete_paths
53
+ .iter()
54
+ .all(|path| path.starts_with(&paths.runtime_root)),
55
+ "remove may delete only exact Team-owned seat paths"
56
+ );
57
+ }
58
+
59
+ #[test]
60
+ fn pi_doctor_separates_configured_roundtrip_backing_and_activity_unknown() {
61
+ let facts = pi_doctor_facts(doctor_input());
62
+
63
+ assert_eq!(facts.mcp, PiMcpStatus::ConfiguredLazy);
64
+ assert!(
65
+ !facts.mcp_connected,
66
+ "wrapper existence is not an MCP roundtrip"
67
+ );
68
+ assert_eq!(facts.backing, PiBackingStatus::Pending);
69
+ assert_eq!(facts.activity, TurnState::Unknown);
70
+ assert_eq!(facts.activity_source, ClassifySource::SessionFile);
71
+ assert_eq!(facts.auth_hint, AuthHintStatus::Unknown);
72
+
73
+ let serialized = serde_json::to_string(&facts).expect("serialize doctor facts");
74
+ assert!(serialized.contains("configured_lazy"));
75
+ assert!(serialized.contains("unknown"));
76
+ for secret_surface in [
77
+ "api_key",
78
+ "access_token",
79
+ "refresh_token",
80
+ "cookie",
81
+ "authorization",
82
+ ] {
83
+ assert!(
84
+ !serialized.to_ascii_lowercase().contains(secret_surface),
85
+ "doctor must expose identity/digest/key names, never credential values"
86
+ );
87
+ }
88
+
89
+ let connected = pi_doctor_facts(PiDoctorInput {
90
+ mcp_roundtrip_at: Some("2026-08-29T10:02:00+00:00".to_string()),
91
+ backing: PiBackingStatus::Captured,
92
+ ..doctor_input()
93
+ });
94
+ assert_eq!(connected.mcp, PiMcpStatus::RoundtripVerified);
95
+ assert!(connected.mcp_connected);
96
+ assert_eq!(connected.backing, PiBackingStatus::Captured);
97
+ assert_eq!(connected.activity, TurnState::Unknown);
98
+ }
@@ -0,0 +1,216 @@
1
+ use std::path::{Path, PathBuf};
2
+ use std::sync::atomic::{AtomicU32, Ordering};
3
+ use std::time::Duration;
4
+
5
+ use crate::lifecycle::launch::pi_mcp::pi_seat_paths;
6
+ use crate::messaging::delivery::{
7
+ paste_to_submit_floor_for_recipient, recipient_allows_explicit_queue_flush, recipient_is_busy,
8
+ recipient_requires_single_enter,
9
+ };
10
+ use crate::model::enums::{Provider, ProviderEffort};
11
+ use crate::provider::adapters::pi::{build_pi_command_argv, PiCommandRequest, PiSessionSelector};
12
+ use crate::provider::session_scan::{scan_session_candidates_once, CaptureSessionContext};
13
+ use crate::provider::{get_adapter, AuthMode, ProviderError, SessionId};
14
+
15
+ static NEXT_ROOT: AtomicU32 = AtomicU32::new(0);
16
+
17
+ fn temp_root(label: &str) -> PathBuf {
18
+ let seq = NEXT_ROOT.fetch_add(1, Ordering::Relaxed);
19
+ let root = std::env::temp_dir().join(format!(
20
+ "team-agent-pi-{label}-{}-{seq}",
21
+ std::process::id()
22
+ ));
23
+ std::fs::create_dir_all(&root).expect("create fixture root");
24
+ root
25
+ }
26
+
27
+ fn write_header(path: &Path, id: &str, cwd: &Path, timestamp: &str) {
28
+ std::fs::create_dir_all(path.parent().expect("session parent")).expect("create session parent");
29
+ let header = serde_json::json!({
30
+ "type": "session",
31
+ "version": 3,
32
+ "id": id,
33
+ "cwd": cwd,
34
+ "timestamp": timestamp,
35
+ });
36
+ std::fs::write(
37
+ path,
38
+ format!("{header}\nsecret conversation body must not be read\n"),
39
+ )
40
+ .expect("write session fixture");
41
+ }
42
+
43
+ fn scan_context(cwd: &Path, root: &Path, id: &str, generation: &str) -> CaptureSessionContext {
44
+ CaptureSessionContext {
45
+ agent_id: "worker-a".to_string(),
46
+ spawn_cwd: cwd.to_path_buf(),
47
+ pane_id: None,
48
+ pane_pid: None,
49
+ spawned_at: Some(generation.to_string()),
50
+ expected_session_id: Some(SessionId::new(id)),
51
+ provider_projects_root: Some(root.to_path_buf()),
52
+ }
53
+ }
54
+
55
+ fn resume_argv(path: &Path) -> Result<Vec<String>, ProviderError> {
56
+ build_pi_command_argv(PiCommandRequest {
57
+ executable: Path::new("/verified/pi"),
58
+ extension: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts"),
59
+ model: "team-agent/qwen3.8-27b",
60
+ effort: ProviderEffort::Medium,
61
+ system_prompt: "worker contract",
62
+ tool_categories: &["mcp_team"],
63
+ session_dir: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/sessions"),
64
+ session: PiSessionSelector::Resume { path },
65
+ agent_id: "worker-a",
66
+ })
67
+ }
68
+
69
+ #[test]
70
+ fn pi_session_capture_requires_header_id_cwd_generation_and_unique_claim() {
71
+ let root = temp_root("session-capture");
72
+ let cwd = root.join("workspace");
73
+ let sessions = root.join("seat/sessions");
74
+ std::fs::create_dir_all(&cwd).expect("create cwd");
75
+ let id = "652756ea-aaee-4437-a30f-40c5606826d5";
76
+ let generation = "2026-08-29T10:00:00Z";
77
+ let session_timestamp = "2026-08-29T10:00:01Z";
78
+ let expected_path = sessions.join("2026/08/29/session.jsonl");
79
+ write_header(&expected_path, id, &cwd, session_timestamp);
80
+
81
+ let context = scan_context(&cwd, &sessions, id, generation);
82
+ let candidates = scan_session_candidates_once(Provider::Pi, &context).expect("Pi scan");
83
+ assert_eq!(candidates.len(), 1);
84
+ assert_eq!(
85
+ candidates[0]
86
+ .captured
87
+ .session_id
88
+ .as_ref()
89
+ .map(SessionId::as_str),
90
+ Some(id)
91
+ );
92
+ assert_eq!(
93
+ candidates[0]
94
+ .captured
95
+ .rollout_path
96
+ .as_ref()
97
+ .map(|path| path.as_path()),
98
+ Some(expected_path.as_path())
99
+ );
100
+
101
+ for (label, header_id, header_cwd, header_timestamp) in [
102
+ ("wrong-id", "wrong-id", cwd.as_path(), session_timestamp),
103
+ ("wrong-cwd", id, root.as_path(), session_timestamp),
104
+ (
105
+ "wrong-generation",
106
+ id,
107
+ cwd.as_path(),
108
+ "2026-08-29T09:00:00+00:00",
109
+ ),
110
+ ] {
111
+ let case_root = root.join(label);
112
+ let case_path = case_root.join(format!("{id}.jsonl"));
113
+ write_header(&case_path, header_id, header_cwd, header_timestamp);
114
+ let case_context = scan_context(&cwd, &case_root, id, generation);
115
+ assert!(
116
+ scan_session_candidates_once(Provider::Pi, &case_context)
117
+ .expect("negative scan")
118
+ .is_empty(),
119
+ "filename or mtime must not override a mismatched header: {label}"
120
+ );
121
+ }
122
+
123
+ let second = sessions.join("duplicate/session.jsonl");
124
+ write_header(&second, id, &cwd, session_timestamp);
125
+ assert_eq!(
126
+ scan_session_candidates_once(Provider::Pi, &context)
127
+ .expect("ambiguous scan")
128
+ .len(),
129
+ 2,
130
+ "multiple exact candidates must remain ambiguous for the shared allocator"
131
+ );
132
+
133
+ std::fs::remove_dir_all(root).expect("remove session fixture");
134
+ }
135
+
136
+ #[test]
137
+ fn pi_restart_uses_exact_path_and_refuses_missing_backing() {
138
+ let root = temp_root("resume");
139
+ let cwd = root.join("workspace");
140
+ let sessions = root.join("seat/sessions");
141
+ std::fs::create_dir_all(&cwd).expect("create cwd");
142
+ let id = "cc09d133-878f-4241-920f-f7ebfefc2a9e";
143
+ let generation = "2026-08-29T10:00:00Z";
144
+ let exact = sessions.join("nested/exact.jsonl");
145
+ write_header(&exact, id, &cwd, "2026-08-29T10:00:01Z");
146
+
147
+ let candidates =
148
+ scan_session_candidates_once(Provider::Pi, &scan_context(&cwd, &sessions, id, generation))
149
+ .expect("resume preflight scan");
150
+ assert_eq!(
151
+ candidates.len(),
152
+ 1,
153
+ "exact backing must revalidate before restart"
154
+ );
155
+
156
+ let argv = resume_argv(&exact).expect("resume exact path");
157
+ assert!(argv
158
+ .windows(2)
159
+ .any(|pair| { pair[0] == "--session" && pair[1] == exact.to_string_lossy().as_ref() }));
160
+ assert!(!argv.iter().any(|arg| {
161
+ matches!(
162
+ arg.as_str(),
163
+ "--session-id" | "-c" | "--continue" | "-r" | "--resume"
164
+ )
165
+ }));
166
+
167
+ std::fs::remove_file(&exact).expect("remove backing tooth");
168
+ let missing = resume_argv(&exact);
169
+ assert!(
170
+ matches!(missing, Err(ProviderError::ResumeUnavailable(_))),
171
+ "missing exact backing must refuse rather than create a new session: {missing:?}"
172
+ );
173
+ std::fs::remove_dir_all(root).expect("remove resume fixture");
174
+ }
175
+
176
+ #[test]
177
+ fn pi_delivery_submits_one_enter_and_never_flushes_or_retries() {
178
+ let idle = serde_json::json!({
179
+ "agents": {
180
+ "worker-a": {"provider": "pi", "status": "running"}
181
+ }
182
+ });
183
+ assert!(recipient_requires_single_enter(&idle, "worker-a"));
184
+ assert!(!recipient_allows_explicit_queue_flush(&idle, "worker-a"));
185
+ assert_eq!(
186
+ paste_to_submit_floor_for_recipient(&idle, "worker-a"),
187
+ Duration::ZERO
188
+ );
189
+ assert!(!recipient_is_busy(&idle, "worker-a"));
190
+
191
+ let busy = serde_json::json!({
192
+ "agents": {
193
+ "worker-a": {"provider": "pi", "status": "busy"}
194
+ }
195
+ });
196
+ assert!(
197
+ recipient_is_busy(&busy, "worker-a"),
198
+ "known busy must take the existing send.deferred_busy path before paste"
199
+ );
200
+ assert!(recipient_requires_single_enter(&busy, "worker-a"));
201
+ assert!(!recipient_allows_explicit_queue_flush(&busy, "worker-a"));
202
+ }
203
+
204
+ #[test]
205
+ fn pi_clone_is_fresh_and_fork_is_not_clone() {
206
+ let workspace = Path::new("/workspace");
207
+ let source_paths = pi_seat_paths(workspace, "team-a", "source");
208
+ let clone_paths = pi_seat_paths(workspace, "team-a", "clone");
209
+ assert_ne!(source_paths.runtime_root, clone_paths.runtime_root);
210
+ assert_ne!(source_paths.wrapper, clone_paths.wrapper);
211
+ assert_ne!(source_paths.sessions, clone_paths.sessions);
212
+
213
+ let source_session = SessionId::new("652756ea-aaee-4437-a30f-40c5606826d5");
214
+ let fork = get_adapter(Provider::Pi).fork(Some(&source_session), AuthMode::Subscription, None);
215
+ assert!(matches!(fork, Err(ProviderError::CapabilityUnsupported(_))));
216
+ }
@@ -1068,6 +1068,7 @@ fn provider_name(provider: Provider) -> &'static str {
1068
1068
  Provider::GeminiCli => "gemini",
1069
1069
  Provider::Grok => "grok",
1070
1070
  Provider::CursorAgent => "cursor_agent",
1071
+ Provider::Pi => "pi",
1071
1072
  Provider::Fake => "fake",
1072
1073
  }
1073
1074
  }
@@ -79,6 +79,13 @@ mod main_preserved;
79
79
  mod mcp_tool_name_format_red;
80
80
  mod phase_b_contracts;
81
81
  mod phase_golden;
82
+ mod pi_catalog_red;
83
+ mod pi_command_red;
84
+ mod pi_compiler_red;
85
+ mod pi_dual_entry_red;
86
+ mod pi_executable_mcp_red;
87
+ mod pi_lifecycle_doctor_red;
88
+ mod pi_session_delivery_red;
82
89
  mod quick_start_worker_readiness_red;
83
90
  mod realmachine_clusters_1_6_red;
84
91
  mod realmachine_residual_g1_g4_red;