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.
@@ -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())