@team-agent/installer 0.1.3 → 0.1.7

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/README.md CHANGED
@@ -83,8 +83,6 @@ Three design choices make this possible:
83
83
 
84
84
  **3. Standards over inventions.** MCP for tool calls, Skill files for role definitions. Anything the broader ecosystem ships, this picks up automatically.
85
85
 
86
- For the full design philosophy and boundaries, see [`docs/team-agent-foundation-and-boundaries.md`](./docs/team-agent-foundation-and-boundaries.md).
87
-
88
86
  ---
89
87
 
90
88
  ## Quick start
package/README.zh.md CHANGED
@@ -82,8 +82,6 @@ Lead 自己想清楚:
82
82
 
83
83
  **3. 用标准协议,不发明协议。** 用 MCP 做工具调用,用 Skill 文件做角色定义。生态发什么,本项目自动获得什么。
84
84
 
85
- 完整设计哲学和边界,见 [`docs/team-agent-foundation-and-boundaries.md`](./docs/team-agent-foundation-and-boundaries.md)。
86
-
87
85
  ---
88
86
 
89
87
  ## 快速开始
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.1.3",
3
+ "version": "0.1.7",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -29,11 +29,11 @@
29
29
  "npm",
30
30
  "scripts",
31
31
  "src",
32
+ "tests",
32
33
  "skills",
33
34
  "templates",
34
35
  "examples",
35
36
  "schemas",
36
- "docs/team-agent-foundation-and-boundaries.md",
37
37
  "crates/team-agent-core/Cargo.toml",
38
38
  "crates/team-agent-core/src",
39
39
  "pyproject.toml",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "teamspec-agent-mode"
3
- version = "0.1.0"
3
+ version = "0.1.4"
4
4
  description = "Spec-first CLI-native team agent runtime for Claude Code, Codex CLI, and Gemini CLI workers."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -10,6 +10,9 @@ from pathlib import Path
10
10
 
