@team-agent/installer 0.5.60 → 0.5.61

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 (54) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +44 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +183 -0
  5. package/crates/team-agent/src/cli/emit.rs +63 -14
  6. package/crates/team-agent/src/cli/leader.rs +8 -1
  7. package/crates/team-agent/src/cli/mod.rs +263 -5
  8. package/crates/team-agent/src/cli/named_address.rs +18 -1
  9. package/crates/team-agent/src/cli/send/presentation.rs +2 -0
  10. package/crates/team-agent/src/cli/spec.rs +4 -1
  11. package/crates/team-agent/src/cli/status_port/store.rs +7 -3
  12. package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
  13. package/crates/team-agent/src/cli/types.rs +33 -0
  14. package/crates/team-agent/src/communication_mode/mod.rs +53 -0
  15. package/crates/team-agent/src/compiler/tests.rs +5 -5
  16. package/crates/team-agent/src/compiler.rs +53 -1
  17. package/crates/team-agent/src/db/message_store.rs +45 -1
  18. package/crates/team-agent/src/fake_worker.rs +21 -1
  19. package/crates/team-agent/src/leader/start.rs +732 -94
  20. package/crates/team-agent/src/leader/tests/identity.rs +64 -57
  21. package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
  22. package/crates/team-agent/src/lib.rs +4 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +1 -1
  24. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  27. package/crates/team-agent/src/lifecycle/worker_command_context.rs +29 -11
  28. package/crates/team-agent/src/mcp_server/normalize.rs +1 -0
  29. package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
  30. package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
  31. package/crates/team-agent/src/mcp_server/tools.rs +100 -66
  32. package/crates/team-agent/src/mcp_server/types.rs +5 -0
  33. package/crates/team-agent/src/mcp_server/wire.rs +27 -21
  34. package/crates/team-agent/src/messaging/delivery.rs +105 -38
  35. package/crates/team-agent/src/messaging/leader_channel.rs +33 -10
  36. package/crates/team-agent/src/messaging/leader_receiver.rs +40 -23
  37. package/crates/team-agent/src/messaging/presentation.rs +109 -0
  38. package/crates/team-agent/src/messaging/results.rs +165 -17
  39. package/crates/team-agent/src/messaging/send.rs +94 -81
  40. package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
  41. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
  42. package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
  43. package/crates/team-agent/src/messaging/types.rs +5 -0
  44. package/crates/team-agent/src/messaging/watchers.rs +5 -0
  45. package/crates/team-agent/src/model/mod.rs +1 -0
  46. package/crates/team-agent/src/model/pane_authority_refusal.rs +358 -0
  47. package/crates/team-agent/src/model/spec.rs +16 -0
  48. package/crates/team-agent/src/provider/session/capture.rs +40 -18
  49. package/crates/team-agent/src/provider/session_scan/claude.rs +92 -3
  50. package/crates/team-agent/src/provider/session_scan/common.rs +23 -7
  51. package/crates/team-agent/src/provider/session_scan.rs +5 -0
  52. package/crates/team-agent/src/topology.rs +3 -0
  53. package/package.json +4 -4
  54. package/skills/team-agent/command-coverage.json +379 -0
