memstack-skill-loader 4.1.1__tar.gz → 4.2.0__tar.gz

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 (34) hide show
  1. {memstack_skill_loader-4.1.1/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.2.0}/PKG-INFO +2 -1
  2. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/pyproject.toml +2 -1
  3. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/__main__.py +9 -0
  5. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/agent_runner.py +211 -41
  6. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/dashboard.html +272 -56
  7. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/dashboard.py +129 -95
  8. memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/__init__.py +0 -0
  9. memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/body_parser.py +107 -0
  10. memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/compressor.py +70 -0
  11. memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/forwarder.py +47 -0
  12. memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/server.py +227 -0
  13. memstack_skill_loader-4.2.0/src/memstack_skill_loader/proxy/stats_tracker.py +103 -0
  14. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/stats.py +144 -2
  15. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0/src/memstack_skill_loader.egg-info}/PKG-INFO +2 -1
  16. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +7 -1
  17. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/requires.txt +1 -0
  18. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/MANIFEST.in +0 -0
  19. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/README.md +0 -0
  20. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/setup.cfg +0 -0
  21. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/categories.py +0 -0
  22. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/compression.py +0 -0
  23. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/config.py +0 -0
  24. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/indexer.py +0 -0
  25. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/license.py +0 -0
  26. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/memory_db.py +0 -0
  27. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/search.py +0 -0
  28. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/server.py +0 -0
  29. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/skill_config.py +0 -0
  30. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  31. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader/version_check.py +0 -0
  32. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  33. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  34. {memstack_skill_loader-4.1.1 → memstack_skill_loader-4.2.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.1.1
3
+ Version: 4.2.0
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0
@@ -8,3 +8,4 @@ Requires-Dist: lancedb>=0.6.0
8
8
  Requires-Dist: sentence-transformers>=2.2.0
9
9
  Requires-Dist: pyarrow>=14.0.0
10
10
  Requires-Dist: httpx>=0.24.0
11
+ Requires-Dist: aiohttp>=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.1.1"
7
+ version = "4.2.0"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -13,6 +13,7 @@ dependencies = [
13
13
  "sentence-transformers>=2.2.0",
14
14
  "pyarrow>=14.0.0",
15
15
  "httpx>=0.24.0",
16
+ "aiohttp>=3.9",
16
17
  ]
17
18
 
18
19
  [project.scripts]
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.1.1"
3
+ __version__ = "4.2.0"
@@ -78,6 +78,15 @@ def main():
78
78
  elif len(sys.argv) > 2 and sys.argv[1] == "run":
79
79
  task = " ".join(sys.argv[2:])
80
80
  _run_agents(task)
81
+ elif len(sys.argv) > 1 and sys.argv[1] == "proxy":
82
+ import argparse
83
+ parser = argparse.ArgumentParser(prog="memstack proxy")
84
+ parser.add_argument("--port", type=int, default=8787)
85
+ parser.add_argument("--host", default="127.0.0.1")
86
+ parser.add_argument("--verbose", action="store_true", help="Show parser diagnostics")
87
+ args = parser.parse_args(sys.argv[2:])
88
+ from .proxy.server import start_proxy
89
+ start_proxy(host=args.host, port=args.port, verbose=args.verbose)
81
90
  elif len(sys.argv) > 1 and sys.argv[1] == "run":
82
91
  print("Usage: python -m memstack_skill_loader run \"Your task description\"",
83
92
  file=sys.stderr)
@@ -1,5 +1,5 @@
1
1
  # Agent Runner v4.0 - MemStack autonomous agent orchestration
2
- """Agent Runner — orchestrates Claude Code agents via --print invocations."""
2
+ """Agent Runner — orchestrates Claude Code agents via interactive streaming."""
3
3
 
4
4
  import json
5
5
  import os
@@ -14,7 +14,42 @@ from typing import Optional
14
14
 
15
15
  import httpx
16
16
 
17
- from .stats import log_agent_invocation
17
+ from .stats import log_agent_invocation, log_api_cost
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Anthropic model pricing (per million tokens, May 2026)
22
+ # ---------------------------------------------------------------------------
23
+
24
+ MODEL_PRICING = {
25
+ "claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
26
+ "claude-opus-4-6": {"input": 5.0, "output": 25.0},
27
+ "claude-opus-4-7": {"input": 5.0, "output": 25.0},
28
+ "claude-haiku-4-5": {"input": 1.0, "output": 5.0},
29
+ }
30
+ # Cache read: 90% discount on input rate
31
+ # Cache creation: 25% premium on input rate
32
+
33
+
34
+ def _calculate_api_cost(
35
+ model: str,
36
+ input_tokens: int,
37
+ output_tokens: int,
38
+ cache_read_tokens: int = 0,
39
+ cache_creation_tokens: int = 0,
40
+ ) -> float:
41
+ """Calculate USD cost from token counts and model pricing."""
42
+ pricing = MODEL_PRICING.get(model, MODEL_PRICING["claude-sonnet-4-6"])
43
+ input_rate = pricing["input"]
44
+ output_rate = pricing["output"]
45
+
46
+ cost = (input_tokens / 1_000_000 * input_rate
47
+ + output_tokens / 1_000_000 * output_rate)
48
+ if cache_read_tokens > 0:
49
+ cost += cache_read_tokens / 1_000_000 * input_rate * 0.1
50
+ if cache_creation_tokens > 0:
51
+ cost += cache_creation_tokens / 1_000_000 * input_rate * 1.25
52
+ return round(cost, 6)
18
53
 
19
54
 
20
55
  # ---------------------------------------------------------------------------
@@ -24,18 +59,47 @@ from .stats import log_agent_invocation
24
59
  STATE_DIR = Path.home() / ".memstack" / "agent-runner"
25
60
  STATE_FILE = STATE_DIR / "state.json"
26
61
 
27
- AGENT_TIMEOUT = 3600 # seconds per --print invocation (default 60 minutes)
62
+ AGENT_TIMEOUT = 3600 # seconds per agent invocation (default 60 minutes)
28
63
  MAX_ITERATIONS = 2
29
64
 
30
65
  ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "")
