@team-agent/installer 0.3.3 → 0.3.5
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 +8 -0
- package/crates/team-agent/src/cli/diagnose.rs +52 -11
- package/crates/team-agent/src/cli/emit.rs +3 -2
- package/crates/team-agent/src/cli/mod.rs +225 -80
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/status_port.rs +135 -7
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
- package/crates/team-agent/src/cli/types.rs +5 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/coordinator/backoff.rs +57 -9
- package/crates/team-agent/src/coordinator/health.rs +65 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/watch.rs +4 -2
- package/crates/team-agent/src/coordinator/tick.rs +195 -43
- package/crates/team-agent/src/leader/helpers.rs +2 -0
- package/crates/team-agent/src/leader/rediscover.rs +1 -0
- package/crates/team-agent/src/leader/start.rs +9 -1
- package/crates/team-agent/src/leader/takeover.rs +18 -1
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +772 -285
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +16 -5
- package/crates/team-agent/src/lifecycle/restart/common.rs +35 -25
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +31 -25
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +5 -5
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +5 -3
- package/crates/team-agent/src/lifecycle/types.rs +4 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +361 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
- package/crates/team-agent/src/mcp_server/tools.rs +65 -9
- package/crates/team-agent/src/mcp_server/wire.rs +2 -1
- package/crates/team-agent/src/message_store.rs +80 -0
- package/crates/team-agent/src/messaging/results.rs +76 -5
- package/crates/team-agent/src/messaging/send.rs +3 -1
- package/crates/team-agent/src/messaging/types.rs +15 -1
- package/crates/team-agent/src/messaging/watchers.rs +68 -30
- package/crates/team-agent/src/model/enums.rs +7 -1
- package/crates/team-agent/src/model/permissions.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +3 -1
- package/crates/team-agent/src/provider/adapter.rs +472 -7
- package/crates/team-agent/src/provider/classify.rs +6 -2
- package/crates/team-agent/src/provider/faults.rs +3 -2
- package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
- package/crates/team-agent/src/provider/types.rs +11 -0
- package/crates/team-agent/src/session_capture.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +95 -19
- package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
- package/crates/team-agent/src/tmux_backend.rs +134 -6
- package/crates/team-agent/src/transport.rs +32 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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(
|
|
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
|
-
) ->
|
|
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(
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
281
|
-
let
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
307
|
-
let
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
|
664
|
-
//
|
|
665
|
-
//
|
|
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
|
}
|
|
@@ -318,6 +330,60 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
|
|
|
318
330
|
format!("ta-{:012x}", hasher.finish() & 0xffff_ffff_ffff)
|
|
319
331
|
}
|
|
320
332
|
|
|
333
|
+
pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
334
|
+
let socket_name = socket_name_for_workspace(workspace);
|
|
335
|
+
let roots = tmux_socket_roots();
|
|
336
|
+
for root in &roots {
|
|
337
|
+
let root = root.canonicalize().unwrap_or_else(|_| root.clone());
|
|
338
|
+
let candidate = root.join(&socket_name);
|
|
339
|
+
if candidate.exists() {
|
|
340
|
+
return Some(candidate.canonicalize().unwrap_or(candidate));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
let uid = unsafe { libc::geteuid() };
|
|
344
|
+
let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
|
|
345
|
+
let default_root = default_root
|
|
346
|
+
.canonicalize()
|
|
347
|
+
.unwrap_or(default_root);
|
|
348
|
+
Some(default_root.join(socket_name))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
pub(crate) fn attach_command_for_workspace(
|
|
352
|
+
workspace: &Path,
|
|
353
|
+
session_name: &SessionName,
|
|
354
|
+
window_name: &str,
|
|
355
|
+
) -> Option<String> {
|
|
356
|
+
let socket_path = socket_path_for_workspace(workspace)?;
|
|
357
|
+
Some(format!(
|
|
358
|
+
"tmux -S {} attach -t {}:{}",
|
|
359
|
+
socket_path.display(),
|
|
360
|
+
session_name.as_str(),
|
|
361
|
+
window_name
|
|
362
|
+
))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
pub(crate) fn attach_commands_for_windows<'a>(
|
|
366
|
+
workspace: &Path,
|
|
367
|
+
session_name: &SessionName,
|
|
368
|
+
window_names: impl IntoIterator<Item = &'a str>,
|
|
369
|
+
) -> Vec<String> {
|
|
370
|
+
window_names
|
|
371
|
+
.into_iter()
|
|
372
|
+
.filter_map(|window_name| attach_command_for_workspace(workspace, session_name, window_name))
|
|
373
|
+
.collect()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
fn tmux_socket_roots() -> Vec<PathBuf> {
|
|
377
|
+
let uid = unsafe { libc::geteuid() };
|
|
378
|
+
let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
|
|
379
|
+
if let Some(tmpdir) = std::env::var_os("TMPDIR") {
|
|
380
|
+
roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
|
|
381
|
+
}
|
|
382
|
+
roots.sort();
|
|
383
|
+
roots.dedup();
|
|
384
|
+
roots
|
|
385
|
+
}
|
|
386
|
+
|
|
321
387
|
pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
|
|
322
388
|
let tmux = std::env::var("TMUX")
|
|
323
389
|
.ok()
|
|
@@ -366,9 +432,10 @@ impl TmuxBackend {
|
|
|
366
432
|
argv: &[String],
|
|
367
433
|
cwd: &Path,
|
|
368
434
|
env: &BTreeMap<String, String>,
|
|
435
|
+
env_unset: &[String],
|
|
369
436
|
first: bool,
|
|
370
437
|
) -> Result<SpawnResult, TransportError> {
|
|
371
|
-
let command = shell_command(argv, cwd, env);
|
|
438
|
+
let command = shell_command(argv, cwd, env, env_unset);
|
|
372
439
|
let spawn_argv = tmux_spawn_argv(session, window, &command, first);
|
|
373
440
|
self.run_spawn(&spawn_argv)?;
|
|
374
441
|
let pane_argv = vec![
|
|
@@ -541,11 +608,24 @@ fn capture_has_pasted_content_prompt(text: &str) -> bool {
|
|
|
541
608
|
const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
|
|
542
609
|
const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
|
|
543
610
|
|
|
544
|
-
fn shell_command(
|
|
611
|
+
fn shell_command(
|
|
612
|
+
argv: &[String],
|
|
613
|
+
cwd: &Path,
|
|
614
|
+
env: &BTreeMap<String, String>,
|
|
615
|
+
env_unset: &[String],
|
|
616
|
+
) -> String {
|
|
545
617
|
let mut parts = Vec::new();
|
|
546
618
|
parts.push("cd".to_string());
|
|
547
619
|
parts.push(shell_quote(&cwd.to_string_lossy()));
|
|
548
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
|
+
}
|
|
549
629
|
for (key, value) in env {
|
|
550
630
|
parts.push(format!("{key}={}", shell_quote(value)));
|
|
551
631
|
}
|
|
@@ -589,7 +669,31 @@ impl Transport for TmuxBackend {
|
|
|
589
669
|
cwd: &Path,
|
|
590
670
|
env: &BTreeMap<String, String>,
|
|
591
671
|
) -> Result<SpawnResult, TransportError> {
|
|
592
|
-
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)
|
|
593
697
|
}
|
|
594
698
|
|
|
595
699
|
fn spawn_into(
|
|
@@ -600,7 +704,7 @@ impl Transport for TmuxBackend {
|
|
|
600
704
|
cwd: &Path,
|
|
601
705
|
env: &BTreeMap<String, String>,
|
|
602
706
|
) -> Result<SpawnResult, TransportError> {
|
|
603
|
-
self.spawn(session, window, argv, cwd, env, false)
|
|
707
|
+
self.spawn(session, window, argv, cwd, env, &[], false)
|
|
604
708
|
}
|
|
605
709
|
|
|
606
710
|
fn inject(
|
|
@@ -744,7 +848,9 @@ impl Transport for TmuxBackend {
|
|
|
744
848
|
}
|
|
745
849
|
|
|
746
850
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
747
|
-
|
|
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}";
|
|
748
854
|
let argv = self.tmux_argv(&[
|
|
749
855
|
"tmux".to_string(),
|
|
750
856
|
"list-panes".to_string(),
|
|
@@ -759,8 +865,28 @@ impl Transport for TmuxBackend {
|
|
|
759
865
|
let mut panes = Vec::new();
|
|
760
866
|
for line in output.stdout.lines().filter(|line| !line.is_empty()) {
|
|
761
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.
|
|
762
874
|
if pane.pane_pid.is_none() {
|
|
763
|
-
|
|
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
|
+
}
|
|
764
890
|
}
|
|
765
891
|
panes.push(pane);
|
|
766
892
|
}
|
|
@@ -852,6 +978,8 @@ impl Transport for TmuxBackend {
|
|
|
852
978
|
}
|
|
853
979
|
}
|
|
854
980
|
|
|
981
|
+
/// swallow batch 2 ① fallback probe (only fires when `#{pane_pid}` came back empty —
|
|
982
|
+
/// see the P5 union note in `list_targets`).
|
|
855
983
|
fn query_pane_pid(backend: &TmuxBackend, pane: &PaneId) -> Result<Option<u32>, TransportError> {
|
|
856
984
|
let argv = backend.tmux_argv(&[
|
|
857
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.3.5",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.5",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.5"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|