@team-agent/installer 0.1.2 → 0.1.4

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
@@ -1,4 +1,4 @@
1
- **English** | [中文](README.zh.md)
1
+ **English** | [中文](https://github.com/Florious95/team-agent/blob/main/README.zh.md)
2
2
 
3
3
  # Team Agent
4
4
 
@@ -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
@@ -100,7 +98,7 @@ This sets up the MCP server, registers the Team Agent skill, and wires it into y
100
98
  Source checkout install:
101
99
 
102
100
  ```bash
103
- git clone <repo-url> team-agent
101
+ git clone https://github.com/Florious95/team-agent.git team-agent
104
102
  cd team-agent
105
103
  npm exec --yes --package . -- team-agent-installer install
106
104
  ```
package/README.zh.md CHANGED
@@ -1,4 +1,4 @@
1
- [English](README.md) | **中文**
1
+ [English](https://github.com/Florious95/team-agent/blob/main/README.md) | **中文**
2
2
 
3
3
  # Team Agent
4
4
 
@@ -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
  ## 快速开始
@@ -99,7 +97,7 @@ npx @team-agent/installer@latest install
99
97
  源码安装:
100
98
 
101
99
  ```bash
102
- git clone <repo-url> team-agent
100
+ git clone https://github.com/Florious95/team-agent.git team-agent
103
101
  cd team-agent
104
102
  npm exec --yes --package . -- team-agent-installer install
105
103
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -8,6 +8,14 @@
8
8
  "tmux",
9
9
  "multi-agent"
10
10
  ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/Florious95/team-agent.git"
14
+ },
15
+ "homepage": "https://github.com/Florious95/team-agent#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/Florious95/team-agent/issues"
18
+ },
11
19
  "bin": {
12
20
  "team-agent-installer": "npm/install.mjs"
13
21
  },
@@ -21,11 +29,11 @@
21
29
  "npm",
22
30
  "scripts",
23
31
  "src",
32
+ "tests",
24
33
  "skills",
25
34
  "templates",
26
35
  "examples",
27
36
  "schemas",
28
- "docs/team-agent-foundation-and-boundaries.md",
29
37
  "crates/team-agent-core/Cargo.toml",
30
38
  "crates/team-agent-core/src",
31
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.2"
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.2"},
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:
@@ -1290,13 +1290,15 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1290
1290
  store.add_result(envelope)
1291
1291
 
1292
1292
  rows = store.results(uncollected_only=True)
1293
- valid_rows: list[tuple[dict[str, Any], dict[str, Any]]] = []
1293
+ valid_rows: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]] = []
1294
1294
  for row in rows:
1295
1295
  envelope: Any = None
1296
1296
  try:
1297
1297
  envelope = json.loads(row["envelope"])
1298
1298
  validate_result_envelope(envelope)
1299
- _find_task(state["tasks"], envelope["task_id"])
1299
+ task = _find_task_or_none(state["tasks"], envelope["task_id"])
1300
+ if task is None and not _is_message_scoped_result(store, envelope):
1301
+ raise RuntimeError(f"unknown task id: {envelope['task_id']}")
1300
1302
  except (json.JSONDecodeError, ValidationError, RuntimeError) as exc:
1301
1303
  invalid_results.append(
1302
1304
  _record_invalid_result(
@@ -1308,7 +1310,7 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1308
1310
  )
1309
1311
  store.mark_result_invalid(row["result_id"], str(exc))
1310
1312
  else:
1311
- valid_rows.append((row, envelope))
1313
+ valid_rows.append((row, envelope, task))
1312
1314
 
1313
1315
  if invalid_results:
1314
1316
  save_runtime_state(workspace, state)
@@ -1326,17 +1328,20 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1326
1328
  collected: list[dict[str, Any]] = []
1327
1329
  collected_results: list[dict[str, Any]] = []
1328
1330
  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"]
1331
+ for row, envelope, task in valid_rows:
1332
+ if task is not None:
1333
+ next_task = _find_task(next_state["tasks"], envelope["task_id"])
1334
+ task_status = _result_status_to_task_status(next_task, envelope["status"])
1335
+ update_task_status(
1336
+ next_state["tasks"],
1337
+ envelope["task_id"],
1338
+ task_status,
1339
+ envelope.get("summary"),
1340
+ envelope.get("artifacts", []),
1341
+ )
1342
+ next_task["accepted_result_id"] = row["result_id"]
1343
+ else:
1344
+ task_status = "message_scoped"
1340
1345
  collected.append(envelope)
1341
1346
  collected_results.append(
1342
1347
  {
@@ -1346,6 +1351,7 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1346
1351
  "status": envelope["status"],
1347
1352
  "summary": envelope.get("summary"),
1348
1353
  "tests": envelope.get("tests", []),
1354
+ "scope": "task" if task is not None else "message",
1349
1355
  }
1350
1356
  )
1351
1357
  event_log.write(
@@ -1354,12 +1360,13 @@ def collect(workspace: Path, result_file: Path | None = None) -> dict[str, Any]:
1354
1360
  task_id=envelope["task_id"],
1355
1361
  status=envelope["status"],
1356
1362
  task_status=task_status,
1357
- retry_count=task.get("retry_count"),
1358
- retry_limit=task.get("retry_limit"),
1363
+ retry_count=task.get("retry_count") if task else None,
1364
+ retry_limit=task.get("retry_limit") if task else None,
1365
+ scope="task" if task is not None else "message",
1359
1366
  )
1360
1367
  state_path = write_team_state(workspace, spec, next_state, [{"envelope": env} for env in collected])
1361
1368
  save_runtime_state(workspace, next_state)
1362
- for row, _ in valid_rows:
1369
+ for row, _, _ in valid_rows:
1363
1370
  store.mark_result_collected(row["result_id"])
1364
1371
  return {
1365
1372
  "ok": not invalid_results,
@@ -1377,6 +1384,8 @@ def report_result(workspace: Path, envelope: dict[str, Any]) -> dict[str, Any]:
1377
1384
  store = MessageStore(workspace)
1378
1385
  result_id = store.add_result(envelope)
1379
1386
  acknowledged = store.acknowledge_task_messages(envelope["task_id"], envelope["agent_id"])
1387
+ if not acknowledged:
1388
+ acknowledged = store.acknowledge_message(envelope["task_id"], envelope["agent_id"])
1380
1389
  event_log = EventLog(workspace)
1381
1390
  notification = _notify_leader_of_report_result(workspace, envelope, result_id, event_log)
1382
1391
  leader_notified = bool(notification.get("ok")) and notification.get("status") in {"submitted", "visible", "delivered", "acknowledged"}
@@ -4018,8 +4027,24 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
4018
4027
  "attempts": attempt_log,
4019
4028
  "verification": prepared["verification"],
4020
4029
  }
4030
+ baseline = _capture_tmux_pane_text(target)
4031
+ if not baseline["ok"]:
4032
+ return {
4033
+ "ok": False,
4034
+ "stage": "pre-paste-capture",
4035
+ "error": baseline.get("error"),
4036
+ "attempts": attempt_log,
4037
+ "verification": "pre_paste_capture_failed",
4038
+ }
4039
+ baseline_capture = baseline["capture"]
4021
4040
  if token:
4022
- pre_visible, pre_verification, pre_capture = _wait_for_message_ready(target, token, 0.0, expected_text=text)
4041
+ pre_visible, pre_verification, pre_capture = _wait_for_message_ready(
4042
+ target,
4043
+ token,
4044
+ 0.0,
4045
+ expected_text=text,
4046
+ allow_pasted_prompt=False,
4047
+ )
4023
4048
  if pre_visible:
4024
4049
  attempt_entry = {
4025
4050
  "attempt": attempt,
@@ -4060,6 +4085,23 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
4060
4085
  "attempts": attempt_log,
4061
4086
  "submit_attempts": submit.get("attempts"),
4062
4087
  }
4088
+ if _capture_has_pasted_content_prompt(baseline_capture):
4089
+ attempt_log.append(
4090
+ {
4091
+ "attempt": attempt,
4092
+ "visible": False,
4093
+ "verification": "preexisting_unverified_pasted_content_prompt",
4094
+ "text_bytes": text_bytes,
4095
+ "ready_timeout_sec": 0.0,
4096
+ }
4097
+ )
4098
+ return {
4099
+ "ok": False,
4100
+ "stage": "preexisting-input",
4101
+ "error": "target pane already has an unverified pasted-content prompt; refusing to paste again to avoid duplicate messages",
4102
+ "attempts": attempt_log,
4103
+ "verification": "preexisting_unverified_pasted_content_prompt",
4104
+ }
4063
4105
  buffered = _tmux_set_buffer_text(buffer_name, text)
4064
4106
  if not buffered["ok"]:
4065
4107
  return {"ok": False, "stage": buffered["stage"], "error": buffered.get("error"), "attempts": attempt_log}
@@ -4068,7 +4110,13 @@ def _tmux_inject_text(target: str, text: str, submit_key: str, buffer_name: str,
4068
4110
  return {"ok": False, "stage": "paste-buffer", "error": proc.stderr.strip(), "attempts": attempt_log}
4069
4111
  time.sleep(0.25)
4070
4112
  if token:
4071
- visible, verification, capture_text = _wait_for_message_ready(target, token, ready_timeout, expected_text=text)
4113
+ visible, verification, capture_text = _wait_for_message_ready(
4114
+ target,
4115
+ token,
4116
+ ready_timeout,
4117
+ expected_text=text,
4118
+ baseline_capture=baseline_capture,
4119
+ )
4072
4120
  else:
4073
4121
  visible, verification, capture_text = True, "no_token", ""
4074
4122
  last_verification = verification
@@ -4237,35 +4285,51 @@ def _wait_for_visible_token(target: str, token: str, timeout: float) -> tuple[bo
4237
4285
  deadline = time.monotonic() + max(timeout, 0.0)
4238
4286
  last = "not_checked"
4239
4287
  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:
4288
+ capture = _capture_tmux_pane_text(target)
4289
+ if capture["ok"]:
4290
+ if token in capture["capture"] or f"[team-agent-token:{token}]" in capture["capture"]:
4243
4291
  return True, "capture_contains_token"
4244
4292
  last = "capture_missing_token"
4245
4293
  else:
4246
- last = f"capture_failed: {capture.stderr.strip()}"
4294
+ last = f"capture_failed: {capture.get('error')}"
4247
4295
  if time.monotonic() >= deadline:
4248
4296
  return False, last
4249
4297
  time.sleep(0.1)
4250
4298
 
4251
4299
 
4252
- def _wait_for_message_ready(target: str, message_id: str, timeout: float, expected_text: str = "") -> tuple[bool, str, str]:
4300
+ def _capture_tmux_pane_text(target: str) -> dict[str, Any]:
4301
+ capture = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{DELIVERY_CAPTURE_LINES}", "-t", target], timeout=5)
4302
+ if capture.returncode != 0:
4303
+ return {"ok": False, "capture": "", "error": capture.stderr.strip() or "tmux capture-pane failed"}
4304
+ return {"ok": True, "capture": capture.stdout}
4305
+
4306
+
4307
+ def _wait_for_message_ready(
4308
+ target: str,
4309
+ message_id: str,
4310
+ timeout: float,
4311
+ expected_text: str = "",
4312
+ allow_pasted_prompt: bool = True,
4313
+ baseline_capture: str = "",
4314
+ ) -> tuple[bool, str, str]:
4253
4315
  deadline = time.monotonic() + max(timeout, 0.0)
4254
4316
  last = "not_checked"
4255
4317
  last_capture = ""
4318
+ baseline_had_pasted_prompt = _capture_has_pasted_content_prompt(baseline_capture)
4256
4319
  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
4320
+ capture = _capture_tmux_pane_text(target)
4321
+ if capture["ok"]:
4322
+ capture_text = capture["capture"]
4323
+ last_capture = capture_text
4324
+ if message_id in capture_text or f"[team-agent-token:{message_id}]" in capture_text:
4325
+ return True, "capture_contains_token", capture_text
4326
+ if expected_text and _capture_contains_message_fragment(capture_text, expected_text):
4327
+ return True, "capture_contains_message_fragment", capture_text
4328
+ if allow_pasted_prompt and _capture_has_pasted_content_prompt(capture_text) and not baseline_had_pasted_prompt:
4329
+ return True, "capture_contains_new_pasted_content_prompt", capture_text
4266
4330
  last = "capture_missing_token"
4267
4331
  else:
4268
- last = f"capture_failed: {capture.stderr.strip()}"
4332
+ last = f"capture_failed: {capture.get('error')}"
4269
4333
  if time.monotonic() >= deadline:
4270
4334
  return False, last, last_capture
4271
4335
  time.sleep(0.1)
@@ -4299,15 +4363,18 @@ def _capture_contains_message_fragment(capture_text: str, expected_text: str) ->
4299
4363
  haystack = _compact_visible_text(capture_text)
4300
4364
  if not haystack:
4301
4365
  return False
4302
- return any(fragment in haystack for fragment in _message_fragment_candidates(expected_text))
4366
+ fragments = _message_fragment_candidates(expected_text)
4367
+ if not fragments:
4368
+ return False
4369
+ return any(fragment in haystack for fragment in fragments)
4303
4370
 
4304
4371
 
4305
4372
  def _message_fragment_candidates(text: str) -> list[str]:
4306
4373
  sanitized = re.sub(r"\[team-agent-token:[^\]]+\]", "", text)
4307
4374
  fragments: list[str] = []
4308
- for line in sanitized.splitlines():
4375
+ for line in _message_content_lines(sanitized):
4309
4376
  compact = _compact_visible_text(line)
4310
- if len(compact) < 18:
4377
+ if not _is_strong_message_fragment(compact):
4311
4378
  continue
4312
4379
  if len(compact) <= 72:
4313
4380
  fragments.append(compact)
@@ -4330,6 +4397,35 @@ def _message_fragment_candidates(text: str) -> list[str]:
4330
4397
  return unique
4331
4398
 
4332
4399
 
4400
+ def _message_content_lines(text: str) -> list[str]:
4401
+ lines = text.splitlines()
4402
+ if lines and lines[0].strip().startswith("Team Agent message from "):
4403
+ lines = lines[1:]
4404
+ return [line for line in lines if line.strip()]
4405
+
4406
+
4407
+ def _is_strong_message_fragment(compact: str) -> bool:
4408
+ if not compact:
4409
+ return False
4410
+ generic_prefixes = (
4411
+ "TeamAgentmessagefrom",
4412
+ "TeamAgentpeermessagefrom",
4413
+ "TeamAgentstoredthisresult",
4414
+ "TeamAgenthascollectedthisresult",
4415
+ "Nomanualpolling",
4416
+ )
4417
+ if compact.startswith(generic_prefixes):
4418
+ return False
4419
+ if re.fullmatch(r"[-::>›❯]+", compact):
4420
+ return False
4421
+ if re.search(r"(msg|res)_[0-9A-Fa-f]{8,}", compact):
4422
+ return True
4423
+ cjk_count = len(re.findall(r"[\u4e00-\u9fff]", compact))
4424
+ if cjk_count >= 4 and len(compact) >= 6:
4425
+ return True
4426
+ return len(compact) >= 18
4427
+
4428
+
4333
4429
  def _compact_visible_text(text: str) -> str:
4334
4430
  return re.sub(r"\s+", "", text)
4335
4431
 
@@ -4668,7 +4764,12 @@ def _deliver_pending_message(
4668
4764
  if ready:
4669
4765
  status = (
4670
4766
  "submitted"
4671
- if verification in {"capture_contains_pasted_content_prompt", "capture_contains_message_fragment"}
4767
+ if verification
4768
+ in {
4769
+ "capture_contains_pasted_content_prompt",
4770
+ "capture_contains_new_pasted_content_prompt",
4771
+ "capture_contains_message_fragment",
4772
+ }
4672
4773
  else "visible"
4673
4774
  )
4674
4775
  store.mark(message_id, status)
@@ -5200,6 +5301,22 @@ def _find_task(tasks: list[dict[str, Any]], task_id: str) -> dict[str, Any]:
5200
5301
  raise RuntimeError(f"unknown task id: {task_id}")
5201
5302
 
5202
5303
 
5304
+ def _find_task_or_none(tasks: list[dict[str, Any]], task_id: str) -> dict[str, Any] | None:
5305
+ for task in tasks:
5306
+ if task.get("id") == task_id:
5307
+ return task
5308
+ return None
5309
+
5310
+
5311
+ def _is_message_scoped_result(store: MessageStore, envelope: dict[str, Any]) -> bool:
5312
+ task_id = str(envelope.get("task_id") or "")
5313
+ agent_id = str(envelope.get("agent_id") or "")
5314
+ if not task_id.startswith("msg_"):
5315
+ return False
5316
+ message = _message_by_id(store, task_id)
5317
+ return bool(message and message.get("recipient") == agent_id)
5318
+
5319
+
5203
5320
  def _find_agent(spec: dict[str, Any], agent_id: str | None) -> dict[str, Any] | None:
5204
5321
  if not agent_id:
5205
5322
  return None