31
66
  API_KEY_FILE = Path.home() / ".memstack" / "api_key"
67
+ OAUTH_TOKEN_FILE = Path.home() / ".memstack" / "oauth_token"
32
68
  API_DEFAULT_MODEL = "claude-sonnet-4-20250514"
33
69
  API_MAX_TOKENS = 16000
34
70
 
35
- if not os.environ.get("ANTHROPIC_API_KEY") and API_KEY_FILE.is_file():
36
- _stored_key = API_KEY_FILE.read_text(encoding="utf-8").strip()
37
- if _stored_key:
38
- os.environ["ANTHROPIC_API_KEY"] = _stored_key
71
+ # API key loading moved to _load_api_key() called on-demand per agent invocation.
72
+ # Do NOT set os.environ["ANTHROPIC_API_KEY"] at module load. It defeats subscription mode.
73
+
74
+
75
+ def _load_api_key() -> str | None:
76
+ """Get the Anthropic API key from environment or stored config file."""
77
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
78
+ if key:
79
+ return key
80
+ try:
81
+ if API_KEY_FILE.is_file():
82
+ stored = API_KEY_FILE.read_text(encoding="utf-8").strip()
83
+ if stored:
84
+ return stored
85
+ except Exception:
86
+ pass
87
+ return None
88
+
89
+ def _load_oauth_token() -> str | None:
90
+ """Get the OAuth token from environment or stored config file."""
91
+ token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
92
+ if token:
93
+ return token
94
+ try:
95
+ if OAUTH_TOKEN_FILE.is_file():
96
+ stored = OAUTH_TOKEN_FILE.read_text(encoding="utf-8").strip()
97
+ if stored:
98
+ return stored
99
+ except Exception:
100
+ pass
101
+ return None
102
+
39
103
 
