ctlrm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. ctlrm/__init__.py +3 -0
  2. ctlrm/__main__.py +307 -0
  3. ctlrm/communication/__init__.py +1 -0
  4. ctlrm/communication/terminal.py +292 -0
  5. ctlrm/managed/__init__.py +1 -0
  6. ctlrm/managed/archive.py +269 -0
  7. ctlrm/managed/area.py +201 -0
  8. ctlrm/managed/artifacts.py +164 -0
  9. ctlrm/managed/cli.py +276 -0
  10. ctlrm/managed/codex_host.py +187 -0
  11. ctlrm/managed/commits.py +178 -0
  12. ctlrm/managed/host.py +58 -0
  13. ctlrm/managed/project_cli.py +162 -0
  14. ctlrm/managed/projects.py +494 -0
  15. ctlrm/managed/providers.py +157 -0
  16. ctlrm/managed/sessions.py +691 -0
  17. ctlrm/managed/storage.py +232 -0
  18. ctlrm/managed/supervisor.py +133 -0
  19. ctlrm/managed/workflow_cli.py +161 -0
  20. ctlrm/managed/workflows.py +696 -0
  21. ctlrm/runtime/__init__.py +1 -0
  22. ctlrm/runtime/documents.py +60 -0
  23. ctlrm/runtime/filesystem.py +65 -0
  24. ctlrm/runtime/location.py +36 -0
  25. ctlrm/runtime/manifest.py +125 -0
  26. ctlrm/runtime/messages.py +69 -0
  27. ctlrm/runtime/participants.py +89 -0
  28. ctlrm/runtime/paths.py +17 -0
  29. ctlrm/runtime/projects.py +113 -0
  30. ctlrm/runtime/room.py +265 -0
  31. ctlrm/runtime/workflows.py +230 -0
  32. ctlrm/scheduler.py +23 -0
  33. ctlrm/version.py +11 -0
  34. ctlrm/web/__init__.py +1 -0
  35. ctlrm/web/api.py +272 -0
  36. ctlrm/web/app.py +74 -0
  37. ctlrm/web/service.py +354 -0
  38. ctlrm/web/static/app.css +1220 -0
  39. ctlrm/web/static/app.js +1319 -0
  40. ctlrm/web/static/index.html +129 -0
  41. ctlrm/web/static/vendor/addon-fit.LICENSE +19 -0
  42. ctlrm/web/static/vendor/addon-fit.js +2 -0
  43. ctlrm/web/static/vendor/xterm.LICENSE +21 -0
  44. ctlrm/web/static/vendor/xterm.css +285 -0
  45. ctlrm/web/static/vendor/xterm.js +2 -0
  46. ctlrm/web/templates/implement-review.yaml +44 -0
  47. ctlrm/web/templates/ship.yaml +81 -0
  48. ctlrm/web/templates/single-agent.yaml +21 -0
  49. ctlrm/web/terminal.py +164 -0
  50. ctlrm-0.1.0.dist-info/METADATA +82 -0
  51. ctlrm-0.1.0.dist-info/RECORD +53 -0
  52. ctlrm-0.1.0.dist-info/WHEEL +4 -0
  53. ctlrm-0.1.0.dist-info/entry_points.txt +2 -0