@@ -0,0 +1,358 @@
1
+ //! Shared typed catalog for ambient tmux pane authority refusals.
2
+ //!
3
+ //! This module owns identities and payload shape only. Launchers and CLI
4
+ //! presenters remain responsible for collecting facts and rendering them.
5
+
6
+ use std::path::PathBuf;
7
+
8
+ pub const REASON_FIELD: &str = "reason";
9
+ pub const AVAILABILITY_FIELD: &str = "availability";
10
+ pub const CAUSE_FIELD: &str = "cause";
11
+ pub const ACTION_REQUIRED_FIELD: &str = "action_required";
12
+ pub const HINT_ACTION_FIELD: &str = "hint_action";
13
+ pub const ACTION_FIELD: &str = "action";
14
+
15
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
16
+ pub enum PaneAuthorityRefusalField {
17
+ RequestedWorkspace,
18
+ ObservedPaneId,
19
+ ObservedPaneWorkspace,
20
+ Endpoint,
21
+ CallerControllingTty,
22
+ ObservedPaneTty,
23
+ }
24
+
25
+ impl PaneAuthorityRefusalField {
26
+ pub const fn as_str(self) -> &'static str {
27
+ match self {
28
+ Self::RequestedWorkspace => "requested_workspace",
29
+ Self::ObservedPaneId => "observed_pane_id",
30
+ Self::ObservedPaneWorkspace => "observed_pane_workspace",
31
+ Self::Endpoint => "endpoint",
32
+ Self::CallerControllingTty => "caller_controlling_tty",
33
+ Self::ObservedPaneTty => "observed_pane_tty",
34
+ }
35
+ }
36
+ }
37
+
38
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
39
+ pub enum PaneAuthorityRefusalReason {
40
+ AmbientTmuxEndpointUnavailable,
41
+ AmbientPaneIdUnavailable,
42
+ AmbientPaneWorkspaceUnavailable,
43
+ CallerControllingTtyUnavailable,
44
+ ObservedPaneTtyUnavailable,
45
+ PaneTtyMismatch,
46
+ PaneWorkspaceMismatch,
47
+ }
48
+
49
+ impl PaneAuthorityRefusalReason {
50
+ pub const ALL: [Self; 7] = [
51
+ Self::AmbientTmuxEndpointUnavailable,
52
+ Self::AmbientPaneIdUnavailable,
53
+ Self::AmbientPaneWorkspaceUnavailable,
54
+ Self::CallerControllingTtyUnavailable,
55
+ Self::ObservedPaneTtyUnavailable,
56
+ Self::PaneTtyMismatch,
57
+ Self::PaneWorkspaceMismatch,
58
+ ];
59
+
60
+ pub const fn as_str(self) -> &'static str {
61
+ match self {
62
+ Self::AmbientTmuxEndpointUnavailable => "AmbientTmuxEndpointUnavailable",
63
+ Self::AmbientPaneIdUnavailable => "AmbientPaneIdUnavailable",
64
+ Self::AmbientPaneWorkspaceUnavailable => "AmbientPaneWorkspaceUnavailable",
65
+ Self::CallerControllingTtyUnavailable => "CallerControllingTtyUnavailable",
66
+ Self::ObservedPaneTtyUnavailable => "ObservedPaneTtyUnavailable",
67
+ Self::PaneTtyMismatch => "PaneTtyMismatch",
68
+ Self::PaneWorkspaceMismatch => "PaneWorkspaceMismatch",
69
+ }
70
+ }
71
+
72
+ pub const fn required_fact_fields(self) -> &'static [PaneAuthorityRefusalField] {
73
+ use PaneAuthorityRefusalField::{
74
+ CallerControllingTty, Endpoint, ObservedPaneId, ObservedPaneTty, ObservedPaneWorkspace,
75
+ RequestedWorkspace,
76
+ };
77
+
78
+ match self {
79
+ Self::AmbientTmuxEndpointUnavailable => &[RequestedWorkspace, Endpoint],
80
+ Self::AmbientPaneIdUnavailable => &[RequestedWorkspace, ObservedPaneId, Endpoint],
81
+ Self::AmbientPaneWorkspaceUnavailable => &[
82
+ RequestedWorkspace,
83
+ ObservedPaneId,
84
+ ObservedPaneWorkspace,
85
+ Endpoint,
86
+ ],
87
+ Self::CallerControllingTtyUnavailable => &[
88
+ RequestedWorkspace,
89
+ ObservedPaneId,
90
+ Endpoint,
91
+ CallerControllingTty,
92
+ ],
93
+ Self::ObservedPaneTtyUnavailable => &[
94
+ RequestedWorkspace,
95
+ ObservedPaneId,
96
+ Endpoint,
97
+ ObservedPaneTty,
98
+ ],
99
+ Self::PaneTtyMismatch => &[
100
+ RequestedWorkspace,
101
+ ObservedPaneId,
102
+ Endpoint,
103
+ CallerControllingTty,
104
+ ObservedPaneTty,
105
+ ],
106
+ Self::PaneWorkspaceMismatch => &[
107
+ RequestedWorkspace,
108
+ ObservedPaneId,
109
+ ObservedPaneWorkspace,
110
+ Endpoint,
111
+ ],
112
+ }
113
+ }
114
+ }
115
+
116
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
117
+ pub enum PaneAuthorityFactAvailability {
118
+ Unavailable,
119
+ }
120
+
121
+ impl PaneAuthorityFactAvailability {
122
+ pub const fn as_str(self) -> &'static str {
123
+ match self {
124
+ Self::Unavailable => "unavailable",
125
+ }
126
+ }
127
+ }
128
+
129
+ #[derive(Clone, Debug, Eq, PartialEq)]
130
+ pub struct UnavailablePaneAuthorityFact<C> {
131
+ pub cause: C,
132
+ }
133
+
134
+ impl<C> UnavailablePaneAuthorityFact<C> {
135
+ pub const fn new(cause: C) -> Self {
136
+ Self { cause }
137
+ }
138
+
139
+ pub const fn availability(&self) -> PaneAuthorityFactAvailability {
140
+ PaneAuthorityFactAvailability::Unavailable
141
+ }
142
+ }
143
+
144
+ macro_rules! unavailable_causes {
145
+ ($name:ident { $($variant:ident => $wire:literal),+ $(,)? }) => {
146
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
147
+ pub enum $name {
148
+ $($variant),+
149
+ }
150
+
151
+ impl $name {
152
+ pub const fn as_str(self) -> &'static str {
153
+ match self {
154
+ $(Self::$variant => $wire),+
155
+ }
156
+ }
157
+ }
158
+ };
159
+ }
160
+
161
+ unavailable_causes!(AmbientTmuxEndpointUnavailableCause {
162
+ TmuxValueNotUnicode => "tmux_value_not_unicode",
163
+ TmuxTupleFieldCountInvalid => "tmux_tuple_field_count_invalid",
164
+ EndpointMissing => "endpoint_missing",
165
+ EndpointNotAbsolute => "endpoint_not_absolute",
166
+ ServerPidInvalid => "server_pid_invalid",
167
+ SessionIndexInvalid => "session_index_invalid",
168
+ });
169
+
170
+ unavailable_causes!(AmbientPaneIdUnavailableCause {
171
+ EnvironmentValueMissing => "environment_value_missing",
172
+ EnvironmentValueEmpty => "environment_value_empty",
173
+ EnvironmentValueNotUnicode => "environment_value_not_unicode",
174
+ });
175
+
176
+ unavailable_causes!(AmbientPaneWorkspaceUnavailableCause {
177
+ PaneQueryFailed => "pane_query_failed",
178
+ PaneNotFound => "pane_not_found",
179
+ CurrentPathMissing => "current_path_missing",
180
+ });
181
+
182
+ unavailable_causes!(CallerControllingTtyUnavailableCause {
183
+ NoControllingTty => "no_controlling_tty",
184
+ DeviceIdentityUnresolvable => "device_identity_unresolvable",
185
+ PlatformUnsupported => "platform_unsupported",
186
+ });
187
+
188
+ unavailable_causes!(ObservedPaneTtyUnavailableCause {
189
+ PaneTtyMissing => "pane_tty_missing",
190
+ PaneTtyUnresolvable => "pane_tty_unresolvable",
191
+ PaneTtyNotCharacterDevice => "pane_tty_not_character_device",
192
+ });
193
+
194
+ #[derive(Clone, Debug, Eq, PartialEq)]
195
+ pub struct AmbientTmuxEndpointUnavailableFacts {
196
+ pub requested_workspace: PathBuf,
197
+ pub endpoint: UnavailablePaneAuthorityFact<AmbientTmuxEndpointUnavailableCause>,
198
+ }
199
+
200
+ #[derive(Clone, Debug, Eq, PartialEq)]
201
+ pub struct AmbientPaneIdUnavailableFacts {
202
+ pub requested_workspace: PathBuf,
203
+ pub observed_pane_id: UnavailablePaneAuthorityFact<AmbientPaneIdUnavailableCause>,
204
+ pub endpoint: String,
205
+ }
206
+
207
+ #[derive(Clone, Debug, Eq, PartialEq)]
208
+ pub struct AmbientPaneWorkspaceUnavailableFacts {
209
+ pub requested_workspace: PathBuf,
210
+ pub observed_pane_id: String,
211
+ pub observed_pane_workspace: UnavailablePaneAuthorityFact<AmbientPaneWorkspaceUnavailableCause>,
212
+ pub endpoint: String,
213
+ }
214
+
215
+ #[derive(Clone, Debug, Eq, PartialEq)]
216
+ pub struct CallerControllingTtyUnavailableFacts {
217
+ pub requested_workspace: PathBuf,
218
+ pub observed_pane_id: String,
219
+ pub endpoint: String,
220
+ pub caller_controlling_tty: UnavailablePaneAuthorityFact<CallerControllingTtyUnavailableCause>,
221
+ }
222
+
223
+ #[derive(Clone, Debug, Eq, PartialEq)]
224
+ pub struct ObservedPaneTtyUnavailableFacts {
225
+ pub requested_workspace: PathBuf,
226
+ pub observed_pane_id: String,
227
+ pub endpoint: String,
228
+ pub observed_pane_tty: UnavailablePaneAuthorityFact<ObservedPaneTtyUnavailableCause>,
229
+ }
230
+
231
+ #[derive(Clone, Debug, Eq, PartialEq)]
232
+ pub struct PaneTtyMismatchFacts {
233
+ pub requested_workspace: PathBuf,
234
+ pub observed_pane_id: String,
235
+ pub endpoint: String,
236
+ pub caller_controlling_tty: u64,
237
+ pub observed_pane_tty: u64,
238
+ }
239
+
240
+ #[derive(Clone, Debug, Eq, PartialEq)]
241
+ pub struct PaneWorkspaceMismatchFacts {
242
+ pub requested_workspace: PathBuf,
243
+ pub observed_pane_id: String,
244
+ pub observed_pane_workspace: PathBuf,
245
+ pub endpoint: String,
246
+ }
247
+
248
+ #[derive(Clone, Debug, Eq, PartialEq)]
249
+ pub enum PaneAuthorityRefusalFacts {
250
+ AmbientTmuxEndpointUnavailable(AmbientTmuxEndpointUnavailableFacts),
251
+ AmbientPaneIdUnavailable(AmbientPaneIdUnavailableFacts),
252
+ AmbientPaneWorkspaceUnavailable(AmbientPaneWorkspaceUnavailableFacts),
253
+ CallerControllingTtyUnavailable(CallerControllingTtyUnavailableFacts),
254
+ ObservedPaneTtyUnavailable(ObservedPaneTtyUnavailableFacts),
255
+ PaneTtyMismatch(PaneTtyMismatchFacts),
256
+ PaneWorkspaceMismatch(PaneWorkspaceMismatchFacts),
257
+ }
258
+
259
+ impl PaneAuthorityRefusalFacts {
260
+ pub const fn reason(&self) -> PaneAuthorityRefusalReason {
261
+ match self {
262
+ Self::AmbientTmuxEndpointUnavailable(_) => {
263
+ PaneAuthorityRefusalReason::AmbientTmuxEndpointUnavailable
264
+ }
265
+ Self::AmbientPaneIdUnavailable(_) => {
266
+ PaneAuthorityRefusalReason::AmbientPaneIdUnavailable
267
+ }
268
+ Self::AmbientPaneWorkspaceUnavailable(_) => {
269
+ PaneAuthorityRefusalReason::AmbientPaneWorkspaceUnavailable
270
+ }
271
+ Self::CallerControllingTtyUnavailable(_) => {
272
+ PaneAuthorityRefusalReason::CallerControllingTtyUnavailable
273
+ }
274
+ Self::ObservedPaneTtyUnavailable(_) => {
275
+ PaneAuthorityRefusalReason::ObservedPaneTtyUnavailable
276
+ }
277
+ Self::PaneTtyMismatch(_) => PaneAuthorityRefusalReason::PaneTtyMismatch,
278
+ Self::PaneWorkspaceMismatch(_) => PaneAuthorityRefusalReason::PaneWorkspaceMismatch,
279
+ }
280
+ }
281
+ }
282
+
283
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
284
+ pub enum PaneAuthorityRecoveryHint {
285
+ AttachLeader,
286
+ }
287
+
288
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
289
+ pub enum PaneAuthorityRecoveryAction {
290
+ OpenTerminalOutsideCurrentTmuxPaneOrAttachFromMatchingPane,
291
+ }
292
+
293
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
294
+ pub struct PaneAuthorityRecovery {
295
+ pub action_required: bool,
296
+ pub hint_action: PaneAuthorityRecoveryHint,
297
+ pub action: PaneAuthorityRecoveryAction,
298
+ }
299
+
300
+ impl PaneAuthorityRecovery {
301
+ pub const REQUIRED: Self = Self {
302
+ action_required: true,
303
+ hint_action: PaneAuthorityRecoveryHint::AttachLeader,
304
+ action:
305
+ PaneAuthorityRecoveryAction::OpenTerminalOutsideCurrentTmuxPaneOrAttachFromMatchingPane,
306
+ };
307
+ }
308
+
309
+ #[derive(Clone, Debug, Eq, PartialEq)]
310
+ pub struct PaneAuthorityRefusal {
311
+ pub facts: PaneAuthorityRefusalFacts,
312
+ pub recovery: PaneAuthorityRecovery,
313
+ }
314
+
315
+ impl PaneAuthorityRefusal {
316
+ pub const fn new(facts: PaneAuthorityRefusalFacts) -> Self {
317
+ Self {
318
+ facts,
319
+ recovery: PaneAuthorityRecovery::REQUIRED,
320
+ }
321
+ }
322
+
323
+ pub const fn reason(&self) -> PaneAuthorityRefusalReason {
324
+ self.facts.reason()
325
+ }
326
+ }
327
+
328
+ #[cfg(test)]
329
+ mod tests {
330
+ use super::{PaneAuthorityRefusalField as Field, PaneAuthorityRefusalReason as Reason};
331
+
332
+ #[test]
333
+ fn reason_catalog_is_closed_and_mismatch_requires_all_four_workspace_facts() {
334
+ assert_eq!(Reason::ALL.len(), 7);
335
+ assert_eq!(
336
+ Reason::PaneWorkspaceMismatch.required_fact_fields(),
337
+ &[
338
+ Field::RequestedWorkspace,
339
+ Field::ObservedPaneId,
340
+ Field::ObservedPaneWorkspace,
341
+ Field::Endpoint,
342
+ ]
343
+ );
344
+ }
345
+
346
+ #[test]
347
+ fn unavailable_reasons_require_the_fact_they_could_not_observe() {
348
+ assert!(Reason::AmbientTmuxEndpointUnavailable
349
+ .required_fact_fields()
350
+ .contains(&Field::Endpoint));
351
+ assert!(Reason::AmbientPaneIdUnavailable
352
+ .required_fact_fields()
353
+ .contains(&Field::ObservedPaneId));
354
+ assert!(Reason::AmbientPaneWorkspaceUnavailable
355
+ .required_fact_fields()
356
+ .contains(&Field::ObservedPaneWorkspace));
357
+ }
358
+ }
@@ -400,6 +400,7 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
400
400
  "preferred_for",