40
104
  SYSTEM_PROMPTS = {
41
105
  "manager": (
@@ -184,25 +248,50 @@ _VALID_AGENT_MODES = ("api", "subscription")
184
248
 
185
249
 
186
250
  def _default_agent_modes() -> dict:
187
- """Return default execution modes based on whether an API key is available."""
188
- has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or ANTHROPIC_BASE_URL)
189
- if has_key:
190
- return {"manager": "api", "builder": "subscription", "reviewer": "api"}
251
+ """Return default execution modes always subscription.
252
+
253
+ The API key enables the *option* to switch to API mode in the dashboard,
254
+ but never pre-selects it.
255
+ """
191
256
  return {"manager": "subscription", "builder": "subscription", "reviewer": "subscription"}
192
257
 
193
258
 
194
259
  def load_agent_modes() -> dict:
195
- """Load agent execution modes from config, falling back to defaults."""
260
+ """Load agent execution modes from config, falling back to defaults.
261
+
262
+ On first run (no ``_agent_modes`` key in config), the computed defaults
263
+ are persisted immediately so subsequent loads read an explicit saved state.
264
+ """
196
265
  cfg = load_builder_tools_config()
197
266
  stored = cfg.get("_agent_modes", {})
267
+ first_run = "_agent_modes" not in cfg
198
268
  defaults = _default_agent_modes()
199
269
  modes = {}
200
270
  for agent in ("manager", "builder", "reviewer"):
201
271
  val = stored.get(agent, "")
202
272
  modes[agent] = val if val in _VALID_AGENT_MODES else defaults[agent]
273
+ if first_run:
274
+ save_agent_modes(modes)
203
275
  return modes
204
276
 
205
277
 
278
+ def _load_agent_display_names() -> dict[str, str]:
279
+ """Load custom agent display names from user profile, falling back to role names."""
280
+ profile_path = Path.home() / ".memstack" / "user_profile.json"
281
+ names = {"manager": "manager", "builder": "builder", "reviewer": "reviewer"}
282
+ try:
283
+ if profile_path.exists():
284
+ data = json.loads(profile_path.read_text(encoding="utf-8"))
285
+ stored = data.get("agent_names", {})
286
+ for role in names:
287
+ custom = stored.get(role, "").strip()
288
+ if custom:
289
+ names[role] = custom.lower()
290
+ except Exception:
291
+ pass
292
+ return names
293
+
294
+
206
295
  def save_agent_modes(modes: dict) -> None:
207
296
  """Save agent execution modes to config."""
208
297
  cfg = load_builder_tools_config()
@@ -329,22 +418,31 @@ def _extract_commit_from_reviewer(reviewer_output: str) -> str:
329
418
 
330
419
 
331
420
  # ---------------------------------------------------------------------------
332
- # Agent invocation via --print
421
+ # Agent invocation via interactive streaming
333
422
  # ---------------------------------------------------------------------------
334
423
 
335
- def _build_env() -> dict:
424
+ def _build_env(strip_api_key: bool = False, inject_api_key: str | None = None) -> dict:
336
425
  """Build environment for subprocess, ensuring Anthropic vars are passed."""
337
426
  env = os.environ.copy()
338
427
  env.pop("MEMSTACK_ENABLE_TTS", None)
428
+ # Strip API key for subscription mode so CC uses subscription instead of API.
429
+ # Set strip_api_key=False to revert.
430
+ if strip_api_key:
431
+ env.pop("ANTHROPIC_API_KEY", None)
432
+ oauth_token = _load_oauth_token()
433
+ if oauth_token:
434
+ env["ANTHROPIC_AUTH_TOKEN"] = oauth_token
435
+ elif inject_api_key:
436
+ env["ANTHROPIC_API_KEY"] = inject_api_key
339
437
  if ANTHROPIC_BASE_URL:
340
438
  env["ANTHROPIC_BASE_URL"] = ANTHROPIC_BASE_URL
341
439
  return env
342
440
 
343
441
 
344
- def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
442
+ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int, str]:
345
443
  """Extract text and token usage from claude --output-format stream-json.
346
444
 
347
- Returns (text, input_tokens, output_tokens, cost_usd, context_tokens).
445
+ Returns (text, input_tokens, output_tokens, cost_usd, context_tokens, model).
348
446
  context_tokens is the last iteration's total tokens (accurate context window fill).
349
447
  """