11
11
  REGRESSION_TESTS = [
12
12
  "tests.run_tests.RuntimeTests.test_send_default_timeout_reports_submitted_unverified",
13
+ "tests.run_tests.RuntimeTests.test_message_fragment_matching_ignores_generic_header",
14
+ "tests.run_tests.RuntimeTests.test_wait_for_message_ready_does_not_accept_old_header",
15
+ "tests.run_tests.RuntimeTests.test_wait_for_message_ready_accepts_only_new_pasted_prompt",
13
16
  "tests.run_tests.RuntimeTests.test_worker_delivery_retries_paste_until_message_ready",
14
17
  "tests.run_tests.RuntimeTests.test_worker_pasted_content_prompt_retries_enter_until_submitted",
15
18
  "tests.run_tests.RuntimeTests.test_worker_pasted_content_prompt_reports_unverified_when_enter_does_not_submit",
@@ -26,6 +29,7 @@ REGRESSION_TESTS = [
26
29
  "tests.run_tests.RuntimeTests.test_start_agent_falls_back_to_fresh_when_resume_window_exits",
27
30
  "tests.run_tests.RuntimeTests.test_broadcast_sends_only_to_current_team_and_excludes_sender",
28
31
  "tests.run_tests.RuntimeTests.test_status_and_collect_expose_uncollected_report_result",
32
+ "tests.run_tests.RuntimeTests.test_collect_accepts_message_scoped_report_result",
29
33
  "tests.run_tests.RuntimeTests.test_report_result_queues_leader_notification_without_blocking_mcp",
30
34
  "tests.run_tests.RuntimeTests.test_mcp_send_message_accepts_thin_args_and_returns_compact_result",
31
35
  "tests.run_tests.RuntimeTests.test_mcp_send_message_accepts_broadcast_target",
@@ -1,3 +1,3 @@
1
1
  """TeamSpec Agent Mode runtime."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
@@ -505,7 +505,7 @@ def handle_mcp(tools: TeamOrchestratorTools, request: dict[str, Any]) -> dict[st
505
505
  "result": {
506
506
  "protocolVersion": request.get("params", {}).get("protocolVersion", "2024-11-05"),
507
507
  "capabilities": {"tools": {}},
508
- "serverInfo": {"name": "team_orchestrator", "version": "0.1.3"},
508
+ "serverInfo": {"name": "team_orchestrator", "version": "0.1.4"},
509
509
  },
510
510
  }
511
511
  if method == "tools/list":
@@ -475,6 +475,29 @@ class MessageStore:
475
475
  )
476
476
  return ids
477
477
 
478
+ def acknowledge_message(self, message_id: str, agent_id: str) -> list[str]:
479
+ now = utcnow()
480
+ with closing(self.connect()) as conn:
481
+ with conn:
482
+ row = conn.execute(
483
+ """
484
+ select message_id from messages
485
+ where message_id = ? and recipient = ? and status in ('pending', 'accepted', 'target_resolved', 'injected', 'visible', 'submitted', 'delivered')
486
+ """,
487
+ (message_id, agent_id),
488
+ ).fetchone()
489
+ if not row:
490
+ return []
491
+ conn.execute(
492
+ """
493
+ update messages
494
+ set status = 'acknowledged', acknowledged_at = ?, updated_at = ?
495
+ where message_id = ? and recipient = ? and status in ('pending', 'accepted', 'target_resolved', 'injected', 'visible', 'submitted', 'delivered')
496
+ """,
497
+ (now, now, message_id, agent_id),
498
+ )
499
+ return [message_id]
500
+
478
501
  def results(self, uncollected_only: bool = False) -> list[dict[str, Any]]:
479
502
  query = "select * from results order by created_at"
480
503
  if uncollected_only:
@@ -35,7 +35,15 @@ from team_agent.providers import ResumeUnavailable, get_adapter, shell_command_f
35
35
  from team_agent.routing import route_task
36
36
  from team_agent.simple_yaml import dumps
37
37
  from team_agent.spec import load_spec, validate_result_envelope, workspace_from_spec
38
- from team_agent.state import load_runtime_state, runtime_state_path, save_runtime_state, write_team_state
38
+ from team_agent.state import (
39
+ SESSION_CAPTURE_FIELDS,
40
+ SESSION_STATE_FIELDS,
41
+ load_runtime_state,
42
+ normalize_agent_session_state,
43
+ runtime_state_path,
44
+ save_runtime_state,
45
+ write_team_state,
46
+ )
39
47
  from team_agent.task_graph import ready_tasks, update_task_status
40
48
  from team_agent.task_graph import TASK_STATUSES
41
49
 
@@ -520,10 +528,7 @@ def _load_snapshot_state(path: Path) -> dict[str, Any] | None:
520
528
  state = json.loads(path.read_text(encoding="utf-8"))
521
529
  except (OSError, json.JSONDecodeError):
522
530
  return None
523
- for agent_state in state.get("agents", {}).values():
524
- if isinstance(agent_state, dict):
525
- for field in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence", "spawn_cwd"]:
526
- agent_state.setdefault(field, None)
531
+ normalize_agent_session_state(state)
527
532
  return state
528
533
 
529
534
 
@@ -1290,13 +1295,15 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1290
1295
  store.add_result(envelope)
1291
1296
 
1292
1297
  rows = store.results(uncollected_only=True)
1293
- valid_rows: list[tuple[dict[str, Any], dict[str, Any]]] = []
1298
+ valid_rows: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]] = []
1294
1299
  for row in rows:
1295
1300
  envelope: Any = None
1296
1301
  try:
1297
1302
  envelope = json.loads(row["envelope"])
1298
1303
  validate_result_envelope(envelope)
1299
- _find_task(state["tasks"], envelope["task_id"])
1304
+ task = _find_task_or_none(state["tasks"], envelope["task_id"])
1305
+ if task is None and not _is_message_scoped_result(store, envelope):
1306
+ raise RuntimeError(f"unknown task id: {envelope['task_id']}")
1300
1307
  except (json.JSONDecodeError, ValidationError, RuntimeError) as exc:
1301
1308
  invalid_results.append(
1302
1309
  _record_invalid_result(
@@ -1308,7 +1315,7 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1308
1315
  )
1309
1316
  store.mark_result_invalid(row["result_id"], str(exc))
1310
1317
  else:
1311
- valid_rows.append((row, envelope))
1318
+ valid_rows.append((row, envelope, task))
1312
1319
 
1313
1320
  if invalid_results:
1314
1321
  save_runtime_state(workspace, state)
@@ -1326,17 +1333,20 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1326
1333
  collected: list[dict[str, Any]] = []
1327
1334
  collected_results: list[dict[str, Any]] = []
1328
1335
  next_state = copy.deepcopy(state)
1329
- for row, envelope in valid_rows:
1330
- task = _find_task(next_state["tasks"], envelope["task_id"])
1331
- task_status = _result_status_to_task_status(task, envelope["status"])
1332
- update_task_status(
1333
- next_state["tasks"],
1334
- envelope["task_id"],
1335
- task_status,
1336
- envelope.get("summary"),
1337
- envelope.get("artifacts", []),
1338
- )
1339
- task["accepted_result_id"] = row["result_id"]
1336
+ for row, envelope, task in valid_rows:
1337
+ if task is not None:
1338
+ next_task = _find_task(next_state["tasks"], envelope["task_id"])
1339
+ task_status = _result_status_to_task_status(next_task, envelope["status"])
1340
+ update_task_status(
1341
+ next_state["tasks"],
1342
+ envelope["task_id"],
1343
+ task_status,
1344
+ envelope.get("summary"),
1345
+ envelope.get("artifacts", []),
1346
+ )
1347
+ next_task["accepted_result_id"] = row["result_id"]
1348
+ else:
1349
+ task_status = "message_scoped"
1340
1350
  collected.append(envelope)
1341
1351
  collected_results.append(
1342
1352
  {
@@ -1346,6 +1356,7 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1346
1356
  "status": envelope["status"],
1347
1357
  "summary": envelope.get("summary"),
1348
1358
  "tests": envelope.get("tests", []),
1359
+ "scope": "task" if task is not None else "message",
1349
1360
  }
1350
1361
  )
1351
1362
  event_log.write(
@@ -1354,12 +1365,13 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1354
1365
  task_id=envelope["task_id"],
1355
1366
  status=envelope["status"],
1356
1367
  task_status=task_status,
1357
- retry_count=task.get("retry_count"),
1358
- retry_limit=task.get("retry_limit"),
1368
+ retry_count=task.get("retry_count") if task else None,
1369
+ retry_limit=task.get("retry_limit") if task else None,
1370
+ scope="task" if task is not None else "message",
1359
1371
  )
1360
1372
  state_path = write_team_state(workspace, spec, next_state, [{"envelope": env} for env in collected])
1361
1373
  save_runtime_state(workspace, next_state)
1362
- for row, _ in valid_rows:
1374
+ for row, _, _ in valid_rows:
1363
1375
  store.mark_result_collected(row["result_id"])
1364
1376
  return {
1365
1377
  "ok": not invalid_results,
@@ -1377,6 +1389,8 @@ def report_result(workspace: Path, envelope: dict[str, Any]) -> dict[str, Any]:
1377
1389
  store = MessageStore(workspace)
1378
1390
  result_id = store.add_result(envelope)
1379
1391
  acknowledged = store.acknowledge_task_messages(envelope["task_id"], envelope["agent_id"])
1392
+ if not acknowledged:
1393
+ acknowledged = store.acknowledge_message(envelope["task_id"], envelope["agent_id"])
1380
1394
  event_log = EventLog(workspace)
1381
1395
  notification = _notify_leader_of_report_result(workspace, envelope, result_id, event_log)
1382
1396
  leader_notified = bool(notification.get("ok")) and notification.get("status") in {"submitted", "visible", "delivered", "acknowledged"}
@@ -1561,8 +1575,7 @@ def _capture_agent_session(
1561
1575
  result = adapter.capture_session_id(agent_id, spawn_context, timeout_s=timeout_s)
1562
1576
  if not isinstance(result, dict) or not result.get("session_id"):
1563
1577
  return None
1564
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence", "spawn_cwd"]:
1565
- agent_state[key] = result.get(key)
1578
+ _copy_session_metadata(agent_state, result)
1566
1579
  agent_state.pop("_pending_session_id", None)
1567
1580
  event_log.write(
1568
1581
  "session.captured",
@@ -1576,6 +1589,16 @@ def _capture_agent_session(
1576
1589
  return result
1577
1590
 
1578
1591
 
1592
+ def _copy_session_metadata(target: dict[str, Any], source: dict[str, Any]) -> None:
1593
+ for key in SESSION_STATE_FIELDS:
1594
+ target[key] = source.get(key)
1595
+
1596
+
1597
+ def _clear_session_capture_fields(target: dict[str, Any]) -> None:
1598
+ for key in SESSION_CAPTURE_FIELDS:
1599
+ target[key] = None
1600
+
1601
+
1579
1602
  def _attach_profile_resume_root(workspace: Path, command_agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
1580
1603
  profile_launch = command_agent.get("_provider_profile") or prepare_agent_profile_launch(workspace, command_agent)
1581
1604
  if not profile_launch:
@@ -1622,8 +1645,7 @@ def _prepare_resume_state(
1622
1645
  if not repaired:
1623
1646
  repaired = adapter.recover_session_id(agent_id, prepared, workspace, exclude_session_ids or set())
1624
1647
  if repaired:
1625
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence", "spawn_cwd"]:
1626
- prepared[key] = repaired.get(key)
1648
+ _copy_session_metadata(prepared, repaired)
1627
1649
  event_log.write(
1628
1650
  "resume.session_repaired",
1629
1651
  agent_id=agent_id,
@@ -1648,8 +1670,7 @@ def _prepare_resume_state(
1648
1670
  f"Cannot resume agent {agent_id}: stored session {session_id} is not available. "
1649
1671
  "Use --allow-fresh only if losing that worker context is acceptable."
1650
1672
  )
1651
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence"]:
1652
- prepared[key] = None
1673
+ _clear_session_capture_fields(prepared)
1653
1674
  event_log.write(
1654
1675
  "resume.session_unavailable",
1655
1676
  agent_id=agent_id,
@@ -1963,8 +1984,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
1963
1984
  if profile_launch.get("claude_projects_root"):
1964
1985
  agent_state["claude_projects_root"] = profile_launch["claude_projects_root"]
1965
1986
  if restart_mode == "fresh":
1966
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence"]:
1967
- agent_state[key] = None
1987
+ _clear_session_capture_fields(agent_state)
1968
1988
  if command_agent.get("_session_id"):
1969
1989
  agent_state["_pending_session_id"] = command_agent["_session_id"]
1970
1990
  _capture_agent_session(
@@ -2222,8 +2242,7 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
2222
2242
  if profile_launch.get("claude_projects_root"):
2223
2243
  agent_state["claude_projects_root"] = profile_launch["claude_projects_root"]
2224
2244
  if start_mode == "fresh":
2225
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence"]:
2226
- agent_state[key] = None
2245
+ _clear_session_capture_fields(agent_state)
2227
2246
  if command_agent.get("_session_id"):
2228
2247
  agent_state["_pending_session_id"] = command_agent["_session_id"]
2229
2248
  _capture_agent_session(workspace, agent_id, agent_state, event_log, timeout_s=1.5, exclude_session_ids=known_session_ids)
@@ -4018,8 +4037,24 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
4018
4037
  "attempts": attempt_log,
4019
4038
  "verification": prepared["verification"],
4020
4039
  }
4040
+ baseline = _capture_tmux_pane_text(target)
4041
+ if not baseline["ok"]:
4042
+ return {
4043
+ "ok": False,
4044
+ "stage": "pre-paste-capture",
4045
+ "error": baseline.get("error"),
4046
+ "attempts": attempt_log,
4047
+ "verification": "pre_paste_capture_failed",
4048
+ }
4049
+ baseline_capture = baseline["capture"]
4021
4050
  if token:
4022
- pre_visible, pre_verification, pre_capture = _wait_for_message_ready(target, token, 0.0, expected_text=text)
4051
+ pre_visible, pre_verification, pre_capture = _wait_for_message_ready(
4052
+ target,
4053
+ token,
4054
+ 0.0,
4055
+ expected_text=text,
4056
+ allow_pasted_prompt=False,
4057
+ )
4023
4058
  if pre_visible:
4024
4059
  attempt_entry = {
4025
4060
  "attempt": attempt,
@@ -4060,6 +4095,23 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
4060
4095
  "attempts": attempt_log,
4061
4096
  "submit_attempts": submit.get("attempts"),
4062
4097
  }
4098
+ if _capture_has_pasted_content_prompt(baseline_capture):
4099
+ attempt_log.append(
4100
+ {
4101
+ "attempt": attempt,
4102
+ "visible": False,
4103
+ "verification": "preexisting_unverified_pasted_content_prompt",
4104
+ "text_bytes": text_bytes,
4105
+ "ready_timeout_sec": 0.0,
4106
+ }
4107
+ )
4108
+ return {
4109
+ "ok": False,
4110
+ "stage": "preexisting-input",
4111
+ "error": "target pane already has an unverified pasted-content prompt; refusing to paste again to avoid duplicate messages",
4112
+ "attempts": attempt_log,
4113
+ "verification": "preexisting_unverified_pasted_content_prompt",
4114
+ }
4063
4115
  buffered = _tmux_set_buffer_text(buffer_name, text)
4064
4116
  if not buffered["ok"]:
4065
4117
  return {"ok": False, "stage": buffered["stage"], "error": buffered.get("error"), "attempts": attempt_log}
@@ -4068,7 +4120,13 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
4068
4120
  return {"ok": False, "stage": "paste-buffer", "error": proc.stderr.strip(), "attempts": attempt_log}
4069
4121
  time.sleep(0.25)
4070
4122
  if token:
4071
- visible, verification, capture_text = _wait_for_message_ready(target, token, ready_timeout, expected_text=text)
4123
+ visible, verification, capture_text = _wait_for_message_ready(
4124
+ target,
4125
+ token,
4126
+ ready_timeout,
4127
+ expected_text=text,
4128
+ baseline_capture=baseline_capture,
4129
+ )
4072
4130
  else:
4073
4131
  visible, verification, capture_text = True, "no_token", ""
4074
4132
  last_verification = verification
@@ -4237,35 +4295,51 @@ def _wait_for_visible_token(target: str, token: str, timeout: float) -> tuple[bo
4237
4295
  deadline = time.monotonic() + max(timeout, 0.0)
4238
4296
  last = "not_checked"
4239
4297
  while True:
4240
- capture = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{DELIVERY_CAPTURE_LINES}", "-t", target], timeout=5)
4241
- if capture.returncode == 0:
4242
- if token in capture.stdout or f"[team-agent-token:{token}]" in capture.stdout:
4298
+ capture = _capture_tmux_pane_text(target)
4299
+ if capture["ok"]:
4300
+ if token in capture["capture"] or f"[team-agent-token:{token}]" in capture["capture"]:
4243
4301
  return True, "capture_contains_token"
4244
4302
  last = "capture_missing_token"
4245
4303
  else:
4246
- last = f"capture_failed: {capture.stderr.strip()}"
4304
+ last = f"capture_failed: {capture.get('error')}"
4247
4305
  if time.monotonic() >= deadline:
4248
4306
  return False, last
4249
4307
  time.sleep(0.1)
4250
4308
 
4251
4309
 
4252
- def _wait_for_message_ready(target: str, message_id: str, timeout: float, expected_text: str = "") -> tuple[bool, str, str]:
4310
+ def _capture_tmux_pane_text(target: str) -> dict[str, Any]:
4311
+ capture = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{DELIVERY_CAPTURE_LINES}", "-t", target], timeout=5)
4312
+ if capture.returncode != 0:
4313
+ return {"ok": False, "capture": "", "error": capture.stderr.strip() or "tmux capture-pane failed"}
4314
+ return {"ok": True, "capture": capture.stdout}
4315
+
4316
+
4317
+ def _wait_for_message_ready(
4318
+ target: str,
4319
+ message_id: str,
4320
+ timeout: float,
4321
+ expected_text: str = "",
4322
+ allow_pasted_prompt: bool = True,
4323
+ baseline_capture: str = "",
4324
+ ) -> tuple[bool, str, str]:
4253
4325
  deadline = time.monotonic() + max(timeout, 0.0)
4254
4326
  last = "not_checked"
4255
4327
  last_capture = ""
4328
+ baseline_had_pasted_prompt = _capture_has_pasted_content_prompt(baseline_capture)
4256
4329
  while True:
4257
- capture = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{DELIVERY_CAPTURE_LINES}", "-t", target], timeout=5)
4258
- if capture.returncode == 0:
4259
- last_capture = capture.stdout
4260
- if message_id in capture.stdout or f"[team-agent-token:{message_id}]" in capture.stdout:
4261
- return True, "capture_contains_token", capture.stdout
4262
- if _capture_has_pasted_content_prompt(capture.stdout):
4263
- return True, "capture_contains_pasted_content_prompt", capture.stdout
4264
- if expected_text and _capture_contains_message_fragment(capture.stdout, expected_text):
4265
- return True, "capture_contains_message_fragment", capture.stdout
4330
+ capture = _capture_tmux_pane_text(target)
4331
+ if capture["ok"]:
4332
+ capture_text = capture["capture"]
4333
+ last_capture = capture_text
4334
+ if message_id in capture_text or f"[team-agent-token:{message_id}]" in capture_text:
4335
+ return True, "capture_contains_token", capture_text
4336
+ if expected_text and _capture_contains_message_fragment(capture_text, expected_text):
4337
+ return True, "capture_contains_message_fragment", capture_text
4338
+ if allow_pasted_prompt and _capture_has_pasted_content_prompt(capture_text) and not baseline_had_pasted_prompt:
4339
+ return True, "capture_contains_new_pasted_content_prompt", capture_text
4266
4340
  last = "capture_missing_token"
4267
4341
  else:
4268
- last = f"capture_failed: {capture.stderr.strip()}"
4342
+ last = f"capture_failed: {capture.get('error')}"
4269
4343
  if time.monotonic() >= deadline:
4270
4344
  return False, last, last_capture
4271
4345
  time.sleep(0.1)
@@ -4299,15 +4373,18 @@ def _capture_contains_message_fragment(capture_text: str, expected_text: str) ->
4299
4373
  haystack = _compact_visible_text(capture_text)
4300
4374
  if not haystack:
4301
4375
  return False
4302
- return any(fragment in haystack for fragment in _message_fragment_candidates(expected_text))
4376
+ fragments = _message_fragment_candidates(expected_text)
4377
+ if not fragments:
4378
+ return False
4379
+ return any(fragment in haystack for fragment in fragments)
4303
4380
 
4304
4381
 
4305
4382
  def _message_fragment_candidates(text: str) -> list[str]:
4306
4383
  sanitized = re.sub(r"\[team-agent-token:[^\]]+\]", "", text)
4307
4384
  fragments: list[str] = []
4308
- for line in sanitized.splitlines():
4385
+ for line in _message_content_lines(sanitized):
4309
4386
  compact = _compact_visible_text(line)
4310
- if len(compact) < 18:
4387
+ if not _is_strong_message_fragment(compact):
4311
4388
  continue
4312
4389
  if len(compact) <= 72:
4313
4390
  fragments.append(compact)
@@ -4330,6 +4407,35 @@ def _message_fragment_candidates(text: str) -> list[str]:
4330
4407
  return unique
4331
4408
 
4332
4409
 
4410
+ def _message_content_lines(text: str) -> list[str]:
4411
+ lines = text.splitlines()
4412
+ if lines and lines[0].strip().startswith("Team Agent message from "):
4413
+ lines = lines[1:]
4414
+ return [line for line in lines if line.strip()]
4415
+
4416
+
4417
+ def _is_strong_message_fragment(compact: str) -> bool:
4418
+ if not compact:
4419
+ return False
4420
+ generic_prefixes = (
4421
+ "TeamAgentmessagefrom",
4422
+ "TeamAgentpeermessagefrom",
4423
+ "TeamAgentstoredthisresult",
4424
+ "TeamAgenthascollectedthisresult",
4425
+ "Nomanualpolling",
4426
+ )
4427
+ if compact.startswith(generic_prefixes):
4428
+ return False
4429
+ if re.fullmatch(r"[-::>›❯]+", compact):
4430
+ return False
4431
+ if re.search(r"(msg|res)_[0-9A-Fa-f]{8,}", compact):
4432
+ return True
4433
+ cjk_count = len(re.findall(r"[\u4e00-\u9fff]", compact))
4434
+ if cjk_count >= 4 and len(compact) >= 6:
4435
+ return True
4436
+ return len(compact) >= 18
4437
+
4438
+
4333
4439
  def _compact_visible_text(text: str) -> str:
4334
4440
  return re.sub(r"\s+", "", text)
4335
4441
 
@@ -4668,7 +4774,12 @@ def _deliver_pending_message(
4668
4774
  if ready:
4669
4775
  status = (
4670
4776
  "submitted"
4671
- if verification in {"capture_contains_pasted_content_prompt", "capture_contains_message_fragment"}
4777
+ if verification
4778
+ in {
4779
+ "capture_contains_pasted_content_prompt",
4780
+ "capture_contains_new_pasted_content_prompt",
4781
+ "capture_contains_message_fragment",
4782
+ }
4672
4783
  else "visible"
4673
4784
  )
4674
4785
  store.mark(message_id, status)
@@ -5200,6 +5311,22 @@ def _find_task(tasks: list[dict[str, Any]], task_id: str) -> dict[str, Any]:
5200
5311
  raise RuntimeError(f"unknown task id: {task_id}")
5201
5312
 
5202
5313
 
5314
+ def _find_task_or_none(tasks: list[dict[str, Any]], task_id: str) -> dict[str, Any] | None:
5315
+ for task in tasks:
5316
+ if task.get("id") == task_id:
5317
+ return task
5318
+ return None
5319
+
5320
+
5321
+ def _is_message_scoped_result(store: MessageStore, envelope: dict[str, Any]) -> bool:
5322
+ task_id = str(envelope.get("task_id") or "")
5323
+ agent_id = str(envelope.get("agent_id") or "")
5324
+ if not task_id.startswith("msg_"):
5325
+ return False
5326
+ message = _message_by_id(store, task_id)
5327
+ return bool(message and message.get("recipient") == agent_id)
5328
+
5329
+
5203
5330
  def _find_agent(spec: dict[str, Any], agent_id: str | None) -> dict[str, Any] | None:
5204
5331
  if not agent_id:
5205
5332
  return None
@@ -10,12 +10,15 @@ from team_agent.paths import runtime_dir
10
10
  from team_agent.simple_yaml import dumps
11
11
 
12
12
 
13
- SESSION_STATE_FIELDS = [
13
+ SESSION_CAPTURE_FIELDS = [
14
14
  "session_id",
15
15
  "rollout_path",
16
16
  "captured_at",
17
17
  "captured_via",
18
18
  "attribution_confidence",
19
+ ]
20
+ SESSION_STATE_FIELDS = [
21
+ *SESSION_CAPTURE_FIELDS,
19
22
  "spawn_cwd",
20
23
  ]
21
24
 
@@ -24,15 +27,22 @@ def runtime_state_path(workspace: Path) -> Path:
24
27
  return runtime_dir(workspace) / "state.json"
25
28
 
26
29
 
30
+ def normalize_agent_session_state(state: dict[str, Any]) -> None:
31
+ agents = state.get("agents", {})
32
+ if not isinstance(agents, dict):
33
+ return
34
+ for agent_state in agents.values():
35
+ if isinstance(agent_state, dict):
36
+ for field in SESSION_STATE_FIELDS:
37
+ agent_state.setdefault(field, None)
38
+
39
+
27
40
  def load_runtime_state(workspace: Path) -> dict[str, Any]:
28
41
  path = runtime_state_path(workspace)
29
42
  if not path.exists():
30
43
  return {"agents": {}, "tasks": [], "session_name": None}
31
44
  state = json.loads(path.read_text(encoding="utf-8"))
32
- for agent_state in state.get("agents", {}).values():
33
- if isinstance(agent_state, dict):
34
- for field in SESSION_STATE_FIELDS:
35
- agent_state.setdefault(field, None)
45
+ normalize_agent_session_state(state)
36
46
  return state
37
47
 
38
48