@team-agent/installer 0.3.4 → 0.3.6

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 (56) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +8 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +51 -10
  5. package/crates/team-agent/src/cli/emit.rs +2 -1
  6. package/crates/team-agent/src/cli/mod.rs +217 -80
  7. package/crates/team-agent/src/cli/send.rs +1 -0
  8. package/crates/team-agent/src/cli/status_port.rs +135 -7
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
  12. package/crates/team-agent/src/cli/types.rs +5 -1
  13. package/crates/team-agent/src/coordinator/backoff.rs +57 -9
  14. package/crates/team-agent/src/coordinator/health.rs +65 -2
  15. package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
  16. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
  17. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  18. package/crates/team-agent/src/coordinator/tick.rs +195 -43
  19. package/crates/team-agent/src/leader/helpers.rs +2 -0
  20. package/crates/team-agent/src/leader/rediscover.rs +1 -0
  21. package/crates/team-agent/src/leader/start.rs +9 -1
  22. package/crates/team-agent/src/leader/takeover.rs +18 -1
  23. package/crates/team-agent/src/lifecycle/launch.rs +434 -29
  24. package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
  25. package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +19 -2
  27. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
  30. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -1
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +44 -9
  32. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
  33. package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
  34. package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
  35. package/crates/team-agent/src/mcp_server/tools.rs +65 -9
  36. package/crates/team-agent/src/mcp_server/wire.rs +2 -1
  37. package/crates/team-agent/src/message_store.rs +80 -0
  38. package/crates/team-agent/src/messaging/results.rs +76 -5
  39. package/crates/team-agent/src/messaging/send.rs +3 -1
  40. package/crates/team-agent/src/messaging/types.rs +15 -1
  41. package/crates/team-agent/src/messaging/watchers.rs +68 -30
  42. package/crates/team-agent/src/model/enums.rs +7 -1
  43. package/crates/team-agent/src/model/permissions.rs +7 -0
  44. package/crates/team-agent/src/model/spec.rs +3 -1
  45. package/crates/team-agent/src/provider/adapter.rs +472 -7
  46. package/crates/team-agent/src/provider/classify.rs +6 -2
  47. package/crates/team-agent/src/provider/faults.rs +3 -2
  48. package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
  49. package/crates/team-agent/src/provider/types.rs +11 -0
  50. package/crates/team-agent/src/session_capture.rs +1 -0
  51. package/crates/team-agent/src/state/persist.rs +95 -19
  52. package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
  53. package/crates/team-agent/src/tmux_backend.rs +80 -6
  54. package/crates/team-agent/src/transport.rs +32 -0
  55. package/npm/install.mjs +21 -0
  56. package/package.json +4 -4
@@ -50,7 +50,9 @@ pub fn latest_explicit_error_fact(provider: Provider, session_log_text: &str) ->
50
50
  match provider {
51
51
  Provider::Codex => codex_latest_explicit_error_fact(&record),
52
52
  Provider::Claude | Provider::ClaudeCode => claude_latest_explicit_error_fact(&record),
53
- Provider::GeminiCli | Provider::Fake => None,
53
+ // C-3-5 cr verdict: copilot N35 通知不依赖 turn-state 分类;一期 Unknown,
54
+ // 与 GeminiCli/Fake 同精神。二期接 sqlite turns 表后再回填。
55
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => None,
54
56
  }
55
57
  }
56
58
 
@@ -155,7 +157,9 @@ fn extract_lifecycle_facts(provider: Provider, records: &[serde_json::Value]) ->
155
157
  .filter_map(|record| match provider {
156
158
  Provider::Claude | Provider::ClaudeCode => claude_lifecycle_fact(record),
157
159
  Provider::Codex => codex_lifecycle_fact(record),
158
- Provider::GeminiCli | Provider::Fake => None,
160
+ // C-3-1 cr verdict: copilot lifecycle facts 一期不导出(classify→None,
161
+ // Unknown);二期读 sqlite turns 表(turn_index/assistant_response)。
162
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => None,
159
163
  })
160
164
  .collect()
161
165
  }
