cloudagent-daemon 0.2.1__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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Workspace: manages ~/.cloudagent/agents/<agent_id>/ directory lifecycle.
|
|
2
|
+
|
|
3
|
+
Spec §6.1 directory layout:
|
|
4
|
+
|
|
5
|
+
~/.cloudagent/agents/<agent_id>/
|
|
6
|
+
├── MEMORY.md (template — §6.8, created once on first registration)
|
|
7
|
+
├── notes/
|
|
8
|
+
├── workspace/
|
|
9
|
+
├── .meta/
|
|
10
|
+
│ ├── identity.json (mode 0o600)
|
|
11
|
+
│ ├── claude-mcp-config.json (regenerated each ensure())
|
|
12
|
+
│ ├── claude-token.txt (per-spawn, written by daemon, deleted after)
|
|
13
|
+
│ └── last-spawn.json
|
|
14
|
+
└── claude/
|
|
15
|
+
|
|
16
|
+
``Workspace`` owns the directory lifecycle (create / archive).
|
|
17
|
+
``AgentWorkspace`` is the per-agent handle used by ClaudeRunner (implements
|
|
18
|
+
the ``AgentWorkspaceLike`` protocol from claude_runner.py).
|
|
19
|
+
|
|
20
|
+
Task #118 (2026-05-12) — agent claude subprocess cwd is now decoupled
|
|
21
|
+
from the metadata directory. Metadata (identity.json / mcp-config /
|
|
22
|
+
last-spawn / MEMORY.md) **always** lives at
|
|
23
|
+
``~/.cloudagent/agents/<agent_id>/`` so a user-chosen cwd never gets
|
|
24
|
+
polluted with platform files. The cwd itself is computed via
|
|
25
|
+
:func:`compute_agent_cwd` with this fallback:
|
|
26
|
+
|
|
27
|
+
agent.workspace_root -- per-agent override
|
|
28
|
+
> <host.workspace_root>/<agent_id>/workspace/ -- host-level default
|
|
29
|
+
> ~/.cloudagent/agents/<agent_id>/workspace/ -- compiled-in default
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Task #123 (2026-05-12) — name of the per-agent override file persisted
|
|
41
|
+
# under ``<metadata_root>/<agent_id>/.meta/`` so subsequent
|
|
42
|
+
# ``for_agent`` calls (and daemon restarts) can rebuild the cwd without
|
|
43
|
+
# requiring the caller to re-pass ``agent_workspace_root``. Single line
|
|
44
|
+
# of UTF-8 text holding the absolute path; absence of the file means
|
|
45
|
+
# "no per-agent override, fall back to host / platform tier".
|
|
46
|
+
_AGENT_WORKSPACE_ROOT_FILENAME = "agent_workspace_root"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_persisted_agent_workspace_root(meta_dir: Path) -> str | None:
|
|
50
|
+
"""Read the persisted per-agent override.
|
|
51
|
+
|
|
52
|
+
Returns the path string when present + non-empty, else ``None`` so
|
|
53
|
+
``compute_agent_cwd`` falls back to the host / platform tier.
|
|
54
|
+
Failures (file missing, OS hiccup) collapse to ``None`` — the file
|
|
55
|
+
is a hint, not a source of truth.
|
|
56
|
+
"""
|
|
57
|
+
p = meta_dir / _AGENT_WORKSPACE_ROOT_FILENAME
|
|
58
|
+
try:
|
|
59
|
+
raw = p.read_text(encoding="utf-8").strip()
|
|
60
|
+
except (FileNotFoundError, OSError):
|
|
61
|
+
return None
|
|
62
|
+
return raw or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_persisted_agent_workspace_root(
|
|
66
|
+
meta_dir: Path, value: str | None
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Persist or clear the per-agent override.
|
|
69
|
+
|
|
70
|
+
``value=None`` deletes the file (cleared override) so subsequent
|
|
71
|
+
reads fall back to the host / platform tier. ``value`` non-None
|
|
72
|
+
overwrites with the absolute path string. Caller guarantees
|
|
73
|
+
``meta_dir`` exists.
|
|
74
|
+
"""
|
|
75
|
+
p = meta_dir / _AGENT_WORKSPACE_ROOT_FILENAME
|
|
76
|
+
if value is None:
|
|
77
|
+
p.unlink(missing_ok=True)
|
|
78
|
+
return
|
|
79
|
+
p.write_text(value, encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compute_agent_cwd(
|
|
83
|
+
agent_id: str,
|
|
84
|
+
*,
|
|
85
|
+
agent_workspace_root: str | None,
|
|
86
|
+
host_workspace_root: str | Path | None,
|
|
87
|
+
metadata_root: Path,
|
|
88
|
+
) -> Path:
|
|
89
|
+
"""Resolve the cwd for an agent's claude subprocess.
|
|
90
|
+
|
|
91
|
+
Fallback order (Task #118 CEO directive):
|
|
92
|
+
|
|
93
|
+
1. ``agent_workspace_root`` (per-agent absolute path; **no**
|
|
94
|
+
``/<agent_id>/workspace/`` suffix is appended — the user's
|
|
95
|
+
choice is used verbatim so they can target the same repo for
|
|
96
|
+
multiple sessions).
|
|
97
|
+
2. ``host_workspace_root / agent_id / workspace`` (the host-level
|
|
98
|
+
default per Task #113; preserves the pre-#118 "one dir per
|
|
99
|
+
agent under a shared root" shape).
|
|
100
|
+
3. ``metadata_root / agent_id / workspace`` (compiled-in
|
|
101
|
+
``~/.cloudagent/agents/<id>/workspace/``).
|
|
102
|
+
|
|
103
|
+
Tilde paths are expanded; relative paths are NOT accepted (the API
|
|
104
|
+
rejects them at create / patch time so they never reach the daemon).
|
|
105
|
+
"""
|
|
106
|
+
if agent_workspace_root:
|
|
107
|
+
return Path(os.path.expanduser(agent_workspace_root))
|
|
108
|
+
if host_workspace_root:
|
|
109
|
+
base = Path(os.path.expanduser(str(host_workspace_root)))
|
|
110
|
+
return base / agent_id / "workspace"
|
|
111
|
+
return metadata_root / agent_id / "workspace"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AgentWorkspace:
|
|
115
|
+
"""Per-agent workspace handle; satisfies the AgentWorkspaceLike protocol.
|
|
116
|
+
|
|
117
|
+
Task #118 (2026-05-12) split:
|
|
118
|
+
|
|
119
|
+
* ``path`` — metadata root (always ``~/.cloudagent/agents/<id>/``).
|
|
120
|
+
Holds identity.json, mcp-config, last-spawn, MEMORY.md.
|
|
121
|
+
* ``cwd`` — claude subprocess cwd. Computed via
|
|
122
|
+
:func:`compute_agent_cwd` from the host / agent overrides.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, path: Path, *, cwd: Path | None = None) -> None:
|
|
126
|
+
self.path = path
|
|
127
|
+
# ``cwd`` defaults to ``<path>/workspace`` so legacy callers
|
|
128
|
+
# that don't supply a cwd still get the pre-#118 behaviour.
|
|
129
|
+
self._cwd = cwd if cwd is not None else path / "workspace"
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def cwd(self) -> Path:
|
|
133
|
+
"""Return the resolved cwd for the agent's claude subprocess."""
|
|
134
|
+
return self._cwd
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# AgentWorkspaceLike protocol implementation
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def last_session_id(self) -> str | None:
|
|
141
|
+
"""Return session_id from last-spawn.json, or None."""
|
|
142
|
+
p = self.path / ".meta" / "last-spawn.json"
|
|
143
|
+
try:
|
|
144
|
+
data = json.loads(p.read_text())
|
|
145
|
+
sid = data.get("session_id")
|
|
146
|
+
return sid if isinstance(sid, str) and sid else None
|
|
147
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def set_last_session_id(self, sid: str) -> None:
|
|
151
|
+
"""Update session_id in last-spawn.json, preserving all other fields."""
|
|
152
|
+
p = self.path / ".meta" / "last-spawn.json"
|
|
153
|
+
try:
|
|
154
|
+
data: dict[str, Any] = json.loads(p.read_text())
|
|
155
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
156
|
+
data = {}
|
|
157
|
+
data["session_id"] = sid
|
|
158
|
+
p.write_text(json.dumps(data, indent=2))
|
|
159
|
+
|
|
160
|
+
def clear_last_session_id(self) -> None:
|
|
161
|
+
"""Drop a stale session_id from last-spawn.json.
|
|
162
|
+
|
|
163
|
+
1k followup — when ``claude --resume <sid>`` fails because the
|
|
164
|
+
session was never persisted (``--print`` mode does not always
|
|
165
|
+
write the session to ``~/.claude/sessions/``), the daemon must
|
|
166
|
+
clear the stored id so the next wake starts fresh. Preserves
|
|
167
|
+
the rest of the file (exit_code / stderr_tail / ts) so the
|
|
168
|
+
operator can still see why the prior wake failed.
|
|
169
|
+
"""
|
|
170
|
+
p = self.path / ".meta" / "last-spawn.json"
|
|
171
|
+
try:
|
|
172
|
+
data: dict[str, Any] = json.loads(p.read_text())
|
|
173
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
174
|
+
# Nothing to clear — file missing or corrupt; either way,
|
|
175
|
+
# last_session_id() will return None going forward.
|
|
176
|
+
return
|
|
177
|
+
if data.get("session_id") is None:
|
|
178
|
+
return
|
|
179
|
+
data["session_id"] = None
|
|
180
|
+
p.write_text(json.dumps(data, indent=2))
|
|
181
|
+
|
|
182
|
+
def mcp_config_path(self) -> Path:
|
|
183
|
+
"""Return path to claude-mcp-config.json."""
|
|
184
|
+
return self.path / ".meta" / "claude-mcp-config.json"
|
|
185
|
+
|
|
186
|
+
def write_last_spawn(
|
|
187
|
+
self, exit_code: int, session_id: str | None, stderr_tail: str
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Write all last-spawn fields atomically (simple overwrite)."""
|
|
190
|
+
p = self.path / ".meta" / "last-spawn.json"
|
|
191
|
+
data = {
|
|
192
|
+
"exit_code": exit_code,
|
|
193
|
+
"session_id": session_id,
|
|
194
|
+
"stderr_tail": stderr_tail,
|
|
195
|
+
"ts": datetime.now(tz=timezone.utc).isoformat(),
|
|
196
|
+
}
|
|
197
|
+
p.write_text(json.dumps(data, indent=2))
|
|
198
|
+
|
|
199
|
+
def delete_token_file(self) -> None:
|
|
200
|
+
"""Best-effort delete of per-spawn token file."""
|
|
201
|
+
token_path = self.path / ".meta" / "claude-token.txt"
|
|
202
|
+
token_path.unlink(missing_ok=True)
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Per-spawn current-session marker (1k hotfix)
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
#
|
|
208
|
+
# The mcp_bridge subprocess is spawned as a child of the ``claude``
|
|
209
|
+
# subprocess and has no other channel to learn the cloudagent
|
|
210
|
+
# business ``session_id`` carried by the deliver frame that triggered
|
|
211
|
+
# the wake. ClaudeRunner writes that session_id to
|
|
212
|
+
# ``.meta/current-session.txt`` BEFORE spawn and deletes it AFTER —
|
|
213
|
+
# the bridge reads the file at startup and includes the session_id
|
|
214
|
+
# in every IPC ``tool_call`` payload. Lifecycle parallels the
|
|
215
|
+
# per-spawn ``claude-token.txt`` file (spec §6.6).
|
|
216
|
+
|
|
217
|
+
def current_session_path(self) -> Path:
|
|
218
|
+
"""Return the path to the per-spawn current-session marker."""
|
|
219
|
+
return self.path / ".meta" / "current-session.txt"
|
|
220
|
+
|
|
221
|
+
def write_current_session(self, session_id: str | None) -> None:
|
|
222
|
+
"""Persist the current deliver's session_id for the bridge to read.
|
|
223
|
+
|
|
224
|
+
``None`` is treated as "delete" — equivalent to
|
|
225
|
+
:meth:`delete_current_session`. The file is written with mode
|
|
226
|
+
0o600 so only the daemon-owning user can read it.
|
|
227
|
+
"""
|
|
228
|
+
p = self.current_session_path()
|
|
229
|
+
if session_id is None:
|
|
230
|
+
self.delete_current_session()
|
|
231
|
+
return
|
|
232
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
p.write_text(session_id)
|
|
234
|
+
try:
|
|
235
|
+
p.chmod(0o600)
|
|
236
|
+
except OSError:
|
|
237
|
+
# On platforms / filesystems that reject chmod we still want
|
|
238
|
+
# the write to succeed — the unix socket already enforces
|
|
239
|
+
# 0o600 ownership.
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
def delete_current_session(self) -> None:
|
|
243
|
+
"""Best-effort delete of the per-spawn current-session marker."""
|
|
244
|
+
try:
|
|
245
|
+
self.current_session_path().unlink()
|
|
246
|
+
except FileNotFoundError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Internal write helpers (called by Workspace.ensure)
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def _write_identity(self, identity: dict) -> None:
|
|
254
|
+
"""Write identity.json with mode 0o600."""
|
|
255
|
+
p = self.path / ".meta" / "identity.json"
|
|
256
|
+
p.write_text(json.dumps(identity, indent=2))
|
|
257
|
+
os.chmod(p, 0o600)
|
|
258
|
+
|
|
259
|
+
def _write_memory_md_template(self, identity: dict) -> None:
|
|
260
|
+
"""Write MEMORY.md from spec §6.8 template (only if it does not exist)."""
|
|
261
|
+
agent_id = identity.get("agent_id", "unknown")
|
|
262
|
+
agent_name = identity.get("agent_name", agent_id)
|
|
263
|
+
role = identity.get("role", "staff")
|
|
264
|
+
reports_to = identity.get("reports_to", "(unset)")
|
|
265
|
+
workspace_slug = identity.get("workspace_slug", "default")
|
|
266
|
+
|
|
267
|
+
content = f"""# Agent Memory — {agent_name}
|
|
268
|
+
|
|
269
|
+
> Auto-generated by cloudagent-daemon on first assignment.
|
|
270
|
+
|
|
271
|
+
## Identity
|
|
272
|
+
- Agent ID: {agent_id}
|
|
273
|
+
- Role: {role}
|
|
274
|
+
- Reports to: {reports_to}
|
|
275
|
+
- Workspace: {workspace_slug}
|
|
276
|
+
|
|
277
|
+
## Active Context
|
|
278
|
+
(none yet)
|
|
279
|
+
|
|
280
|
+
## Notes Index
|
|
281
|
+
(none yet)
|
|
282
|
+
|
|
283
|
+
## Long-term Memory
|
|
284
|
+
(none yet)
|
|
285
|
+
"""
|
|
286
|
+
(self.path / "MEMORY.md").write_text(content)
|
|
287
|
+
|
|
288
|
+
def _write_mcp_config(self, identity: dict) -> None:
|
|
289
|
+
"""Write claude-mcp-config.json pointing to the mcp-bridge subcommand."""
|
|
290
|
+
agent_id = identity.get("agent_id", "unknown")
|
|
291
|
+
token_file = str(self.path / ".meta" / "claude-token.txt")
|
|
292
|
+
config = {
|
|
293
|
+
"mcpServers": {
|
|
294
|
+
"cloudagent": {
|
|
295
|
+
"command": "cloudagent-daemon",
|
|
296
|
+
"args": [
|
|
297
|
+
"mcp-bridge",
|
|
298
|
+
"--agent-id",
|
|
299
|
+
agent_id,
|
|
300
|
+
"--token-file",
|
|
301
|
+
token_file,
|
|
302
|
+
],
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
self.mcp_config_path().write_text(json.dumps(config, indent=2))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class Workspace:
|
|
310
|
+
"""Manages ~/.cloudagent/agents/ — owns agent dir lifecycles.
|
|
311
|
+
|
|
312
|
+
Task #118 (2026-05-12) — ``root`` is the **metadata** root (always
|
|
313
|
+
``~/.cloudagent/agents/`` in production; tests override). Agent cwd
|
|
314
|
+
for claude is computed separately via :func:`compute_agent_cwd`
|
|
315
|
+
using ``host_workspace_root`` (set at daemon start) + the per-agent
|
|
316
|
+
``workspace_root`` (passed to :meth:`ensure`). Splitting these means
|
|
317
|
+
a user-chosen project directory never gets polluted with
|
|
318
|
+
``identity.json`` / ``MEMORY.md`` / mcp-config.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
DEFAULT_ROOT = Path.home() / ".cloudagent" / "agents"
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def default_root_for_profile(cls, profile: str | None = None) -> Path:
|
|
325
|
+
"""Workspace root for *profile* — default keeps legacy
|
|
326
|
+
``~/.cloudagent/agents``, named profiles get
|
|
327
|
+
``~/.cloudagent/agents-<name>``."""
|
|
328
|
+
# Lazy import keeps this module import-light for tooling that
|
|
329
|
+
# only touches AgentWorkspace.
|
|
330
|
+
from .paths import profile_workspace_root
|
|
331
|
+
|
|
332
|
+
return profile_workspace_root(profile)
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
root: Path | None = None,
|
|
337
|
+
*,
|
|
338
|
+
host_workspace_root: Path | None = None,
|
|
339
|
+
profile: str | None = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
if root is not None:
|
|
342
|
+
self.root = root
|
|
343
|
+
elif profile is not None:
|
|
344
|
+
self.root = self.default_root_for_profile(profile)
|
|
345
|
+
else:
|
|
346
|
+
self.root = self.DEFAULT_ROOT
|
|
347
|
+
# Task #118 — host-level default for agent cwd (Task #113's
|
|
348
|
+
# daemon ``--workspace-root`` setting). Used as the second-tier
|
|
349
|
+
# fallback in :func:`compute_agent_cwd`. ``None`` ⇒ third tier
|
|
350
|
+
# (``self.root / <id> / workspace``) is used.
|
|
351
|
+
self.host_workspace_root = host_workspace_root
|
|
352
|
+
|
|
353
|
+
def for_agent(
|
|
354
|
+
self, agent_id: str, *, agent_workspace_root: str | None = None
|
|
355
|
+
) -> AgentWorkspace:
|
|
356
|
+
"""Return AgentWorkspace handle for agent_id (does not create dirs).
|
|
357
|
+
|
|
358
|
+
Task #118 — ``agent_workspace_root`` (the per-agent override
|
|
359
|
+
from ``Agent.workspace_root``) participates in cwd resolution
|
|
360
|
+
but does NOT change where metadata lives.
|
|
361
|
+
|
|
362
|
+
Task #123 (2026-05-12) — when the caller doesn't pass
|
|
363
|
+
``agent_workspace_root`` (typically: claude_runner's per-deliver
|
|
364
|
+
``for_agent(agent_id)`` call), we fall back to reading the
|
|
365
|
+
persisted value from ``<path>/.meta/agent_workspace_root``.
|
|
366
|
+
That file is written by :meth:`ensure` and lets daemon restarts
|
|
367
|
+
+ lazy lookups recover the user-chosen cwd without round-
|
|
368
|
+
tripping to backend. ``None`` from the file (missing / empty)
|
|
369
|
+
cleanly cascades into the host / platform tier in
|
|
370
|
+
:func:`compute_agent_cwd`.
|
|
371
|
+
"""
|
|
372
|
+
path = self.root / agent_id
|
|
373
|
+
if agent_workspace_root is None:
|
|
374
|
+
agent_workspace_root = _read_persisted_agent_workspace_root(
|
|
375
|
+
path / ".meta"
|
|
376
|
+
)
|
|
377
|
+
cwd = compute_agent_cwd(
|
|
378
|
+
agent_id,
|
|
379
|
+
agent_workspace_root=agent_workspace_root,
|
|
380
|
+
host_workspace_root=self.host_workspace_root,
|
|
381
|
+
metadata_root=self.root,
|
|
382
|
+
)
|
|
383
|
+
return AgentWorkspace(path, cwd=cwd)
|
|
384
|
+
|
|
385
|
+
def ensure(
|
|
386
|
+
self,
|
|
387
|
+
agent_id: str,
|
|
388
|
+
identity: dict,
|
|
389
|
+
*,
|
|
390
|
+
agent_workspace_root: str | None = None,
|
|
391
|
+
) -> AgentWorkspace:
|
|
392
|
+
"""Idempotent: create metadata dir + identity + MEMORY.md + mcp
|
|
393
|
+
config under ``~/.cloudagent/agents/<id>/``. The claude cwd is
|
|
394
|
+
resolved via :func:`compute_agent_cwd` and its parent dirs are
|
|
395
|
+
created so the spawn can ``cwd=...`` into it.
|
|
396
|
+
|
|
397
|
+
Task #118 — ``agent_workspace_root`` (when set) chooses a cwd
|
|
398
|
+
OUTSIDE the metadata root; we deliberately do NOT write any
|
|
399
|
+
platform files there to keep the user's project directory
|
|
400
|
+
clean.
|
|
401
|
+
"""
|
|
402
|
+
ws = self.for_agent(
|
|
403
|
+
agent_id, agent_workspace_root=agent_workspace_root
|
|
404
|
+
)
|
|
405
|
+
ws.path.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
# Metadata-side subdirs always exist under ``ws.path``.
|
|
407
|
+
for sub in ("notes", ".meta", "claude"):
|
|
408
|
+
(ws.path / sub).mkdir(exist_ok=True)
|
|
409
|
+
# Task #123 (2026-05-12) — persist the agent_workspace_root
|
|
410
|
+
# override so subsequent ``for_agent(agent_id)`` calls (per-
|
|
411
|
+
# deliver lookups in claude_runner, daemon restarts) can
|
|
412
|
+
# recover the user-chosen cwd without the caller having to
|
|
413
|
+
# re-pass it. ``None`` deletes the file: explicit assign-frame
|
|
414
|
+
# "clear override" semantic per Task #122. The lazy claude_
|
|
415
|
+
# runner path also calls ``ensure(agent_id, identity)`` without
|
|
416
|
+
# the kwarg → None → file deletion, BUT that path only runs
|
|
417
|
+
# when ``ws.path`` did not previously exist (truly new agent on
|
|
418
|
+
# this daemon), so there's no override file to clobber.
|
|
419
|
+
_write_persisted_agent_workspace_root(
|
|
420
|
+
ws.path / ".meta", agent_workspace_root
|
|
421
|
+
)
|
|
422
|
+
# Ensure the chosen cwd exists too. When ``agent_workspace_root``
|
|
423
|
+
# is set this is a user-supplied path (e.g. ~/projects/foo) —
|
|
424
|
+
# mkdir(parents=True, exist_ok=True) makes the directory if it
|
|
425
|
+
# doesn't already exist. When the fallback chain picks
|
|
426
|
+
# ``ws.path / "workspace"`` we still need to create it.
|
|
427
|
+
ws.cwd.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
ws._write_identity(identity)
|
|
429
|
+
if not (ws.path / "MEMORY.md").exists():
|
|
430
|
+
ws._write_memory_md_template(identity)
|
|
431
|
+
ws._write_mcp_config(identity)
|
|
432
|
+
return ws
|
|
433
|
+
|
|
434
|
+
def archive(self, agent_id: str) -> None:
|
|
435
|
+
"""Mark agent as archived (touch .meta/archived.txt). 30-day cleanup is future."""
|
|
436
|
+
ws = self.for_agent(agent_id)
|
|
437
|
+
archived_marker = ws.path / ".meta" / "archived.txt"
|
|
438
|
+
# Ensure the dir exists before trying to write; if it never existed, still create marker.
|
|
439
|
+
ws.path.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
(ws.path / ".meta").mkdir(exist_ok=True)
|
|
441
|
+
archived_marker.write_text(datetime.now(tz=timezone.utc).isoformat())
|