@team-agent/installer 0.5.52 → 0.5.54

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 (87) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +111 -3
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  7. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  8. package/crates/team-agent/src/cli/send.rs +1 -0
  9. package/crates/team-agent/src/cli/spec.rs +4 -1
  10. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +12 -0
  15. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  16. package/crates/team-agent/src/db/message_store.rs +31 -2
  17. package/crates/team-agent/src/db/migration.rs +7 -6
  18. package/crates/team-agent/src/db/schema.rs +18 -5
  19. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  20. package/crates/team-agent/src/kill_audit.rs +67 -0
  21. package/crates/team-agent/src/leader/lease.rs +324 -57
  22. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  24. package/crates/team-agent/src/leader/start.rs +144 -23
  25. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  26. package/crates/team-agent/src/leader/types.rs +6 -0
  27. package/crates/team-agent/src/lib.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  30. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  31. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  32. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  33. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  34. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  35. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  36. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  37. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  42. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  43. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  44. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  46. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  47. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  48. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  51. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  52. package/crates/team-agent/src/mcp_server/tools.rs +89 -4
  53. package/crates/team-agent/src/mcp_server/types.rs +7 -0
  54. package/crates/team-agent/src/mcp_server/wire.rs +65 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  56. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  57. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  58. package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
  59. package/crates/team-agent/src/messaging/mod.rs +2 -1
  60. package/crates/team-agent/src/messaging/persist.rs +68 -2
  61. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  62. package/crates/team-agent/src/messaging/results.rs +130 -3
  63. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  64. package/crates/team-agent/src/messaging/send.rs +54 -2
  65. package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
  66. package/crates/team-agent/src/messaging/types.rs +2 -0
  67. package/crates/team-agent/src/messaging/watchers.rs +1 -0
  68. package/crates/team-agent/src/provider/adapter.rs +54 -13
  69. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  70. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  71. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  72. package/crates/team-agent/src/provider/session/capture.rs +395 -52
  73. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  74. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  75. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  76. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  77. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  78. package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
  79. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  80. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  81. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  82. package/crates/team-agent/src/provider/tests.rs +1 -0
  83. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  84. package/crates/team-agent/src/tmux_backend.rs +29 -2
  85. package/package.json +4 -4
  86. package/schemas/result-envelope.schema.json +10 -0
  87. package/skills/team-agent/SKILL.md +11 -3