401
401
  "avoid_for",
402
402
  "output_contract",
403
+ "communication_mode",
403
404
  "paused",
404
405
  "auth_mode",
405
406
  "profile",
@@ -412,6 +413,21 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
412
413
  if !agent.is_map() {
413
414
  return;
414
415
  }
416
+ if let Some(value) = agent.get("communication_mode") {
417
+ match value.as_str() {
418
+ Some(raw) if crate::communication_mode::CommunicationMode::parse(raw).is_none() => {
419
+ errors.push(format!(
420
+ "{path}/communication_mode: unknown communication_mode '{raw}'"
421
+ ));
422
+ }
423
+ None => {
424
+ errors.push(format!(
425
+ "{path}/communication_mode: unknown communication_mode"
426
+ ));
427
+ }
428
+ Some(_) => {}
429
+ }
430
+ }
415
431
  // 0.4.x provider effort MVP step 3: effort syntactic + semantic validation.
416
432
  if let Some(raw) = agent.get("effort").and_then(Yaml::as_str) {
417
433
  match crate::model::enums::ProviderEffort::parse(raw) {
@@ -284,12 +284,22 @@ where
284
284
  .zip(candidate.captured.session_id.as_ref())
285
285
  .is_some_and(|(expected, captured)| expected.as_str() != captured.as_str());
286
286
  if mismatch {
287
- report.ambiguous.push(AmbiguousSessionCapture {
288
- agent_id: item.agent_id.clone(),
289
- spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
290
- });
287
+ let ambiguity_changed = agent_obj
288
+ .get("attribution_ambiguous")
289
+ .and_then(Value::as_bool)
290
+ != Some(true);
291
+ if !finalize_ambiguous || ambiguity_changed {
292
+ report.ambiguous.push(AmbiguousSessionCapture {
293
+ agent_id: item.agent_id.clone(),
294
+ spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
295
+ });
296
+ }
291
297
  if finalize_ambiguous {
292
- agent_obj.insert("attribution_ambiguous".to_string(), serde_json::json!(true));
298
+ if ambiguity_changed {
299
+ agent_obj
300
+ .insert("attribution_ambiguous".to_string(), serde_json::json!(true));
301
+ report.changed = true;
302
+ }
293
303
  // 0.4.6 tuple-atomic contract (audit:93): do NOT write
294
304
  // `captured_at` for ambiguity diagnostics. `captured_at`
295
305
  // is part of the authoritative tuple and may only be
@@ -299,7 +309,6 @@ where
299
309
  // "looks captured" rows. Ambiguity diagnostics live in
300
310
  // the `attribution_ambiguous` flag + events; persist
301
311
  // event log for the timestamp.
302
- report.changed = true;
303
312
  }
304
313
  continue;
305
314
  }
@@ -317,19 +326,29 @@ where
317
326
  continue;
318
327
  }
319
328
  if ambiguous_ids.contains(&item.agent_id) {
320
- report.ambiguous.push(AmbiguousSessionCapture {
321
- agent_id: item.agent_id.clone(),
322
- spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
323
- });
329
+ let ambiguity_changed = agent_obj
330
+ .get("attribution_ambiguous")
331
+ .and_then(Value::as_bool)
332
+ != Some(true)
333
+ || agent_obj.get("capture_state").and_then(Value::as_str)
334
+ != Some("attribution_ambiguous");
335
+ if !finalize_ambiguous || ambiguity_changed {
336
+ report.ambiguous.push(AmbiguousSessionCapture {
337
+ agent_id: item.agent_id.clone(),
338
+ spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
339
+ });
340
+ }
324
341
  if finalize_ambiguous {
325
- agent_obj.insert("attribution_ambiguous".to_string(), serde_json::json!(true));
326
- agent_obj.insert(
327
- "capture_state".to_string(),
328
- serde_json::json!("attribution_ambiguous"),
329
- );
330
- // 0.4.6 tuple-atomic contract: see comment above. Removed
331
- // `captured_at` ambiguity write.
332
- report.changed = true;
342
+ if ambiguity_changed {
343
+ agent_obj.insert("attribution_ambiguous".to_string(), serde_json::json!(true));
344
+ agent_obj.insert(
345
+ "capture_state".to_string(),
346
+ serde_json::json!("attribution_ambiguous"),
347
+ );
348
+ // 0.4.6 tuple-atomic contract: see comment above. Removed
349
+ // `captured_at` ambiguity write.
350
+ report.changed = true;
351
+ }
333
352
  }
334
353
  continue;
335
354
  }
@@ -666,6 +685,9 @@ fn pending_session_capture<F>(
666
685
  where
667
686
  F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
668
687
  {
688
+ if agent.get("capture_state").and_then(Value::as_str) == Some("attribution_ambiguous") {
689
+ return None;
690
+ }
669
691
  let provider = incomplete_resumable_provider(agent, adapter_for)?;
670
692
  let spawn_cwd = agent
671
693
  .get("spawn_cwd")
@@ -1,6 +1,7 @@
1
1
  use std::path::{Path, PathBuf};
2
2
 
3
- use crate::provider::types::SessionId;
3
+ use crate::provider::helpers::find_session_id;
4
+ use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
4
5
  use crate::provider::Provider;
5
6
 
6
7
  use super::{CaptureSessionContext, CapturedSessionCandidate};
@@ -14,7 +15,7 @@ pub(crate) fn projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<Path
14
15
  Some(home.join(".claude").join("projects").join(encoded))
15
16
  }
16
17
 
17
- fn encode_projects_dir(path: &str) -> String {
18
+ pub(super) fn encode_projects_dir(path: &str) -> String {
18
19
  let mut out = String::with_capacity(path.len());
19
20
  for c in path.chars() {
20
21
  if c.is_ascii_alphanumeric() {
@@ -26,6 +27,65 @@ fn encode_projects_dir(path: &str) -> String {
26
27
  out
27
28
  }
28
29
 
30
+ pub(super) fn scan_expected_session(
31
+ context: &CaptureSessionContext,
32
+ ) -> Vec<CapturedSessionCandidate> {
33
+ let Some(expected) = context.expected_session_id.as_ref() else {
34
+ return Vec::new();
35
+ };
36
+ let Some(projects_root) = context.provider_projects_root.clone().or_else(|| {
37
+ std::env::var_os("HOME")
38
+ .map(PathBuf::from)
39
+ .map(|home| home.join(".claude").join("projects"))
40
+ }) else {
41
+ return Vec::new();
42
+ };
43
+ let canonical =
44
+ std::fs::canonicalize(&context.spawn_cwd).unwrap_or_else(|_| context.spawn_cwd.clone());
45
+ let encoded = encode_projects_dir(&canonical.to_string_lossy());
46
+ if encoded.is_empty() {
47
+ return Vec::new();
48
+ }
49
+ let path = projects_root
50
+ .join(encoded)
51
+ .join(format!("{}.jsonl", expected.as_str()));
52
+ let Ok(text) = super::common::read_head_text(&path, super::common::CAPTURE_HEAD_BYTES) else {
53
+ return Vec::new();
54
+ };
55
+ let records = super::common::parse_session_records(&text);
56
+ let session_matches = records
57
+ .iter()
58
+ .find_map(find_session_id)
59
+ .is_some_and(|session_id| session_id == expected.as_str());
60
+ let has_lifecycle_record = records.iter().any(|record| {
61
+ matches!(
62
+ record.get("type").and_then(serde_json::Value::as_str),
63
+ Some("user" | "assistant")
64
+ )
65
+ });
66
+ if !session_matches
67
+ || !has_lifecycle_record
68
+ || records_have_leader_marker(&records)
69
+ || !records.iter().any(has_cwd_field)
70
+ {
71
+ return Vec::new();
72
+ }
73
+ let embedded_agent_id = super::common::embedded_team_agent_worker_id_from_text(&text);
74
+ let positive_agent_id_match = embedded_agent_id.as_deref() == Some(context.agent_id.as_str());
75
+ vec![CapturedSessionCandidate {
76
+ captured: CapturedSession {
77
+ session_id: Some(expected.clone()),
78
+ rollout_path: Some(RolloutPath::new(path)),
79
+ captured_via: CaptureVia::FsWatch,
80
+ attribution_confidence: Confidence::High,
81
+ spawn_cwd: context.spawn_cwd.clone(),
82
+ },
83
+ embedded_agent_id,
84
+ positive_agent_id_match,
85
+ agent_path_match: false,
86
+ }]
87
+ }
88
+
29
89
  pub(crate) fn rollout_path_has_leader_marker(provider: Provider, rollout_path: &Path) -> bool {
30
90
  if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
31
91
  return false;
@@ -112,6 +172,7 @@ mod tests {
112
172
  std::fs::create_dir_all(dir).unwrap();
113
173
  let path = dir.join(format!("{uuid}.jsonl"));
114
174
  let line = serde_json::json!({
175
+ "type": "user",
115
176
  "sessionId": uuid,
116
177
  "cwd": cwd.to_string_lossy(),
117
178
  });
@@ -119,6 +180,11 @@ mod tests {
119
180
  path
120
181
  }
121
182
 
183
+ fn expected_transcript_dir(projects_root: &Path, cwd: &Path) -> PathBuf {
184
+ let canonical = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
185
+ projects_root.join(encode_projects_dir(&canonical.to_string_lossy()))
186
+ }
187
+
122
188
  #[test]
123
189
  fn claude_leader_marker_in_custom_title_is_detected() {
124
190
  let records = vec![serde_json::json!({
@@ -205,7 +271,11 @@ mod tests {
205
271
  std::fs::create_dir_all(&cwd).unwrap();
206
272
  let proj = base.join("projects");
207
273
  write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
208
- write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
274
+ let expected = write_transcript(
275
+ &expected_transcript_dir(&proj, &cwd),
276
+ "22222222-2222-4222-8222-222222222222",
277
+ &cwd,
278
+ );
209
279
  let ctx = CaptureSessionContext {
210
280
  agent_id: "w1".to_string(),
211
281
  spawn_cwd: cwd.clone(),
@@ -221,6 +291,10 @@ mod tests {
221
291
  out[0].captured.session_id.as_ref().unwrap().as_str(),
222
292
  "22222222-2222-4222-8222-222222222222"
223
293
  );
294
+ assert_eq!(
295
+ out[0].captured.rollout_path.as_ref().unwrap().as_path(),
296
+ expected
297
+ );
224
298
  let _ = std::fs::remove_dir_all(&base);
225
299
  }
226
300
 
@@ -284,6 +358,21 @@ mod tests {
284
358
  let proj = base.join("projects");
285
359
  let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
286
360
  let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
361
+ let addressed =
362
+ expected_transcript_dir(&proj, &cwd).join("99999999-9999-4999-8999-999999999999.jsonl");
363
+ std::fs::create_dir_all(addressed.parent().unwrap()).unwrap();
364
+ std::fs::write(
365
+ &addressed,
366
+ format!(
367
+ "{}\n",
368
+ serde_json::json!({
369
+ "type": "user",
370
+ "sessionId": "22222222-2222-4222-8222-222222222222",
371
+ "cwd": cwd.to_string_lossy(),
372
+ })
373
+ ),
374
+ )
375
+ .unwrap();
287
376
  let _ = (leader, stale);
288
377
  let ctx = CaptureSessionContext {
289
378
  agent_id: "claude-worker".to_string(),