aicortexpy 0.1.0__py3-none-any.whl

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 (58) hide show
  1. aicortexpy/__init__.py +3 -0
  2. aicortexpy/__main__.py +5 -0
  3. aicortexpy/acp_client.py +559 -0
  4. aicortexpy/acp_conn.py +853 -0
  5. aicortexpy/acp_errors.py +107 -0
  6. aicortexpy/agent_env.py +88 -0
  7. aicortexpy/artifact_ws.py +233 -0
  8. aicortexpy/artifacts.py +67 -0
  9. aicortexpy/attachments.py +85 -0
  10. aicortexpy/auto_commit.py +106 -0
  11. aicortexpy/capabilities.py +92 -0
  12. aicortexpy/chat_acp_pool.py +321 -0
  13. aicortexpy/cli.py +133 -0
  14. aicortexpy/client.py +438 -0
  15. aicortexpy/codex_config.py +98 -0
  16. aicortexpy/config.py +298 -0
  17. aicortexpy/daemon.py +677 -0
  18. aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
  19. aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
  20. aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
  21. aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
  22. aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
  23. aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
  24. aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
  25. aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
  26. aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
  27. aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
  28. aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
  29. aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
  30. aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
  31. aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
  32. aicortexpy/design_seed.py +59 -0
  33. aicortexpy/diskusage.py +209 -0
  34. aicortexpy/execenv.py +264 -0
  35. aicortexpy/gc.py +286 -0
  36. aicortexpy/handoff_bundle.py +65 -0
  37. aicortexpy/health.py +178 -0
  38. aicortexpy/local_skills.py +157 -0
  39. aicortexpy/models.py +385 -0
  40. aicortexpy/openclaw_config.py +153 -0
  41. aicortexpy/pinned_workdir.py +60 -0
  42. aicortexpy/platform_info.py +61 -0
  43. aicortexpy/poisoned.py +44 -0
  44. aicortexpy/process.py +168 -0
  45. aicortexpy/procutil.py +61 -0
  46. aicortexpy/prompt.py +235 -0
  47. aicortexpy/repo.py +462 -0
  48. aicortexpy/runner.py +969 -0
  49. aicortexpy/runtime_config.py +540 -0
  50. aicortexpy/sandbox.py +264 -0
  51. aicortexpy/stream.py +422 -0
  52. aicortexpy/terminal.py +147 -0
  53. aicortexpy/tunnel.py +82 -0
  54. aicortexpy/wakeup.py +222 -0
  55. aicortexpy-0.1.0.dist-info/METADATA +106 -0
  56. aicortexpy-0.1.0.dist-info/RECORD +58 -0
  57. aicortexpy-0.1.0.dist-info/WHEEL +5 -0
  58. aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/runner.py ADDED
