aicortexpy 0.2.2__tar.gz → 0.2.3__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 (102) hide show
  1. {aicortexpy-0.2.2/aicortexpy.egg-info → aicortexpy-0.2.3}/PKG-INFO +1 -1
  2. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/__init__.py +1 -1
  3. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/acp_conn.py +13 -8
  4. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/acp_errors.py +23 -0
  5. aicortexpy-0.2.3/aicortexpy/app_workdir.py +72 -0
  6. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/artifact_ws.py +5 -1
  7. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/artifacts.py +6 -0
  8. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/auto_commit.py +3 -1
  9. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/client.py +116 -1
  10. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/agent_cmd.py +5 -0
  11. aicortexpy-0.2.3/aicortexpy/commands/graph_cmd.py +204 -0
  12. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/memory_cmd.py +1 -1
  13. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/config.py +3 -1
  14. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/daemon.py +26 -9
  15. aicortexpy-0.2.3/aicortexpy/data/default_skills/aicortex-graph/SKILL.md +46 -0
  16. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/execenv.py +13 -1
  17. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/models.py +12 -3
  18. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/openclaw_config.py +3 -1
  19. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/process.py +16 -3
  20. aicortexpy-0.2.3/aicortexpy/procutil.py +113 -0
  21. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/repo.py +13 -12
  22. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/runner.py +8 -5
  23. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/runtime_config.py +73 -1
  24. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/terminal.py +3 -0
  25. {aicortexpy-0.2.2 → aicortexpy-0.2.3/aicortexpy.egg-info}/PKG-INFO +1 -1
  26. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy.egg-info/SOURCES.txt +3 -1
  27. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/pyproject.toml +1 -1
  28. aicortexpy-0.2.3/tests/test_app_workdir.py +59 -0
  29. aicortexpy-0.2.2/aicortexpy/commands/graph_cmd.py +0 -69
  30. aicortexpy-0.2.2/aicortexpy/data/default_skills/aicortex-graph/SKILL.md +0 -30
  31. aicortexpy-0.2.2/aicortexpy/procutil.py +0 -61
  32. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/MANIFEST.in +0 -0
  33. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/README.md +0 -0
  34. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortex/__init__.py +0 -0
  35. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortex/__main__.py +0 -0
  36. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/__main__.py +0 -0
  37. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/acp_client.py +0 -0
  38. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/agent_env.py +0 -0
  39. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/attachments.py +0 -0
  40. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/capabilities.py +0 -0
  41. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/chat_acp_pool.py +0 -0
  42. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/cli.py +0 -0
  43. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/cli_context.py +0 -0
  44. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/codex_config.py +0 -0
  45. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/__init__.py +0 -0
  46. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/attachment_cmd.py +0 -0
  47. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/auth_cmd.py +0 -0
  48. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/autopilot_cmd.py +0 -0
  49. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/config_cmd.py +0 -0
  50. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/daemon_cmd.py +0 -0
  51. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/issue_cmd.py +0 -0
  52. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/label_cmd.py +0 -0
  53. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/meeting_cmd.py +0 -0
  54. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/project_cmd.py +0 -0
  55. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/repo_cmd.py +0 -0
  56. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/runtime_cmd.py +0 -0
  57. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/setup_cmd.py +0 -0
  58. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/skill_cmd.py +0 -0
  59. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/squad_cmd.py +0 -0
  60. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/update_cmd.py +0 -0
  61. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/version_cmd.py +0 -0
  62. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/commands/workspace_cmd.py +0 -0
  63. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/agent-memory/SKILL.md +0 -0
  64. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +0 -0
  65. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +0 -0
  66. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +0 -0
  67. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +0 -0
  68. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/aicortex-basics/SKILL.md +0 -0
  69. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +0 -0
  70. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +0 -0
  71. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/graphify/SKILL.md +0 -0
  72. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/question-form/SKILL.md +0 -0
  73. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/test-report-generator/SKILL.md +0 -0
  74. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +0 -0
  75. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +0 -0
  76. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/design_seed.py +0 -0
  77. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/diskusage.py +0 -0
  78. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/gc.py +0 -0
  79. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/handoff_bundle.py +0 -0
  80. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/health.py +0 -0
  81. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/id_resolve.py +0 -0
  82. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/local_skills.py +0 -0
  83. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/oauth_login.py +0 -0
  84. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/output.py +0 -0
  85. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/permission.py +0 -0
  86. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/pinned_workdir.py +0 -0
  87. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/platform_client.py +0 -0
  88. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/platform_info.py +0 -0
  89. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/poisoned.py +0 -0
  90. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/prompt.py +0 -0
  91. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/sandbox.py +0 -0
  92. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/security.py +0 -0
  93. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/stream.py +0 -0
  94. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/tunnel.py +0 -0
  95. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy/wakeup.py +0 -0
  96. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy.egg-info/dependency_links.txt +0 -0
  97. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy.egg-info/entry_points.txt +0 -0
  98. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy.egg-info/requires.txt +0 -0
  99. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/aicortexpy.egg-info/top_level.txt +0 -0
  100. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/scripts/aicortex +0 -0
  101. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/scripts/aicortex.cmd +0 -0
  102. {aicortexpy-0.2.2 → aicortexpy-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: aicortexpy
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: AICortex Python CLI and local agent runtime daemon (Go CLI compatible)
5
5
  Author: AICortex
6
6
  License: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """AICortex Python CLI + local agent runtime daemon."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -23,7 +23,11 @@ from .acp_client import (
23
23
  kiro_tool_name,
24
24
  resolve_resumed_session_id,
25
25
  )
26
- from .acp_errors import ACPProviderErrorSniffer, promote_acp_result_on_provider_error
26
+ from .acp_errors import (
27
+ ACPProviderErrorSniffer,
28
+ enrich_kiro_acp_error,
29
+ promote_acp_result_on_provider_error,
30
+ )
27
31
  from .procutil import kill_process_tree, popen_kwargs_for_killable_group
28
32
  from .security import SecurityPolicy, build_cursor_acp_global_flags, default_security_policy
29
33
 
@@ -73,15 +77,16 @@ def kiro_acp_supported(executable: str) -> bool:
73
77
 
74
78
 
75
79
  def _acp_help_ok(executable: str, *, extra_before: list[str]) -> bool:
80
+ from .procutil import run_no_window
81
+
76
82
  path = shutil.which(executable) or executable
77
83
  args = [path, *extra_before]
78
84
  try:
79
- proc = subprocess.run(
85
+ proc = run_no_window(
80
86
  args,
81
87
  capture_output=True,
82
88
  timeout=5,
83
89
  check=False,
84
- **_hide_window(),
85
90
  )
86
91
  return proc.returncode == 0
87
92
  except (OSError, subprocess.TimeoutExpired):
@@ -89,11 +94,9 @@ def _acp_help_ok(executable: str, *, extra_before: list[str]) -> bool:
89
94
 
90
95
 
91
96
  def _hide_window() -> dict:
92
- if sys.platform != "win32":
93
- return {}
94
- startup = subprocess.STARTUPINFO()
95
- startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
96
- return {"startupinfo": startup}
97
+ from .procutil import popen_kwargs_hidden_console
98
+
99
+ return popen_kwargs_hidden_console()
97
100
 
98
101
 
99
102
  def choose_cursor_invocation(executable: str, args: list[str]) -> tuple[str, list[str]]:
@@ -344,6 +347,8 @@ class _BaseACPConn:
344
347
  status, error = promote_acp_result_on_provider_error(
345
348
  status, error, output, self._sniffer
346
349
  )
350
+ if self.provider == "kiro" and status == "failed" and error:
351
+ error = enrich_kiro_acp_error(error)
347
352
 
348
353
  usage = None
349
354
  with client._usage_lock:
@@ -19,6 +19,10 @@ _ACP_TERMINAL_ERROR_RE = re.compile(
19
19
  _ACP_AGENT_OUTPUT_TERMINAL_RE = re.compile(r"API call failed after \d+ retr(?:y|ies)")
20
20
  _ACP_MAX_ERROR_LINES = 8
21
21
 
22
+ _KIRO_EXPLICIT_AUTH_RE = re.compile(r"no token|not logged in", re.IGNORECASE)
23
+ _KIRO_DISPATCH_RE = re.compile(r"dispatch failure", re.IGNORECASE)
24
+ _KIRO_LOGIN_HINT_RE = re.compile(r"kiro-cli login", re.IGNORECASE)
25
+
22
26
 
23
27
  class ACPProviderErrorSniffer:
24
28
  def __init__(self, provider: str) -> None:
@@ -105,3 +109,22 @@ def promote_acp_result_on_provider_error(
105
109
  if msg:
106
110
  return "failed", msg
107
111
  return final_status, final_error
112
+
113
+
114
+ def enrich_kiro_acp_error(error: str) -> str:
115
+ """Append login/retry hints from Kiro ACP error text alone (no extra CLI probes)."""
116
+ if not error or _KIRO_LOGIN_HINT_RE.search(error):
117
+ return error
118
+ if _KIRO_EXPLICIT_AUTH_RE.search(error):
119
+ return (
120
+ "kiro-cli is not logged in. Run `kiro-cli login`, then retry. "
121
+ f"({error})"
122
+ )
123
+ if _KIRO_DISPATCH_RE.search(error):
124
+ # Kiro often surfaces logout/network problems only as "dispatch failure".
125
+ return (
126
+ f"{error} "
127
+ "(common causes: kiro-cli not logged in - run `kiro-cli login`; "
128
+ "or transient AWS Q network error - retry)"
129
+ )
130
+ return error
@@ -0,0 +1,72 @@
1
+ """Pinned app template / instance workdirs (port of Go daemon/app_workdir.go)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .client import Task
10
+
11
+ log = logging.getLogger("aicortexpy.app_workdir")
12
+
13
+ _DEFAULT_SEED = b"<!DOCTYPE html><html><body><h1>App</h1></body></html>\n"
14
+
15
+
16
+ def app_uses_pinned_workdir(task: Task) -> bool:
17
+ return bool(task.app_id.strip()) and task.app_mode in ("edit", "run")
18
+
19
+
20
+ def prepare_app_workdir(workspaces_root: Path, task: Task) -> Path:
21
+ """Create/reuse app template or instance dir; seed index.html from task.app_html."""
22
+ ws = (task.workspace_id or "").strip()
23
+ app_id = task.app_id.strip()
24
+ if not ws or not app_id:
25
+ raise ValueError("missing app workdir identity")
26
+
27
+ base = workspaces_root / ws / "apps" / app_id
28
+ mode = task.app_mode
29
+ if mode == "edit":
30
+ directory = base / "template"
31
+ elif mode == "run":
32
+ if task.app_workdir_mode == "shared":
33
+ directory = base / "instances" / "shared"
34
+ else:
35
+ user_id = task.app_user_id.strip() or "anonymous"
36
+ directory = base / "instances" / "users" / user_id
37
+ else:
38
+ raise ValueError(f"unknown app mode {mode!r}")
39
+
40
+ directory.mkdir(parents=True, exist_ok=True)
41
+ index_path = directory / "index.html"
42
+ seed_text = task.app_html.strip()
43
+ seed = seed_text.encode("utf-8") if seed_text else _DEFAULT_SEED
44
+ need_write = (not index_path.is_file()) or index_path.stat().st_size == 0
45
+ if not need_write and seed_text:
46
+ # Server AppHTML is authoritative at task start; refresh stale disk
47
+ # copies (e.g. first-run default seed) so complete_task won't clobber DB.
48
+ try:
49
+ existing = index_path.read_text(encoding="utf-8")
50
+ except OSError:
51
+ existing = ""
52
+ if existing.strip() != seed_text:
53
+ need_write = True
54
+ if need_write:
55
+ index_path.write_bytes(seed)
56
+ log.info("seeded app index.html at %s mode=%s", index_path, mode)
57
+ return directory
58
+
59
+
60
+ def read_app_index_html(work_dir: str | Path) -> str:
61
+ path = Path(work_dir) / "index.html"
62
+ return path.read_text(encoding="utf-8")
63
+
64
+
65
+ def is_app_workdir_path(path: Path) -> bool:
66
+ """True when path sits under .../apps/<id>/(template|instances)/..."""
67
+ parts = path.resolve().parts
68
+ try:
69
+ i = parts.index("apps")
70
+ except ValueError:
71
+ return False
72
+ return i + 1 < len(parts)
@@ -110,10 +110,14 @@ class ArtifactBrowser:
110
110
  st = child.stat()
111
111
  except OSError:
112
112
  continue
113
+ child_rel = (
114
+ f"{rel.rstrip('/')}/{child.name}".lstrip("/") if rel else child.name
115
+ )
116
+ # Protocol (Go ArtifactListEntry) expects `path`, not `rel_path`.
113
117
  entries.append(
114
118
  {
115
119
  "name": child.name,
116
- "rel_path": f"{rel.rstrip('/')}/{child.name}".lstrip("/") if rel else child.name,
120
+ "path": child_rel,
117
121
  "is_dir": child.is_dir(),
118
122
  "size": st.st_size if child.is_file() else 0,
119
123
  "mtime": int(st.st_mtime),
@@ -64,4 +64,10 @@ def report_issue_artifacts(
64
64
  try:
65
65
  report(items)
66
66
  except Exception as e:
67
+ # Issue preview is feature-flagged off by default (404 "not found").
68
+ # Chat/app workdirs often have index.html that is not an issue artifact.
69
+ status = getattr(e, "status", None)
70
+ if status in (400, 404):
71
+ log.debug("report artifacts skipped: %s", e)
72
+ return
67
73
  log.warning("report artifacts failed: %s", e)
@@ -27,7 +27,9 @@ EXCLUDE = [
27
27
 
28
28
 
29
29
  def _git(cwd: Path, *args: str) -> tuple[int, str, str]:
30
- proc = subprocess.run(
30
+ from .procutil import run_no_window
31
+
32
+ proc = run_no_window(
31
33
  ["git", *args],
32
34
  cwd=str(cwd),
33
35
  capture_output=True,
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  import ssl
7
8
  import urllib.error
8
9
  import urllib.request
@@ -12,6 +13,7 @@ from typing import Any
12
13
  from .platform_info import host_os
13
14
 
14
15
  MAX_ATTACHMENT_BYTES = 100 << 20
16
+ log = logging.getLogger("aicortexpy")
15
17
 
16
18
 
17
19
  class APIError(Exception):
@@ -31,6 +33,29 @@ def is_not_found(err: Exception, needle: str) -> bool:
31
33
  )
32
34
 
33
35
 
36
+ def is_connection_reset(err: BaseException) -> bool:
37
+ """True when the peer closed the TCP socket mid-request (e.g. WinError 10054)."""
38
+ if isinstance(err, ConnectionResetError):
39
+ return True
40
+ if isinstance(err, BrokenPipeError):
41
+ return True
42
+ if isinstance(err, OSError):
43
+ # Windows: 10054 (reset), 10053 (local abort); POSIX: ECONNRESET=104 / macOS 54
44
+ if getattr(err, "winerror", None) in (10054, 10053):
45
+ return True
46
+ if getattr(err, "errno", None) in (104, 54):
47
+ return True
48
+ # urllib wraps the socket error in URLError.reason
49
+ reason = getattr(err, "reason", None)
50
+ if isinstance(reason, BaseException) and reason is not err:
51
+ return is_connection_reset(reason)
52
+ cause = getattr(err, "__cause__", None) or getattr(err, "__context__", None)
53
+ if isinstance(cause, BaseException) and cause is not err:
54
+ return is_connection_reset(cause)
55
+ msg = str(err)
56
+ return "10054" in msg or "10053" in msg
57
+
58
+
34
59
  @dataclass
35
60
  class Task:
36
61
  raw: dict[str, Any]
@@ -177,6 +202,38 @@ class Task:
177
202
  def session_kind(self) -> str:
178
203
  return self._s("session_kind")
179
204
 
205
+ @property
206
+ def app_id(self) -> str:
207
+ return self._s("app_id")
208
+
209
+ @property
210
+ def app_mode(self) -> str:
211
+ return self._s("app_mode")
212
+
213
+ @property
214
+ def app_name(self) -> str:
215
+ return self._s("app_name")
216
+
217
+ @property
218
+ def app_description(self) -> str:
219
+ return self._s("app_description")
220
+
221
+ @property
222
+ def app_spec(self) -> str:
223
+ return self._s("app_spec")
224
+
225
+ @property
226
+ def app_workdir_mode(self) -> str:
227
+ return self._s("app_workdir_mode")
228
+
229
+ @property
230
+ def app_html(self) -> str:
231
+ return self._s("app_html")
232
+
233
+ @property
234
+ def app_user_id(self) -> str:
235
+ return self._s("app_user_id")
236
+
180
237
  @property
181
238
  def squad_id(self) -> str:
182
239
  return self._s("squad_id")
@@ -238,6 +295,9 @@ class DaemonClient:
238
295
  "Authorization": f"Bearer {self.token}",
239
296
  "X-Client-Platform": "daemon",
240
297
  "X-Client-OS": host_os(),
298
+ # Avoid Windows urllib keep-alive reuse of half-closed sockets
299
+ # (WinError 10054 mid-claim after the server already dispatched).
300
+ "Connection": "close",
241
301
  }
242
302
  if self.version:
243
303
  headers["X-Client-Version"] = self.version
@@ -293,7 +353,32 @@ class DaemonClient:
293
353
  return data if isinstance(data, dict) else {}
294
354
 
295
355
  def claim_task(self, runtime_id: str) -> Task | None:
296
- data = self.post(f"/api/daemon/runtimes/{runtime_id}/tasks/claim", {})
356
+ path = f"/api/daemon/runtimes/{runtime_id}/tasks/claim"
357
+ try:
358
+ data = self.post(path, {})
359
+ except Exception as e:
360
+ # Server may have already marked the task dispatched before the
361
+ # response was reset. Retry once: ClaimTaskForRuntime redelivers
362
+ # unstarted dispatched rows. Only fail orphans if retry also dies
363
+ # — otherwise we kill healthy work that just lost the HTTP body.
364
+ if not is_connection_reset(e):
365
+ raise
366
+ log.warning(
367
+ "claim %s connection reset; retrying once for redelivery",
368
+ runtime_id[:8],
369
+ )
370
+ try:
371
+ data = self.post(path, {})
372
+ except Exception as e2:
373
+ if is_connection_reset(e2):
374
+ n = self.fail_orphaned_dispatched(runtime_id, reason=str(e2))
375
+ if n:
376
+ log.warning(
377
+ "claim %s retry reset; failed %d orphaned dispatched task(s)",
378
+ runtime_id[:8],
379
+ n,
380
+ )
381
+ raise
297
382
  if not isinstance(data, dict):
298
383
  return None
299
384
  task = data.get("task")
@@ -301,6 +386,33 @@ class DaemonClient:
301
386
  return None
302
387
  return Task(task)
303
388
 
389
+ def list_pending_tasks(self, runtime_id: str) -> list[dict[str, Any]]:
390
+ data = self.get(f"/api/daemon/runtimes/{runtime_id}/tasks/pending")
391
+ return [x for x in data if isinstance(x, dict)] if isinstance(data, list) else []
392
+
393
+ def fail_orphaned_dispatched(self, runtime_id: str, *, reason: str = "") -> int:
394
+ """Fail dispatched tasks left behind when a claim response was lost."""
395
+ try:
396
+ pending = self.list_pending_tasks(runtime_id)
397
+ except Exception:
398
+ return 0
399
+ failed = 0
400
+ msg = "claim response lost (connection reset); please retry"
401
+ if reason:
402
+ msg = f"{msg}: {reason}"
403
+ for row in pending:
404
+ if str(row.get("status") or "") != "dispatched":
405
+ continue
406
+ tid = str(row.get("id") or "")
407
+ if not tid:
408
+ continue
409
+ try:
410
+ self.fail_task(tid, msg, failure_reason="runtime_recovery")
411
+ failed += 1
412
+ except Exception:
413
+ continue
414
+ return failed
415
+
304
416
  def start_task(self, task_id: str) -> None:
305
417
  self.post(f"/api/daemon/tasks/{task_id}/start", {})
306
418
 
@@ -318,6 +430,7 @@ class DaemonClient:
318
430
  work_dir: str = "",
319
431
  session_id: str = "",
320
432
  branch_name: str = "",
433
+ app_html: str = "",
321
434
  ) -> None:
322
435
  body: dict[str, Any] = {"output": output}
323
436
  if work_dir:
@@ -326,6 +439,8 @@ class DaemonClient:
326
439
  body["session_id"] = session_id
327
440
  if branch_name:
328
441
  body["branch_name"] = branch_name
442
+ if app_html.strip():
443
+ body["app_html"] = app_html
329
444
  self.post(f"/api/daemon/tasks/{task_id}/complete", body)
330
445
 
331
446
  def fail_task(
@@ -28,6 +28,7 @@ def add_parser(sub: argparse._SubParsersAction) -> None:
28
28
  create.add_argument("--name", required=True)
29
29
  create.add_argument("--instructions", default="")
30
30
  create.add_argument("--from-template", default="")
31
+ create.add_argument("--runtime-id", default="", help="Runtime id to execute this agent")
31
32
  create.add_argument("--output", choices=["json", "table"], default="json")
32
33
  update = asub.add_parser("update", help="Update agent")
33
34
  update.add_argument("agent_id")
@@ -79,9 +80,13 @@ def run(ns: argparse.Namespace) -> int:
79
80
  if ns.agent_command == "create":
80
81
  if ns.from_template:
81
82
  body = {"name": ns.name, "template": ns.from_template, "workspace_id": client.workspace_id}
83
+ if ns.runtime_id:
84
+ body["runtime_id"] = ns.runtime_id
82
85
  print_json(client.post("/api/agents/from-template", body))
83
86
  else:
84
87
  body = {"name": ns.name, "workspace_id": client.workspace_id}
88
+ if ns.runtime_id:
89
+ body["runtime_id"] = ns.runtime_id
85
90
  if ns.instructions:
86
91
  body["instructions"] = ns.instructions
87
92
  print_json(client.post("/api/agents", body))
@@ -0,0 +1,204 @@
1
+ """graph / knowledge commands (aligned with Go `aicortex graph`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from ..cli_context import require_client
11
+ from ..output import print_json
12
+ from ..platform_client import PlatformError
13
+
14
+
15
+ def add_parser(sub: argparse._SubParsersAction) -> None:
16
+ p = sub.add_parser("graph", help="Workspace Knowledge search/write/ensure")
17
+ gsub = p.add_subparsers(dest="graph_command", required=True)
18
+
19
+ cols = gsub.add_parser("collections", help="List Knowledge collections bound to this Agent")
20
+ cols.add_argument("--agent-id", default="", help="Override AICORTEX_AGENT_ID")
21
+ cols.add_argument("--output", choices=["json", "table"], default="json")
22
+
23
+ ensure = gsub.add_parser(
24
+ "ensure",
25
+ help="Create a Knowledge collection if missing and bind it to this Agent",
26
+ )
27
+ ensure.add_argument("--slug", required=True, help="Collection slug, e.g. APP_DATA_<app_id>")
28
+ ensure.add_argument("--name", default="", help="Display name (defaults to slug)")
29
+ ensure.add_argument("--description", default="")
30
+ ensure.add_argument(
31
+ "--visibility",
32
+ default="workspace",
33
+ choices=["private", "workspace", "restricted", "global"],
34
+ )
35
+ ensure.add_argument("--agent-id", default="", help="Override AICORTEX_AGENT_ID")
36
+ ensure.add_argument("--output", choices=["json", "table"], default="json")
37
+
38
+ search = gsub.add_parser("search")
39
+ search.add_argument("query", nargs="?", default="")
40
+ search.add_argument("--q", default="", help="Query (alternative to positional)")
41
+ search.add_argument("--collection", "--collection-id", dest="collection", default="")
42
+ search.add_argument("--mode", default="auto")
43
+ search.add_argument("-n", type=int, default=10)
44
+ search.add_argument("--output", choices=["json", "table"], default="json")
45
+
46
+ walk = gsub.add_parser("walk")
47
+ walk.add_argument("query", nargs="?", default="")
48
+ walk.add_argument("--q", default="")
49
+ walk.add_argument("--collection", "--collection-id", dest="collection", default="")
50
+ walk.add_argument("-n", type=int, default=5)
51
+ walk.add_argument("--output", choices=["json", "table"], default="json")
52
+
53
+ fetch = gsub.add_parser("fetch")
54
+ fetch.add_argument("--chunk-ids", required=True, help="comma-separated")
55
+ fetch.add_argument("--output", choices=["json", "table"], default="json")
56
+
57
+ write = gsub.add_parser("write")
58
+ write.add_argument("--collection", "--collection-id", dest="collection", default="")
59
+ write.add_argument("--title", required=True)
60
+ write.add_argument("--body", default="")
61
+ write.add_argument("--body-file", default="")
62
+ write.add_argument("--content", default="", help="Alias for --body (legacy)")
63
+ write.add_argument("--agent-id", default="", help="Override AICORTEX_AGENT_ID for binding lookup")
64
+ write.add_argument("--output", choices=["json", "table"], default="json")
65
+
66
+
67
+ def _agent_id(ns: argparse.Namespace) -> str:
68
+ return (
69
+ str(getattr(ns, "agent_id", "") or "").strip()
70
+ or os.environ.get("AICORTEX_AGENT_ID", "").strip()
71
+ )
72
+
73
+
74
+ def _query(ns: argparse.Namespace) -> str:
75
+ return (str(getattr(ns, "query", "") or "").strip() or str(getattr(ns, "q", "") or "").strip())
76
+
77
+
78
+ def _resolve_body(ns: argparse.Namespace) -> str:
79
+ body = str(getattr(ns, "body", "") or "").strip()
80
+ if body:
81
+ return body
82
+ content = str(getattr(ns, "content", "") or "").strip()
83
+ if content:
84
+ return content
85
+ path = str(getattr(ns, "body_file", "") or "").strip()
86
+ if path:
87
+ return Path(path).read_text(encoding="utf-8")
88
+ return ""
89
+
90
+
91
+ def _load_bound_collections(client, agent_id: str) -> list[dict]:
92
+ if agent_id:
93
+ rows = client.get(f"/api/agents/{agent_id}/knowledge")
94
+ else:
95
+ rows = client.get("/api/knowledge/collections")
96
+ if not isinstance(rows, list):
97
+ return []
98
+ out = []
99
+ for c in rows:
100
+ if not isinstance(c, dict):
101
+ continue
102
+ # Skip Agent Memory collections (owner_agent_id set)
103
+ if str(c.get("owner_agent_id") or "").strip():
104
+ continue
105
+ out.append(c)
106
+ return out
107
+
108
+
109
+ def _match_collection(cols: list[dict], key: str) -> dict:
110
+ key = (key or "").strip()
111
+ if not key:
112
+ if len(cols) == 1:
113
+ return cols[0]
114
+ if not cols:
115
+ raise ValueError(
116
+ "no bound Knowledge collections; run: aicortex graph ensure --slug <slug> --name <name>"
117
+ )
118
+ raise ValueError("--collection is required when more than one library is bound")
119
+ kl = key.lower()
120
+ for c in cols:
121
+ if str(c.get("id") or "") == key or str(c.get("slug") or "").lower() == kl:
122
+ return c
123
+ raise ValueError(
124
+ f'collection "{key}" is not bound to this Agent; '
125
+ f"run: aicortex graph ensure --slug {key} --name {key}"
126
+ )
127
+
128
+
129
+ def run(ns: argparse.Namespace) -> int:
130
+ client = require_client(ns)
131
+ try:
132
+ if ns.graph_command == "collections":
133
+ cols = _load_bound_collections(client, _agent_id(ns))
134
+ rows = [{"id": c.get("id"), "slug": c.get("slug"), "name": c.get("name")} for c in cols]
135
+ print_json({"count": len(rows), "collections": rows})
136
+ return 0
137
+
138
+ if ns.graph_command == "ensure":
139
+ agent_id = _agent_id(ns)
140
+ if not agent_id:
141
+ raise ValueError(
142
+ "AICORTEX_AGENT_ID is required (set automatically during agent runs)"
143
+ )
144
+ slug = str(ns.slug).strip()
145
+ name = str(ns.name or "").strip() or slug
146
+ body = {
147
+ "slug": slug,
148
+ "name": name,
149
+ "description": str(ns.description or ""),
150
+ "visibility": str(ns.visibility or "workspace"),
151
+ }
152
+ print_json(client.post(f"/api/agents/{agent_id}/knowledge/ensure", body))
153
+ return 0
154
+
155
+ if ns.graph_command == "search":
156
+ q = _query(ns)
157
+ if not q:
158
+ raise ValueError("query is required")
159
+ body: dict = {"query": q, "mode": ns.mode, "limit": ns.n}
160
+ if ns.collection:
161
+ body["collection"] = ns.collection
162
+ print_json(client.post("/api/knowledge/search", body))
163
+ return 0
164
+
165
+ if ns.graph_command == "walk":
166
+ q = _query(ns)
167
+ if not q:
168
+ raise ValueError("query is required")
169
+ body = {"query": q, "limit": ns.n}
170
+ if ns.collection:
171
+ body["collection"] = ns.collection
172
+ print_json(client.post("/api/knowledge/walk", body))
173
+ return 0
174
+
175
+ if ns.graph_command == "fetch":
176
+ ids = [x.strip() for x in ns.chunk_ids.split(",") if x.strip()]
177
+ if not ids:
178
+ raise ValueError("--chunk-ids is required")
179
+ print_json(client.post("/api/knowledge/fetch", {"chunk_ids": ids}))
180
+ return 0
181
+
182
+ if ns.graph_command == "write":
183
+ body_text = _resolve_body(ns)
184
+ if not body_text.strip():
185
+ raise ValueError("--body, --body-file, or --content is required")
186
+ cols = _load_bound_collections(client, _agent_id(ns))
187
+ col = _match_collection(cols, ns.collection)
188
+ print_json(
189
+ client.post(
190
+ f"/api/knowledge/collections/{col['id']}/documents",
191
+ {
192
+ "title": ns.title,
193
+ "body": body_text,
194
+ "source_id": "aicortex-graph",
195
+ "source_kind": "document",
196
+ "origin": "aicortex",
197
+ },
198
+ )
199
+ )
200
+ return 0
201
+ except (PlatformError, ValueError, OSError) as e:
202
+ print(str(e), file=sys.stderr)
203
+ return 1
204
+ return 2
@@ -37,7 +37,7 @@ def run(ns: argparse.Namespace) -> int:
37
37
  if ns.memory_command in {"ensure", "show"}:
38
38
  print_json(client.get(f"/api/agents/{ns.agent_id}/memory"))
39
39
  return 0
40
- body = {"title": ns.title, "content": ns.content, "append": ns.memory_command == "append"}
40
+ body = {"title": ns.title, "body": ns.content, "append": ns.memory_command == "append"}
41
41
  print_json(client.post(f"/api/knowledge/collections/{ns.collection_id}/documents", body))
42
42
  return 0
43
43
  except PlatformError as e:
@@ -332,9 +332,11 @@ def detect_agent_version(executable: str) -> str:
332
332
  import re
333
333
  import subprocess
334
334
 
335
+ from .procutil import run_no_window
336
+
335
337
  for args in ([executable, "--version"], [executable, "version"], [executable, "-V"]):
336
338
  try:
337
- proc = subprocess.run(
339
+ proc = run_no_window(
338
340
  args,
339
341
  capture_output=True,
340
342
  text=True,