@@ -33,6 +33,7 @@ pub fn tools_contract() -> Vec<Value> {
33
33
  McpTool::StopAgent,
34
34
  McpTool::ResetAgent,
35
35
  McpTool::AddAgent,
36
+ McpTool::CloneAgent,
36
37
  McpTool::ForkAgent,
37
38
  McpTool::RequestHuman,
38
39
  McpTool::StuckList,
@@ -368,16 +369,20 @@ fn python_repr(value: &str) -> String {
368
369
  fn tool_contract(tool: McpTool) -> Value {
369
370
  let (description, required) = match tool {
370
371
  McpTool::SendMessage => (
371
- "Send a message to a teammate, the leader, or '*' for all other team members. Provide only target and content; Team Agent fills sender, task id, ack policy, and delivery metadata.",
372
+ "Send a message to a teammate, the leader, or '*' for all other team members. Team Agent fills identity and delivery metadata; optional presentation routing is durable and never drops the message.",
372
373
  vec!["to", "content"],
373
374
  ),
374
375
  McpTool::AssignTask => ("Assign or update a task in the team graph and deliver it to its assignee.", vec!["task"]),
375
- McpTool::ReportResult => ("Report task completion with a result envelope.", Vec::new()),
376
+ McpTool::ReportResult => (
377
+ "Report task completion with a durable result envelope. Optional presentation routing controls live leader display, not persistence.",
378
+ Vec::new(),
379
+ ),
376
380
  McpTool::UpdateState => ("Append a note to team state and rewrite team_state.md.", vec!["note"]),
377
381
  McpTool::GetTeamStatus => ("Return machine-readable team status.", Vec::new()),
378
382
  McpTool::StopAgent => ("Stop a running worker.", vec!["agent_id"]),
379
383
  McpTool::ResetAgent => ("Reset one worker to a fresh session.", vec!["agent_id", "discard_session"]),
380
384
  McpTool::AddAgent => ("Add a first-class worker from a role file.", vec!["new_agent_id", "role_file_path"]),
385
+ McpTool::CloneAgent => ("Clone a worker role into a fresh provider session.", vec!["source_agent_id", "as_agent_id"]),
381
386
  McpTool::ForkAgent => ("Fork a running worker.", vec!["source_agent_id", "as_agent_id"]),
382
387
  McpTool::RequestHuman => ("Ask the leader or user for human input.", vec!["question"]),
383
388
  McpTool::StuckList => ("List manually suppressed idle alerts.", Vec::new()),
@@ -417,6 +422,11 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
417
422
  string_property("Target agent id, 'leader', or '*' for broadcast."),
418
423
  );
419
424
  insert_property(&mut properties, "content", string_property("Message body."));
425
+ insert_property(
426
+ &mut properties,
427
+ "presentation",
428
+ presentation_property("Optional durable presentation routing."),
429
+ );
420
430
  }
421
431
  McpTool::ReportResult => {
422
432
  insert_property(
@@ -465,6 +475,11 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
465
475
  "agent_id",
466
476
  string_property("Optional reporting agent id override."),
467
477
  );
478
+ insert_property(
479
+ &mut properties,
480
+ "presentation",
481
+ presentation_property("Optional durable presentation routing."),
482
+ );
468
483
  }
469
484
  McpTool::UpdateState => {
470
485
  insert_property(
@@ -522,6 +537,23 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
522
537
  string_property("Optional display label."),
523
538
  );
524
539
  }
540
+ McpTool::CloneAgent => {
541
+ insert_property(
542
+ &mut properties,
543
+ "source_agent_id",
544
+ string_property("Agent id to clone from."),
545
+ );
546
+ insert_property(
547
+ &mut properties,
548
+ "as_agent_id",
549
+ string_property("Agent id for the cloned worker."),
550
+ );
551
+ insert_property(
552
+ &mut properties,
553
+ "label",
554
+ string_property("Optional display label."),
555
+ );
556
+ }
525
557
  McpTool::RequestHuman => {
526
558
  insert_property(
527
559
  &mut properties,
@@ -571,6 +603,23 @@ fn object_property(description: &str) -> Value {
571
603
  serde_json::json!({"type": "object", "description": description, "additionalProperties": true})
572
604
  }
573
605
 
606
+ fn presentation_property(description: &str) -> Value {
607
+ serde_json::json!({
608
+ "type": "object",
609
+ "description": description,
610
+ "properties": {
611
+ "sink": {"type": "string", "enum": ["leader", "casefile", "silent"]},
612
+ "class": {"type": "string", "enum": [
613
+ "message", "progress", "stage_result", "stage_pass", "bounce",
614
+ "blocking", "final_review", "timeout"
615
+ ]},
616
+ "case_id": {"type": "string"}
617
+ },
618
+ "required": ["sink", "class"],
619
+ "additionalProperties": false
620
+ })
621
+ }
622
+
574
623
  fn array_property(description: &str) -> Value {
575
624
  serde_json::json!({"type": "array", "description": description, "items": {"type": "object", "additionalProperties": true}})
576
625
  }
@@ -591,12 +640,13 @@ pub(crate) fn dispatch_tool(
591
640
  McpTool::SendMessage => {
592
641
  let target = message_target_from_value(args.get("to"));
593
642
  let content = args.get("content").and_then(Value::as_str).unwrap_or("");
594
- let outcome = tools.send_message(
643
+ let outcome = tools.send_message_with_presentation(
595
644
  &target,
596
645
  content,
597
646
  None,
598
647
  None,
599
648
  None,
649
+ args.get("presentation"),
600
650
  )?;
601
651
  match outcome {
602
652
  SendOutcome::WorkerAccepted { .. } => Ok(ToolOk {
@@ -605,7 +655,7 @@ pub(crate) fn dispatch_tool(
605
655
  SendOutcome::Direct(ok) => Ok(ok),
606
656
  }
607
657
  }
608
- McpTool::ReportResult => tools.report_result(
658
+ McpTool::ReportResult => tools.report_result_with_presentation(
609
659
  args.get("envelope"),
610
660
  args.get("summary").and_then(Value::as_str),
611
661
  // cr verdict (T3-1 refined): an unknown status literal normalizes to
@@ -637,6 +687,7 @@ pub(crate) fn dispatch_tool(
637
687
  .map(Vec::as_slice),
638
688
  args.get("task_id").and_then(Value::as_str),
639
689
  args.get("agent_id").and_then(Value::as_str),
690
+ args.get("presentation"),
640
691
  ),
641
692
  McpTool::UpdateState => {
642
693
  tools.update_state(args.get("note").and_then(Value::as_str).unwrap_or(""))
@@ -668,6 +719,15 @@ pub(crate) fn dispatch_tool(
668
719
  .unwrap_or(""),
669
720
  args.get("label").and_then(Value::as_str),
670
721
  ),
722
+ McpTool::CloneAgent => tools.clone_agent(
723
+ args.get("source_agent_id")
724
+ .and_then(Value::as_str)
725
+ .unwrap_or(""),
726
+ args.get("as_agent_id")
727
+ .and_then(Value::as_str)
728
+ .unwrap_or(""),
729
+ args.get("label").and_then(Value::as_str),
730
+ ),
671
731
  McpTool::RequestHuman => tools.request_human(
672
732
  args.get("question").and_then(Value::as_str).unwrap_or(""),
673
733
  args.get("task_id").and_then(Value::as_str),
@@ -695,6 +755,7 @@ fn scope_ceiling_tool(tool: McpTool) -> bool {
695
755
  | McpTool::GetTeamStatus
696
756
  | McpTool::StopAgent
697
757
  | McpTool::ResetAgent
758
+ | McpTool::CloneAgent
698
759
  | McpTool::ForkAgent
699
760
  )
700
761
  }
@@ -57,6 +57,7 @@ pub fn deliver_stored_message(
57
57
  requires_ack,
58
58
  None,
59
59
  super::InitialDisposition::Accepted,
60
+ None,
60
61
  )?
61
62
  else {
62
63
  unreachable!("internal delivery does not accept caller-supplied ids")
@@ -1828,6 +1829,7 @@ pub fn deliver_pending_messages(
1828
1829
  let store = MessageStore::open(workspace)?;
1829
1830
  let message_ids = {
1830
1831
  let conn = crate::db::schema::open_db(store.db_path())?;
1832
+ // `stored_only` is intentionally absent: C6 chose durable presentation without C5.
1831
1833
  let mut stmt = conn.prepare(
1832
1834
  "select message_id from messages
1833
1835
  where status in (
@@ -25,6 +25,7 @@ static RESULT_COUNTER: AtomicU64 = AtomicU64::new(0);
25
25
  pub(crate) fn status_wire(status: DeliveryStatus) -> &'static str {
26
26
  match status {
27
27
  DeliveryStatus::Delivered => "delivered",
28
+ DeliveryStatus::StoredOnly => "stored_only",
28
29
  DeliveryStatus::Failed => "failed",
29
30
  DeliveryStatus::Queued => "queued",
30
31
  DeliveryStatus::Blocked => "blocked",
@@ -101,6 +101,7 @@ pub fn resolve_live_leader_channel(
101
101
  .current_path
102
102
  .as_deref()
103
103
  .is_some_and(|path| !path_is_in_workspace(path, workspace))
104
+ && !explicit_claim_authority_matches(workspace, receiver, &observed)
104
105
  {
105
106
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
106
107
  }
@@ -113,14 +114,40 @@ pub fn resolve_live_leader_channel(
113
114
  }))
114
115
  }
115
116
 
116
- fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
117
- let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
118
- let workspace = workspace
119
- .canonicalize()
120
- .unwrap_or_else(|_| workspace.to_path_buf());
117
+ fn explicit_claim_authority_matches(
118
+ workspace: &Path,
119
+ receiver: &Value,
120
+ observed: &PaneInfo,
121
+ ) -> bool {
122
+ if string_field(receiver, "scope_authority") != Some("explicit_claim") {
123
+ return false;
124
+ }
125
+ let Some(authorized_workspace) = string_field(receiver, "authorized_team_workspace") else {
126
+ return false;
127
+ };
128
+ if canonical_path(Path::new(authorized_workspace)) != canonical_path(workspace) {
129
+ return false;
130
+ }
131
+ let Some(recorded_nonce) = string_field(receiver, "binding_nonce") else {
132
+ return false;
133
+ };
134
+ observed
135
+ .leader_env
136
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
137
+ .filter(|nonce| !nonce.is_empty())
138
+ .is_some_and(|live_nonce| live_nonce == recorded_nonce)
139
+ }
140
+
141
+ pub(crate) fn path_is_in_workspace(path: &Path, workspace: &Path) -> bool {
142
+ let path = canonical_path(path);
143
+ let workspace = canonical_path(workspace);
121
144
  path == workspace || path.starts_with(workspace)
122
145
  }
123
146
 
147
+ fn canonical_path(path: &Path) -> std::path::PathBuf {
148
+ path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
149
+ }
150
+
124
151
  fn receiver_transport_conflicts(receiver: &Value) -> bool {
125
152
  let mode = receiver.get("mode").and_then(Value::as_str);
126
153
  let transport_kind = receiver.get("transport_kind").and_then(Value::as_str);
@@ -14,6 +14,7 @@ use crate::transport::{
14
14
 
15
15
  use super::helpers::MessageStatusShadow;
16
16
  use super::persist::persist_internal_send;
17
+ use super::presentation::PresentationDecision;
17
18
  use super::{
18
19
  DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, InitialDisposition,
19
20
  InternalSendKind, MessagingError, PersistResolution,
@@ -69,6 +70,35 @@ pub fn send_to_leader_receiver_with_message_id(
69
70
  result_id: Option<&str>,
70
71
  requested_message_id: Option<&str>,
71
72
  event_log: &EventLog,
73
+ ) -> Result<DeliveryOutcome, MessagingError> {
74
+ send_to_leader_receiver_with_presentation(
75
+ workspace,
76
+ state,
77
+ leader_id,
78
+ content,
79
+ task_id,
80
+ sender,
81
+ requires_ack,
82
+ result_id,
83
+ requested_message_id,
84
+ &PresentationDecision::default(),
85
+ event_log,
86
+ )
87
+ }
88
+
89
+ #[allow(clippy::too_many_arguments)]
90
+ pub fn send_to_leader_receiver_with_presentation(
91
+ workspace: &Path,
92
+ state: &serde_json::Value,
93
+ leader_id: &str,
94
+ content: &str,
95
+ task_id: Option<&TaskId>,
96
+ sender: &str,
97
+ requires_ack: bool,
98
+ result_id: Option<&str>,
99
+ requested_message_id: Option<&str>,
100
+ presentation: &PresentationDecision,
101
+ event_log: &EventLog,
72
102
  ) -> Result<DeliveryOutcome, MessagingError> {
73
103
  let store = MessageStore::open(workspace)?;
74
104
  let owner_team = active_team_key(workspace, state);
@@ -90,6 +120,7 @@ pub fn send_to_leader_receiver_with_message_id(
90
120
  false,
91
121
  requested_message_id,
92
122
  InitialDisposition::Accepted,
123
+ Some(presentation),
93
124
  )? {
94
125
  PersistResolution::Duplicate(requested) => {
95
126
  return Ok(DeliveryOutcome {
@@ -258,8 +289,7 @@ pub fn deliver_to_leader_fallback_pane(
258
289
  attempt_payload.clone(),
259
290
  )?;
260
291
 
261
- let Some(pane_id) = pane_id.filter(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
262
- else {
292
+ let Some(receiver) = leader_receiver_record(state) else {
263
293
  let failed = serde_json::json!({
264
294
  "message_id": message_id,
265
295
  "result_id": result_id,
@@ -267,7 +297,7 @@ pub fn deliver_to_leader_fallback_pane(
267
297
  "pane_id": null,
268
298
  "primary_error": primary_error,
269
299
  "delivered_via": "fallback_pane",
270
- "reason": "no_bound_pane",
300
+ "reason": "no_bound_receiver",
271
301
  });
272
302
  event_log.write("leader_receiver.fallback_pane_failed", failed)?;
273
303
  return Ok(DeliveryOutcome {
@@ -283,7 +313,6 @@ pub fn deliver_to_leader_fallback_pane(
283
313
  };
284
314
 
285
315
  let rendered = render_fallback_pane_message(content, message_id, primary_error);
286
- let target = Target::Pane(PaneId::new(&pane_id));
287
316
  let payload = InjectPayload::Text(rendered);
288
317
  // 0.5.5 gate054 cross-team notify boundary: when the leader receiver
289
318
  // has a recorded `tmux_socket` (canonical), that socket is a HARD
@@ -301,25 +330,63 @@ pub fn deliver_to_leader_fallback_pane(
301
330
  // stays pending in the store, coordinator surfaces the pending
302
331
  // notification via status/monitor, and no cross-server injection is
303
332
  // attempted.
304
- let inject_result: Result<InjectReport, TransportError> = match leader_tmux_socket(state) {
305
- Some(socket) => {
306
- let backend = crate::transport_factory::tmux_endpoint_transport(socket);
307
- backend.inject(&target, &payload, Key::Enter, true)
308
- }
309
- None => {
310
- // No socket recorded → legacy (pre-0.5) shape where the receiver
311
- // was implicitly bound to the workspace-canonical tmux server.
312
- // Keep the workspace→default chain here (single team assumption)
313
- // but never mix it with a recorded-socket team.
314
- let backend = crate::transport_factory::tmux_workspace_transport(workspace);
315
- backend
316
- .inject(&target, &payload, Key::Enter, true)
317
- .or_else(|_| {
318
- let backend = crate::transport_factory::tmux_default_transport();
319
- backend.inject(&target, &payload, Key::Enter, true)
320
- })
333
+ let socket_bound = leader_tmux_socket(state).is_some();
334
+ let transports: Vec<Box<dyn Transport>> = match leader_tmux_socket(state) {
335
+ Some(socket) => vec![Box::new(crate::transport_factory::tmux_endpoint_transport(
336
+ socket,
337
+ ))],
338
+ None => vec![
339
+ Box::new(crate::transport_factory::tmux_workspace_transport(
340
+ workspace,
341
+ )),
342
+ Box::new(crate::transport_factory::tmux_default_transport()),
343
+ ],
344
+ };
345
+ let mut resolved = None;
346
+ let mut refusal = "leader channel unavailable".to_string();
347
+ for transport in transports {
348
+ match super::leader_channel::resolve_live_leader_channel(
349
+ workspace,
350
+ receiver,
351
+ transport.as_ref(),
352
+ ) {
353
+ super::leader_channel::LeaderChannelResolution::Live(
354
+ super::leader_channel::LiveLeaderChannel::DirectTmux(channel),
355
+ ) => {
356
+ resolved = Some((transport, channel));
357
+ break;
358
+ }
359
+ other => refusal = format!("{other:?}"),
321
360
  }
361
+ }
362
+ let Some((transport, channel)) = resolved else {
363
+ event_log.write(
364
+ "leader_receiver.fallback_pane_failed",
365
+ serde_json::json!({
366
+ "message_id": message_id,
367
+ "result_id": result_id,
368
+ "owner_team_id": owner_team_id,
369
+ "pane_id": pane_id,
370
+ "primary_error": primary_error,
371
+ "delivered_via": "fallback_pane",
372
+ "socket_bound": socket_bound,
373
+ "reason": refusal,
374
+ }),
375
+ )?;
376
+ return Ok(DeliveryOutcome {
377
+ ok: false,
378
+ status: DeliveryStatus::Blocked,
379
+ message_status: MessageStatusShadow("blocked".to_string()),
380
+ message_id: Some(message_id.to_string()),
381
+ verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
382
+ stage: None,
383
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
384
+ channel: Some("rebind_required".to_string()),
385
+ });
322
386
  };
387
+ let target = Target::Pane(PaneId::new(channel.pane_id));
388
+ let inject_result: Result<InjectReport, TransportError> =
389
+ transport.inject(&target, &payload, Key::Enter, true);
323
390
 
324
391
  match inject_result {
325
392
  Ok(report) => {
@@ -486,6 +553,17 @@ fn leader_record_field<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
486
553
  .or_else(|| receiver_or_owner_field(state, "team_owner", field))
487
554
  }
488
555
 
556
+ fn leader_receiver_record(state: &Value) -> Option<&Value> {
557
+ state.get("leader_receiver").or_else(|| {
558
+ let active = state.get("active_team_key").and_then(Value::as_str)?;
559
+ state
560
+ .get("teams")
561
+ .and_then(Value::as_object)
562
+ .and_then(|teams| teams.get(active))
563
+ .and_then(|team| team.get("leader_receiver"))
564
+ })
565
+ }
566
+
489
567
  fn owner_epoch_i64(state: &Value) -> Option<i64> {
490
568
  owner_epoch(state).and_then(|epoch| i64::try_from(epoch).ok())
491
569
  }
@@ -596,6 +674,7 @@ pub fn enqueue_leader_mailbox_until_attach(
596
674
  false,
597
675
  None,
598
676
  InitialDisposition::QueuedUntilLeaderAttach,
677
+ None,
599
678
  )?
600
679
  else {
601
680
  unreachable!("offline mailbox does not accept caller-supplied ids")
@@ -68,6 +68,7 @@ pub mod leader_channel;
68
68
  pub mod leader_receiver;
69
69
  pub mod peers;
70
70
  pub mod persist;
71
+ pub mod presentation;
71
72
  pub mod results;
72
73
  pub mod scheduler;
73
74
  pub mod selftest;
@@ -94,7 +95,7 @@ pub use leader_channel::{
94
95
  pub use leader_receiver::{
95
96
  deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
96
97
  mirror_peer_message_to_leader, send_to_leader_receiver,
97
- send_to_leader_receiver_with_message_id,
98
+ send_to_leader_receiver_with_message_id, send_to_leader_receiver_with_presentation,
98
99
  };
99
100
  pub use peers::allow_peer_talk;
100
101
  pub use persist::{
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
5
5
  use crate::db::message_store::{MessageRowStatus, MessageStore, PersistMessageInput};
6
6
  use crate::model::ids::{AgentId, TaskId, TeamKey};
7
7
 
8
+ use super::presentation::PresentationDecision;
8
9
  use super::send::TrustedSender;
9
10
  use super::MessagingError;
10
11
 
@@ -64,6 +65,7 @@ impl DeliveryBlocker {
64
65
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65
66
  pub enum InitialDisposition {
66
67
  Accepted,
68
+ StoredOnly,
67
69
  QueuedUntilLeaderAttach,
68
70
  Blocked(DeliveryBlocker),
69
71
  }
@@ -72,6 +74,7 @@ impl InitialDisposition {
72
74
  fn row_status(self) -> MessageRowStatus {
73
75
  match self {
74
76
  Self::Accepted => MessageRowStatus::Accepted,
77
+ Self::StoredOnly => MessageRowStatus::StoredOnly,
75
78
  Self::QueuedUntilLeaderAttach => MessageRowStatus::QueuedUntilLeaderAttach,
76
79
  Self::Blocked(DeliveryBlocker::CoordinatorUnavailable) => {
77
80
  MessageRowStatus::QueuedCoordinatorUnavailable
@@ -82,7 +85,7 @@ impl InitialDisposition {
82
85
  fn error(self) -> Option<&'static str> {
83
86
  match self {
84
87
  Self::Blocked(blocker) => Some(blocker.as_str()),
85
- Self::Accepted | Self::QueuedUntilLeaderAttach => None,
88
+ Self::Accepted | Self::StoredOnly | Self::QueuedUntilLeaderAttach => None,
86
89
  }
87
90
  }
88
91
  }
@@ -100,6 +103,7 @@ pub struct ResolvedSendIntent {
100
103
  pub requires_ack: bool,
101
104
  pub requested_message_id: Option<String>,
102
105
  pub initial_disposition: InitialDisposition,
106
+ pub presentation: PresentationDecision,
103
107
  }
104
108
 
105
109
  impl ResolvedSendIntent {
@@ -128,6 +132,7 @@ impl ResolvedSendIntent {
128
132
  requires_ack,
129
133
  requested_message_id,
130
134
  initial_disposition: InitialDisposition::Accepted,
135
+ presentation: PresentationDecision::default(),
131
136
  }
132
137
  }
133
138
  }
@@ -158,6 +163,7 @@ pub fn persist_resolved_send(
158
163
  }
159
164
  }
160
165
  let status = intent.initial_disposition.row_status();
166
+ let presentation = serde_json::to_string(&intent.presentation)?;
161
167
  let message_id = store.persist_message(PersistMessageInput {
162
168
  message_id: intent.requested_message_id.as_deref(),
163
169
  owner_team_id: intent.owner_team_id.as_ref().map(TeamKey::as_str),
@@ -168,6 +174,7 @@ pub fn persist_resolved_send(
168
174
  requires_ack: intent.requires_ack,
169
175
  status,
170
176
  content: &intent.content,
177
+ presentation: &presentation,
171
178
  error: intent.initial_disposition.error(),
172
179
  })?;
173
180
  Ok(PersistResolution::Persisted(PersistedSend {
@@ -177,7 +184,9 @@ pub fn persist_resolved_send(
177
184
  row_status: status,
178
185
  blocker: match intent.initial_disposition {
179
186
  InitialDisposition::Blocked(blocker) => Some(blocker),
180
- InitialDisposition::Accepted | InitialDisposition::QueuedUntilLeaderAttach => None,
187
+ InitialDisposition::Accepted
188
+ | InitialDisposition::StoredOnly
189
+ | InitialDisposition::QueuedUntilLeaderAttach => None,
181
190
  },
182
191
  }))
183
192
  }
@@ -195,6 +204,7 @@ pub(crate) fn persist_internal_send(
195
204
  requires_ack: bool,
196
205
  requested_message_id: Option<&str>,
197
206
  initial_disposition: InitialDisposition,
207
+ presentation: Option<&PresentationDecision>,
198
208
  ) -> Result<PersistResolution, MessagingError> {
199
209
  let mut intent = ResolvedSendIntent::accepted(
200
210
  SendOrigin::Internal(kind),
@@ -209,6 +219,9 @@ pub(crate) fn persist_internal_send(
209
219
  requested_message_id.map(ToOwned::to_owned),
210
220
  );
211
221
  intent.initial_disposition = initial_disposition;
222
+ if let Some(presentation) = presentation {
223
+ intent.presentation = presentation.clone();
224
+ }
212
225
  persist_resolved_send(&intent)
213
226
  }
214
227
 
@@ -266,6 +279,59 @@ mod tests {
266
279
  assert_eq!(error.as_deref(), Some("coordinator_unavailable"));
267
280
  }
268
281
 
282
+ #[test]
283
+ fn stored_only_persists_requested_presentation_without_delivery_eligibility() {
284
+ let workspace = workspace("stored-only");
285
+ let mut intent = ResolvedSendIntent::accepted(
286
+ SendOrigin::Mcp,
287
+ &workspace,
288
+ Some(TeamKey::new("team-a")),
289
+ LogicalRecipient::Leader,
290
+ TrustedSender::from_runtime_identity(AgentId::new("worker_a")),
291
+ None,
292
+ "stage evidence",
293
+ None,
294
+ false,
295
+ None,
296
+ );
297
+ intent.initial_disposition = InitialDisposition::StoredOnly;
298
+ intent.presentation = super::super::presentation::decide_presentation(
299
+ &super::super::presentation::PresentationRequest {
300
+ sink: super::super::presentation::PresentationSink::Casefile,
301
+ class: super::super::presentation::PresentationClass::StageResult,
302
+ case_id: Some("case-7".to_string()),
303
+ },
304
+ super::super::presentation::PresentationSource::Send,
305
+ );
306
+ let PersistResolution::Persisted(persisted) = persist_resolved_send(&intent).unwrap()
307
+ else {
308
+ panic!("fresh intent must persist")
309
+ };
310
+ assert_eq!(persisted.row_status, MessageRowStatus::StoredOnly);
311
+ let store = MessageStore::open(&workspace).unwrap();
312
+ let connection = crate::db::schema::open_db(store.db_path()).unwrap();
313
+ let (status, presentation): (String, String) = connection
314
+ .query_row(
315
+ "select status, presentation from messages where message_id = ?1",
316
+ [&persisted.message_id],
317
+ |row| Ok((row.get(0)?, row.get(1)?)),
318
+ )
319
+ .unwrap();
320
+ assert_eq!(status, "stored_only");
321
+ assert_eq!(
322
+ serde_json::from_str::<serde_json::Value>(&presentation).unwrap(),
323
+ serde_json::json!({
324
+ "sink": "casefile",
325
+ "class": "stage_result",
326
+ "case_id": "case-7",
327
+ "requested_sink": "casefile",
328
+ "effective_sink": "casefile",
329
+ "policy_reason": "requested_sink:casefile",
330
+ "policy_version": "team-presentation-v1"
331
+ })
332
+ );
333
+ }
334
+
269
335
  #[test]
270
336
  fn production_row_creation_is_owned_by_persisted_primitive() {
271
337
  for (name, source) in [