@@ -0,0 +1,969 @@
1
+ """Agent launcher: execenv, cancel, stream, pin, repos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+ from .acp_conn import (
20
+ ACP_ONESHOT_PROVIDERS,
21
+ ACPPromptResult,
22
+ choose_cursor_invocation,
23
+ run_acp_oneshot,
24
+ )
25
+ from .agent_env import build_agent_environ
26
+ from .attachments import materialize_attachments
27
+ from .chat_acp_pool import (
28
+ ChatACPPool,
29
+ agent_id_from_task,
30
+ pool_key,
31
+ skip_cursor_chat_session_resume,
32
+ )
33
+ from .client import DaemonClient, Task
34
+ from .config import AgentEntry, Config
35
+ from .execenv import Environment, prepare_environment, provider_needs_inline_system_prompt
36
+ from .procutil import kill_process_tree, popen_kwargs_for_killable_group
37
+ from .repo import ensure_repos_for_task
38
+ from .sandbox import apply_plan_to_command, cleanup_plan, prepare_provider_sandbox
39
+ from .stream import STREAM_JSON_PROVIDERS, StreamCollector
40
+
41
+ log = logging.getLogger("aicortexpy.runner")
42
+
43
+ MessageFlush = Callable[[list[dict]], None]
44
+ SessionPin = Callable[[str, str], None] # session_id, work_dir
45
+
46
+
47
+ @dataclass
48
+ class RunResult:
49
+ ok: bool
50
+ output: str
51
+ work_dir: str
52
+ error: str = ""
53
+ sandbox_label: str = ""
54
+ cancelled: bool = False
55
+ session_id: str = ""
56
+ messages: list[dict] = field(default_factory=list)
57
+ usage: list[dict] = field(default_factory=list)
58
+
59
+
60
+ def _custom_args(agent: dict | None) -> list[str]:
61
+ if not agent:
62
+ return []
63
+ args = agent.get("custom_args")
64
+ if isinstance(args, list):
65
+ return [str(a) for a in args]
66
+ return []
67
+
68
+
69
+ def _resolve_model(entry: AgentEntry, task: Task) -> str:
70
+ if entry.model:
71
+ return entry.model
72
+ agent = task.agent or {}
73
+ return str(agent.get("model") or "")
74
+
75
+
76
+ def _custom_args_has(extra: list[str], flag: str) -> bool:
77
+ prefix = flag + "="
78
+ return any(a == flag or a.startswith(prefix) for a in extra)
79
+
80
+
81
+ def _pi_session_path(task: Task) -> str:
82
+ resume = (task.prior_session_id or "").strip()
83
+ if resume:
84
+ path = Path(resume)
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ if not path.exists():
87
+ path.touch()
88
+ return str(path)
89
+ base = Path.home() / ".aicortex" / "pi-sessions"
90
+ base.mkdir(parents=True, exist_ok=True)
91
+ name = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%f") + ".jsonl"
92
+ path = base / name
93
+ path.touch()
94
+ return str(path)
95
+
96
+
97
+ def _split_pi_model(model: str) -> tuple[str, str]:
98
+ model = (model or "").strip()
99
+ if "/" in model:
100
+ provider, mid = model.split("/", 1)
101
+ return provider.strip(), mid.strip()
102
+ return "", model
103
+
104
+
105
+ def _build_command(
106
+ provider: str,
107
+ entry: AgentEntry,
108
+ prompt: str,
109
+ task: Task,
110
+ *,
111
+ work_dir: Path | None = None,
112
+ system_prompt: str = "",
113
+ ) -> list[str]:
114
+ path = entry.path
115
+ agent = task.agent
116
+ extra = _custom_args(agent)
117
+ model = _resolve_model(entry, task)
118
+
119
+ if provider == "claude":
120
+ cmd = [
121
+ path,
122
+ "-p",
123
+ prompt,
124
+ "--output-format",
125
+ "stream-json",
126
+ "--verbose",
127
+ "--permission-mode",
128
+ "bypassPermissions",
129
+ "--strict-mcp-config",
130
+ ]
131
+ if model:
132
+ cmd.extend(["--model", model])
133
+ if task.prior_session_id:
134
+ cmd.extend(["--resume", task.prior_session_id])
135
+ # Filter blocked custom args that daemon owns.
136
+ filtered = _filter_blocked_args(
137
+ extra, {"--mcp-config", "--output-format", "--permission-mode", "--strict-mcp-config"}
138
+ )
139
+ cmd.extend(filtered)
140
+ return cmd
141
+
142
+ if provider == "codex":
143
+ cmd = [path, "exec", "--skip-git-repo-check", prompt]
144
+ if model:
145
+ cmd.extend(["-m", model])
146
+ cmd.extend(extra)
147
+ return cmd
148
+
149
+ if provider == "gemini":
150
+ cmd = [path, "-p", prompt, "--yolo", "-o", "stream-json"]
151
+ if model:
152
+ cmd.extend(["-m", model])
153
+ if task.prior_session_id:
154
+ cmd.extend(["-r", task.prior_session_id])
155
+ cmd.extend(extra)
156
+ return cmd
157
+
158
+ if provider == "cursor":
159
+ cmd = [path, "-p", prompt, "--output-format", "stream-json", "--yolo"]
160
+ if work_dir is not None:
161
+ cmd.extend(["--workspace", str(work_dir)])
162
+ if model:
163
+ cmd.extend(["--model", model])
164
+ if task.prior_session_id and not task.chat_session_id:
165
+ cmd.extend(["--resume", task.prior_session_id])
166
+ cmd.extend(extra)
167
+ return cmd
168
+
169
+ if provider == "openclaw":
170
+ session_id = task.prior_session_id or f"aicortex-{uuid.uuid4().hex}"
171
+ msg = prompt
172
+ if system_prompt:
173
+ msg = system_prompt + "\n\n" + prompt
174
+ cmd = [path, "agent", "--local", "--json", "--session-id", session_id]
175
+ if model and not _custom_args_has(extra, "--agent"):
176
+ cmd.extend(["--agent", model])
177
+ cmd.extend(extra)
178
+ cmd.extend(["--message", msg])
179
+ return cmd
180
+
181
+ if provider == "pi":
182
+ session_path = _pi_session_path(task)
183
+ cmd = [path, "-p", "--mode", "json", "--session", session_path]
184
+ if model:
185
+ pi_provider, pi_model = _split_pi_model(model)
186
+ if pi_provider:
187
+ cmd.extend(["--provider", pi_provider])
188
+ if pi_model:
189
+ cmd.extend(["--model", pi_model])
190
+ if system_prompt:
191
+ cmd.extend(["--append-system-prompt", system_prompt])
192
+ cmd.extend(extra)
193
+ cmd.append(prompt)
194
+ return cmd
195
+
196
+ if provider == "opencode":
197
+ cmd = [path, "run", "--format", "json"]
198
+ if model:
199
+ cmd.extend(["--model", model])
200
+ if system_prompt:
201
+ cmd.extend(["--prompt", system_prompt])
202
+ if task.prior_session_id:
203
+ cmd.extend(["--session", task.prior_session_id])
204
+ cmd.extend(_filter_blocked_args(extra, {"--format"}))
205
+ cmd.append(prompt)
206
+ return cmd
207
+
208
+ if provider == "copilot":
209
+ cmd = [
210
+ path,
211
+ "-p",
212
+ prompt,
213
+ "--output-format",
214
+ "json",
215
+ "--allow-all",
216
+ "--no-ask-user",
217
+ ]
218
+ if model:
219
+ cmd.extend(["--model", model])
220
+ if task.prior_session_id:
221
+ cmd.extend(["--resume", task.prior_session_id])
222
+ cmd.extend(
223
+ _filter_blocked_args(
224
+ extra,
225
+ {
226
+ "-p",
227
+ "--output-format",
228
+ "--allow-all",
229
+ "--yolo",
230
+ "--no-ask-user",
231
+ "--resume",
232
+ "--acp",
233
+ },
234
+ )
235
+ )
236
+ return cmd
237
+
238
+ return [path, prompt, *extra]
239
+
240
+
241
+ def _filter_blocked_args(args: list[str], blocked: set[str]) -> list[str]:
242
+ """Drop daemon-owned flags (and their values) from custom_args."""
243
+ out: list[str] = []
244
+ skip_next = False
245
+ for a in args:
246
+ if skip_next:
247
+ skip_next = False
248
+ continue
249
+ name = a.split("=", 1)[0]
250
+ if name in blocked or a in blocked:
251
+ if "=" not in a and name in blocked:
252
+ skip_next = True
253
+ continue
254
+ out.append(a)
255
+ return out
256
+
257
+
258
+ def _hide_window_kwargs() -> dict:
259
+ if sys.platform != "win32":
260
+ return {}
261
+ startup = subprocess.STARTUPINFO()
262
+ startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
263
+ return {"startupinfo": startup}
264
+
265
+
266
+ def _cleanup_mcp_tmp(path: str | None) -> None:
267
+ if not path:
268
+ return
269
+ try:
270
+ os.unlink(path)
271
+ except OSError:
272
+ pass
273
+
274
+
275
+ def run_agent(
276
+ task: Task,
277
+ provider: str,
278
+ entry: AgentEntry,
279
+ workspaces_root: Path,
280
+ timeout: float,
281
+ *,
282
+ client: DaemonClient | None = None,
283
+ cfg: Config | None = None,
284
+ task_slot: int = 0,
285
+ cancel_event: threading.Event | None = None,
286
+ on_messages: MessageFlush | None = None,
287
+ on_session: SessionPin | None = None,
288
+ acp_pool: ChatACPPool | None = None,
289
+ idle_watchdog: float = 0,
290
+ ) -> RunResult:
291
+ env_info: Environment = prepare_environment(workspaces_root, task, provider)
292
+ work_dir = env_info.work_dir
293
+
294
+ # Design→dev handoff zip (Priority 1b in Go runTask).
295
+ if task.handoff_bundle_url:
296
+ try:
297
+ from .handoff_bundle import materialize_handoff_bundle
298
+
299
+ materialize_handoff_bundle(
300
+ work_dir,
301
+ task.handoff_bundle_url,
302
+ token=(cfg.token if cfg else ""),
303
+ server_base=(cfg.server_base_url if cfg else ""),
304
+ )
305
+ except Exception as e:
306
+ return RunResult(
307
+ ok=False,
308
+ output="",
309
+ work_dir=str(work_dir),
310
+ error=f"handoff bundle: {e}",
311
+ )
312
+
313
+ if task.design_example_id:
314
+ try:
315
+ from .design_seed import seed_example_workdir
316
+
317
+ seed_example_workdir(
318
+ work_dir,
319
+ task.design_example_id,
320
+ task.design_example_bundles,
321
+ design_studio=bool(task.design_mode),
322
+ )
323
+ except Exception as e:
324
+ log.warning("design seed: %s", e)
325
+
326
+ if client is not None:
327
+ try:
328
+ materialize_attachments(client, task, work_dir)
329
+ # refresh prompt if attachments arrived for design/chat
330
+ if task.design_incoming_rel_paths:
331
+ from .prompt import build_prompt
332
+
333
+ (work_dir / "PROMPT.md").write_text(
334
+ build_prompt(task, provider), encoding="utf-8"
335
+ )
336
+ except Exception as e:
337
+ log.warning("attachments: %s", e)
338
+
339
+ try:
340
+ ensure_repos_for_task(workspaces_root, task, work_dir)
341
+ except Exception as e:
342
+ log.warning("repo prepare: %s", e)
343
+
344
+ prompt = (work_dir / "PROMPT.md").read_text(encoding="utf-8")
345
+ system_prompt = ""
346
+ if provider_needs_inline_system_prompt(provider):
347
+ system_prompt = env_info.runtime_brief or ""
348
+ plan = prepare_provider_sandbox(provider, work_dir)
349
+ if env_info.codex_home:
350
+ plan.env_updates["CODEX_HOME"] = str(env_info.codex_home)
351
+ if env_info.env_updates:
352
+ plan.env_updates.update(env_info.env_updates)
353
+ for n in plan.notes or []:
354
+ log.info("sandbox: %s", n)
355
+
356
+ if cfg is not None:
357
+ environ = build_agent_environ(
358
+ os.environ.copy(),
359
+ cfg=cfg,
360
+ task=task,
361
+ task_slot=task_slot,
362
+ extra=plan.env_updates,
363
+ )
364
+ else:
365
+ environ = os.environ.copy()
366
+ environ.update(plan.env_updates)
367
+ if provider == "gemini":
368
+ environ.setdefault("GEMINI_CLI_TRUST_WORKSPACE", "true")
369
+ if provider == "opencode":
370
+ environ.setdefault("OPENCODE_PERMISSION", '{"*":"allow"}')
371
+
372
+ mcp_tmp: str | None = None
373
+ agent = task.agent or {}
374
+ mcp_raw = agent.get("mcp_config")
375
+ if provider == "claude" and mcp_raw:
376
+ try:
377
+ if isinstance(mcp_raw, (dict, list)):
378
+ payload = json.dumps(mcp_raw, ensure_ascii=False)
379
+ else:
380
+ payload = str(mcp_raw)
381
+ fd, mcp_tmp = tempfile.mkstemp(prefix="aicortex-mcp-", suffix=".json")
382
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
383
+ fh.write(payload)
384
+ except OSError as e:
385
+ log.warning("mcp config tempfile failed: %s", e)
386
+ mcp_tmp = None
387
+
388
+ # Chat + Cursor/Kiro: prefer long-lived ACP pool; fall back to one-shot CLI.
389
+ if (
390
+ acp_pool is not None
391
+ and task.chat_session_id
392
+ and acp_pool.enabled_for(provider)
393
+ ):
394
+ acp_result = _run_via_acp_pool(
395
+ task=task,
396
+ provider=provider,
397
+ entry=entry,
398
+ work_dir=work_dir,
399
+ prompt=prompt,
400
+ timeout=timeout,
401
+ environ=environ,
402
+ plan_label=plan.policy_label,
403
+ acp_pool=acp_pool,
404
+ cancel_event=cancel_event,
405
+ on_messages=on_messages,
406
+ on_session=on_session,
407
+ idle_watchdog=idle_watchdog,
408
+ system_prompt=system_prompt,
409
+ )
410
+ if acp_result is not None:
411
+ cleanup_plan(plan)
412
+ _cleanup_mcp_tmp(mcp_tmp)
413
+ return acp_result
414
+ log.warning("%s chat acp pool failed, falling back to one-shot backend", provider)
415
+
416
+ # Non-chat ACP providers (kiro/hermes/kimi/dsh) — always JSON-RPC, not argv prompt.
417
+ if provider in ACP_ONESHOT_PROVIDERS:
418
+ oneshot = _run_via_acp_oneshot(
419
+ task=task,
420
+ provider=provider,
421
+ entry=entry,
422
+ work_dir=work_dir,
423
+ prompt=prompt,
424
+ timeout=timeout,
425
+ environ=environ,
426
+ plan_label=plan.policy_label,
427
+ cancel_event=cancel_event,
428
+ on_messages=on_messages,
429
+ on_session=on_session,
430
+ idle_watchdog=idle_watchdog,
431
+ system_prompt=system_prompt,
432
+ )
433
+ cleanup_plan(plan)
434
+ _cleanup_mcp_tmp(mcp_tmp)
435
+ return oneshot
436
+
437
+ cmd = _build_command(
438
+ provider, entry, prompt, task, work_dir=work_dir, system_prompt=system_prompt
439
+ )
440
+ if mcp_tmp and provider == "claude":
441
+ cmd.extend(["--mcp-config", mcp_tmp])
442
+ if sys.platform == "win32" and sum(len(a) for a in cmd) > 7000:
443
+ short = (
444
+ f"Read and follow {work_dir / 'PROMPT.md'}. "
445
+ "Complete the AICortex task described there."
446
+ )
447
+ cmd = _build_command(
448
+ provider, entry, short, task, work_dir=work_dir, system_prompt=system_prompt
449
+ )
450
+ if provider == "cursor" and cmd:
451
+ argv0, args = choose_cursor_invocation(cmd[0], cmd[1:])
452
+ cmd = [argv0, *args]
453
+ cmd = apply_plan_to_command(plan, cmd)
454
+
455
+ log.info(
456
+ "spawn agent provider=%s sandbox=%s cwd=%s cmd0=%s",
457
+ provider,
458
+ plan.policy_label,
459
+ work_dir,
460
+ cmd[0],
461
+ )
462
+
463
+ collector = StreamCollector(provider=provider)
464
+ if provider == "pi":
465
+ for i, a in enumerate(cmd):
466
+ if a == "--session" and i + 1 < len(cmd):
467
+ collector.session_id = cmd[i + 1]
468
+ break
469
+ elif provider == "openclaw":
470
+ for i, a in enumerate(cmd):
471
+ if a == "--session-id" and i + 1 < len(cmd):
472
+ collector.session_id = cmd[i + 1]
473
+ break
474
+ pending_msgs: list[dict] = []
475
+ last_flush = time.time()
476
+ pinned_once = False
477
+ parse_stream = provider in STREAM_JSON_PROVIDERS
478
+
479
+ def flush_messages(force: bool = False) -> None:
480
+ nonlocal pending_msgs, last_flush
481
+ if not on_messages or not pending_msgs:
482
+ return
483
+ if not force and len(pending_msgs) < 5 and time.time() - last_flush < 2.0:
484
+ return
485
+ batch = pending_msgs
486
+ pending_msgs = []
487
+ last_flush = time.time()
488
+ try:
489
+ on_messages(batch)
490
+ except Exception as e:
491
+ log.warning("flush messages failed: %s", e)
492
+
493
+ def maybe_pin() -> None:
494
+ nonlocal pinned_once
495
+ if on_session and collector.session_id and not pinned_once:
496
+ pinned_once = True
497
+ try:
498
+ on_session(collector.session_id, str(work_dir))
499
+ except Exception as e:
500
+ log.warning("pin session failed: %s", e)
501
+
502
+ popen_kw = {
503
+ "cwd": str(work_dir),
504
+ "env": environ,
505
+ "stdout": subprocess.PIPE,
506
+ "stderr": subprocess.PIPE,
507
+ "text": True,
508
+ "bufsize": 1,
509
+ **popen_kwargs_for_killable_group(),
510
+ **_hide_window_kwargs(),
511
+ }
512
+
513
+ try:
514
+ proc = subprocess.Popen(cmd, **popen_kw)
515
+ except FileNotFoundError:
516
+ cleanup_plan(plan)
517
+ _cleanup_mcp_tmp(mcp_tmp)
518
+ return RunResult(
519
+ ok=False,
520
+ output="",
521
+ work_dir=str(work_dir),
522
+ error=f"agent executable not found: {entry.path}",
523
+ sandbox_label=plan.policy_label,
524
+ )
525
+ except OSError as e:
526
+ cleanup_plan(plan)
527
+ _cleanup_mcp_tmp(mcp_tmp)
528
+ return RunResult(
529
+ ok=False,
530
+ output="",
531
+ work_dir=str(work_dir),
532
+ error=str(e),
533
+ sandbox_label=plan.policy_label,
534
+ )
535
+
536
+ # Early pin of work_dir even without session
537
+ if on_session:
538
+ try:
539
+ on_session("", str(work_dir))
540
+ except Exception:
541
+ pass
542
+
543
+ stdout_chunks: list[str] = []
544
+ stderr_chunks: list[str] = []
545
+ cancelled = False
546
+ semantic_timeout = 0.0
547
+ if provider == "codex" and cfg is not None:
548
+ semantic_timeout = float(cfg.codex_semantic_inactivity or 0)
549
+ last_activity = time.time()
550
+ last_activity_desc = "spawn"
551
+ activity_lock = threading.Lock()
552
+ semantic_killed = False
553
+
554
+ def _touch_activity(desc: str) -> None:
555
+ nonlocal last_activity, last_activity_desc
556
+ with activity_lock:
557
+ last_activity = time.time()
558
+ last_activity_desc = desc
559
+
560
+ def _reader(stream, sink: list[str], parse: bool, label: str) -> None:
561
+ assert stream is not None
562
+ for line in stream:
563
+ sink.append(line)
564
+ _touch_activity(label)
565
+ if parse:
566
+ before = len(collector.messages)
567
+ collector.feed_line(line)
568
+ if len(collector.messages) > before:
569
+ pending_msgs.extend(collector.messages[before:])
570
+ flush_messages()
571
+ maybe_pin()
572
+
573
+ t_out = threading.Thread(
574
+ target=_reader,
575
+ args=(proc.stdout, stdout_chunks, parse_stream, "stdout"),
576
+ name="agent-stdout",
577
+ daemon=True,
578
+ )
579
+ t_err = threading.Thread(
580
+ target=_reader,
581
+ args=(proc.stderr, stderr_chunks, False, "stderr"),
582
+ name="agent-stderr",
583
+ daemon=True,
584
+ )
585
+ t_out.start()
586
+ t_err.start()
587
+
588
+ deadline = time.time() + timeout
589
+ while True:
590
+ if cancel_event is not None and cancel_event.is_set():
591
+ cancelled = True
592
+ log.info("cancel requested; killing agent pid=%s", proc.pid)
593
+ kill_process_tree(proc.pid)
594
+ break
595
+ rc = proc.poll()
596
+ if rc is not None:
597
+ break
598
+ now = time.time()
599
+ if now >= deadline:
600
+ log.info("agent timeout; killing pid=%s", proc.pid)
601
+ kill_process_tree(proc.pid)
602
+ break
603
+ if semantic_timeout > 0:
604
+ with activity_lock:
605
+ idle = now - last_activity
606
+ idle_desc = last_activity_desc
607
+ if idle >= semantic_timeout:
608
+ semantic_killed = True
609
+ log.warning(
610
+ "codex semantic inactivity timeout after %.0fs (last=%s idle=%.1fs); killing pid=%s",
611
+ semantic_timeout,
612
+ idle_desc,
613
+ idle,
614
+ proc.pid,
615
+ )
616
+ kill_process_tree(proc.pid)
617
+ break
618
+ time.sleep(0.2)
619
+
620
+ try:
621
+ proc.wait(timeout=10)
622
+ except subprocess.TimeoutExpired:
623
+ kill_process_tree(proc.pid)
624
+ proc.wait(timeout=5)
625
+
626
+ t_out.join(timeout=5)
627
+ t_err.join(timeout=5)
628
+ cleanup_plan(plan)
629
+ _cleanup_mcp_tmp(mcp_tmp)
630
+ maybe_pin()
631
+
632
+ stdout = "".join(stdout_chunks)
633
+ stderr = "".join(stderr_chunks)
634
+ if not parse_stream:
635
+ collector.finalize_plain_output(stdout if stdout.strip() else stderr)
636
+ pending_msgs.extend(
637
+ m for m in collector.messages if m not in pending_msgs
638
+ )
639
+ flush_messages(force=True)
640
+
641
+ usage_list = [collector.usage.as_dict()] if collector.usage.any else []
642
+ output = collector.output_text() or (stdout if stdout.strip() else stderr)
643
+
644
+ common = dict(
645
+ work_dir=str(work_dir),
646
+ sandbox_label=plan.policy_label,
647
+ session_id=collector.session_id,
648
+ messages=list(collector.messages),
649
+ usage=usage_list,
650
+ )
651
+
652
+ if cancelled:
653
+ return RunResult(ok=False, output=output, error="cancelled", cancelled=True, **common)
654
+
655
+ if semantic_killed:
656
+ with activity_lock:
657
+ desc = last_activity_desc
658
+ return RunResult(
659
+ ok=False,
660
+ output=output,
661
+ error=(
662
+ f"codex semantic inactivity timeout after {semantic_timeout:.0f}s "
663
+ f"without agent progress (last activity: {desc})"
664
+ ),
665
+ **common,
666
+ )
667
+
668
+ if proc.returncode is None:
669
+ return RunResult(
670
+ ok=False,
671
+ output=output,
672
+ error=f"agent timeout after {timeout:.0f}s",
673
+ **common,
674
+ )
675
+
676
+ if proc.returncode != 0:
677
+ err = f"agent exited {proc.returncode}"
678
+ if stderr.strip():
679
+ err = f"{err}: {stderr.strip()[:2000]}"
680
+ return RunResult(ok=False, output=output, error=err, **common)
681
+
682
+ return RunResult(ok=True, output=output or "(no output)", **common)
683
+
684
+
685
+ def _acp_message_flusher(on_messages: MessageFlush | None) -> tuple[list[dict], Any]:
686
+ """Return (messages_acc, on_message_cb) that streams batches to the server."""
687
+ messages: list[dict] = []
688
+ pending: list[dict] = []
689
+ last_flush = time.time()
690
+
691
+ def flush(force: bool = False) -> None:
692
+ nonlocal pending, last_flush
693
+ if not on_messages or not pending:
694
+ return
695
+ if not force and len(pending) < 3 and time.time() - last_flush < 0.5:
696
+ return
697
+ batch = pending
698
+ pending = []
699
+ last_flush = time.time()
700
+ try:
701
+ on_messages(batch)
702
+ except Exception as e:
703
+ log.warning("flush acp messages failed: %s", e)
704
+
705
+ def on_message(msg: dict) -> None:
706
+ messages.append(msg)
707
+ pending.append(msg)
708
+ flush()
709
+
710
+ on_message.flush = flush # type: ignore[attr-defined]
711
+ return messages, on_message
712
+
713
+
714
+ def _acp_result_to_run_result(
715
+ *,
716
+ result: ACPPromptResult,
717
+ provider: str,
718
+ model: str,
719
+ work: str,
720
+ plan_label: str,
721
+ timeout: float,
722
+ messages: list[dict],
723
+ on_session: SessionPin | None,
724
+ ) -> RunResult:
725
+ if result.session_id and on_session:
726
+ try:
727
+ on_session(result.session_id, work)
728
+ except Exception as e:
729
+ log.warning("pin acp session failed: %s", e)
730
+
731
+ usage_list = []
732
+ if result.usage and any(result.usage.values()):
733
+ usage_list.append(
734
+ {
735
+ "provider": provider,
736
+ "model": model,
737
+ "input_tokens": int(result.usage.get("input_tokens") or 0),
738
+ "output_tokens": int(result.usage.get("output_tokens") or 0),
739
+ "cache_read_tokens": int(result.usage.get("cache_read_tokens") or 0),
740
+ "cache_write_tokens": 0,
741
+ }
742
+ )
743
+ msgs = messages or list(result.messages or [])
744
+ common = dict(
745
+ work_dir=work,
746
+ sandbox_label=plan_label,
747
+ session_id=result.session_id,
748
+ messages=msgs,
749
+ usage=usage_list,
750
+ )
751
+ if result.status in ("aborted", "cancelled"):
752
+ return RunResult(
753
+ ok=False,
754
+ output=result.output,
755
+ error=result.error or "cancelled",
756
+ cancelled=True,
757
+ **common,
758
+ )
759
+ if result.status in ("timeout", "idle_watchdog"):
760
+ return RunResult(
761
+ ok=False,
762
+ output=result.output,
763
+ error=result.error or f"agent {result.status} after {timeout:.0f}s",
764
+ **common,
765
+ )
766
+ if result.status != "completed":
767
+ return RunResult(
768
+ ok=False,
769
+ output=result.output,
770
+ error=result.error or f"acp status={result.status}",
771
+ **common,
772
+ )
773
+ return RunResult(ok=True, output=result.output or "(no output)", **common)
774
+
775
+
776
+ def _run_via_acp_pool(
777
+ *,
778
+ task: Task,
779
+ provider: str,
780
+ entry: AgentEntry,
781
+ work_dir: Path,
782
+ prompt: str,
783
+ timeout: float,
784
+ environ: dict[str, str],
785
+ plan_label: str,
786
+ acp_pool: ChatACPPool,
787
+ cancel_event: threading.Event | None,
788
+ on_messages: MessageFlush | None,
789
+ on_session: SessionPin | None,
790
+ idle_watchdog: float = 0,
791
+ system_prompt: str = "",
792
+ ) -> RunResult | None:
793
+ """Returns RunResult on ACP success/terminal status, or None to fall back."""
794
+ model = _resolve_model(entry, task)
795
+ agent_id = agent_id_from_task(task)
796
+ resume = ""
797
+ if not skip_cursor_chat_session_resume(provider, task.chat_session_id):
798
+ resume = task.prior_session_id
799
+ custom_args = _custom_args(task.agent)
800
+ work = str(work_dir)
801
+ messages, on_message = _acp_message_flusher(on_messages)
802
+
803
+ if on_session:
804
+ try:
805
+ on_session("", work)
806
+ except Exception:
807
+ pass
808
+
809
+ result = acp_pool.run_prompt(
810
+ provider=provider,
811
+ chat_session_id=task.chat_session_id,
812
+ agent_id=agent_id,
813
+ work_dir=work,
814
+ model=model,
815
+ prompt=prompt,
816
+ resume_session_id=resume,
817
+ custom_args=custom_args,
818
+ timeout=timeout,
819
+ env=environ,
820
+ cancel_event=cancel_event,
821
+ on_message=on_message,
822
+ idle_watchdog=idle_watchdog,
823
+ system_prompt=system_prompt,
824
+ )
825
+
826
+ # Open failed → caller falls back to one-shot.
827
+ if result.status == "failed" and "chat acp open failed" in (result.error or ""):
828
+ return None
829
+
830
+ # Resume failed before a session was established → retry fresh once.
831
+ if result.status == "failed" and resume and not result.session_id:
832
+ log.warning(
833
+ "%s chat acp resume failed, retrying with fresh session: %s",
834
+ provider,
835
+ result.error,
836
+ )
837
+ acp_pool.remove_entry(pool_key(provider, task.chat_session_id, agent_id, work))
838
+ first_usage = result.usage
839
+ result = acp_pool.run_prompt(
840
+ provider=provider,
841
+ chat_session_id=task.chat_session_id,
842
+ agent_id=agent_id,
843
+ work_dir=work,
844
+ model=model,
845
+ prompt=prompt,
846
+ resume_session_id="",
847
+ custom_args=custom_args,
848
+ timeout=timeout,
849
+ env=environ,
850
+ cancel_event=cancel_event,
851
+ on_message=on_message,
852
+ idle_watchdog=idle_watchdog,
853
+ system_prompt=system_prompt,
854
+ )
855
+ if result.status == "failed" and "chat acp open failed" in (result.error or ""):
856
+ return None
857
+ if first_usage and result.usage:
858
+ result.usage = {
859
+ "input_tokens": int(first_usage.get("input_tokens") or 0)
860
+ + int(result.usage.get("input_tokens") or 0),
861
+ "output_tokens": int(first_usage.get("output_tokens") or 0)
862
+ + int(result.usage.get("output_tokens") or 0),
863
+ "cache_read_tokens": int(first_usage.get("cache_read_tokens") or 0)
864
+ + int(result.usage.get("cache_read_tokens") or 0),
865
+ }
866
+ elif first_usage and not result.usage:
867
+ result.usage = first_usage
868
+
869
+ on_message.flush(force=True) # type: ignore[attr-defined]
870
+ return _acp_result_to_run_result(
871
+ result=result,
872
+ provider=provider,
873
+ model=model,
874
+ work=work,
875
+ plan_label=plan_label,
876
+ timeout=timeout,
877
+ messages=messages,
878
+ on_session=on_session,
879
+ )
880
+
881
+
882
+ def _run_via_acp_oneshot(
883
+ *,
884
+ task: Task,
885
+ provider: str,
886
+ entry: AgentEntry,
887
+ work_dir: Path,
888
+ prompt: str,
889
+ timeout: float,
890
+ environ: dict[str, str],
891
+ plan_label: str,
892
+ cancel_event: threading.Event | None,
893
+ on_messages: MessageFlush | None,
894
+ on_session: SessionPin | None,
895
+ idle_watchdog: float = 0,
896
+ system_prompt: str = "",
897
+ ) -> RunResult:
898
+ model = _resolve_model(entry, task)
899
+ work = str(work_dir)
900
+ messages, on_message = _acp_message_flusher(on_messages)
901
+ resume = task.prior_session_id
902
+ if on_session:
903
+ try:
904
+ on_session("", work)
905
+ except Exception:
906
+ pass
907
+
908
+ result = run_acp_oneshot(
909
+ provider,
910
+ entry.path,
911
+ cwd=work,
912
+ prompt=prompt,
913
+ model=model,
914
+ resume_session_id=resume,
915
+ custom_args=_custom_args(task.agent),
916
+ timeout=timeout,
917
+ env=environ,
918
+ system_prompt=system_prompt,
919
+ on_message=on_message,
920
+ cancel_event=cancel_event,
921
+ idle_watchdog=idle_watchdog,
922
+ )
923
+
924
+ # Resume failed before session → retry fresh once (matches Go daemon).
925
+ if result.status == "failed" and resume and not result.session_id:
926
+ log.warning(
927
+ "%s acp resume failed, retrying with fresh session: %s",
928
+ provider,
929
+ result.error,
930
+ )
931
+ first_usage = result.usage
932
+ result = run_acp_oneshot(
933
+ provider,
934
+ entry.path,
935
+ cwd=work,
936
+ prompt=prompt,
937
+ model=model,
938
+ resume_session_id="",
939
+ custom_args=_custom_args(task.agent),
940
+ timeout=timeout,
941
+ env=environ,
942
+ system_prompt=system_prompt,
943
+ on_message=on_message,
944
+ cancel_event=cancel_event,
945
+ idle_watchdog=idle_watchdog,
946
+ )
947
+ if first_usage and result.usage:
948
+ result.usage = {
949
+ "input_tokens": int(first_usage.get("input_tokens") or 0)
950
+ + int(result.usage.get("input_tokens") or 0),
951
+ "output_tokens": int(first_usage.get("output_tokens") or 0)
952
+ + int(result.usage.get("output_tokens") or 0),
953
+ "cache_read_tokens": int(first_usage.get("cache_read_tokens") or 0)
954
+ + int(result.usage.get("cache_read_tokens") or 0),
955
+ }
956
+ elif first_usage and not result.usage:
957
+ result.usage = first_usage
958
+
959
+ on_message.flush(force=True) # type: ignore[attr-defined]
960
+ return _acp_result_to_run_result(
961
+ result=result,
962
+ provider=provider,
963
+ model=model,
964
+ work=work,
965
+ plan_label=plan_label,
966
+ timeout=timeout,
967
+ messages=messages,
968
+ on_session=on_session,
969
+ )