ctlrm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Top-level package metadata for ctlrm."""
2
+
3
+ from ctlrm.version import __version__ as __version__
ctlrm/__main__.py ADDED
@@ -0,0 +1,307 @@
1
+ """Typer command-line interface for filesystem-backed ctlrm rooms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable, Sequence
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Annotated, TypeVar
10
+ from uuid import uuid4
11
+
12
+ import typer
13
+ import yaml
14
+ from pydantic import ValidationError
15
+
16
+ from ctlrm.runtime.manifest import RoleAssignment, RoomManifest
17
+ from ctlrm.runtime.messages import MailboxMessage
18
+ from ctlrm.runtime.room import RoomRuntime
19
+
20
+ from ctlrm.managed.cli import session_app, supervisor_app
21
+
22
+ from ctlrm.managed.workflow_cli import workflow_app
23
+ from ctlrm.managed.project_cli import project_app, job_app
24
+
25
+ app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
26
+ app.add_typer(session_app, name="session")
27
+ app.add_typer(workflow_app, name="workflow")
28
+ app.add_typer(project_app, name="project")
29
+ app.add_typer(job_app, name="job")
30
+ app.add_typer(supervisor_app, name="supervisor")
31
+ _Result = TypeVar("_Result")
32
+
33
+
34
+ def _now() -> str:
35
+ """Return the current UTC time in an interoperable representation."""
36
+ return datetime.now(timezone.utc).isoformat()
37
+
38
+
39
+ def _read_text(value: str | None, path: Path | None) -> str:
40
+ """Resolve text supplied inline, from a file, or through standard input."""
41
+ if value is not None:
42
+ return value
43
+ if path is not None:
44
+ return path.read_text(encoding="utf-8")
45
+ if stdin_buffer := getattr(sys.stdin, "buffer", None):
46
+ return stdin_buffer.read().decode("utf-8")
47
+ return sys.stdin.read()
48
+
49
+
50
+ def _run(action: Callable[[], _Result]) -> _Result:
51
+ """Convert expected domain failures into stable command exit codes."""
52
+ try:
53
+ return action()
54
+ except typer.Exit:
55
+ raise
56
+ except FileExistsError as exc:
57
+ typer.echo(f"conflict: {exc.filename or exc}", err=True)
58
+ raise typer.Exit(3) from exc
59
+ except FileNotFoundError as exc:
60
+ typer.echo(f"not found: {exc.filename or exc}", err=True)
61
+ raise typer.Exit(4) from exc
62
+ except (ValidationError, ValueError, yaml.YAMLError) as exc:
63
+ typer.echo(f"invalid: {exc}", err=True)
64
+ raise typer.Exit(2) from exc
65
+ except (OSError, RuntimeError) as exc:
66
+ typer.echo(f"room error: {exc}", err=True)
67
+ raise typer.Exit(5) from exc
68
+
69
+
70
+ def _parse_assignments(values: list[str]) -> list[RoleAssignment]:
71
+ """Parse repeated PARTICIPANT=ROLE assignment options."""
72
+ assignments = []
73
+ for value in values:
74
+ participant, separator, role = value.partition("=")
75
+ if not separator:
76
+ raise ValueError(f"assignment must be PARTICIPANT=ROLE: {value!r}")
77
+ assignments.append(RoleAssignment(participant=participant, role=role))
78
+ return assignments
79
+
80
+
81
+ def _runtime(ctx: typer.Context) -> RoomRuntime:
82
+ """Return the room runtime stored by the root callback."""
83
+ assert isinstance(ctx.obj, RoomRuntime)
84
+ return ctx.obj
85
+
86
+
87
+ @app.callback()
88
+ def root(
89
+ ctx: typer.Context,
90
+ root_path: Annotated[
91
+ Path | None,
92
+ typer.Option("--root", help="Project root containing the .ctlrm room."),
93
+ ] = None,
94
+ ) -> None:
95
+ """Coordinate agents through a project-local shared directory."""
96
+ ctx.obj = RoomRuntime((root_path or Path.cwd()).resolve())
97
+
98
+
99
+ @app.command()
100
+ def serve(
101
+ port: Annotated[int, typer.Option(min=1024, max=65535)] = 8766,
102
+ data: Annotated[
103
+ Path | None, typer.Option(help="Override the central user data directory.")
104
+ ] = None,
105
+ ) -> None:
106
+ """Open a local browser workbench for managed projects and agent terminals."""
107
+ import uvicorn
108
+ from ctlrm.managed.projects import ProjectStore
109
+ from ctlrm.web.app import create_app
110
+
111
+ web = create_app(ProjectStore(data), port=port)
112
+ typer.echo(f"Control room: http://127.0.0.1:{port}/#token={web.state.token}")
113
+ uvicorn.run(
114
+ web, host="127.0.0.1", port=port, access_log=False, ws_max_size=65536, ws_max_queue=8
115
+ )
116
+
117
+
118
+ @app.command()
119
+ def init(
120
+ ctx: typer.Context,
121
+ participant_id: Annotated[str, typer.Option("--id", help="Room author's participant ID.")],
122
+ name: Annotated[str, typer.Option(help="Display name for the room author.")],
123
+ assign: Annotated[
124
+ list[str],
125
+ typer.Option(help="Role assignment as PARTICIPANT=ROLE; repeat for the full roster."),
126
+ ],
127
+ prompt: Annotated[
128
+ str | None, typer.Option(help="Room prompt; defaults to standard input.")
129
+ ] = None,
130
+ prompt_file: Annotated[
131
+ Path | None,
132
+ typer.Option("--prompt-file", help="Read the room prompt from a UTF-8 file."),
133
+ ] = None,
134
+ kind: Annotated[str, typer.Option(help="Participant kind.")] = "agent",
135
+ provider: Annotated[str | None, typer.Option(help="Agent provider, if applicable.")] = None,
136
+ restart_command: Annotated[
137
+ str | None,
138
+ typer.Option("--restart-command", help="Command to restart this session; stored only."),
139
+ ] = None,
140
+ capability: Annotated[list[str], typer.Option(help="Author capability; may be repeated.")] = [],
141
+ ) -> None:
142
+ """Create the prompt and role roster, then register their author."""
143
+ if prompt is not None and prompt_file is not None:
144
+ raise typer.BadParameter("use either --prompt or --prompt-file, not both")
145
+
146
+ def initialize() -> None:
147
+ room = RoomManifest(
148
+ id=f"room-{uuid4().hex}",
149
+ author=participant_id,
150
+ created_at=_now(),
151
+ assignments=_parse_assignments(assign),
152
+ prompt=_read_text(prompt, prompt_file),
153
+ )
154
+ result = _runtime(ctx).initialize(
155
+ room,
156
+ name=name,
157
+ kind=kind,
158
+ provider=provider,
159
+ restart_command=restart_command,
160
+ capabilities=capability,
161
+ joined_at=_now(),
162
+ )
163
+ typer.echo(result.room_path)
164
+ typer.echo(result.signature_path)
165
+ if not result.created:
166
+ typer.echo("room already initialized; author registration recovered", err=True)
167
+
168
+ _run(initialize)
169
+
170
+
171
+ @app.command()
172
+ def join(
173
+ ctx: typer.Context,
174
+ participant_id: Annotated[str, typer.Option("--id", help="Assigned participant ID.")],
175
+ name: Annotated[str, typer.Option(help="Participant display name.")],
176
+ kind: Annotated[str, typer.Option(help="Participant kind.")] = "agent",
177
+ provider: Annotated[str | None, typer.Option(help="Agent provider, if applicable.")] = None,
178
+ restart_command: Annotated[
179
+ str | None,
180
+ typer.Option("--restart-command", help="Command to restart this session; stored only."),
181
+ ] = None,
182
+ capability: Annotated[
183
+ list[str], typer.Option(help="Participant capability; may be repeated.")
184
+ ] = [],
185
+ ) -> None:
186
+ """Accept the role assigned in the room roster and write a signature."""
187
+
188
+ def register() -> None:
189
+ path = _runtime(ctx).join_participant(
190
+ participant_id=participant_id,
191
+ name=name,
192
+ kind=kind,
193
+ provider=provider,
194
+ capabilities=capability,
195
+ restart_command=restart_command,
196
+ joined_at=_now(),
197
+ )
198
+ typer.echo(path)
199
+
200
+ _run(register)
201
+
202
+
203
+ @app.command()
204
+ def send(
205
+ ctx: typer.Context,
206
+ sender: Annotated[str, typer.Option("--from", help="Registered sender ID.")],
207
+ recipient: Annotated[str, typer.Option("--to", help="Registered recipient ID.")],
208
+ title: Annotated[str, typer.Option(help="Short message title.")],
209
+ kind: Annotated[str, typer.Option(help="Message kind.")] = "message",
210
+ body: Annotated[
211
+ str | None, typer.Option(help="Message body; defaults to standard input.")
212
+ ] = None,
213
+ body_file: Annotated[
214
+ Path | None,
215
+ typer.Option("--body-file", help="Read the body from a UTF-8 file."),
216
+ ] = None,
217
+ file: Annotated[list[str], typer.Option(help="Related file path; may be repeated.")] = [],
218
+ message_id: Annotated[str | None, typer.Option("--id", help="Explicit message ID.")] = None,
219
+ execution_id: Annotated[str | None, typer.Option(help="Owning workflow execution ID.")] = None,
220
+ ) -> None:
221
+ """Deliver an immutable message between registered participants."""
222
+ if body is not None and body_file is not None:
223
+ raise typer.BadParameter("use either --body or --body-file, not both")
224
+
225
+ def deliver() -> None:
226
+ identifier = (
227
+ message_id or f"msg-{datetime.now(timezone.utc):%Y%m%dT%H%M%S}-{uuid4().hex[:8]}"
228
+ )
229
+ message = MailboxMessage(
230
+ id=identifier,
231
+ from_=sender,
232
+ to=recipient,
233
+ kind=kind,
234
+ title=title,
235
+ status="new",
236
+ created_at=_now(),
237
+ files=file,
238
+ body=_read_text(body, body_file),
239
+ )
240
+ runtime = _runtime(ctx)
241
+ if (runtime.root / "area.yaml").exists():
242
+ from ctlrm.managed.area import Area
243
+ from ctlrm.managed.workflows import WorkflowService
244
+
245
+ area = Area.load(runtime.project_root)
246
+ if area.data["mode"] == "workflow":
247
+ if not execution_id:
248
+ raise ValueError("workflow messages require --execution-id from the assignment")
249
+ typer.echo(WorkflowService(area).send_message(message, execution_id))
250
+ return
251
+ if execution_id:
252
+ raise ValueError("execution IDs require a managed workflow")
253
+ typer.echo(runtime.send_message(message))
254
+
255
+ _run(deliver)
256
+
257
+
258
+ @app.command()
259
+ def inbox(
260
+ ctx: typer.Context,
261
+ participant: Annotated[str, typer.Option(help="Participant whose inbox should be listed.")],
262
+ ) -> None:
263
+ """List messages in one participant inbox."""
264
+
265
+ def show() -> None:
266
+ """List valid items and diagnose malformed neighbors."""
267
+ errors: list[tuple[Path, Exception]] = []
268
+ for message in _runtime(ctx).read_inbox(
269
+ participant, on_error=lambda path, exc: errors.append((path, exc))
270
+ ):
271
+ typer.echo(f"{message.id}\t{message.from_}\t{message.kind}\t{message.title}")
272
+ _report_invalid_files(errors)
273
+
274
+ _run(show)
275
+
276
+
277
+ @app.command()
278
+ def participants(ctx: typer.Context) -> None:
279
+ """List participant signatures and their assigned roles."""
280
+
281
+ def show() -> None:
282
+ """List valid identities and diagnose malformed neighbors."""
283
+ errors: list[tuple[Path, Exception]] = []
284
+ for signature in _runtime(ctx).list_signatures(
285
+ on_error=lambda path, exc: errors.append((path, exc))
286
+ ):
287
+ typer.echo(f"{signature.id}\t{signature.role}\t{signature.kind}\t{signature.name}")
288
+ _report_invalid_files(errors)
289
+
290
+ _run(show)
291
+
292
+
293
+ def _report_invalid_files(errors: list[tuple[Path, Exception]]) -> None:
294
+ """Report all invalid files after rendering the valid entries."""
295
+ for path, error in errors:
296
+ typer.echo(f"invalid: {path}: {error}", err=True)
297
+ if errors:
298
+ raise typer.Exit(2)
299
+
300
+
301
+ def main(argv: Sequence[str] | None = None) -> None:
302
+ """Run the ctlrm command-line application."""
303
+ app(args=list(argv) if argv is not None else None, prog_name="ctlrm")
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()
@@ -0,0 +1 @@
1
+ """Modules for inter-agent communication."""
@@ -0,0 +1,292 @@
1
+ """Owned terminal management through libtmux, with process identity checks."""
2
+
3
+ from pathlib import Path
4
+ from functools import wraps
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import shlex
9
+ import signal
10
+ import time
11
+ import sys
12
+ from typing import Protocol
13
+
14
+ import libtmux
15
+
16
+ from ctlrm.runtime.location import runtime_path
17
+ from ctlrm.managed.storage import read_record
18
+
19
+
20
+ class StopPending(RuntimeError):
21
+ """An owned process has been signaled but has not exited yet."""
22
+
23
+
24
+ def _translate_errors(method):
25
+ """Keep library-specific exceptions inside the terminal adapter."""
26
+
27
+ @wraps(method)
28
+ def call(*args, **kwargs):
29
+ """Translate a failed tmux operation into the service error contract."""
30
+ try:
31
+ return method(*args, **kwargs)
32
+ except libtmux.exc.LibTmuxException as error:
33
+ raise RuntimeError(f"tmux operation failed: {error}") from error
34
+
35
+ return call
36
+
37
+
38
+ def process_identity(pid: int) -> str | None:
39
+ """Return an OS start identity, treating dead/zombie processes as absent."""
40
+ try:
41
+ value = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
42
+ if value[0] == "Z":
43
+ return None
44
+ boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
45
+ return f"{boot}:{pid}:{value[19]}"
46
+ except FileNotFoundError:
47
+ return None
48
+
49
+
50
+ def children(pid: int) -> list[int]:
51
+ """Read direct provider children of the stable host process on Linux."""
52
+ try:
53
+ text = Path(f"/proc/{pid}/task/{pid}/children").read_text()
54
+ return [int(value) for value in text.split() if process_identity(int(value))]
55
+ except FileNotFoundError:
56
+ return []
57
+
58
+
59
+ class Terminal(Protocol):
60
+ """Boundary faked by deterministic supervisor tests."""
61
+
62
+ def exists(self, spec: dict) -> bool:
63
+ """Return whether the deterministic terminal name is occupied."""
64
+ ...
65
+
66
+ def ensure(self, spec: dict) -> dict:
67
+ """Create or reconcile a verified owned terminal."""
68
+ ...
69
+
70
+ def adopt(self, spec: dict) -> dict:
71
+ """Verify an existing launch without creating one if it disappeared."""
72
+ ...
73
+
74
+ def health(self, spec: dict, identity: dict) -> str:
75
+ """Return running, exited, or missing; mismatched ownership raises."""
76
+ ...
77
+
78
+ def wake(self, spec: dict, identity: dict, reference: str) -> None:
79
+ """Deliver a bounded literal mailbox reference to a verified provider."""
80
+ ...
81
+
82
+ def stop(self, spec: dict, identity: dict) -> None:
83
+ """Stop only the verified session and its provider processes."""
84
+ ...
85
+
86
+
87
+ class TmuxTerminal:
88
+ """One isolated server namespace per coordination area."""
89
+
90
+ def __init__(self, area_id: str) -> None:
91
+ """Use a dedicated socket and ignore user tmux configuration."""
92
+ self.socket = f"ctlrm-{area_id}"
93
+ self.server = libtmux.Server(socket_name=self.socket, config_file="/dev/null")
94
+
95
+ def _session(self, spec: dict):
96
+ """Find one exact session name without selecting user terminals."""
97
+ return self.server.sessions.get(session_name=spec["terminal_name"], default=None)
98
+
99
+ @_translate_errors
100
+ def exists(self, spec: dict) -> bool:
101
+ """Return whether this generation's terminal name is occupied."""
102
+ return self._session(spec) is not None
103
+
104
+ def _identity(self, spec: dict, session) -> dict:
105
+ """Verify creation-time environment, host command, root, and process identity."""
106
+ panes = list(session.panes)
107
+ if len(panes) != 1:
108
+ raise ValueError("owned session has an unexpected pane layout")
109
+ pane = panes[0]
110
+ token = session.cmd("show-environment", "CTLRM_LAUNCH_TOKEN").stdout
111
+ if token != [f"CTLRM_LAUNCH_TOKEN={spec['token']}"]:
112
+ raise ValueError("terminal ownership token mismatch")
113
+ host_pid = int(pane.pane_pid)
114
+ expected = [
115
+ sys.executable,
116
+ "-m",
117
+ "ctlrm.managed.host",
118
+ spec["root"],
119
+ spec["id"],
120
+ str(spec["generation"]),
121
+ ]
122
+ try:
123
+ command = Path(f"/proc/{host_pid}/cmdline").read_bytes().split(b"\0")
124
+ actual = [part.decode() for part in command if part]
125
+ cwd = Path(f"/proc/{host_pid}/cwd").resolve(strict=True)
126
+ except FileNotFoundError as error:
127
+ raise ValueError("terminal host disappeared during identity validation") from error
128
+ if actual != expected or cwd != Path(spec["root"]):
129
+ raise ValueError(
130
+ "terminal host command or worktree mismatch; inspect before reconciliation"
131
+ )
132
+ server_pid = int(pane.cmd("display-message", "-p", "#{pid}").stdout[0])
133
+ host_start, server_start = process_identity(host_pid), process_identity(server_pid)
134
+ if not host_start or not server_start:
135
+ raise ValueError("terminal process identity is unavailable")
136
+ return {
137
+ "socket": self.socket,
138
+ "socket_path": pane.cmd("display-message", "-p", "#{socket_path}").stdout[0],
139
+ "server": server_start,
140
+ "host": host_start,
141
+ "pane": pane.pane_id,
142
+ "host_pid": host_pid,
143
+ "session": session.session_id,
144
+ }
145
+
146
+ @_translate_errors
147
+ def ensure(self, spec: dict) -> dict:
148
+ """Create a stable host or adopt only an exact creation-time identity match."""
149
+ session = self._session(spec)
150
+ if session is None:
151
+ command = [
152
+ sys.executable,
153
+ "-m",
154
+ "ctlrm.managed.host",
155
+ spec["root"],
156
+ spec["id"],
157
+ str(spec["generation"]),
158
+ ]
159
+ session = self.server.new_session(
160
+ session_name=spec["terminal_name"],
161
+ start_directory=spec["root"],
162
+ window_command="exec " + shlex.join(command),
163
+ environment={"CTLRM_LAUNCH_TOKEN": spec["token"]},
164
+ x=120,
165
+ y=40,
166
+ )
167
+ deadline = time.monotonic() + 2
168
+ while True:
169
+ try:
170
+ identity = self._identity(spec, session)
171
+ break
172
+ except ValueError:
173
+ if time.monotonic() >= deadline:
174
+ raise
175
+ time.sleep(0.02)
176
+ session.set_option("@ctlrm-area", spec["area_id"])
177
+ session.set_option("@ctlrm-session", spec["id"])
178
+ session.set_option("@ctlrm-generation", str(spec["generation"]))
179
+ return identity
180
+
181
+ @_translate_errors
182
+ def adopt(self, spec: dict) -> dict:
183
+ """Reconcile an existing launch without starting a process on a race."""
184
+ session = self._session(spec)
185
+ if session is None:
186
+ raise ValueError("launch disappeared before readiness reconciliation")
187
+ return self._identity(spec, session)
188
+
189
+ def _verified(self, spec: dict, identity: dict):
190
+ """Resolve a recorded terminal and reject server restart/PID reuse."""
191
+ session = self._session(spec)
192
+ if session is None:
193
+ return None
194
+ try:
195
+ observed = self._identity(spec, session)
196
+ except (libtmux.exc.LibTmuxException, ValueError, OSError):
197
+ # A session can disappear after lookup. Only confirmed absence permits recovery.
198
+ if (
199
+ self._session(spec) is None
200
+ and process_identity(identity["host_pid"]) != identity["host"]
201
+ ):
202
+ return None
203
+ raise
204
+ if observed != identity:
205
+ raise ValueError("terminal identity changed; refusing to adopt or stop it")
206
+ return session
207
+
208
+ @_translate_errors
209
+ def health(self, spec: dict, identity: dict) -> str:
210
+ """A live host with no provider child is an exited agent, not a healthy pane."""
211
+ session = self._verified(spec, identity)
212
+ if session is None:
213
+ return "missing"
214
+ if children(identity["host_pid"]):
215
+ return "running"
216
+ exit_path = (
217
+ runtime_path(Path(spec["root"]))
218
+ / "sessions"
219
+ / spec["id"]
220
+ / f"exit-{spec['generation']}.json"
221
+ )
222
+ if exit_path.exists():
223
+ if read_record(exit_path).get("token") != spec["token"]:
224
+ raise ValueError("provider exit observation has a mismatched launch token")
225
+ return "exited"
226
+ return "starting"
227
+
228
+ @_translate_errors
229
+ def wake(self, spec: dict, identity: dict, reference: str) -> None:
230
+ """Type only a bounded ASCII reference, never a user's multiline prompt."""
231
+ if (
232
+ not reference.isascii()
233
+ or len(reference.encode()) > 256
234
+ or "\n" in reference
235
+ or "\r" in reference
236
+ ):
237
+ raise ValueError("terminal references must be a single ASCII line of at most 256 bytes")
238
+ session = self._verified(spec, identity)
239
+ if session is None or self.health(spec, identity) != "running":
240
+ raise ValueError("provider is not running")
241
+ session.panes[0].send_keys(reference, literal=True)
242
+
243
+ @_translate_errors
244
+ def stop(self, spec: dict, identity: dict) -> None:
245
+ """Kill only the verified dedicated session, propagating terminal hangup."""
246
+ session = self._verified(spec, identity)
247
+ if session is not None:
248
+ host_pid = identity["host_pid"]
249
+ group = os.getpgid(host_pid)
250
+ if group != host_pid:
251
+ raise ValueError("terminal host does not own its process group")
252
+ os.killpg(group, signal.SIGKILL)
253
+ # Killing the dedicated process group also closes the pane/session.
254
+ deadline = time.monotonic() + 2
255
+ while process_identity(host_pid) == identity["host"] and time.monotonic() < deadline:
256
+ time.sleep(0.02)
257
+ if process_identity(identity["host_pid"]) == identity["host"]:
258
+ raise StopPending("terminal stop is pending; host process still exists")
259
+
260
+ @_translate_errors
261
+ def capture(self, spec: dict, identity: dict) -> str:
262
+ """Capture bounded recent output from a verified owned terminal."""
263
+ session = self._verified(spec, identity)
264
+ if session is None:
265
+ return "[terminal is missing]"
266
+ return "\n".join(session.panes[0].capture_pane(start=-200) or [])
267
+
268
+ @_translate_errors
269
+ def attach_command(self, spec: dict, identity: dict, *, readonly: bool = False) -> list[str]:
270
+ """Build an interactive client command only after verifying the owned generation."""
271
+ session = self._verified(spec, identity)
272
+ if session is None:
273
+ raise ValueError("terminal is missing")
274
+ # libtmux captures stdio; native attachment needs a caller-owned terminal or PTY.
275
+ binary = self.server.tmux_bin or shutil.which("tmux")
276
+ if not binary:
277
+ raise RuntimeError("tmux executable is missing")
278
+ return [
279
+ binary,
280
+ "-S",
281
+ identity["socket_path"],
282
+ "attach-session",
283
+ *(["-f", "read-only"] if readonly else []),
284
+ "-t",
285
+ session.session_id,
286
+ ]
287
+
288
+ def attach(self, spec: dict, identity: dict) -> None:
289
+ """Attach the interactive client through a verified libtmux session."""
290
+ result = subprocess.run(self.attach_command(spec, identity))
291
+ if result.returncode:
292
+ raise RuntimeError(f"interactive tmux client exited with status {result.returncode}")
@@ -0,0 +1 @@
1
+ """Managed worktree sessions and durable workflow services."""