350
448
  text_parts: list[str] = []
@@ -354,6 +452,7 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
354
452
  output_tokens = 0
355
453
  cost_usd = 0.0
356
454
  context_tokens = 0
455
+ stream_model = ""
357
456
  last_assistant_usage: dict = {}
358
457
  for line in raw.splitlines():
359
458
  line = line.strip()
@@ -365,6 +464,9 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
365
464
  continue
366
465
  msg_type = obj.get("type")
367
466
  if msg_type == "assistant":
467
+ msg_model = obj.get("message", {}).get("model", "")
468
+ if msg_model:
469
+ stream_model = msg_model
368
470
  usage = obj.get("message", {}).get("usage", {})
369
471
  if usage:
370
472
  last_assistant_usage = usage
@@ -399,7 +501,7 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
399
501
  text = "[Agent completed with tool calls but no text summary]\n\n" + "\n\n".join(tool_parts)
400
502
  else:
401
503
  text = ""
402
- return text, input_tokens, output_tokens, cost_usd, context_tokens
504
+ return text, input_tokens, output_tokens, cost_usd, context_tokens, stream_model
403
505
 
404
506
 
405
507
  def _extract_text_from_stream_line(line: str) -> Optional[str]:
@@ -434,17 +536,18 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
434
536
  log_path: Optional[Path] = None, timeout: int = 600,
435
537
  model: str = "", session_id: Optional[str] = None,
436
538
  working_dir: str = "",
539
+ display_name: str = "",
437
540
  ) -> tuple[str, int, int]:
438
541
  """Call the Anthropic Messages API directly via httpx.
439
542
 
440
543
  Returns (text, input_tokens, output_tokens).
441
544
  """
442
- api_key = os.environ.get("ANTHROPIC_API_KEY", "")
545
+ api_key = _load_api_key() or ""
443
546
  base_url = ANTHROPIC_BASE_URL or "https://api.anthropic.com"
444
547
 
445
548
  if not api_key and not ANTHROPIC_BASE_URL:
446
549
  raise RuntimeError(
447
- f"{name}: ANTHROPIC_API_KEY not set and no ANTHROPIC_BASE_URL proxy configured"
550
+ f"{name}: No API key found (env or ~/.memstack/api_key) and no ANTHROPIC_BASE_URL proxy configured"
448
551
  )
449
552
 
450
553
  url = f"{base_url.rstrip('/')}/v1/messages"
@@ -502,22 +605,45 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
502
605
  usage = data.get("usage", {})
503
606
  input_tokens = usage.get("input_tokens", 0)
504
607
  output_tokens = usage.get("output_tokens", 0)
608
+ cache_read_tokens = usage.get("cache_read_input_tokens", 0)
609
+ cache_creation_tokens = usage.get("cache_creation_input_tokens", 0)
610
+ total_input_tokens = input_tokens + cache_read_tokens
611
+
612
+ resp_model = data.get("model", model or "claude-sonnet-4-6")
613
+ actual_cost = _calculate_api_cost(
614
+ resp_model, input_tokens, output_tokens,
615
+ cache_read_tokens, cache_creation_tokens,
616
+ )
505
617
 
506
618
  if log_path:
507
619
  ts = time.strftime("%H:%M:%S")
508
620
  with open(log_path, "a", encoding="utf-8") as f:
509
621
  f.write(f"[{ts}] Response: {len(output)} chars, "
510
- f"in={input_tokens} out={output_tokens}\n")
622
+ f"in={total_input_tokens} out={output_tokens}\n")
511
623
 
624
+ stats_name = display_name or name
512
625
  try:
513
626
  log_agent_invocation(
514
- name, len(prompt), len(output), session_id, working_dir,
515
- input_tokens=input_tokens, output_tokens=output_tokens, cost_usd=0.0,
627
+ stats_name, len(prompt), len(output), session_id, working_dir,
628
+ input_tokens=total_input_tokens, output_tokens=output_tokens, cost_usd=actual_cost,
629
+ )
630
+ except Exception:
631
+ pass
632
+
633
+ try:
634
+ _project = os.path.basename(working_dir) if working_dir else None
635
+ log_api_cost(
636
+ session_id=session_id, agent_name=stats_name, model=resp_model,
637
+ input_tokens=total_input_tokens, output_tokens=output_tokens,
638
+ actual_cost_usd=actual_cost, execution_mode="api",
639
+ project=_project,
640
+ cache_read_tokens=cache_read_tokens,
641
+ cache_creation_tokens=cache_creation_tokens,
516
642
  )
517
643
  except Exception:
518
644
  pass
519
645
 
520
- return output, input_tokens, output_tokens
646
+ return output, total_input_tokens, output_tokens
521
647
 
522
648
 
523
649
  # ---------------------------------------------------------------------------
@@ -528,13 +654,15 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
528
654
  skip_permissions: bool = False,
529
655
  session_id: Optional[str] = None, timeout: int = AGENT_TIMEOUT,
530
656
  model: str = "", disallowed_tools: Optional[list[str]] = None,
531
- session: Optional["Session"] = None) -> tuple[str, int, int, int]:
532
- """Run a single claude --print invocation and return the output."""
657
+ session: Optional["Session"] = None,
658
+ display_name: str = "",
659
+ execution_mode: str = "subscription") -> tuple[str, int, int, int]:
660
+ """Run a single claude interactive streaming invocation and return the output."""
533
661
  claude_bin = shutil.which("claude")
534
662
  if not claude_bin:
535
663
  raise FileNotFoundError("'claude' CLI not found on PATH")
536
664
 
537
- cmd = [claude_bin, "--print", "--verbose", "--output-format", "stream-json"]
665
+ cmd = [claude_bin, "--verbose", "--output-format", "stream-json", "--input-format", "stream-json"]
538
666
  if skip_permissions:
539
667
  cmd.append("--dangerously-skip-permissions")
540
668
  if model:
@@ -552,23 +680,19 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
552
680
  f.write(f"[{ts}] === Invoking {name} ===\n")
553
681
  f.write(f"[{ts}] Prompt length: {len(prompt)} chars\n")
554
682
 
555
- prompt_dir = log_path.parent if log_path else Path.home() / ".memstack" / "agent-runner"
556
- prompt_dir.mkdir(parents=True, exist_ok=True)
557
- prompt_file = prompt_dir / f"{name}_prompt.txt"
558
- prompt_file.write_text(prompt, encoding="utf-8")
559
-
560
- stdin_fh = open(prompt_file, "r", encoding="utf-8") # noqa: SIM115
561
683
  global _current_process
562
684
  proc = subprocess.Popen(
563
685
  cmd,
564
- stdin=stdin_fh,
686
+ stdin=subprocess.PIPE,
565
687
  stdout=subprocess.PIPE,
566
688
  stderr=subprocess.PIPE,
567
689
  cwd=working_dir,
568
- env=_build_env(),
690
+ env=_build_env(
691
+ strip_api_key=(execution_mode == "subscription"),
692
+ inject_api_key=_load_api_key() if execution_mode == "api" else None,
693
+ ),
569
694
  creationflags=0,
570
695
  )
571
- stdin_fh.close()
572
696
  with _lock:
573
697
  _current_process = proc
574
698
 
@@ -603,10 +727,24 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
603
727
  for raw_line in proc.stderr:
604
728
  stderr_chunks.append(raw_line.decode("utf-8", errors="replace"))
605
729
 
730
+ def _write_stdin() -> None:
731
+ try:
732
+ msg = json.dumps({"type": "user", "message": {"role": "user", "content": prompt}})
733
+ proc.stdin.write((msg + "\n").encode("utf-8"))
734
+ proc.stdin.flush()
735
+ finally:
736
+ proc.stdin.close()
737
+
738
+ # Start reader threads BEFORE writing stdin to prevent pipe deadlock.
739
+ # On Windows, anonymous pipes have a ~4KB buffer. Large prompts exceed
740
+ # this, blocking the write until the subprocess reads. If the subprocess
741
+ # writes init events to stdout first, nobody drains them — deadlock.
606
742
  t_out = threading.Thread(target=_read_stdout, daemon=True)