@@ -17,7 +17,8 @@ pub fn explicit_error_fact(record: &serde_json::Value, provider: Provider) -> Op
17
17
  match provider {
18
18
  Provider::Codex => codex_explicit_error_fact(record),
19
19
  Provider::Claude | Provider::ClaudeCode => claude_explicit_error_fact(record),
20
- Provider::GeminiCli | Provider::Fake => None,
20
+ // copilot 一期不接 jsonl 真相源(C-3-5)。
21
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => None,
21
22
  }
22
23
  }
23
24
 
@@ -59,7 +60,7 @@ fn fault_fact(provider: Provider, record: &serde_json::Value) -> Option<FaultFac
59
60
  match provider {
60
61
  Provider::Claude | Provider::ClaudeCode => claude_fault_fact(record),
61
62
  Provider::Codex => codex_fault_fact(record),
62
- Provider::GeminiCli | Provider::Fake => None,
63
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => None,
63
64
  }
64
65
  }
65
66
 
@@ -232,17 +232,31 @@ fn max_two(a: Option<usize>, b: Option<usize>) -> Option<usize> {
232
232
  /// + push {prompt:"codex_update_available", action:"sent_skip"}; on `Ready` -> stop. Loops up to
233
233
  /// `checks` (golden default 30), `sleep_s` (golden 0.5) between iterations. Returns the ordered
234
234
  /// `handled` list. Capture is full scrollback (golden `tmux capture-pane -p -S - -t <target>`).
235
+ /// swallow batch 2 ② (A1): the structured startup-prompt outcome — `handled` keeps the
236
+ /// Python golden list shape; `capture_error` carries the FIRST capture failure so the
237
+ /// caller can surface it (an unobservable pane must never be silently treated as
238
+ /// "no prompts to handle").
239
+ #[derive(Debug, Clone, Default)]
240
+ pub struct StartupPromptOutcome {
241
+ pub handled: Vec<HandledPrompt>,
242
+ pub capture_error: Option<String>,
243
+ }
244
+
235
245
  pub fn codex_handle_startup_prompts(
236
246
  transport: &dyn Transport,
237
247
  target: &Target,
238
248
  checks: usize,
239
249
  sleep_s: f64,
240
- ) -> Vec<HandledPrompt> {
250
+ ) -> StartupPromptOutcome {
241
251
  let mut handled = Vec::new();
252
+ let mut capture_error: Option<String> = None;
242
253
  for _ in 0..checks {
243
254
  let screen = match transport.capture(target, CaptureRange::Full) {
244
255
  Ok(captured) => captured.text,
245
- Err(_) => String::new(),
256
+ Err(error) => {
257
+ capture_error.get_or_insert_with(|| error.to_string());
258
+ String::new()
259
+ }
246
260
  };
247
261
  match classify_codex_startup_screen(&screen) {
248
262
  StartupScreenDecision::SkipUpdatePrompt => {
@@ -265,7 +279,7 @@ pub fn codex_handle_startup_prompts(
265
279
  StartupScreenDecision::KeepPolling => sleep_between_polls(sleep_s),
266
280
  }
267
281
  }
268
- handled
282
+ StartupPromptOutcome { handled, capture_error }
269
283
  }
270
284
 
271
285
  pub fn claude_handle_startup_prompts(
@@ -273,12 +287,16 @@ pub fn claude_handle_startup_prompts(
273
287
  target: &Target,
274
288
  checks: usize,
275
289
  sleep_s: f64,
276
- ) -> Vec<HandledPrompt> {
290
+ ) -> StartupPromptOutcome {
277
291
  let mut handled = Vec::new();
292
+ let mut capture_error: Option<String> = None;
278
293
  for _ in 0..checks {
279
294
  let screen = match transport.capture(target, CaptureRange::Full) {
280
295
  Ok(captured) => captured.text,
281
- Err(_) => String::new(),
296
+ Err(error) => {
297
+ capture_error.get_or_insert_with(|| error.to_string());
298
+ String::new()
299
+ }
282
300
  };
283
301
  match classify_claude_startup_screen(&screen) {
284
302
  StartupScreenDecision::AnswerWorkspaceTrust => {
@@ -295,7 +313,7 @@ pub fn claude_handle_startup_prompts(
295
313
  }
296
314
  }
297
315
  }
298
- handled
316
+ StartupPromptOutcome { handled, capture_error }
299
317
  }
300
318
 
301
319
  fn max_rfind(output: &str, needles: &[&str]) -> Option<usize> {
@@ -498,7 +516,7 @@ mod tests {
498
516
  };
499
517
  let target = Target::Pane(PaneId::new("%1"));
500
518
 
501
- let handled = codex_handle_startup_prompts(&t, &target, 5, 0.0);
519
+ let handled = codex_handle_startup_prompts(&t, &target, 5, 0.0).handled;
502
520
 
503
521
  assert_eq!(
504
522
  handled,
@@ -143,10 +143,17 @@ pub enum Confidence {
143
143
  }
144
144
 
145
145
  /// auth_hint 状态(`adapter.py:38` 等)。doctor 用。
146
+ ///
147
+ /// `PresentWeak`(C-A-5 cr verdict v2):provider 无 auth-status 子命令(如 copilot
148
+ /// — main-help Commands 节仅 completion/help/init/login/mcp/plugin/update/version,
149
+ /// **无 status**)时,framework 只能弱检测(命令在 PATH + ~/.copilot/config.json
150
+ /// 存在 等表层信号)。诚实 surface(MUST-NOT-13)— 不假报"已登录",doctor 文案
151
+ /// 标"weak / no auth-status command available"。
146
152
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147
153
  #[serde(rename_all = "snake_case")]
148
154
  pub enum AuthHintStatus {
149
155
  Present,
156
+ PresentWeak,
150
157
  Missing,
151
158
  MissingOrUnknown,
152
159
  Unknown,
@@ -311,6 +318,10 @@ pub struct ProviderCaps {
311
318
  #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
312
319
  pub struct ProviderCommandOverrides {
313
320
  pub model: Option<String>,
321
+ /// Python `provider_env.py:62-65` — profile CODEX_PROFILE/NATIVE_PROFILE → `--profile <x>`.
322
+ pub codex_profile: Option<String>,
323
+ /// Python `provider_env.py:66-79` — compatible_api model_provider `-c` items, verbatim.
324
+ pub codex_config: Vec<String>,
314
325
  }
315
326
 
316
327
  #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -609,6 +609,7 @@ fn parse_provider(raw: &str) -> Option<Provider> {
609
609
  "claude" => Some(Provider::Claude),
610
610
  "claude_code" => Some(Provider::ClaudeCode),
611
611
  "codex" => Some(Provider::Codex),
612
+ "copilot" => Some(Provider::Copilot),
612
613
  "gemini_cli" => Some(Provider::GeminiCli),
613
614
  "fake" => Some(Provider::Fake),
614
615
  _ => None,
@@ -270,20 +270,27 @@ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value>
270
270
  }
271
271
 
272
272
  fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_agent_ids: &BTreeSet<String>) {
273
- if !same_runtime_projection(incoming, latest) {
274
- return;
273
+ // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
274
+ // the top-level<->active-team cross projections depend on which team is active); the
275
+ // per-team `teams.<k>.agents` merge below is team-key self-identifying and must run
276
+ // even when another process flipped session_name/active_team_key between this
277
+ // writer's load and save.
278
+ let projection_matches = same_runtime_projection(incoming, latest);
279
+ if projection_matches {
280
+ preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
281
+ preserve_latest_ownership_fields(incoming, latest);
275
282
  }
276
- preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
277
- preserve_latest_ownership_fields(incoming, latest);
278
283
 
279
284
  let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
280
- if let Some(active_team) = active_team.as_deref() {
281
- let latest_active_agents = latest
282
- .get("teams")
283
- .and_then(Value::as_object)
284
- .and_then(|teams| teams.get(active_team))
285
- .and_then(|entry| entry.get("agents"));
286
- preserve_missing_agents(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
285
+ if projection_matches {
286
+ if let Some(active_team) = active_team.as_deref() {
287
+ let latest_active_agents = latest
288
+ .get("teams")
289
+ .and_then(Value::as_object)
290
+ .and_then(|teams| teams.get(active_team))
291
+ .and_then(|entry| entry.get("agents"));
292
+ preserve_missing_agents(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
293
+ }
287
294
  }
288
295
 
289
296
  let latest_teams = latest.get("teams").and_then(Value::as_object);
@@ -303,11 +310,13 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
303
310
  preserve_latest_ownership_fields(incoming_entry, latest_entry);
304
311
  }
305
312
  }
306
- if let Some(active_team) = active_team.as_deref() {
307
- let latest_top_agents = latest.get("agents");
308
- if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
309
- preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
310
- preserve_latest_ownership_fields(incoming_entry, latest);
313
+ if projection_matches {
314
+ if let Some(active_team) = active_team.as_deref() {
315
+ let latest_top_agents = latest.get("agents");
316
+ if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
317
+ preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
318
+ preserve_latest_ownership_fields(incoming_entry, latest);
319
+ }
311
320
  }
312
321
  }
313
322
  }
@@ -384,9 +393,38 @@ fn preserve_missing_agents(
384
393
  if deleted_agent_ids.contains(agent_id) {
385
394
  continue;
386
395
  }
387
- incoming_map
388
- .entry(agent_id.clone())
389
- .or_insert_with(|| latest_agent.clone());
396
+ match incoming_map.entry(agent_id.clone()) {
397
+ serde_json::map::Entry::Vacant(slot) => {
398
+ slot.insert(latest_agent.clone());
399
+ }
400
+ serde_json::map::Entry::Occupied(mut existing) => {
401
+ backfill_capture_fields(existing.get_mut(), latest_agent);
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ /// A0/R2: session-capture fields are written by another process (capture/update_state)
408
+ /// between a writer's load and save; a stale incoming row must not regress them to null.
409
+ /// Monotonic backfill ONLY for the capture field family (no generic deep-merge, so a
410
+ /// deliberate field clear elsewhere is not masked).
411
+ fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
412
+ const CAPTURE_FIELDS: [&str; 5] = [
413
+ "session_id",
414
+ "rollout_path",
415
+ "captured_at",
416
+ "captured_via",
417
+ "attribution_confidence",
418
+ ];
419
+ let Some(incoming_row) = incoming_agent.as_object_mut() else {
420
+ return;
421
+ };
422
+ for field in CAPTURE_FIELDS {
423
+ if incoming_row.get(field).is_none_or(Value::is_null) {
424
+ if let Some(value) = latest_agent.get(field).filter(|value| !value.is_null()) {
425
+ incoming_row.insert(field.to_string(), value.clone());
426
+ }
427
+ }
390
428
  }
391
429
  }
392
430
 
@@ -878,4 +916,42 @@ mod tests {
878
916
  let after = std::fs::read_to_string(runtime_state_path(&ws)).unwrap();
879
917
  assert_eq!(after, before, "已是迁移等价形的 legacy 文件不得 spurious 重写");
880
918
  }
919
+
920
+ // A0 GREEN 回归锁(.team/artifacts/a0-rs-lostupdate-locate.md §5.3):锁内 preserve 把磁盘
921
+ // latest 多出的 agent 补回 stale incoming(防 Python A0 lost-update),同时 deleted_agent_ids
922
+ // 豁免位必须让 remove-agent 的删除不被 merge 复活(persist.rs:383-385)。
923
+ #[test]
924
+ fn a0_green_lock_preserve_fills_missing_agents_but_deleted_ids_stay_dead() {
925
+ let ws = temp_ws();
926
+ std::fs::create_dir_all(runtime_dir(&ws)).unwrap();
927
+ std::fs::write(
928
+ runtime_state_path(&ws),
929
+ serde_json::to_string_pretty(&json!({
930
+ "session_name": "team-a",
931
+ "active_team_key": "team-a",
932
+ "agents": {
933
+ "w1": {"status": "running", "provider": "codex", "agent_id": "w1"},
934
+ "kept": {"status": "running", "provider": "codex", "agent_id": "kept"},
935
+ "gone": {"status": "running", "provider": "codex", "agent_id": "gone"},
936
+ },
937
+ }))
938
+ .unwrap(),
939
+ )
940
+ .unwrap();
941
+ let incoming = json!({
942
+ "session_name": "team-a",
943
+ "active_team_key": "team-a",
944
+ "agents": { "w1": {"status": "running", "provider": "codex", "agent_id": "w1"} },
945
+ });
946
+ save_runtime_state_with_deleted_agents(&ws, &incoming, &["gone"]).unwrap();
947
+ let saved = read_state(&ws);
948
+ assert!(
949
+ saved.pointer("/agents/kept").is_some_and(Value::is_object),
950
+ "锁内 preserve 必须把磁盘 latest 多出的 `kept` 补回 stale incoming;saved={saved}"
951
+ );
952
+ assert!(
953
+ saved.pointer("/agents/gone").is_none(),
954
+ "deleted_agent_ids 豁免:被 remove 的 `gone` 不得被 preserve 复活;saved={saved}"
955
+ );
956
+ }
881
957
  }
@@ -660,15 +660,14 @@
660
660
  // ── 11. list_targets (TRANSPORT TRIO) — `list-panes -a -F TMUX_PANE_FORMAT` + per-line parse ────
661
661
  // Golden _legacy_pane_discovery.py:29-33 _tmux_list_panes: `tmux list-panes -a -F <TMUX_PANE_FORMAT>`
662
662
  // (returncode != 0 -> []), parse each tab line via _parse_tmux_pane_info. TMUX_PANE_FORMAT
663
- // (runtime.py:456-460) is the byte-exact 11-field tab string locked below. RED today: list_targets is
664
- // unimplemented!() -> PANIC. Porter: build the argv, split each stdout line on '\t', map the fields
665
- // into PaneInfo (pane_active=="1" -> active). leader_env / pane_pid are the reverse-env real-machine
666
- // bit (no field in TMUX_PANE_FORMAT) — out of this canned parse; the structured fields are locked here.
663
+ // (runtime.py:456-460) is the byte-exact tab string locked below; P5 (C-P5-3) appends
664
+ // `#{pane_pid}` as field 12 so pane pids ride the single list-panes call (the per-pane
665
+ // display-message N+1 fallback is gone). leader_env stays the reverse-env real-machine bit.
667
666
  #[test]
668
667
  fn list_targets_argv_and_parses_tmux_pane_format() {
669
- const FMT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}";
670
- let stdout = "%7\tteam-x\t0\twin0\t0\t/dev/ttys003\tcodex\t1\t/Users/me/work\t1\t0\n\
671
- %8\tteam-x\t1\twin1\t0\t/dev/ttys004\tnode\t0\t/Users/me/other\t0\t0\n";
668
+ const FMT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}\t#{pane_pid}";
669
+ let stdout = "%7\tteam-x\t0\twin0\t0\t/dev/ttys003\tcodex\t1\t/Users/me/work\t1\t0\t41001\n\
670
+ %8\tteam-x\t1\twin1\t0\t/dev/ttys004\tnode\t0\t/Users/me/other\t0\t0\t41002\n";
672
671
  let (be, rec) = backend_with(MockResp::Out(ok(stdout)), vec![]);
673
672
  let panes = be.list_targets().expect("list_targets ok");
674
673
  assert_eq!(
@@ -692,6 +691,8 @@
692
691
  "field[8] -> pane_current_path"
693
692
  );
694
693
  assert!(!panes[1].active, "field[7] pane_active='0' -> active=false");
694
+ assert_eq!(p.pane_pid, Some(41001), "field[11] -> pane_pid (P5 C-P5-3, no N+1 fallback)");
695
+ assert_eq!(panes[1].pane_pid, Some(41002), "field[11] -> pane_pid (second pane)");
695
696
 
696
697
  // nonzero exit -> empty vec (golden returncode != 0 -> []).
697
698
  let (be, _r) = backend_with(MockResp::Out(fail(1, "no server running on /tmp/tmux-x/default")), vec![]);
@@ -169,6 +169,9 @@ pub struct TmuxBackend {
169
169
  /// `Some(name)` for a per-team socket -> every `tmux` argv gets `-L <name>` injected after the
170
170
  /// leading "tmux" token; `None` (default) -> bare `tmux` on the shared default socket.
171
171
  socket: Option<TmuxSocketEndpoint>,
172
+ /// swallow batch 2: workspace for failure-observability events (`tmux.*_failed`);
173
+ /// `None` for non-workspace-bound backends (no event log to write to).
174
+ event_workspace: Option<PathBuf>,
172
175
  }
173
176
 
174
177
  enum TmuxSocketEndpoint {
@@ -183,6 +186,7 @@ impl TmuxBackend {
183
186
  Self {
184
187
  runner: Box::new(RealCommandRunner),
185
188
  socket: None,
189
+ event_workspace: None,
186
190
  }
187
191
  }
188
192
 
@@ -195,6 +199,7 @@ impl TmuxBackend {
195
199
  socket: Some(TmuxSocketEndpoint::Name(socket_name_for_workspace(
196
200
  workspace,
197
201
  ))),
202
+ event_workspace: Some(workspace.to_path_buf()),
198
203
  }
199
204
  }
200
205
 
@@ -205,6 +210,7 @@ impl TmuxBackend {
205
210
  Self {
206
211
  runner: Box::new(RealCommandRunner),
207
212
  socket: Some(TmuxSocketEndpoint::Name(socket.to_string())),
213
+ event_workspace: None,
208
214
  }
209
215
  }
210
216
  }
@@ -216,6 +222,7 @@ impl TmuxBackend {
216
222
  Self {
217
223
  runner: Box::new(RealCommandRunner),
218
224
  socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())),
225
+ event_workspace: None,
219
226
  }
220
227
  } else {
221
228
  Self::new()
@@ -227,6 +234,7 @@ impl TmuxBackend {
227
234
  Self {
228
235
  runner,
229
236
  socket: None,
237
+ event_workspace: None,
230
238
  }
231
239
  }
232
240
 
@@ -238,6 +246,7 @@ impl TmuxBackend {
238
246
  socket: Some(TmuxSocketEndpoint::Name(socket_name_for_workspace(
239
247
  workspace,
240
248
  ))),
249
+ event_workspace: Some(workspace.to_path_buf()),
241
250
  }
242
251
  }
243
252
 
@@ -249,16 +258,19 @@ impl TmuxBackend {
249
258
  Self {
250
259
  runner,
251
260
  socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())),
261
+ event_workspace: None,
252
262
  }
253
263
  } else if endpoint.is_empty() || endpoint == "default" {
254
264
  Self {
255
265
  runner,
256
266
  socket: None,
267
+ event_workspace: None,
257
268
  }
258
269
  } else {
259
270
  Self {
260
271
  runner,
261
272
  socket: None,
273
+ event_workspace: None,
262
274
  }
263
275
  }
264
276
  }
@@ -420,9 +432,10 @@ impl TmuxBackend {
420
432
  argv: &[String],
421
433
  cwd: &Path,
422
434
  env: &BTreeMap<String, String>,
435
+ env_unset: &[String],
423
436
  first: bool,
424
437
  ) -> Result<SpawnResult, TransportError> {
425
- let command = shell_command(argv, cwd, env);
438
+ let command = shell_command(argv, cwd, env, env_unset);
426
439
  let spawn_argv = tmux_spawn_argv(session, window, &command, first);
427
440
  self.run_spawn(&spawn_argv)?;
428
441
  let pane_argv = vec![
@@ -595,11 +608,24 @@ fn capture_has_pasted_content_prompt(text: &str) -> bool {
595
608
  const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
596
609
  const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
597
610
 
598
- fn shell_command(argv: &[String], cwd: &Path, env: &BTreeMap<String, String>) -> String {
611
+ fn shell_command(
612
+ argv: &[String],
613
+ cwd: &Path,
614
+ env: &BTreeMap<String, String>,
615
+ env_unset: &[String],
616
+ ) -> String {
599
617
  let mut parts = Vec::new();
600
618
  parts.push("cd".to_string());
601
619
  parts.push(shell_quote(&cwd.to_string_lossy()));
602
620
  parts.push("&&".to_string());
621
+ // D9 (#264) / Python providers.py:142-145 + provider_env.py:86 — profile env_unset keys
622
+ // must be unset in the shell itself: the `sh -lc` line inherits the tmux SERVER's stale
623
+ // environment, which exec-prefix assignments cannot clear.
624
+ for key in env_unset {
625
+ parts.push("unset".to_string());
626
+ parts.push(key.clone());
627
+ parts.push("&&".to_string());
628
+ }
603
629
  for (key, value) in env {
604
630
  parts.push(format!("{key}={}", shell_quote(value)));
605
631
  }
@@ -643,7 +669,31 @@ impl Transport for TmuxBackend {
643
669
  cwd: &Path,
644
670
  env: &BTreeMap<String, String>,
645
671
  ) -> Result<SpawnResult, TransportError> {
646
- self.spawn(session, window, argv, cwd, env, true)
672
+ self.spawn(session, window, argv, cwd, env, &[], true)
673
+ }
674
+
675
+ fn spawn_first_with_env_unset(
676
+ &self,
677
+ session: &SessionName,
678
+ window: &WindowName,
679
+ argv: &[String],
680
+ cwd: &Path,
681
+ env: &BTreeMap<String, String>,
682
+ env_unset: &[String],
683
+ ) -> Result<SpawnResult, TransportError> {
684
+ self.spawn(session, window, argv, cwd, env, env_unset, true)
685
+ }
686
+
687
+ fn spawn_into_with_env_unset(
688
+ &self,
689
+ session: &SessionName,
690
+ window: &WindowName,
691
+ argv: &[String],
692
+ cwd: &Path,
693
+ env: &BTreeMap<String, String>,
694
+ env_unset: &[String],
695
+ ) -> Result<SpawnResult, TransportError> {
696
+ self.spawn(session, window, argv, cwd, env, env_unset, false)
647
697
  }
648
698
 
649
699
  fn spawn_into(
@@ -654,7 +704,7 @@ impl Transport for TmuxBackend {
654
704
  cwd: &Path,
655
705
  env: &BTreeMap<String, String>,
656
706
  ) -> Result<SpawnResult, TransportError> {
657
- self.spawn(session, window, argv, cwd, env, false)
707
+ self.spawn(session, window, argv, cwd, env, &[], false)
658
708
  }
659
709
 
660
710
  fn inject(
@@ -798,7 +848,9 @@ impl Transport for TmuxBackend {
798
848
  }
799
849
 
800
850
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
801
- const TMUX_PANE_FORMAT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}";
851
+ // P5 (C-P5-3): `#{pane_pid}` rides the single list-panes call (field index 11),
852
+ // killing the per-pane display-message N+1 fallback.
853
+ const TMUX_PANE_FORMAT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}\t#{pane_pid}";
802
854
  let argv = self.tmux_argv(&[
803
855
  "tmux".to_string(),
804
856
  "list-panes".to_string(),
@@ -813,8 +865,28 @@ impl Transport for TmuxBackend {
813
865
  let mut panes = Vec::new();
814
866
  for line in output.stdout.lines().filter(|line| !line.is_empty()) {
815
867
  if let Some(mut pane) = parse_pane_info_line(line) {
868
+ // 0.3.5 integration union: P5 (C-P5-3) makes `#{pane_pid}` ride the
869
+ // single list-panes call — on real tmux the fallback below never fires.
870
+ // swallow batch 2 ① keeps it as a RESILIENT degrade for panes whose pid
871
+ // field came back empty (e.g. older tmux without #{pane_pid}): a single
872
+ // pane's probe failure must not fail the WHOLE list — the pane degrades
873
+ // to pane_pid=None and the failure is observable.
816
874
  if pane.pane_pid.is_none() {
817
- pane.pane_pid = query_pane_pid(self, &pane.pane_id)?;
875
+ match query_pane_pid(self, &pane.pane_id) {
876
+ Ok(pid) => pane.pane_pid = pid,
877
+ Err(error) => {
878
+ if let Some(workspace) = &self.event_workspace {
879
+ let _ = crate::event_log::EventLog::new(workspace).write(
880
+ "tmux.pane_pid_query_failed",
881
+ serde_json::json!({
882
+ "pane_id": pane.pane_id.as_str(),
883
+ "session": pane.session.as_str(),
884
+ "error": error.to_string(),
885
+ }),
886
+ );
887
+ }
888
+ }
889
+ }
818
890
  }
819
891
  panes.push(pane);
820
892
  }
@@ -906,6 +978,8 @@ impl Transport for TmuxBackend {
906
978
  }
907
979
  }
908
980
 
981
+ /// swallow batch 2 ① fallback probe (only fires when `#{pane_pid}` came back empty —
982
+ /// see the P5 union note in `list_targets`).
909
983
  fn query_pane_pid(backend: &TmuxBackend, pane: &PaneId) -> Result<Option<u32>, TransportError> {
910
984
  let argv = backend.tmux_argv(&[
911
985
  "tmux".to_string(),
@@ -421,6 +421,38 @@ pub trait Transport: Send + Sync {
421
421
  env: &BTreeMap<String, String>,
422
422
  ) -> Result<SpawnResult, TransportError>;
423
423
 
424
+ /// D9 (#264): spawn + profile `env_unset` keys that must be REALLY unset in the worker
425
+ /// shell line (Python providers.py:142-145 sources an env file whose first lines are
426
+ /// `unset <KEY>`) — the tmux server environment can carry stale values that plain
427
+ /// env-map removal cannot clear. Default forwards to the plain spawn: backends without
428
+ /// an inherited-shell layer have nothing stale to unset.
429
+ fn spawn_first_with_env_unset(
430
+ &self,
431
+ session: &SessionName,
432
+ window: &WindowName,
433
+ argv: &[String],
434
+ cwd: &Path,
435
+ env: &BTreeMap<String, String>,
436
+ env_unset: &[String],
437
+ ) -> Result<SpawnResult, TransportError> {
438
+ let _ = env_unset;
439
+ self.spawn_first(session, window, argv, cwd, env)
440
+ }
441
+
442
+ /// 同 [`Transport::spawn_first_with_env_unset`],对应 `spawn_into`。
443
+ fn spawn_into_with_env_unset(
444
+ &self,
445
+ session: &SessionName,
446
+ window: &WindowName,
447
+ argv: &[String],
448
+ cwd: &Path,
449
+ env: &BTreeMap<String, String>,
450
+ env_unset: &[String],
451
+ ) -> Result<SpawnResult, TransportError> {
452
+ let _ = env_unset;
453
+ self.spawn_into(session, window, argv, cwd, env)
454
+ }
455
+
424
456
  // —— INJECT / CAPTURE / QUERY(RIE):按稳定 Target 寻址 ——
425
457
 
426
458
  /// 归并 set/load-buffer + paste-buffer + send submit;空文本走纯 send-keys
package/npm/install.mjs CHANGED
@@ -11,6 +11,7 @@ const packageRoot = path.resolve(__dirname, "..");
11
11
  const require = createRequire(import.meta.url);
12
12
  const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
13
13
  const DOCTOR_TIMEOUT_MS = 5000;
14
+ const VERSION_SMOKE_TIMEOUT_MS = 5000;
14
15
 
15
16
  const command = process.argv[2] || "install";
16
17
  const args = process.argv.slice(3);
@@ -88,6 +89,26 @@ function install(argv) {
88
89
  console.log("skill: installed for Codex and Claude");
89
90
  console.log(`PATH: ensure ${binDir} is on PATH`);
90
91
 
92
+ // 0.3.6 hotfix · C-5 cr verdict — post-install binary smoke 门(走 `--help`
93
+ // 子命令,因为 0.3.x CLI 现阶段没有 --version)。真跑一次 binary 才能抓住
94
+ // loader 级失败(glibc 不兼容 / cpu 错配 / 下载损坏 / 平台子包未装到位 等),
95
+ // 不止依赖 file 元数据。失败输出走三行式(错/动作/日志),非零退出。
96
+ // C-2 cr verdict 守护:本步不做 libc 探测、不读 /lib/x86_64-linux-gnu/libc.so.6,
97
+ // 通用 smoke 而非 platform-aware 逻辑。
98
+ const binarySmoke = spawnSync(teamAgent, ["--help"], {
99
+ text: true,
100
+ encoding: "utf8",
101
+ timeout: VERSION_SMOKE_TIMEOUT_MS,
102
+ });
103
+ if (binarySmoke.status !== 0) {
104
+ const log = (binarySmoke.stderr || binarySmoke.stdout || "").trim() || "no stderr/stdout";
105
+ console.error(`ERROR: team-agent --help failed (status=${binarySmoke.status ?? "signal"})`);
106
+ console.error(`ACTION: verify your platform is supported, reinstall, or open an issue with the log below`);
107
+ console.error(`LOG: ${teamAgent} --help => ${log}`);
108
+ process.exit(1);
109
+ }
110
+ console.log("smoke: team-agent --help ok");
111
+
91
112
  const doctorWorkspace = makeDoctorWorkspace();
92
113
  try {
93
114
  const doctor = spawnSync(teamAgent, ["doctor", "--json", "--workspace", doctorWorkspace], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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.3.4",
24
- "@team-agent/cli-darwin-x64": "0.3.4",
25
- "@team-agent/cli-linux-x64": "0.3.4"
23
+ "@team-agent/cli-darwin-arm64": "0.3.6",
24
+ "@team-agent/cli-darwin-x64": "0.3.6",
25
+ "@team-agent/cli-linux-x64": "0.3.6"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",