heyecco 0.1.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,72 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ dist/
14
+ build/
15
+ *.egg-info/
16
+
17
+ # Node / Next.js
18
+ node_modules/
19
+ .next/
20
+ out/
21
+ .turbo/
22
+ *.tsbuildinfo
23
+ next-env.d.ts
24
+
25
+ # Env / secrets — credentials live in Railway, fetched via `railway run`
26
+ .env
27
+ .env.local
28
+ .env.*.local
29
+ *.pem
30
+ *.key
31
+
32
+ # IDE
33
+ .vscode/
34
+ .idea/
35
+ *.swp
36
+ *.swo
37
+
38
+ # Claude Code — agent worktrees are ephemeral scratch, never commit them
39
+ .claude/worktrees/
40
+
41
+ # OS
42
+ .DS_Store
43
+ Thumbs.db
44
+
45
+ # Local data
46
+ *.db
47
+ *.sqlite
48
+ *.sqlite3
49
+ data/
50
+ state/
51
+
52
+ # Build artifacts
53
+ .cache/
54
+ .parcel-cache/
55
+
56
+ # Lorah harness guard — these belong in lorah-app repo, not ecco (ECO-89)
57
+ backend/ecco/lorah_scheduler/
58
+ backend/tests/test_linear_queue_priority.py
59
+
60
+ # Test artifacts
61
+ frontend/coverage/
62
+ frontend/playwright-report/
63
+ frontend/test-results/
64
+ frontend/package-lock.json
65
+
66
+ # stray local screenshot (untracked 2026-08-20)
67
+ railway.png
68
+
69
+ docs/ai-powered-development-phases.md
70
+
71
+ # ECO-1198: Sanity Studio local state
72
+ studio/.sanity/
heyecco-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: heyecco
3
+ Version: 0.1.0
4
+ Summary: Start and resume an Ecco agent board without an account
5
+ Project-URL: Homepage, https://heyecco.com/for-agents
6
+ Project-URL: Documentation, https://heyecco.com/for-agents
7
+ License-Expression: AGPL-3.0-only
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Office/Business :: Groupware
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: tomlkit<1,>=0.13.2
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Ecco CLI
18
+
19
+ This package supplies signup-free agent boards and safe connector migration.
20
+ Python 3.11+ is required. Start from any directory without a GitHub checkout or
21
+ Ecco account:
22
+
23
+ ```sh
24
+ uvx heyecco new "Launch Acme" --task "Draft the launch plan"
25
+ ```
26
+
27
+ For local development:
28
+
29
+ ```sh
30
+ cd cli
31
+ uv sync
32
+ uv run ecco --help
33
+ ```
34
+
35
+ ## Start a board without an account
36
+
37
+ ```sh
38
+ heyecco new "Launch Acme" \
39
+ --task "Draft the launch plan" \
40
+ --task-body "## Outcome\nA reviewable launch plan exists."
41
+ ```
42
+
43
+ The public PyPI package is named `heyecco` because the `ecco` package name belongs
44
+ to an unrelated project. It installs both `heyecco` and `ecco` commands; existing
45
+ `--task-title` calls remain supported as an alias for `--task`.
46
+
47
+ The command creates caller-owned random worker, claim and recovery secrets, writes
48
+ them to a private state file before making the HTTPS request, and prints one JSON
49
+ receipt containing public board metadata plus the state-file and claim-link-file
50
+ paths. It never prints a raw secret or writes one into a repository or native
51
+ client configuration. The board accepts 100 task creations and agent access ends
52
+ after seven days; the private claim link remains usable for 30 more days.
53
+
54
+ If the first response is lost, the CLI retries the exact request once with the
55
+ stored recovery proof. If the outcome remains unknown, use the reported state:
56
+
57
+ ```sh
58
+ ecco resume --state /private/path/ATTEMPT.json --retry-bootstrap
59
+ ```
60
+
61
+ To run an agent with the saved bearer without printing it, place the agent command
62
+ after `--`. The child receives `ECCO_AGENT_TOKEN`, `ECCO_MCP_URL`, and
63
+ `ECCO_WORKSPACE_ID` in its environment:
64
+
65
+ ```sh
66
+ ecco resume --state /private/path/ATTEMPT.json -- your-agent-command
67
+ ```
68
+
69
+ The child or its supported MCP configuration should read the token from
70
+ `ECCO_AGENT_TOKEN`. The full human handoff URL is in the mode-0600
71
+ `*.claim-link.txt` file named in the receipt; share it only with the intended
72
+ owner.
73
+
74
+ ### Supported paths
75
+
76
+ | Platform | `ecco new` / `resume` | Private state |
77
+ | --- | --- | --- |
78
+ | macOS | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
79
+ | Linux | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
80
+ | Windows | Supported | `%LOCALAPPDATA%\ecco\boards`, protected by the user profile ACL |
81
+
82
+ Connector migration below remains POSIX-only because it uses advisory file locks.
83
+
84
+ ## Migrate an existing connector
85
+
86
+ Close the client before applying a change. Preview from the project where the
87
+ connector is used; select its existing name and scope, not a new identity:
88
+
89
+ ```sh
90
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project
91
+ ```
92
+
93
+ For Codex use `--client codex`. Claude supports `user`, `project`, and `local`;
94
+ Codex supports `user` and `project`. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are
95
+ honored. No credential-store files are read and no Keychain process is invoked.
96
+ Preview is read-only and prints a digest, not configuration contents.
97
+
98
+ Repeat with the returned digest to apply:
99
+
100
+ ```sh
101
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
102
+ --apply --expected-digest DIGEST_FROM_PREVIEW --client-closed
103
+ ```
104
+
105
+ Only the exact legacy Ecco Railway URL is replaced by
106
+ `https://api.heyecco.com/mcp`. Existing connector names, OAuth registration,
107
+ permissions, unrelated settings and formatting are retained. The canonical URL
108
+ is a no-op. Public OAuth metadata must match before a changed file is written.
109
+ Its check has a 12-second wall-clock deadline and never sends credentials.
110
+
111
+ Duplicated names across local config layers, selected profiles, known managed
112
+ files, unsafe files and changed previews stop migration. The command never edits
113
+ managed policy, trust settings or OAuth caches. On-disk discovery does not prove
114
+ the effective configuration of a running app: cloud policy, launch overrides and
115
+ desktop-specific behavior remain the native client's responsibility. If the
116
+ client still reports a different URL, inspect that client's active source; do not
117
+ delete other connectors or weaken resource validation.
118
+
119
+ Apply returns `reload_required`, **not connected**. Reload the connector. A native
120
+ OAuth client may require authorization for the canonical resource because cached
121
+ grants can be bound to the old resource or client registration. This command does
122
+ not transplant refresh tokens, trigger login or assume the old grant is valid for
123
+ the new URL. Under the no-Keychain policy, do not launch macOS Claude OAuth login
124
+ until a supported non-Keychain path has been selected. Configuration parsing,
125
+ authorization and an authenticated `list_projects` call are separate proofs.
126
+
127
+ ## Recovery
128
+
129
+ Apply retains a private mode-0600 backup beside the config and reports its path.
130
+ It may contain existing configuration secrets: never share or commit it. To undo,
131
+ preview the current file again and use its new digest:
132
+
133
+ ```sh
134
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
135
+ --rollback /absolute/config.ecco-backup-SUFFIX \
136
+ --expected-digest CURRENT_DIGEST --client-closed
137
+ ```
138
+
139
+ Rollback works offline and refuses to overwrite later edits. An `inspect_required`
140
+ result means replacement occurred but filesystem durability could not be confirmed;
141
+ inspect the file and retained backup rather than retrying blindly. Ecco's advisory
142
+ lock coordinates its own processes, not arbitrary editors.
143
+
144
+ ## Verification
145
+
146
+ ```sh
147
+ uv run ruff check .
148
+ uv run ruff format --check .
149
+ uv run python -m unittest discover -s tests -v
150
+ uv build --wheel
151
+ # macOS only, with installed Claude and Codex; no auth or model calls:
152
+ ECCO_NATIVE_CLIENT_SMOKE=1 uv run python -m unittest discover -s tests -p test_native_clients.py -v
153
+ ```
154
+
155
+ The opt-in native checks use disposable configs and deny network, Keychain and
156
+ real-client configuration access at the OS boundary. They verify parsing, not
157
+ the normal-permission end-to-end login acceptance owned by ECO-1032.
@@ -0,0 +1,141 @@
1
+ # Ecco CLI
2
+
3
+ This package supplies signup-free agent boards and safe connector migration.
4
+ Python 3.11+ is required. Start from any directory without a GitHub checkout or
5
+ Ecco account:
6
+
7
+ ```sh
8
+ uvx heyecco new "Launch Acme" --task "Draft the launch plan"
9
+ ```
10
+
11
+ For local development:
12
+
13
+ ```sh
14
+ cd cli
15
+ uv sync
16
+ uv run ecco --help
17
+ ```
18
+
19
+ ## Start a board without an account
20
+
21
+ ```sh
22
+ heyecco new "Launch Acme" \
23
+ --task "Draft the launch plan" \
24
+ --task-body "## Outcome\nA reviewable launch plan exists."
25
+ ```
26
+
27
+ The public PyPI package is named `heyecco` because the `ecco` package name belongs
28
+ to an unrelated project. It installs both `heyecco` and `ecco` commands; existing
29
+ `--task-title` calls remain supported as an alias for `--task`.
30
+
31
+ The command creates caller-owned random worker, claim and recovery secrets, writes
32
+ them to a private state file before making the HTTPS request, and prints one JSON
33
+ receipt containing public board metadata plus the state-file and claim-link-file
34
+ paths. It never prints a raw secret or writes one into a repository or native
35
+ client configuration. The board accepts 100 task creations and agent access ends
36
+ after seven days; the private claim link remains usable for 30 more days.
37
+
38
+ If the first response is lost, the CLI retries the exact request once with the
39
+ stored recovery proof. If the outcome remains unknown, use the reported state:
40
+
41
+ ```sh
42
+ ecco resume --state /private/path/ATTEMPT.json --retry-bootstrap
43
+ ```
44
+
45
+ To run an agent with the saved bearer without printing it, place the agent command
46
+ after `--`. The child receives `ECCO_AGENT_TOKEN`, `ECCO_MCP_URL`, and
47
+ `ECCO_WORKSPACE_ID` in its environment:
48
+
49
+ ```sh
50
+ ecco resume --state /private/path/ATTEMPT.json -- your-agent-command
51
+ ```
52
+
53
+ The child or its supported MCP configuration should read the token from
54
+ `ECCO_AGENT_TOKEN`. The full human handoff URL is in the mode-0600
55
+ `*.claim-link.txt` file named in the receipt; share it only with the intended
56
+ owner.
57
+
58
+ ### Supported paths
59
+
60
+ | Platform | `ecco new` / `resume` | Private state |
61
+ | --- | --- | --- |
62
+ | macOS | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
63
+ | Linux | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
64
+ | Windows | Supported | `%LOCALAPPDATA%\ecco\boards`, protected by the user profile ACL |
65
+
66
+ Connector migration below remains POSIX-only because it uses advisory file locks.
67
+
68
+ ## Migrate an existing connector
69
+
70
+ Close the client before applying a change. Preview from the project where the
71
+ connector is used; select its existing name and scope, not a new identity:
72
+
73
+ ```sh
74
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project
75
+ ```
76
+
77
+ For Codex use `--client codex`. Claude supports `user`, `project`, and `local`;
78
+ Codex supports `user` and `project`. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are
79
+ honored. No credential-store files are read and no Keychain process is invoked.
80
+ Preview is read-only and prints a digest, not configuration contents.
81
+
82
+ Repeat with the returned digest to apply:
83
+
84
+ ```sh
85
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
86
+ --apply --expected-digest DIGEST_FROM_PREVIEW --client-closed
87
+ ```
88
+
89
+ Only the exact legacy Ecco Railway URL is replaced by
90
+ `https://api.heyecco.com/mcp`. Existing connector names, OAuth registration,
91
+ permissions, unrelated settings and formatting are retained. The canonical URL
92
+ is a no-op. Public OAuth metadata must match before a changed file is written.
93
+ Its check has a 12-second wall-clock deadline and never sends credentials.
94
+
95
+ Duplicated names across local config layers, selected profiles, known managed
96
+ files, unsafe files and changed previews stop migration. The command never edits
97
+ managed policy, trust settings or OAuth caches. On-disk discovery does not prove
98
+ the effective configuration of a running app: cloud policy, launch overrides and
99
+ desktop-specific behavior remain the native client's responsibility. If the
100
+ client still reports a different URL, inspect that client's active source; do not
101
+ delete other connectors or weaken resource validation.
102
+
103
+ Apply returns `reload_required`, **not connected**. Reload the connector. A native
104
+ OAuth client may require authorization for the canonical resource because cached
105
+ grants can be bound to the old resource or client registration. This command does
106
+ not transplant refresh tokens, trigger login or assume the old grant is valid for
107
+ the new URL. Under the no-Keychain policy, do not launch macOS Claude OAuth login
108
+ until a supported non-Keychain path has been selected. Configuration parsing,
109
+ authorization and an authenticated `list_projects` call are separate proofs.
110
+
111
+ ## Recovery
112
+
113
+ Apply retains a private mode-0600 backup beside the config and reports its path.
114
+ It may contain existing configuration secrets: never share or commit it. To undo,
115
+ preview the current file again and use its new digest:
116
+
117
+ ```sh
118
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
119
+ --rollback /absolute/config.ecco-backup-SUFFIX \
120
+ --expected-digest CURRENT_DIGEST --client-closed
121
+ ```
122
+
123
+ Rollback works offline and refuses to overwrite later edits. An `inspect_required`
124
+ result means replacement occurred but filesystem durability could not be confirmed;
125
+ inspect the file and retained backup rather than retrying blindly. Ecco's advisory
126
+ lock coordinates its own processes, not arbitrary editors.
127
+
128
+ ## Verification
129
+
130
+ ```sh
131
+ uv run ruff check .
132
+ uv run ruff format --check .
133
+ uv run python -m unittest discover -s tests -v
134
+ uv build --wheel
135
+ # macOS only, with installed Claude and Codex; no auth or model calls:
136
+ ECCO_NATIVE_CLIENT_SMOKE=1 uv run python -m unittest discover -s tests -p test_native_clients.py -v
137
+ ```
138
+
139
+ The opt-in native checks use disposable configs and deny network, Keychain and
140
+ real-client configuration access at the OS boundary. They verify parsing, not
141
+ the normal-permission end-to-end login acceptance owned by ECO-1032.
@@ -0,0 +1 @@
1
+ """Deterministic Ecco client setup; never a model or an auth server."""
@@ -0,0 +1,282 @@
1
+ """Signup-free board bootstrap with caller-owned, private recovery state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import secrets
10
+ import subprocess
11
+ import tempfile
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from uuid import uuid4
18
+
19
+ CONTRACT = "ecco.signup-free.v1"
20
+ DEFAULT_API = "https://api.heyecco.com"
21
+ MAX_RESPONSE = 262_144
22
+
23
+
24
+ class BootstrapFailure(ValueError):
25
+ pass
26
+
27
+
28
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
29
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
30
+ return None
31
+
32
+
33
+ _OPENER = urllib.request.build_opener(_NoRedirect())
34
+
35
+
36
+ def _sha(value: str) -> str:
37
+ return hashlib.sha256(value.encode()).hexdigest()
38
+
39
+
40
+ def _state_root() -> Path:
41
+ if os.name == "nt":
42
+ base = os.environ.get("LOCALAPPDATA")
43
+ if not base:
44
+ raise BootstrapFailure("LOCALAPPDATA is required for private state.")
45
+ return Path(base) / "ecco" / "boards"
46
+ base = os.environ.get("XDG_STATE_HOME")
47
+ root = Path(base) if base else Path.home() / ".local" / "state"
48
+ return root / "ecco" / "boards"
49
+
50
+
51
+ def _private_directory(path: Path) -> None:
52
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
53
+ if path.is_symlink() or not path.is_dir():
54
+ raise BootstrapFailure("State directory must be a real directory.")
55
+ if os.name != "nt":
56
+ status = path.stat()
57
+ if status.st_uid != os.getuid() or status.st_mode & 0o077:
58
+ raise BootstrapFailure("State directory must be private (mode 0700).")
59
+
60
+
61
+ def _write_private(path: Path, data: bytes) -> None:
62
+ _private_directory(path.parent)
63
+ fd, temporary = tempfile.mkstemp(prefix=".ecco-state-", dir=path.parent)
64
+ try:
65
+ if os.name != "nt":
66
+ os.fchmod(fd, 0o600)
67
+ with os.fdopen(fd, "wb") as stream:
68
+ stream.write(data)
69
+ stream.flush()
70
+ os.fsync(stream.fileno())
71
+ os.replace(temporary, path)
72
+ temporary = ""
73
+ if os.name != "nt" and path.stat().st_mode & 0o077:
74
+ raise BootstrapFailure("Private state permissions could not be enforced.")
75
+ finally:
76
+ if temporary:
77
+ Path(temporary).unlink(missing_ok=True)
78
+
79
+
80
+ def _read_private(path: Path) -> dict[str, Any]:
81
+ path = path.absolute()
82
+ if path != path.resolve() or not path.is_file():
83
+ raise BootstrapFailure("State must be a direct path to a regular file.")
84
+ status = path.stat()
85
+ if os.name != "nt" and (status.st_uid != os.getuid() or status.st_mode & 0o077):
86
+ raise BootstrapFailure("State file must be private (mode 0600).")
87
+ try:
88
+ value = json.loads(path.read_bytes())
89
+ except (OSError, ValueError, TypeError):
90
+ raise BootstrapFailure("State file is unavailable or invalid.") from None
91
+ if not isinstance(value, dict) or value.get("contract_version") != CONTRACT:
92
+ raise BootstrapFailure("State file uses an unsupported contract.")
93
+ return value
94
+
95
+
96
+ def _api_origin(value: str) -> str:
97
+ parsed = urllib.parse.urlsplit(value)
98
+ if (
99
+ parsed.scheme != "https"
100
+ or not parsed.hostname
101
+ or parsed.username
102
+ or parsed.password
103
+ or parsed.path not in {"", "/"}
104
+ or parsed.query
105
+ or parsed.fragment
106
+ ):
107
+ raise BootstrapFailure("API base must be an exact HTTPS origin.")
108
+ return value.rstrip("/")
109
+
110
+
111
+ def _request_json(
112
+ url: str, payload: dict[str, Any], *, attempt_id: str, recovery: str | None
113
+ ) -> tuple[int, dict[str, Any]]:
114
+ headers = {
115
+ "Content-Type": "application/json",
116
+ "Accept": "application/json",
117
+ "User-Agent": "ecco-cli/0.1",
118
+ "Idempotency-Key": attempt_id,
119
+ }
120
+ if recovery is not None:
121
+ headers["Ecco-Recovery"] = recovery
122
+ request = urllib.request.Request(
123
+ url, data=json.dumps(payload, separators=(",", ":")).encode(), headers=headers
124
+ )
125
+ try:
126
+ response = _OPENER.open(request, timeout=15)
127
+ except urllib.error.HTTPError as exc:
128
+ body = exc.read(MAX_RESPONSE + 1)
129
+ if len(body) > MAX_RESPONSE:
130
+ raise BootstrapFailure(f"Bootstrap failed with HTTP {exc.code}.") from None
131
+ try:
132
+ document = json.loads(body)
133
+ except (ValueError, TypeError):
134
+ document = {}
135
+ code = document.get("code") if isinstance(document, dict) else None
136
+ if not isinstance(code, str) or re.fullmatch(r"[a-z0-9_]{1,64}", code) is None:
137
+ code = None
138
+ raise BootstrapFailure(
139
+ f"Bootstrap failed with HTTP {exc.code}"
140
+ + (f" ({code})." if code is not None else ".")
141
+ ) from None
142
+ with response:
143
+ body = response.read(MAX_RESPONSE + 1)
144
+ if len(body) > MAX_RESPONSE:
145
+ raise BootstrapFailure("Bootstrap returned an oversized response.")
146
+ try:
147
+ document = json.loads(body)
148
+ except (ValueError, TypeError):
149
+ raise BootstrapFailure("Bootstrap returned invalid JSON.") from None
150
+ if not isinstance(document, dict) or document.get("contract_version") != CONTRACT:
151
+ raise BootstrapFailure("Bootstrap returned an unsupported contract.")
152
+ return response.status, document
153
+
154
+
155
+ def create(
156
+ *,
157
+ board_name: str,
158
+ task_title: str | None,
159
+ task_body: str | None,
160
+ client_name: str,
161
+ client_version: str,
162
+ api_base: str = DEFAULT_API,
163
+ state_directory: Path | None = None,
164
+ ) -> dict[str, Any]:
165
+ api_base = _api_origin(api_base)
166
+ attempt_id = str(uuid4())
167
+ # Worker authentication intentionally reserves exact 64-character hex
168
+ # credentials; other bearer shapes are routed through OAuth.
169
+ worker = secrets.token_hex(32)
170
+ capability = secrets.token_urlsafe(32)
171
+ recovery = secrets.token_urlsafe(32)
172
+ payload: dict[str, Any] = {
173
+ "contract_version": CONTRACT,
174
+ "board_name": board_name,
175
+ "client": {"name": client_name, "version": client_version},
176
+ "worker_token_sha256": _sha(worker),
177
+ "claim_capability_sha256": _sha(capability),
178
+ "recovery_secret_sha256": _sha(recovery),
179
+ }
180
+ if task_title is not None:
181
+ payload["first_task"] = {"title": task_title, "body": task_body}
182
+ selected_root = state_directory or _state_root()
183
+ if selected_root.is_symlink():
184
+ raise BootstrapFailure("State directory cannot be a symbolic link.")
185
+ root = selected_root.expanduser().resolve()
186
+ state_path = root / f"{attempt_id}.json"
187
+ state: dict[str, Any] = {
188
+ "contract_version": CONTRACT,
189
+ "attempt_id": attempt_id,
190
+ "api_base": api_base,
191
+ "request": payload,
192
+ "worker_token": worker,
193
+ "claim_capability": capability,
194
+ "recovery_secret": recovery,
195
+ "manifest": None,
196
+ }
197
+ _write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
198
+ try:
199
+ _, manifest = _request_json(
200
+ api_base + "/agent-start",
201
+ payload,
202
+ attempt_id=attempt_id,
203
+ recovery=None,
204
+ )
205
+ except (urllib.error.URLError, TimeoutError):
206
+ try:
207
+ _, manifest = _request_json(
208
+ api_base + "/agent-start",
209
+ payload,
210
+ attempt_id=attempt_id,
211
+ recovery=recovery,
212
+ )
213
+ except (urllib.error.URLError, TimeoutError) as exc:
214
+ raise BootstrapFailure(
215
+ f"Bootstrap outcome is unknown; retry with state {state_path}."
216
+ ) from exc
217
+ state["manifest"] = manifest
218
+ claim_base = manifest.get("claim", {}).get("url_base")
219
+ if not isinstance(claim_base, str):
220
+ raise BootstrapFailure("Bootstrap response omitted the claim URL.")
221
+ claim_file = root / f"{attempt_id}.claim-link.txt"
222
+ full_claim = claim_base + "#capability=" + urllib.parse.quote(capability, safe="")
223
+ _write_private(claim_file, (full_claim + "\n").encode())
224
+ _write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
225
+ return _public_result(state_path, claim_file, manifest)
226
+
227
+
228
+ def retry(state_path: Path) -> dict[str, Any]:
229
+ state = _read_private(state_path)
230
+ manifest = state.get("manifest")
231
+ if manifest is None:
232
+ _, manifest = _request_json(
233
+ _api_origin(str(state["api_base"])) + "/agent-start",
234
+ state["request"],
235
+ attempt_id=str(state["attempt_id"]),
236
+ recovery=str(state["recovery_secret"]),
237
+ )
238
+ state["manifest"] = manifest
239
+ _write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
240
+ claim_file = state_path.with_name(state_path.stem + ".claim-link.txt")
241
+ claim_base = manifest["claim"]["url_base"]
242
+ value = (
243
+ claim_base
244
+ + "#capability="
245
+ + urllib.parse.quote(str(state["claim_capability"]), safe="")
246
+ )
247
+ _write_private(claim_file, (value + "\n").encode())
248
+ return _public_result(state_path, claim_file, manifest)
249
+
250
+
251
+ def run(state_path: Path, command: list[str]) -> int:
252
+ if not command:
253
+ raise BootstrapFailure("Resume requires a command after --.")
254
+ state = _read_private(state_path)
255
+ manifest = state.get("manifest")
256
+ if not isinstance(manifest, dict):
257
+ raise BootstrapFailure("Bootstrap is incomplete; retry it first.")
258
+ environment = os.environ.copy()
259
+ environment.update(
260
+ {
261
+ "ECCO_AGENT_TOKEN": str(state["worker_token"]),
262
+ "ECCO_MCP_URL": str(manifest["connection"]["mcp_url"]),
263
+ "ECCO_WORKSPACE_ID": str(manifest["board"]["workspace_id"]),
264
+ }
265
+ )
266
+ return subprocess.run(command, env=environment, check=False).returncode
267
+
268
+
269
+ def _public_result(
270
+ state_path: Path, claim_file: Path, manifest: dict[str, Any]
271
+ ) -> dict[str, Any]:
272
+ return {
273
+ "status": "ready",
274
+ "board": manifest.get("board"),
275
+ "first_task": manifest.get("first_task"),
276
+ "connection": manifest.get("connection"),
277
+ "limits": manifest.get("limits"),
278
+ "claim_url_base": manifest.get("claim", {}).get("url_base"),
279
+ "state_file": str(state_path),
280
+ "claim_link_file": str(claim_file),
281
+ "secrets_printed": False,
282
+ }