607
743
  t_err = threading.Thread(target=_read_stderr, daemon=True)
744
+ t_in = threading.Thread(target=_write_stdin, daemon=True)
608
745
  t_out.start()
609
746
  t_err.start()
747
+ t_in.start()
610
748
 
611
749
  watchdog = threading.Timer(timeout, _watchdog_kill)
612
750
  watchdog.daemon = True
@@ -629,6 +767,7 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
629
767
  if _current_process is proc:
630
768
  _current_process = None
631
769
 
770
+ t_in.join(timeout=10)
632
771
  t_out.join(timeout=10)
633
772
  t_err.join(timeout=10)
634
773
 
@@ -644,7 +783,7 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
644
783
 
645
784
  raw_stdout = stdout_data or ""
646
785
  stderr = stderr_data or ""
647
- output, input_tokens, output_tokens, cost_usd, context_tokens = _parse_stream_json(raw_stdout)
786
+ output, input_tokens, output_tokens, cost_usd, context_tokens, stream_model = _parse_stream_json(raw_stdout)
648
787
  output = output.strip()
649
788
  ts = time.strftime("%H:%M:%S")
650
789
 
@@ -667,14 +806,28 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
667
806
  f"{name} exited with code {proc.returncode}: {stderr[:300]}"
668
807
  )
669
808
 
809
+ stats_name = display_name or name
670
810
  try:
671
811
  log_agent_invocation(
672
- name, len(prompt), len(output), session_id, working_dir,
812
+ stats_name, len(prompt), len(output), session_id, working_dir,
673
813
  input_tokens=input_tokens, output_tokens=output_tokens, cost_usd=cost_usd,
674
814
  )
675
815
  except Exception:
676
816
  pass
677
817
 
818
+ try:
819
+ resolved_model = stream_model or model or "claude-sonnet-4-6"
820
+ exec_mode = "api" if cost_usd and cost_usd > 0 else "subscription"
821
+ _project = os.path.basename(working_dir) if working_dir else None
822
+ log_api_cost(
823
+ session_id=session_id, agent_name=stats_name, model=resolved_model,
824
+ input_tokens=input_tokens, output_tokens=output_tokens,
825
+ actual_cost_usd=cost_usd, execution_mode=exec_mode,
826
+ project=_project,
827
+ )
828
+ except Exception:
829
+ pass
830
+
678
831
  return output, input_tokens, output_tokens, context_tokens
679
832
 
680
833
 
@@ -697,6 +850,8 @@ class Session:
697
850
  self.auto_commit = auto_commit
698
851
  self.blocked_mcp_servers = blocked_mcp_servers or []
699
852
  self.auto_committed = False
853
+ self.total_duration_seconds: int = 0
854
+ self._start_monotonic: float = 0.0
700
855
  self.timeout = max(5, min(120, timeout_minutes)) * 60
701
856
  self.status = "running"
702
857
  self.result: Optional[str] = None
@@ -723,6 +878,8 @@ class Session:
723
878
 
724
879
  def _save_state(self) -> None:
725
880
  STATE_DIR.mkdir(parents=True, exist_ok=True)
881
+ if self.status in ("completed", "error", "stopped") and self.total_duration_seconds == 0 and self._start_monotonic > 0:
882
+ self.total_duration_seconds = round(time.monotonic() - self._start_monotonic)
726
883
  data = {
727
884
  "session_id": self.session_id,
728
885
  "status": self.status,
@@ -735,6 +892,7 @@ class Session:
735
892
  "max_iterations": self.max_iterations,
736
893
  "result": self.result,
737
894
  "auto_committed": self.auto_committed,
895
+ "total_duration_seconds": self.total_duration_seconds,
738
896
  }
739
897
  try:
740
898
  STATE_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
@@ -786,6 +944,7 @@ class Session:
786
944
  "result": self.result,
787
945
  "auto_committed": self.auto_committed,
788
946
  "commit_summary": commit_summary,
947
+ "total_duration_seconds": self.total_duration_seconds,
789
948
  }
790
949
 
