navig-agentctl 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: navig-agentctl
3
+ Version: 0.2.0
4
+ Summary: Agent Control Center (devops section): sessions, worktrees & tasks — one read-only view, then a controller.
5
+ Author-email: NAVIG Development Team <opensource@navig.run>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://navig.run
8
+ Project-URL: Documentation, https://navig.run/docs
9
+ Keywords: navig,cli,agent,orchestration,plugin,devops,worktrees,sessions
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: navig>=0.1.0
22
+ Requires-Dist: typer>=0.14.0
23
+ Requires-Dist: rich>=13.7.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
26
+
27
+ # navig-agentctl
28
+
29
+ <!-- navig:plugin-card:start -->
30
+ <!-- Generated by scripts/gen_plugin_catalog.py — edit the plugin's pyproject.toml, not this block. -->
31
+
32
+ > Agent Control Center (devops section): sessions, worktrees & tasks — one read-only view, then a controller.
33
+
34
+ **A navig extension** — installing it pulls in navig and adds the commands below · v0.2.0 · not on PyPI yet (install from source below)
35
+
36
+ **Install**
37
+
38
+ ```bash
39
+ pip install "git+https://github.com/navig-run/plugins#subdirectory=navig-agentctl"
40
+ navig agentctl --help
41
+ ```
42
+
43
+ It is not a separate program: it needs navig, and installing it installs navig for you.
44
+
45
+ <details><summary>Commands</summary>
46
+
47
+ | Command | What it does |
48
+ |---|---|
49
+ | `navig agentctl sessions` | Live agent sessions (who is working where, idle or active). |
50
+ | `navig agentctl status` | One-line overview: sessions, worktrees (cwd repo), tasks. |
51
+ | `navig agentctl tasks` | Unified tasks from the board and the work queue. |
52
+ | `navig agentctl worktrees` | Git worktrees of a repo: branch, ahead/behind, dirty, last commit. |
53
+
54
+ </details>
55
+
56
+ <!-- navig:plugin-card:end -->
57
+
58
+ **Agent Control Center** — the devops section for managing AI coding agents across
59
+ sessions, git worktrees and task records. Local-first, provider-agnostic, read-only
60
+ in Phase 1.
61
+
62
+ ## Phases
63
+
64
+ | Phase | Name | What it does |
65
+ |---|---|---|
66
+ | 1 | Observer | Discover live agent sessions · list worktrees + conflict radar · unified task read-model (board + work queue). **Changes nothing.** |
67
+ | **2a (this)** | Heartbeat | `POST /api/deck/agents/heartbeat` — the extension annotates workspace/editor/roster context; daemon-owned `state`/`pid`/`reason` are never written. |
68
+ | 2b | Controller | Launch CLI agents into worktrees, assign task specs, pause/terminate owned processes, collect handoff reports, detect ownership conflicts. |
69
+ | 3 | Orchestrator | Schedule by dependency, allocate by capacity, trigger reviews, recover interrupted work — production deploys always ask a human first. |
70
+
71
+ ## CLI
72
+
73
+ ```
74
+ navig agentctl sessions [--project P] [--json]
75
+ navig agentctl worktrees [--project PATH] [--conflicts] [--json]
76
+ navig agentctl tasks [--source board|work] [--json]
77
+ navig agentctl status [--json]
78
+ ```
79
+
80
+ ## Deck routes (the contract)
81
+
82
+ All under `/api/deck/agents`, gated on the `agentctl` module. GET-only in Phase 1:
83
+
84
+ - `GET /api/deck/agents/sessions` — live sessions (from `navig procs` semantics) merged with the persistent registry.
85
+ - `GET /api/deck/agents/worktrees?project=&conflicts=` — worktrees + pairwise conflict radar.
86
+ - `GET /api/deck/agents/tasks` — board cards + work items, one normalised shape.
87
+
88
+ The OS app (Control Center tile) and navig-dev (VS Code tree views) are both
89
+ consumers of this one contract.
90
+
91
+ ## Store
92
+
93
+ `~/.navig/data/store/agentctl.db` — session ledger (who was active where, with
94
+ state-change history) and task bindings (which session owns which task). Task
95
+ truth stays in `board.db` / `work.db`; this ledger only answers ownership.
96
+
97
+ ## Non-negotiables
98
+
99
+ Local-first · provider-agnostic adapters · explicit process ownership · safe git
100
+ handling · no duplicate task execution · **no automatic merge or deployment**.
@@ -0,0 +1,74 @@
1
+ # navig-agentctl
2
+
3
+ <!-- navig:plugin-card:start -->
4
+ <!-- Generated by scripts/gen_plugin_catalog.py — edit the plugin's pyproject.toml, not this block. -->
5
+
6
+ > Agent Control Center (devops section): sessions, worktrees & tasks — one read-only view, then a controller.
7
+
8
+ **A navig extension** — installing it pulls in navig and adds the commands below · v0.2.0 · not on PyPI yet (install from source below)
9
+
10
+ **Install**
11
+
12
+ ```bash
13
+ pip install "git+https://github.com/navig-run/plugins#subdirectory=navig-agentctl"
14
+ navig agentctl --help
15
+ ```
16
+
17
+ It is not a separate program: it needs navig, and installing it installs navig for you.
18
+
19
+ <details><summary>Commands</summary>
20
+
21
+ | Command | What it does |
22
+ |---|---|
23
+ | `navig agentctl sessions` | Live agent sessions (who is working where, idle or active). |
24
+ | `navig agentctl status` | One-line overview: sessions, worktrees (cwd repo), tasks. |
25
+ | `navig agentctl tasks` | Unified tasks from the board and the work queue. |
26
+ | `navig agentctl worktrees` | Git worktrees of a repo: branch, ahead/behind, dirty, last commit. |
27
+
28
+ </details>
29
+
30
+ <!-- navig:plugin-card:end -->
31
+
32
+ **Agent Control Center** — the devops section for managing AI coding agents across
33
+ sessions, git worktrees and task records. Local-first, provider-agnostic, read-only
34
+ in Phase 1.
35
+
36
+ ## Phases
37
+
38
+ | Phase | Name | What it does |
39
+ |---|---|---|
40
+ | 1 | Observer | Discover live agent sessions · list worktrees + conflict radar · unified task read-model (board + work queue). **Changes nothing.** |
41
+ | **2a (this)** | Heartbeat | `POST /api/deck/agents/heartbeat` — the extension annotates workspace/editor/roster context; daemon-owned `state`/`pid`/`reason` are never written. |
42
+ | 2b | Controller | Launch CLI agents into worktrees, assign task specs, pause/terminate owned processes, collect handoff reports, detect ownership conflicts. |
43
+ | 3 | Orchestrator | Schedule by dependency, allocate by capacity, trigger reviews, recover interrupted work — production deploys always ask a human first. |
44
+
45
+ ## CLI
46
+
47
+ ```
48
+ navig agentctl sessions [--project P] [--json]
49
+ navig agentctl worktrees [--project PATH] [--conflicts] [--json]
50
+ navig agentctl tasks [--source board|work] [--json]
51
+ navig agentctl status [--json]
52
+ ```
53
+
54
+ ## Deck routes (the contract)
55
+
56
+ All under `/api/deck/agents`, gated on the `agentctl` module. GET-only in Phase 1:
57
+
58
+ - `GET /api/deck/agents/sessions` — live sessions (from `navig procs` semantics) merged with the persistent registry.
59
+ - `GET /api/deck/agents/worktrees?project=&conflicts=` — worktrees + pairwise conflict radar.
60
+ - `GET /api/deck/agents/tasks` — board cards + work items, one normalised shape.
61
+
62
+ The OS app (Control Center tile) and navig-dev (VS Code tree views) are both
63
+ consumers of this one contract.
64
+
65
+ ## Store
66
+
67
+ `~/.navig/data/store/agentctl.db` — session ledger (who was active where, with
68
+ state-change history) and task bindings (which session owns which task). Task
69
+ truth stays in `board.db` / `work.db`; this ledger only answers ownership.
70
+
71
+ ## Non-negotiables
72
+
73
+ Local-first · provider-agnostic adapters · explicit process ownership · safe git
74
+ handling · no duplicate task execution · **no automatic merge or deployment**.
@@ -0,0 +1,9 @@
1
+ """navig-agentctl — the Agent Control Center (devops section).
2
+
3
+ Phase 1 (Observer): a read-only view over what is already true — live agent
4
+ sessions, git worktrees and task records — exposed as deck routes, a CLI
5
+ (``navig agentctl``) and the Control Center OS tile. It never launches,
6
+ stops or merges anything; that is Phase 2+.
7
+ """
8
+
9
+ __version__ = "0.2.0"
@@ -0,0 +1,203 @@
1
+ """``navig agentctl`` — the Agent Control Center from the terminal.
2
+
3
+ Read-only Observer verbs mirroring the deck routes. Tables follow the house
4
+ style (Rich, one wrappable column, semantic glyphs); anything a script might
5
+ parse also offers ``--json``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ import typer
14
+
15
+ agentctl_app = typer.Typer(
16
+ name="agentctl",
17
+ help="Agent Control Center: sessions, worktrees & tasks (read-only).",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+
22
+ def _print_json(data: Any) -> None:
23
+ typer.echo(json.dumps(data, indent=2, default=str))
24
+
25
+
26
+ def _table(headers: list[str], rows: list[list[str]], no_wrap: set[int] | None = None):
27
+ from navig.console_helper import Table, console
28
+
29
+ table = Table(box=None, show_header=True, padding=(0, 2))
30
+ nw = no_wrap or set()
31
+ for i, h in enumerate(headers):
32
+ table.add_column(h, no_wrap=i in nw)
33
+ for row in rows:
34
+ table.add_row(*row)
35
+ console.print(table)
36
+
37
+
38
+ def _state_glyph(state: str) -> str:
39
+ return {
40
+ "active": "[green]● active[/green]",
41
+ "idle": "[dim]○ idle[/dim]",
42
+ "young": "[cyan]◐ young[/cyan]",
43
+ "stale": "[red]✗ stale[/red]",
44
+ }.get(state, f"[dim]? {state or 'unknown'}[/dim]")
45
+
46
+
47
+ @agentctl_app.command("sessions")
48
+ def sessions_cmd(
49
+ project: str = typer.Option("", "--project", "-p", help="Filter by project label substring."),
50
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
51
+ ):
52
+ """Live agent sessions (who is working where, idle or active)."""
53
+ from navig_agentctl.snapshots import session_snapshot
54
+
55
+ rows = session_snapshot()
56
+ if project:
57
+ rows = [r for r in rows if project.lower() in (r.get("project") or "").lower()]
58
+ if json_out:
59
+ _print_json(rows)
60
+ return
61
+ if not rows:
62
+ typer.echo("No agent sessions found.")
63
+ return
64
+ _table(
65
+ ["State", "Session", "Project", "Owner", "Mem", "Reason"],
66
+ [
67
+ [
68
+ _state_glyph(r["state"]),
69
+ (r["session_id"] or "")[:12],
70
+ (r.get("project") or "")[:28],
71
+ (r.get("owner") or "")[:16],
72
+ f"{r.get('mem_mb') or 0:.0f}MB",
73
+ (r.get("reason") or "")[:40],
74
+ ]
75
+ for r in rows
76
+ ],
77
+ no_wrap={0, 1, 4},
78
+ )
79
+ typer.echo(f"\n{len(rows)} session(s). Next: navig agentctl worktrees --project <path>")
80
+
81
+
82
+ @agentctl_app.command("worktrees")
83
+ def worktrees_cmd(
84
+ project: str = typer.Option(".", "--project", "-p", help="Repo path (default: current directory)."),
85
+ conflicts: bool = typer.Option(False, "--conflicts", help="Also run the pairwise conflict radar."),
86
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
87
+ ):
88
+ """Git worktrees of a repo: branch, ahead/behind, dirty, last commit."""
89
+ from navig_agentctl.snapshots import conflict_pairs, worktree_snapshot
90
+
91
+ # Typed paths follow the operator: resolve against the invocation cwd, never
92
+ # the process cwd (navig chdirs into the active space).
93
+ from navig.platform.paths import resolve_user_path
94
+
95
+ root = resolve_user_path(project)
96
+ if not root.exists():
97
+ typer.echo(f"Path not found: {root}")
98
+ raise typer.Exit(1)
99
+ wts = worktree_snapshot(root)
100
+ pairs = conflict_pairs(root) if conflicts else []
101
+ if json_out:
102
+ _print_json({"project": str(root), "worktrees": wts, "conflicts": pairs})
103
+ return
104
+ if not wts:
105
+ typer.echo(f"No worktrees found under {root}")
106
+ return
107
+ _table(
108
+ ["Worktree", "Branch", "±", "Dirty", "Last commit"],
109
+ [
110
+ [
111
+ w["name"][:28],
112
+ (w["branch"] or "detached")[:24],
113
+ f"+{w['ahead']}/-{w['behind']}",
114
+ "[yellow]●[/yellow]" if w["dirty"] else "[dim]○[/dim]",
115
+ (w["last_commit"]["subject"] or "")[:36],
116
+ ]
117
+ for w in wts
118
+ ],
119
+ no_wrap={2, 3},
120
+ )
121
+ if conflicts:
122
+ if not pairs:
123
+ typer.echo("\n[green]No merge conflicts across worktrees.[/green]")
124
+ else:
125
+ _table(
126
+ ["A", "B", "Status"],
127
+ [
128
+ [p.get("a", "")[:18], p.get("b", "")[:18], p.get("status", "")[:20]]
129
+ for p in pairs
130
+ ],
131
+ no_wrap={2},
132
+ )
133
+ typer.echo(f"\n{len(wts)} worktree(s). Next: navig agentctl tasks")
134
+
135
+
136
+ @agentctl_app.command("tasks")
137
+ def tasks_cmd(
138
+ source: str = typer.Option("", "--source", "-s", help="Filter: board | work."),
139
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
140
+ ):
141
+ """Unified tasks from the board and the work queue."""
142
+ from navig_agentctl.snapshots import task_snapshot
143
+
144
+ rows = task_snapshot()
145
+ if source:
146
+ rows = [r for r in rows if r.get("source") == source]
147
+ if json_out:
148
+ _print_json(rows)
149
+ return
150
+ if not rows:
151
+ typer.echo("No task records found.")
152
+ return
153
+ _table(
154
+ ["Ref", "Source", "Status", "Title", "Owner"],
155
+ [
156
+ [
157
+ r["ref"][:26],
158
+ r["source"],
159
+ (r["status"] or "")[:10],
160
+ (r["title"] or "")[:40],
161
+ (r["owner"] or "")[:16],
162
+ ]
163
+ for r in rows
164
+ ],
165
+ no_wrap={0, 1, 2},
166
+ )
167
+ typer.echo(f"\n{len(rows)} task(s). Next: navig agentctl sessions")
168
+
169
+
170
+ @agentctl_app.command("status")
171
+ def status_cmd(
172
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
173
+ ):
174
+ """One-line overview: sessions, worktrees (cwd repo), tasks."""
175
+ from navig_agentctl.snapshots import session_snapshot, task_snapshot, worktree_snapshot
176
+
177
+ from navig.platform.paths import invocation_cwd
178
+
179
+ sessions = session_snapshot()
180
+ tasks = task_snapshot()
181
+ wts = worktree_snapshot(invocation_cwd())
182
+ active = sum(1 for s in sessions if s["state"] == "active")
183
+ blocked = sum(1 for t in tasks if t.get("gate") == "waiting")
184
+ summary = {
185
+ "sessions_total": len(sessions),
186
+ "sessions_active": active,
187
+ "worktrees": len(wts),
188
+ "tasks_total": len(tasks),
189
+ "tasks_waiting": blocked,
190
+ }
191
+ if json_out:
192
+ _print_json(summary)
193
+ return
194
+ _table(
195
+ ["Metric", "Value"],
196
+ [
197
+ ["Sessions (active / total)", f"{active} / {len(sessions)}"],
198
+ ["Worktrees (this repo)", str(len(wts))],
199
+ ["Tasks (waiting / total)", f"{blocked} / {len(tasks)}"],
200
+ ],
201
+ no_wrap={1},
202
+ )
203
+ typer.echo("\nNext: navig agentctl sessions · worktrees --project <path> · tasks")
@@ -0,0 +1,229 @@
1
+ """Agent Control Center deck routes — the read-only Observer API.
2
+
3
+ AUTHORITATIVE CONTRACT FIXTURE: ``plugins/navig-agentctl/tests/fixtures/agents-contract.json``.
4
+ The OS Control Center app and tools/forge/navig-dev both validate against it —
5
+ one fixture, two consumers; a shape change updates the fixture in the same
6
+ commit as the route change. (navig-dev's drift-alarm walks
7
+ ``plugins/navig-agentctl/**`` for it — the routes are a PLUGIN, not core.)
8
+
9
+ Gated on the ``agentctl`` module being enabled. Phase 1 is GET-only; the ONE
10
+ write is the Phase-2 annotation heartbeat (no process control):
11
+
12
+ GET /api/deck/agents/sessions → {sessions: [...], generated_at}
13
+ GET /api/deck/agents/worktrees?project= → {worktrees: [...], conflicts: [...]}
14
+ GET /api/deck/agents/tasks → {tasks: [...], generated_at}
15
+ POST /api/deck/agents/heartbeat → {ok, session_id} annotation-only
16
+
17
+ Heartbeat semantics (contract): payload carries ONLY annotation fields
18
+ (``session_id`` + optional ``project_root, worktree, agent_kind, editor,
19
+ owner, last_activity_at``); unknown/daemon fields in the body are ignored,
20
+ never written. Daemon-derived state always wins — the route cannot change
21
+ ``state``/``pid``/``reason`` of a session the process table owns.
22
+
23
+ Future local-only mutations (launch/assign/pause/terminate) need BOTH guards
24
+ (is_local_request in the brain AND the OS server's refuseRemoteManagement
25
+ list); the heartbeat is annotation, not control, so it rides normal deck auth.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import logging
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ try:
36
+ from aiohttp import web
37
+ except ImportError: # pragma: no cover — gateway always has aiohttp
38
+ web = None # type: ignore[assignment]
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ _BASE = "/api/deck/agents"
43
+
44
+
45
+ def _json(data: dict[str, Any], status: int = 200):
46
+ return web.json_response(data, status=status)
47
+
48
+
49
+ def _resolve_root(request) -> Path | None:
50
+ """Project root from ?project= (a path), else the primary checkout of cwd.
51
+
52
+ Typed paths follow the operator: a path the user typed goes through
53
+ resolve_user_path — never Path.cwd() guessing for user input.
54
+ """
55
+ raw = (request.query.get("project") or "").strip()
56
+ if not raw:
57
+ from navig.platform.paths import invocation_cwd
58
+
59
+ raw = str(invocation_cwd())
60
+ try:
61
+ from navig.platform.paths import resolve_user_path
62
+
63
+ root = resolve_user_path(raw)
64
+ except Exception: # noqa: BLE001
65
+ root = Path(raw)
66
+ if not root.exists():
67
+ return None
68
+ return root
69
+
70
+
71
+ async def handle_sessions(request):
72
+ """Live sessions (procs-derived) merged with the registry ledger."""
73
+ from navig_agentctl.registry import get_agentctl_store
74
+ from navig_agentctl.snapshots import record_snapshot, session_snapshot
75
+
76
+ try:
77
+ # to_thread: psutil process scans take seconds — run on the loop would
78
+ # freeze EVERY other gateway request (deck, telegram) behind this one.
79
+ rows = await asyncio.to_thread(session_snapshot)
80
+ except Exception as exc: # noqa: BLE001 — observer must never 500 the app
81
+ logger.warning("agentctl sessions snapshot failed: %r", exc)
82
+ rows = []
83
+ try:
84
+ def _ingest() -> dict:
85
+ store = get_agentctl_store()
86
+ record_snapshot(rows, store)
87
+ store.mark_stale()
88
+ return {r["session_id"]: r for r in store.list_sessions()}
89
+
90
+ ledger = await asyncio.to_thread(_ingest)
91
+ except Exception as exc: # noqa: BLE001
92
+ logger.debug("agentctl registry unavailable: %r", exc)
93
+ ledger = {}
94
+
95
+ # Live rows win; registry-only rows surface as their last known state
96
+ # (stale included — the recovery breadcrumb Phase 3 needs).
97
+ seen = {r["session_id"] for r in rows}
98
+ merged = list(rows)
99
+ for sid, r in ledger.items():
100
+ if sid not in seen:
101
+ merged.append(
102
+ {
103
+ "session_id": sid,
104
+ "pid": r.get("pid"),
105
+ "project_root": r.get("project_root") or "",
106
+ "project": r.get("project") or "",
107
+ "worktree": r.get("worktree") or "",
108
+ "agent_kind": r.get("agent_kind") or "",
109
+ "editor": r.get("editor") or "",
110
+ "state": r.get("state") or "stale",
111
+ "reason": r.get("reason") or "not seen in the latest snapshot",
112
+ "last_activity_at": r.get("last_activity_at"),
113
+ "owner": r.get("owner") or "",
114
+ "mem_mb": 0.0,
115
+ }
116
+ )
117
+ return _json({"sessions": merged, "generated_at": _now()})
118
+
119
+
120
+ async def handle_worktrees(request):
121
+ """Worktrees of one repo + the pairwise conflict radar."""
122
+ from navig_agentctl.snapshots import conflict_pairs, worktree_snapshot
123
+
124
+ root = _resolve_root(request)
125
+ if root is None:
126
+ return _json({"error": "project path not found"}, status=404)
127
+ try:
128
+ # to_thread: N worktrees × 3 git subprocesses measured 36-150s on the
129
+ # live box — on the loop it froze the whole gateway (reproduced: a
130
+ # 14ms route took 147s while this ran).
131
+ wts = await asyncio.to_thread(worktree_snapshot, root)
132
+ except Exception as exc: # noqa: BLE001
133
+ logger.warning("agentctl worktree snapshot failed: %r", exc)
134
+ return _json({"error": f"worktree snapshot failed: {exc}"}, status=500)
135
+ # Conflict radar is opt-in: pairwise merge-tree is O(n²) git calls — a
136
+ # 31-worktree repo is ~450 pairs and the scan runs off-loop (to_thread),
137
+ # so the rest of the gateway stays live while it grinds. The CLI mirrors
138
+ # the opt-in (`--conflicts` is a flag, never the default).
139
+ with_conflicts = (request.query.get("conflicts") or "0").lower() in ("1", "true", "yes")
140
+ pairs = await asyncio.to_thread(conflict_pairs, root) if with_conflicts else []
141
+ return _json(
142
+ {"project": str(root), "worktrees": wts, "conflicts": pairs, "generated_at": _now()}
143
+ )
144
+
145
+
146
+ async def handle_tasks(request):
147
+ """Unified task read-model: board cards + work items."""
148
+ from navig_agentctl.snapshots import task_snapshot
149
+
150
+ try:
151
+ tasks = await asyncio.to_thread(task_snapshot)
152
+ except Exception as exc: # noqa: BLE001
153
+ logger.warning("agentctl task snapshot failed: %r", exc)
154
+ return _json({"error": f"task snapshot failed: {exc}"}, status=500)
155
+ return _json({"tasks": tasks, "generated_at": _now()})
156
+
157
+
158
+ def _reject(msg: str):
159
+ """Heartbeat 400s must be VISIBLE: a silent rejection is a client that
160
+ retries forever with a payload nobody ever sees fail (the forge client's
161
+ 404-standdown made exactly this class of drift undetectable before)."""
162
+ logger.warning("agentctl heartbeat rejected: %s", msg)
163
+ return _json({"error": msg}, status=400)
164
+
165
+
166
+ async def handle_heartbeat(request):
167
+ """POST — annotation-only session heartbeat (the ONE write in Phase 1/2a).
168
+
169
+ Adds editor context the daemon cannot derive (workspace, editor, roster
170
+ owner) to the registry. Never changes state/pid/reason — see the module
171
+ docstring for the contract. Validation is fail-closed: a malformed body
172
+ is 400, never a partial write.
173
+ """
174
+ from navig_agentctl.registry import get_agentctl_store
175
+
176
+ try:
177
+ payload = await request.json()
178
+ except Exception: # noqa: BLE001 — non-JSON body
179
+ return _reject("body must be a JSON object")
180
+ if not isinstance(payload, dict):
181
+ return _reject("body must be a JSON object")
182
+
183
+ sid = payload.get("session_id")
184
+ if not isinstance(sid, str) or not sid.strip() or len(sid) > 200:
185
+ return _reject("session_id is required (non-empty string, ≤200 chars)")
186
+ if any(ord(c) < 32 for c in sid): # control chars never reach the store
187
+ return _reject("session_id contains control characters")
188
+
189
+ annotations: dict[str, str] = {}
190
+ for key in ("project_root", "worktree", "agent_kind", "editor", "owner"):
191
+ val = payload.get(key)
192
+ if val is None:
193
+ annotations[key] = ""
194
+ continue
195
+ if not isinstance(val, str) or len(val) > 1024:
196
+ return _reject(f"{key} must be a string of at most 1024 chars")
197
+ annotations[key] = val
198
+ last_activity = payload.get("last_activity_at")
199
+ if last_activity is not None and (
200
+ not isinstance(last_activity, str) or len(last_activity) > 64
201
+ ):
202
+ return _reject("last_activity_at must be a string of at most 64 chars")
203
+
204
+ try:
205
+ store = get_agentctl_store()
206
+ store.annotate_session(
207
+ {"session_id": sid.strip(), **annotations, "last_activity_at": last_activity}
208
+ )
209
+ except Exception as exc: # noqa: BLE001 — store down is a 500, not a 200
210
+ logger.warning("agentctl heartbeat store failed: %r", exc)
211
+ return _json({"error": f"heartbeat store failed: {exc}"}, status=500)
212
+ return _json({"ok": True, "session_id": sid.strip()})
213
+
214
+
215
+ def _now() -> str:
216
+ from datetime import datetime, timezone
217
+
218
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
219
+
220
+
221
+ def register(app: "web.Application") -> None:
222
+ from navig.modules.gate import requires_module
223
+
224
+ g = requires_module("agentctl")
225
+ app.router.add_get(f"{_BASE}/sessions", g(handle_sessions))
226
+ app.router.add_get(f"{_BASE}/worktrees", g(handle_worktrees))
227
+ app.router.add_get(f"{_BASE}/tasks", g(handle_tasks))
228
+ app.router.add_post(f"{_BASE}/heartbeat", g(handle_heartbeat))
229
+ logger.debug("navig-agentctl: Observer routes + heartbeat mounted under %s", _BASE)