791
950
 
@@ -804,17 +963,19 @@ _current_process = None
804
963
  # ---------------------------------------------------------------------------
805
964
 
806
965
  def _orchestrate(session: Session) -> None:
807
- """Run the Manager → Builder → Reviewer loop using --print invocations."""
966
+ """Run the Manager → Builder → Reviewer loop."""
967
+ session._start_monotonic = time.monotonic()
808
968
  session_log_dir = STATE_DIR / "sessions" / session.session_id
809
969
 
810
970
  try:
811
- # Load execution modes for this run
971
+ # Load execution modes and custom display names for this run
812
972
  agent_modes = load_agent_modes()
813
- has_api_key = bool(os.environ.get("ANTHROPIC_API_KEY") or ANTHROPIC_BASE_URL)
973
+ display_names = _load_agent_display_names()
974
+ has_api_key = bool(_load_api_key() or ANTHROPIC_BASE_URL)
814
975
  for _ag in ("manager", "builder", "reviewer"):
815
976
  if agent_modes[_ag] == "api" and not has_api_key:
816
977
  agent_modes[_ag] = "subscription"
817
- print(f"[agent-runner] {_ag}: mode=api but no API key set, falling back to subscription", file=sys.stderr)
978
+ print(f"[agent-runner] {_ag}: mode=api but no API key found, falling back to subscription", file=sys.stderr)
818
979
 
819
980
  # Step 1: Manager analyzes the task
820
981
  session.agents["manager"]["status"] = "busy"
@@ -839,6 +1000,7 @@ def _orchestrate(session: Session) -> None:
839
1000
  model=session.models.get("manager", ""),
840
1001
  session_id=session.session_id,
841
1002
  working_dir=session.working_dir,
1003
+ display_name=display_names["manager"],
842
1004
  )
843
1005
  m_ctx = m_in
844
1006
  else:
@@ -848,6 +1010,8 @@ def _orchestrate(session: Session) -> None:
848
1010
  skip_permissions=True, session_id=session.session_id,
849
1011
  timeout=min(600, session.timeout),
850
1012
  model=session.models.get("manager", ""),
1013
+ display_name=display_names["manager"],
1014
+ execution_mode=agent_modes["manager"],
851
1015
  )
852
1016
  except subprocess.TimeoutExpired:
853
1017
  session.agents["manager"]["status"] = "timeout"
@@ -912,6 +1076,8 @@ def _orchestrate(session: Session) -> None:
912
1076
  model=session.models.get("builder", ""),
913
1077
  disallowed_tools=session.blocked_mcp_servers,
914
1078
  session=session,
1079
+ display_name=display_names["builder"],
1080
+ execution_mode=agent_modes["builder"],
915
1081
  )
916
1082
  else:
917
1083
  builder_output, b_in, b_out = _invoke_api_agent(
@@ -922,6 +1088,7 @@ def _orchestrate(session: Session) -> None:
922
1088
  model=session.models.get("builder", ""),
923
1089
  session_id=session.session_id,
924
1090
  working_dir=session.working_dir,
1091
+ display_name=display_names["builder"],
925
1092
  )
926
1093
  b_ctx = b_in
927
1094
  except subprocess.TimeoutExpired:
@@ -990,6 +1157,7 @@ def _orchestrate(session: Session) -> None:
990
1157
  model=session.models.get("reviewer", ""),
991
1158
  session_id=session.session_id,
992
1159
  working_dir=session.working_dir,
1160
+ display_name=display_names["reviewer"],
993
1161
  )
994
1162
  r_ctx = r_in
995
1163
  else:
@@ -999,6 +1167,8 @@ def _orchestrate(session: Session) -> None:
999
1167
  skip_permissions=True, session_id=session.session_id,
1000
1168
  timeout=session.timeout,
1001
1169
  model=session.models.get("reviewer", ""),
1170
+ display_name=display_names["reviewer"],
1171
+ execution_mode=agent_modes["reviewer"],
1002
1172
  )
1003
1173
  except subprocess.TimeoutExpired:
1004
1174
  session.agents["reviewer"]["status"] = "timeout"