superwait 0.2.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.
superwait/SKILL.md ADDED
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: superwait
3
+ description: Wait for agents and external conditions together, with any/all/quorum, early wake conditions, long deadlines, and useful partial results.
4
+ ---
5
+
6
+ # Superwait
7
+
8
+ Express when you want to wake. You choose the workflow; native tools remain
9
+ available for spawning, steering, or collecting additional results.
10
+
11
+ ## One request
12
+
13
+ Call wait_for with a request. For example, wait for two reviewers, wake early
14
+ on a blocker, and stop after two hours:
15
+
16
+ ~~~json
17
+ {
18
+ "agents": ["/root/reviewer_a", "/root/reviewer_b", "/root/reviewer_c"],
19
+ "session": "parent-session-from-startup-context",
20
+ "mode": "quorum",
21
+ "quorum": 2,
22
+ "wake_on": [{"kind": "signal", "key": "review-42/blocker", "state": "blocked", "label": "Review blocked"}],
23
+ "timeout": "2h"
24
+ }
25
+ ~~~
26
+
27
+ agents accepts native IDs and Codex task paths directly. The installed host
28
+ is the default provider; set provider for a different host. Hooks must be
29
+ installed before spawning. Codex paths become resolvable when its stop hook
30
+ supplies the worker metadata. Codex task paths require the parent session from
31
+ the SessionStart context supplied by setup; this prevents matching an old
32
+ conversation with the same names. The Codex CLI fallback fills it from the
33
+ calling thread's environment. Native UUIDs need no extra scope.
34
+ list_agents is available for discovery or troubleshooting, not a prerequisite.
35
+
36
+ mode is all by default, or any, or quorum with a count. Add targets
37
+ for other conditions; they and agents count toward the same threshold.
38
+ wake_on returns early independently of that threshold. Add label to any
39
+ condition to make its meaning explicit in the result.
40
+
41
+ ~~~json
42
+ {"kind":"file","path":"/workspace/ci-failed.json","label":"CI failed"}
43
+ {"kind":"http","url":"http://localhost:8080/health","status":200}
44
+ {"kind":"signal","key":"build-42","state":"ready"}
45
+ {"kind":"file","path":"/workspace/server.log","event":"contains","text":"Ready"}
46
+ {"kind":"command","argv":["python3","/workspace/check_ci.py"],"exit_code":0}
47
+ ~~~
48
+
49
+ Files support exists (default), missing, changed, and literal contains.
50
+ HTTP matches the status code. Command probes run repeatedly without a shell;
51
+ use an observational check, not the build/deploy itself. Prefer absolute paths.
52
+ interval controls polling, default 1 second; increase it for remote services.
53
+
54
+ ## Use the result
55
+
56
+ - status: matched, interrupted, timed_out, or error.
57
+ - reason and triggered: why the wait ended and which early condition fired.
58
+ - ready: observed results and event cursors; pending: remaining work/errors.
59
+ - continue_wait: a ready-to-use request for the remaining work, or null.
60
+
61
+ After handling an interruption, pass continue_wait back as the next request
62
+ if you want to continue. It retains the absolute deadline and file-change
63
+ baselines, carries the remaining quorum, and consumes delivered event signals.
64
+ Persistent conditions such as an existing blocker file must clear or be removed
65
+ from wake_on before proceeding. After reaching a quorum, continuation waits
66
+ for all remaining work. It does not repeat reports you already received.
67
+
68
+ timeout accepts 30s, 10m, 2h, or 1d. A timezone-qualified deadline
69
+ overrides it. A timed-out continuation remains expired; choose a new deadline
70
+ explicitly if you want more time. For a resumed worker, use an agent target
71
+ with after set to its last returned cursor to await a new response:
72
+
73
+ ~~~json
74
+ {"session":"parent-session","targets":[{"kind":"agent","provider":"codex","id":"/root/reviewer_a","after":42}],"timeout":"2h"}
75
+ ~~~
76
+
77
+ A stopped response is not proof the assignment passed; read its result. Another
78
+ stop hook can continue a worker. Unknown workers stay pending. Cursor stop
79
+ hooks sometimes lack enough identity: use an explicit unique outcome signal
80
+ for indistinguishable concurrent assignments. Signal keys should identify the
81
+ particular task; existing matching signals count unless after excludes them.
82
+ Use signal(key, state, data) for explicit checkpoints and outcomes.
83
+
84
+ ## Long waits and cancellation
85
+
86
+ Setup configures Codex/Claude's MCP timeout for 24 hours by default. For waits
87
+ beyond the configured host limit, or long Cursor waits, use the same request
88
+ with superwait wait --request wait.json --output result.json in the host's
89
+ background terminal. The CLI emits one final result; normal host notifications
90
+ or native collection retrieve it. It does not wake a closed conversation.
91
+
92
+ Cancel the MCP call or send Ctrl-C to the CLI to stop waiting. The observed
93
+ workers continue; only the wait's own probes are cleaned up. The process and
94
+ machine must stay alive. CLI exits: 0 matched/interrupted, 124 timeout,
95
+ 130 cancelled, 2 error. details: true adds raw observations for diagnosis.
superwait/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Conditional waits with no model calls between observations."""
superwait/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
superwait/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """A shell interface and stdio MCP server sharing one wait engine."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .store import Store
12
+
13
+
14
+ def parser():
15
+ root = argparse.ArgumentParser(description="Wait for conditions without repeated model calls.")
16
+ root.add_argument("--db", type=Path, help="Local event database (or SUPERWAIT_DB).")
17
+ root.add_argument("--provider", dest="default_provider", choices=["codex", "claude", "cursor"],
18
+ help="Default host for the agents shorthand.")
19
+ commands = root.add_subparsers(dest="command", required=True)
20
+ wait = commands.add_parser("wait", help="Wait once; print one final JSON result.")
21
+ wait.add_argument("--request", default="-", help="JSON request file, or - for stdin.")
22
+ wait.add_argument("--timeout", help="Override request duration: 30s, 10m, 2h, 1d.")
23
+ wait.add_argument("--output", type=Path, help="Also atomically save the final result here.")
24
+ signal = commands.add_parser("signal", help="Publish a local checkpoint or outcome.")
25
+ signal.add_argument("key")
26
+ signal.add_argument("--state", default="ready")
27
+ signal.add_argument("--data", default="{}", help="Small JSON object.")
28
+ agents = commands.add_parser("agents", help="List recent hook-observed subagents.")
29
+ agents.add_argument("provider", choices=["codex", "claude", "cursor"])
30
+ agents.add_argument("--session")
31
+ hook = commands.add_parser("hook", help="Host lifecycle adapter; reads JSON on stdin.")
32
+ hook.add_argument("provider", choices=["codex", "claude", "cursor"])
33
+ commands.add_parser("serve", help="Serve the MCP API over stdio.")
34
+ setup = commands.add_parser("setup", help="Configure hooks, MCP, and the skill in a project.")
35
+ setup.add_argument("provider", choices=["codex", "claude", "cursor"])
36
+ setup.add_argument("--project", required=True, type=Path)
37
+ setup.add_argument("--max-wait", default="24h", help="Host tool timeout, where supported.")
38
+ prune = commands.add_parser("prune", help="Delete old local observations.")
39
+ prune.add_argument("--days", type=float, default=30)
40
+ return root
41
+
42
+
43
+ def main():
44
+ args = parser().parse_args()
45
+ code = 0
46
+ try:
47
+ if args.command == "setup":
48
+ from .setup import configure
49
+ result = configure(args.provider, args.project, args.db, args.max_wait)
50
+ else:
51
+ store = Store(args.db)
52
+ if args.command == "wait":
53
+ from .engine import wait_for
54
+ from .models import Agent, WaitRequest
55
+ raw = json.loads(sys.stdin.read() if args.request == "-" else Path(args.request).read_text())
56
+ if not isinstance(raw, dict):
57
+ raise ValueError("wait request must be a JSON object")
58
+ if args.default_provider and raw.get("provider") is None:
59
+ raw["provider"] = args.default_provider
60
+ if args.timeout:
61
+ raw["timeout"] = args.timeout
62
+ raw.pop("deadline", None)
63
+ request = WaitRequest.model_validate(raw)
64
+ if (request.provider or "codex") == "codex" and request.session is None and os.environ.get("CODEX_THREAD_ID"):
65
+ if any(isinstance(t, Agent) and t.provider == "codex" and t.id.startswith("/")
66
+ and t.session is None for t in [*request.conditions, *request.wake_conditions]):
67
+ request = request.model_copy(update={"session": os.environ["CODEX_THREAD_ID"]})
68
+ result = asyncio.run(wait_for(request, store))
69
+ code = {"timed_out": 124, "error": 2}.get(result["status"], 0)
70
+ elif args.command == "signal":
71
+ data = json.loads(args.data)
72
+ if not isinstance(data, dict) or not args.key or not args.state:
73
+ raise ValueError("signal requires nonempty key/state and object data")
74
+ result = {"seq": store.emit("signal", args.key, args.state, data=data)}
75
+ elif args.command == "agents":
76
+ result = {"agents": store.agents(args.provider, args.session), "limit": 50}
77
+ elif args.command == "hook":
78
+ from .hooks import record_hook
79
+ result = record_hook(args.provider, json.load(sys.stdin), store)
80
+ elif args.command == "serve":
81
+ from .server import serve
82
+ serve(store, args.default_provider or "codex")
83
+ return
84
+ elif args.command == "prune":
85
+ if args.days <= 0:
86
+ raise ValueError("days must be positive")
87
+ result = {"deleted": store.prune(args.days)}
88
+ except KeyboardInterrupt:
89
+ result, code = {"status": "cancelled"}, 130
90
+ except (ValueError, OSError, sqlite3.Error) as exc:
91
+ if args.command == "hook":
92
+ # A recorder never asks the host to retry, continue, or block an agent.
93
+ print(f"superwait hook: {exc}", file=sys.stderr)
94
+ result = {"permission": "allow"} if args.provider == "cursor" else {}
95
+ else:
96
+ result, code = {"status": "error", "error": str(exc)}, 2
97
+ rendered = json.dumps(result, ensure_ascii=False)
98
+ if args.command == "wait" and args.output:
99
+ import tempfile
100
+ args.output.parent.mkdir(parents=True, exist_ok=True)
101
+ fd, temporary = tempfile.mkstemp(prefix=".superwait-", dir=args.output.parent)
102
+ try:
103
+ with os.fdopen(fd, "w") as f:
104
+ f.write(rendered + "\n")
105
+ os.replace(temporary, args.output)
106
+ finally:
107
+ Path(temporary).unlink(missing_ok=True)
108
+ print(rendered)
109
+ raise SystemExit(code)
superwait/engine.py ADDED
@@ -0,0 +1,244 @@
1
+ """Concurrent observations, monotonic deadlines, and cancellation."""
2
+
3
+ import asyncio
4
+ import os
5
+ import signal
6
+ import sqlite3
7
+ import stat
8
+ import time
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+
12
+ import httpx2 as httpx
13
+
14
+ from .models import Agent, Command, File, HTTP, Signal, WaitRequest
15
+ from .store import AmbiguousAgent, Store
16
+
17
+
18
+ def file_stamp(path):
19
+ try:
20
+ stat = path.stat()
21
+ return [stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns]
22
+ except FileNotFoundError:
23
+ return None
24
+
25
+
26
+ def baseline_for(target):
27
+ if not isinstance(target, File) or target.event != "changed":
28
+ return None
29
+ if target.since is not None:
30
+ return None if target.since == "missing" else target.since
31
+ return file_stamp(Path(target.path).expanduser())
32
+
33
+
34
+ async def command_result(target):
35
+ spawning = asyncio.create_task(asyncio.create_subprocess_exec(
36
+ *target.argv, cwd=target.cwd, stdout=asyncio.subprocess.PIPE,
37
+ stderr=asyncio.subprocess.PIPE, start_new_session=(os.name == "posix"),
38
+ ))
39
+ cancelled = False
40
+ try:
41
+ process = await asyncio.shield(spawning)
42
+ except asyncio.CancelledError:
43
+ # Obtain the handle even if another condition wins during process creation.
44
+ process = await spawning
45
+ cancelled = True
46
+
47
+ async def tail(stream):
48
+ value = b""
49
+ while chunk := await stream.read(8192):
50
+ value = (value + chunk)[-4096:]
51
+ return value.decode(errors="replace")
52
+
53
+ readers = [asyncio.create_task(tail(process.stdout)), asyncio.create_task(tail(process.stderr))]
54
+ finished = False
55
+ try:
56
+ if cancelled:
57
+ raise asyncio.CancelledError
58
+ async with asyncio.timeout(target.probe_timeout):
59
+ code = await process.wait()
60
+ stdout, stderr = await asyncio.gather(*readers)
61
+ finished = True
62
+ return {"matched": code == target.exit_code, "exit_code": code, "stdout": stdout, "stderr": stderr}
63
+ finally:
64
+ # Only terminate probes we launched; never the agents or jobs being observed.
65
+ if not finished:
66
+ try:
67
+ if os.name == "posix":
68
+ os.killpg(process.pid, signal.SIGKILL)
69
+ elif process.returncode is None:
70
+ process.kill()
71
+ except ProcessLookupError:
72
+ pass
73
+ await process.wait()
74
+ for reader in readers:
75
+ if not reader.done():
76
+ reader.cancel()
77
+ await asyncio.gather(*readers, return_exceptions=True)
78
+
79
+
80
+ async def observe(target, store, client, baseline=None):
81
+ try:
82
+ if isinstance(target, (Agent, Signal)):
83
+ if isinstance(target, Agent):
84
+ event = store.agent(target.id, target.provider, target.session)
85
+ matched = event and event["state"] in target.states and event["seq"] > target.after
86
+ else:
87
+ event = store.signal(target.key, target.state, target.after)
88
+ matched = bool(event)
89
+ return {"matched": bool(matched), "state": event["state"] if event else "unknown", "event": event}
90
+ if isinstance(target, File):
91
+ path = Path(target.path).expanduser().absolute()
92
+ stamp = file_stamp(path)
93
+ if target.event == "contains":
94
+ if stamp is None:
95
+ return {"matched": False, "state": "missing"}
96
+ needle = target.text.encode()
97
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
98
+ with os.fdopen(fd, "rb") as f:
99
+ if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):
100
+ return {"matched": False, "error": "contains requires a regular file"}
101
+ overlap = b""
102
+ matched = False
103
+ while block := f.read(65536):
104
+ data = overlap + block
105
+ if needle in data:
106
+ matched = True
107
+ break
108
+ overlap = data[-(len(needle) - 1):] if len(needle) > 1 else b""
109
+ await asyncio.sleep(0) # Large logs remain interruptible.
110
+ else:
111
+ matched = {"exists": stamp is not None, "missing": stamp is None, "changed": stamp != baseline}[target.event]
112
+ return {"matched": matched, "path": str(path), "stamp": stamp}
113
+ if isinstance(target, HTTP):
114
+ async with client.stream("GET", target.url) as response:
115
+ return {"matched": response.status_code == target.status, "status_code": response.status_code}
116
+ if isinstance(target, Command):
117
+ return await command_result(target)
118
+ except AmbiguousAgent as exc:
119
+ return {"matched": False, "state": "ambiguous", "error": str(exc)}
120
+ except (OSError, ValueError, sqlite3.Error, httpx.HTTPError, TimeoutError) as exc:
121
+ # Connection failures and nonzero probes are observations, not successful conditions.
122
+ return {"matched": False, "error": str(exc)[:500] or type(exc).__name__}
123
+ raise TypeError("unsupported target")
124
+
125
+
126
+ async def wait_for(request: WaitRequest, store: Store, progress=None):
127
+ started = time.monotonic()
128
+ duration = request.duration()
129
+ end = started + duration
130
+ deadline = request.deadline.isoformat() if request.deadline else (datetime.now(timezone.utc) + timedelta(seconds=duration)).isoformat()
131
+ primary = request.conditions
132
+ wake_conditions = request.wake_conditions
133
+ targets = [*primary, *wake_conditions]
134
+ baselines = [baseline_for(t) for t in targets]
135
+ results = [{"matched": False, "state": "pending"} for _ in targets]
136
+ needed = len(primary) if request.mode == "all" else (request.quorum if request.mode == "quorum" else 1)
137
+ checks = 0
138
+ async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
139
+ changed = asyncio.Event()
140
+
141
+ async def probe(i):
142
+ nonlocal checks
143
+ while True:
144
+ checks += 1
145
+ results[i] = await observe(targets[i], store, client, baselines[i])
146
+ changed.set()
147
+ await asyncio.sleep(request.interval)
148
+
149
+ async def keepalive():
150
+ while True:
151
+ await asyncio.sleep(15)
152
+ await progress(time.monotonic() - started, duration)
153
+
154
+ def card(t, r):
155
+ identity = {Agent: "id", Signal: "key", File: "path", HTTP: "url", Command: "argv"}[type(t)]
156
+ value = getattr(t, identity)
157
+ c = {"label": t.label or value, "kind": t.kind, identity: value,
158
+ "state": r.get("state", "matched" if r["matched"] else "pending")}
159
+ if isinstance(t, Agent):
160
+ c["provider"] = t.provider
161
+ if e := r.get("event"):
162
+ c["cursor"] = e["seq"]
163
+ if isinstance(t, Agent):
164
+ c.update(id=e["key"], handle=t.id, session=e["session"])
165
+ if r["matched"]:
166
+ data = e["data"]
167
+ field = "report" if data.get("report") else "summary"
168
+ c["result"] = data.get(field, "") if isinstance(t, Agent) else data
169
+ if isinstance(t, Agent) and data.get(field + "_truncated"):
170
+ c["result_truncated"] = True
171
+ if data.get("transcript_path"):
172
+ c["transcript_path"] = data["transcript_path"]
173
+ elif isinstance(t, (Agent, Signal)) and e["seq"] <= t.after:
174
+ c["state"] = "awaiting_new_event"
175
+ for key in ("error", "status_code", "exit_code", "stdout", "stderr"):
176
+ if key in r and r[key] != "":
177
+ c[key] = r[key]
178
+ return c
179
+
180
+ def continuing(t, r, baseline, wake=False):
181
+ data = t.model_dump(exclude_none=True, exclude_defaults=True)
182
+ if isinstance(t, File) and t.event == "changed":
183
+ data["since"] = (r.get("stamp") if wake and r["matched"] else baseline) or "missing"
184
+ if isinstance(t, (Agent, Signal)) and wake and r["matched"]:
185
+ data["after"] = r["event"]["seq"]
186
+ return data
187
+
188
+ def outcome(status, error=None):
189
+ main = list(zip(primary, results, baselines))
190
+ wake = list(zip(wake_conditions, results[len(primary):], baselines[len(primary):]))
191
+ ready = [card(t, r) for t, r, _ in main if r["matched"]]
192
+ pending = [card(t, r) for t, r, _ in main if not r["matched"]]
193
+ triggered = [card(t, r) for t, r, _ in wake if r["matched"]]
194
+ reason = {
195
+ "matched": f"{len(ready)} of {len(primary)} targets ready; {needed} required.",
196
+ "interrupted": "Wake condition matched: " + ", ".join(str(c["label"]) for c in triggered),
197
+ "timed_out": f"Deadline reached with {len(pending)} targets still pending.",
198
+ "error": error or "Resolve the reported target error before waiting again.",
199
+ }[status]
200
+ continuation = None
201
+ if pending and status != "error":
202
+ remaining = needed - len(ready)
203
+ continuation = {"targets": [continuing(t, r, b) for t, r, b in main if not r["matched"]],
204
+ "mode": "all", "deadline": deadline, "interval": request.interval,
205
+ "wake_on": [continuing(t, r, b, True) for t, r, b in wake]}
206
+ if 0 < remaining < len(pending):
207
+ continuation.update(mode="quorum", quorum=remaining)
208
+ result = {"status": status, "reason": reason, "ready": ready, "pending": pending,
209
+ "triggered": triggered, "progress": {"ready": len(ready), "required": needed, "total": len(primary)},
210
+ "deadline": deadline, "elapsed_seconds": round(time.monotonic() - started, 3),
211
+ "continue_wait": continuation}
212
+ if request.details:
213
+ result.update(checks=checks,
214
+ targets=[{"target": t.model_dump(exclude_none=True), **r} for t, r, _ in main],
215
+ wake_on=[{"target": t.model_dump(exclude_none=True), **r} for t, r, _ in wake])
216
+ return result
217
+
218
+ try:
219
+ async with asyncio.timeout_at(end):
220
+ async with asyncio.TaskGroup() as group:
221
+ workers = [group.create_task(probe(i)) for i in range(len(targets))]
222
+ if progress:
223
+ workers.append(group.create_task(keepalive()))
224
+ try:
225
+ while True:
226
+ await changed.wait()
227
+ changed.clear()
228
+ # Each condition polls independently: a slow probe must
229
+ # not delay checking a fast-changing wake condition.
230
+ if any(r.get("state") == "ambiguous" for r in results):
231
+ return outcome("error")
232
+ identities = [(r["event"]["provider"], r["event"]["session"], r["event"]["key"])
233
+ for t, r in zip(primary, results) if isinstance(t, Agent) and r.get("event")]
234
+ if len(identities) != len(set(identities)):
235
+ return outcome("error", "Two targets resolve to the same agent; remove the duplicate handle.")
236
+ if any(r["matched"] for r in results[len(primary):]):
237
+ return outcome("interrupted")
238
+ if sum(r["matched"] for r in results[:len(primary)]) >= needed:
239
+ return outcome("matched")
240
+ finally:
241
+ for worker in workers:
242
+ worker.cancel()
243
+ except TimeoutError:
244
+ return outcome("timed_out")
superwait/hooks.py ADDED
@@ -0,0 +1,102 @@
1
+ """Translate documented host lifecycle payloads into local observations."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .store import Store
8
+
9
+
10
+ def task_key(payload):
11
+ value = [payload.get("task"), payload.get("subagent_type")]
12
+ return hashlib.sha256(json.dumps(value, ensure_ascii=False).encode()).hexdigest()
13
+
14
+
15
+ def codex_aliases(payload):
16
+ """Read only the metadata header of the exact transcript supplied by Codex.
17
+
18
+ Codex 0.153 exposes task paths in session metadata but not lifecycle hooks.
19
+ UUID and parent checks prevent correlating a different worker's transcript.
20
+ No history scan, body parsing, model call, or host-state mutation is needed.
21
+ """
22
+ path = payload.get("agent_transcript_path")
23
+ if not path:
24
+ return []
25
+ try:
26
+ with Path(path).open("rb") as f:
27
+ line = f.readline()
28
+ record = json.loads(line)
29
+ meta = record.get("payload", {})
30
+ source = meta.get("source", {})
31
+ spawn = source.get("subagent", {}).get("thread_spawn", {}) if isinstance(source, dict) else {}
32
+ if (record.get("type") != "session_meta" or meta.get("id") != payload.get("agent_id")
33
+ or spawn.get("parent_thread_id") != payload.get("session_id")):
34
+ return []
35
+ path = spawn.get("agent_path")
36
+ return [path] if isinstance(path, str) and path else []
37
+ except (OSError, ValueError, AttributeError):
38
+ return []
39
+
40
+
41
+ def record_hook(provider: str, payload: dict, store: Store):
42
+ if not isinstance(payload, dict):
43
+ raise ValueError("hook payload must be a JSON object")
44
+ event = payload.get("hook_event_name", "")
45
+ name = event.lower()
46
+ session = payload.get("session_id") or ""
47
+ if provider == "codex" and name == "sessionstart" and session:
48
+ return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext":
49
+ f"Superwait parent session: {session}. When waiting on Codex task paths such as /root/reviewer, "
50
+ "set request.session to this value. Native UUIDs do not require it."}}
51
+ agent = payload.get("agent_id")
52
+ data = {"agent_type": payload.get("agent_type", ""), "source": event}
53
+ state = None
54
+ if provider == "cursor":
55
+ session = payload.get("parent_conversation_id") or payload.get("conversation_id") or session
56
+ agent = payload.get("subagent_id")
57
+ data["agent_type"] = payload.get("subagent_type", "")
58
+ data["task_key"] = task_key(payload)
59
+ if name == "subagentstop" and not agent:
60
+ # Cursor's documented stop payload omits the start ID. Never guess
61
+ # between concurrent identical tasks, even when one finished first.
62
+ candidates = [a for a in store.agents("cursor", session, None)
63
+ if a["state"] == "running" and a["data"].get("task_key") == data["task_key"]]
64
+ if payload.get("task") and len(candidates) == 1:
65
+ agent = candidates[0]["key"]
66
+ else:
67
+ store.emit("diagnostic", "cursor-unmatched-stop", "unmatched", provider="cursor", session=session,
68
+ data={"reason": "stop event has no unambiguous subagent ID; use an explicit signal", "candidates": len(candidates)})
69
+ return {}
70
+ summary = str(payload.get("summary") or "")
71
+ data["summary"] = summary[:4096]
72
+ data["summary_truncated"] = len(summary) > 4096
73
+ if path := payload.get("agent_transcript_path"):
74
+ data["transcript_path"] = path
75
+ state = {"completed": "stopped", "error": "error", "aborted": "aborted"}.get(payload.get("status"))
76
+ else:
77
+ summary = str(payload.get("last_assistant_message") or "")
78
+ data["summary"] = summary[:4096]
79
+ data["summary_truncated"] = len(summary) > 4096
80
+ if path := payload.get("agent_transcript_path"):
81
+ data["transcript_path"] = path
82
+ if name == "subagentstart":
83
+ state = "running"
84
+ elif name == "subagentstop":
85
+ state = state or "stopped"
86
+ elif name == "posttooluse" and payload.get("tool_name") == "SubagentHandback" and agent:
87
+ # A delivered report is not itself a completion event.
88
+ prior = store.latest("agent", agent, provider=provider, session=session)
89
+ state = prior["state"] if prior else "running"
90
+ report = str(payload.get("tool_input", {}).get("message") or "")
91
+ data = {**(prior["data"] if prior else {}), "report": report[:8192], "report_truncated": len(report) > 8192}
92
+ else:
93
+ return {}
94
+ if not session or not agent:
95
+ raise ValueError("lifecycle event is missing its session or agent ID")
96
+ prior = store.latest("agent", agent, provider=provider, session=session)
97
+ if provider == "codex":
98
+ data["aliases"] = codex_aliases(payload) or (prior["data"].get("aliases", []) if prior else [])
99
+ if name != "subagentstart" and prior:
100
+ data = {**prior["data"], **data}
101
+ store.emit("agent", agent, state, provider=provider, session=session, data=data)
102
+ return {"permission": "allow"} if provider == "cursor" and name == "subagentstart" else {}
superwait/models.py ADDED
@@ -0,0 +1,128 @@
1
+ """The same validated request schema is used by MCP, Python, and the CLI."""
2
+
3
+ import math
4
+ import re
5
+ from datetime import datetime, timezone
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+
10
+
11
+ class Model(BaseModel):
12
+ model_config = ConfigDict(extra="forbid")
13
+
14
+
15
+ class Condition(Model):
16
+ label: str | None = Field(default=None, description="Optional name used in the result, e.g. CI failed.")
17
+
18
+
19
+ class Agent(Condition):
20
+ kind: Literal["agent"]
21
+ provider: Literal["codex", "claude", "cursor"]
22
+ id: str = Field(min_length=1)
23
+ session: str | None = None
24
+ states: list[Literal["running", "stopped", "error", "aborted"]] = Field(
25
+ default_factory=lambda: ["stopped", "error", "aborted"], min_length=1
26
+ )
27
+ after: int = Field(default=0, ge=0, description="Only match an event newer than this cursor.")
28
+
29
+
30
+ class Signal(Condition):
31
+ kind: Literal["signal"]
32
+ key: str = Field(min_length=1)
33
+ state: str | None = None
34
+ after: int = Field(default=0, ge=0)
35
+
36
+
37
+ class File(Condition):
38
+ kind: Literal["file"]
39
+ path: str = Field(min_length=1)
40
+ event: Literal["exists", "missing", "changed", "contains"] = "exists"
41
+ text: str | None = None
42
+ since: list[int] | Literal["missing"] | None = Field(default=None,
43
+ description="Returned in continue_wait to preserve a changed-file baseline. Omit for a new wait.")
44
+
45
+ @model_validator(mode="after")
46
+ def check_text(self):
47
+ if self.event == "contains" and not self.text:
48
+ raise ValueError("file contains requires nonempty text")
49
+ return self
50
+
51
+
52
+ class HTTP(Condition):
53
+ kind: Literal["http"]
54
+ url: str = Field(pattern=r"^https?://")
55
+ status: int = Field(default=200, ge=100, le=599)
56
+
57
+
58
+ class Command(Condition):
59
+ kind: Literal["command"]
60
+ argv: list[str] = Field(min_length=1, description="An observational command, repeated without a shell.")
61
+ cwd: str | None = None
62
+ exit_code: int = 0
63
+ probe_timeout: float = Field(default=10, gt=0, allow_inf_nan=False)
64
+
65
+
66
+ Target = Annotated[Agent | Signal | File | HTTP | Command, Field(discriminator="kind")]
67
+
68
+
69
+ def seconds(value: str) -> float:
70
+ match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\s*", value)
71
+ if not match:
72
+ raise ValueError("duration must be a number with ms, s, m, h, or d (for example 2h)")
73
+ result = float(match[1]) * {None: 1, "ms": .001, "s": 1, "m": 60, "h": 3600, "d": 86400}[match[2]]
74
+ if not math.isfinite(result) or result <= 0:
75
+ raise ValueError("duration must be finite and positive")
76
+ return result
77
+
78
+
79
+ class WaitRequest(Model):
80
+ agents: list[str] = Field(default_factory=list, description="Native IDs, or Codex task paths with the parent session. No UUID lookup needed.")
81
+ provider: Literal["codex", "claude", "cursor"] | None = Field(default=None,
82
+ description="Defaults to the host selected during setup; standalone Python defaults to Codex.")
83
+ session: str | None = Field(default=None, description="Parent session from SessionStart context; required for Codex task paths.")
84
+ targets: list[Target] = Field(default_factory=list)
85
+ timeout: str = "10m"
86
+ mode: Literal["any", "all", "quorum"] = "all"
87
+ quorum: int | None = Field(default=None, gt=0)
88
+ wake_on: list[Target] = Field(default_factory=list)
89
+ interval: float = Field(default=1, ge=.05, allow_inf_nan=False)
90
+ deadline: datetime | None = Field(default=None, description="Absolute deadline; survives a caller retry. Overrides timeout.")
91
+ details: bool = Field(default=False, description="Include raw probe observations for troubleshooting.")
92
+
93
+ @property
94
+ def conditions(self):
95
+ provider = self.provider or "codex"
96
+ return [*(Agent(kind="agent", provider=provider, id=id, session=self.session) for id in self.agents),
97
+ *self._scoped(self.targets)]
98
+
99
+ @property
100
+ def wake_conditions(self):
101
+ return self._scoped(self.wake_on)
102
+
103
+ def _scoped(self, targets):
104
+ return [t.model_copy(update={"session": self.session})
105
+ if isinstance(t, Agent) and t.provider == (self.provider or "codex") and t.session is None and self.session
106
+ else t for t in targets]
107
+
108
+ @model_validator(mode="after")
109
+ def validate_request(self):
110
+ seconds(self.timeout)
111
+ targets = self.conditions
112
+ if not targets:
113
+ raise ValueError("provide agents or targets to wait for")
114
+ if self.deadline and self.deadline.tzinfo is None:
115
+ raise ValueError("deadline must include a timezone")
116
+ if self.mode == "quorum":
117
+ if self.quorum is None or self.quorum > len(targets):
118
+ raise ValueError("quorum must be between 1 and the number of targets")
119
+ elif self.quorum is not None:
120
+ raise ValueError("quorum is only valid with mode=quorum")
121
+ if len({t.model_dump_json(exclude={"label"}) for t in targets}) != len(targets):
122
+ raise ValueError("duplicate targets would count the same outcome twice")
123
+ return self
124
+
125
+ def duration(self) -> float:
126
+ if self.deadline:
127
+ return max(0, (self.deadline - datetime.now(timezone.utc)).total_seconds())
128
+ return seconds(self.timeout)
superwait/server.py ADDED
@@ -0,0 +1,64 @@
1
+ """Thin MCP transport; the engine also runs directly as a CLI."""
2
+
3
+ from typing import Any, Literal
4
+
5
+ from mcp.server.mcpserver import Context, MCPServer
6
+
7
+ from .engine import wait_for as run_wait
8
+ from .models import WaitRequest
9
+ from .store import Store
10
+
11
+
12
+ def serve(store: Store, default_provider="codex"):
13
+ server = MCPServer("superwait", instructions=(
14
+ "Express a wait in one request: agents, any/all/quorum, early wake conditions, and deadline. "
15
+ f"The agents shorthand defaults to {default_provider}. Use native IDs or Codex /root/task paths directly. "
16
+ "Codex task paths require session from the SessionStart context to avoid matching another conversation. "
17
+ "Results give ready reports, pending work, triggered conditions, and a copyable continue_wait request. "
18
+ "list_agents is for discovery or troubleshooting; a stopped response is not proof its task passed. "
19
+ "Unknown agents remain pending. signal reports checkpoints or blockers explicitly. "
20
+ "For a wait longer than the host's MCP timeout, use the superwait CLI in its background terminal. "
21
+ "Cancelling a wait never stops the agents it observes."
22
+ ))
23
+
24
+ @server.tool()
25
+ async def wait_for(request: WaitRequest, ctx: Context) -> dict[str, Any]:
26
+ """Wait for any/all/quorum targets or wake_on conditions, until a deadline.
27
+
28
+ Use agents=[native_handle, ...] for workers; add targets for other conditions.
29
+ A target is agent, signal, file, http, or command. Command probes execute
30
+ repeatedly without a shell: use observational checks. File changed uses
31
+ the state at call entry. timeout accepts 30s, 10m, 2h, 1d. An absolute
32
+ deadline overrides timeout and preserves the deadline on retries.
33
+ Returns matched/interrupted/timed_out/error, ready reports, pending work,
34
+ and triggered conditions. Pass continue_wait back as request to continue
35
+ remaining work with the same deadline. details=true adds raw observations.
36
+ Protocol cancellation stops this wait and its own probes only.
37
+ """
38
+ async def progress(elapsed, duration):
39
+ await ctx.report_progress(elapsed, duration)
40
+ if request.provider is None:
41
+ request = request.model_copy(update={"provider": default_provider})
42
+ return await run_wait(request, store, progress)
43
+
44
+ @server.tool()
45
+ def list_agents(provider: Literal["codex", "claude", "cursor"], session: str | None = None) -> dict[str, Any]:
46
+ """List up to 50 recent hook-observed agents and their event cursors.
47
+
48
+ Specify session when known. Empty means no hooks have observed these
49
+ agents, not that they finished. after=seq waits for a later observation.
50
+ """
51
+ return {"agents": store.agents(provider, session), "limit": 50}
52
+
53
+ @server.tool()
54
+ def signal(key: str, state: str = "ready", data: dict | None = None) -> dict[str, Any]:
55
+ """Publish an explicit checkpoint or outcome to local waiting processes.
56
+
57
+ Use a task-specific unique key shared with the waiting agent. This does
58
+ not send a message, wake a closed conversation, or change an agent's work.
59
+ """
60
+ if not key or not state:
61
+ raise ValueError("key and state cannot be empty")
62
+ return {"seq": store.emit("signal", key, state, data=data)}
63
+
64
+ server.run(transport="stdio")
superwait/setup.py ADDED
@@ -0,0 +1,105 @@
1
+ """Project-local setup, preserving unrelated settings and saving originals."""
2
+
3
+ import json
4
+ import os
5
+ import shlex
6
+ import sys
7
+ import tomllib
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from .models import seconds
12
+ from .store import default_db
13
+
14
+
15
+ def write(path: Path, contents: str):
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ if path.exists():
18
+ if path.read_text() == contents:
19
+ return
20
+ backup = path.with_name(path.name + ".superwait-backup")
21
+ if not backup.exists():
22
+ fd = os.open(backup, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
23
+ with os.fdopen(fd, "wb") as f:
24
+ f.write(path.read_bytes())
25
+ path.write_text(contents)
26
+
27
+
28
+ def configure(provider, project, db=None, max_wait="24h"):
29
+ project = project.expanduser().absolute()
30
+ db = (db or default_db()).expanduser().absolute()
31
+ prefix = [sys.executable, "-m", "superwait", "--db", str(db), "--provider", provider]
32
+ hook_command = shlex.join([*prefix, "hook", provider])
33
+ server = {"command": prefix[0], "args": [*prefix[1:], "serve"]}
34
+ host_timeout = int(seconds(max_wait)) + 30
35
+ hostdir = {"codex": ".codex", "claude": ".claude", "cursor": ".cursor"}[provider]
36
+ written = []
37
+
38
+ def put(path, content):
39
+ write(path, content)
40
+ written.append(str(path))
41
+
42
+ def read_json(path):
43
+ value = json.loads(path.read_text()) if path.exists() else {}
44
+ if not isinstance(value, dict):
45
+ raise ValueError(f"expected a JSON object in {path}")
46
+ return value
47
+
48
+ hookpath = project / hostdir / ("settings.json" if provider == "claude" else "hooks.json")
49
+ hook_config = read_json(hookpath)
50
+ if not isinstance(hook_config.get("hooks", {}), dict):
51
+ raise ValueError("existing hooks must be an object")
52
+
53
+ if provider == "codex":
54
+ path = project / hostdir / "config.toml"
55
+ text = path.read_text() if path.exists() else ""
56
+ parsed = tomllib.loads(text)
57
+ start, stop = "# BEGIN superwait", "# END superwait"
58
+ if start in text:
59
+ before, rest = text.split(start, 1)
60
+ _, after = rest.split(stop, 1)
61
+ text = before.rstrip() + after
62
+ elif "superwait" in parsed.get("mcp_servers", {}):
63
+ raise ValueError("existing superwait MCP entry is not managed by setup; edit it explicitly")
64
+ fragment = (f'{start}\n[mcp_servers.superwait]\ncommand = {json.dumps(server["command"])}\n'
65
+ f'args = {json.dumps(server["args"])}\ntool_timeout_sec = {host_timeout}\n{stop}\n')
66
+ put(path, text.rstrip() + "\n\n" + fragment)
67
+ else:
68
+ path = project / (".mcp.json" if provider == "claude" else ".cursor/mcp.json")
69
+ config = read_json(path)
70
+ if provider == "claude":
71
+ server["timeout"] = host_timeout * 1000
72
+ config.setdefault("mcpServers", {})["superwait"] = server
73
+ put(path, json.dumps(config, indent=2) + "\n")
74
+
75
+ config = hook_config
76
+ hooks = config.setdefault("hooks", {})
77
+ if provider == "cursor":
78
+ config.setdefault("version", 1)
79
+ events = ["subagentStart", "subagentStop"] if provider == "cursor" else ["SubagentStart", "SubagentStop"]
80
+ if provider == "claude":
81
+ events.append("PostToolUse")
82
+ if provider == "codex":
83
+ events.append("SessionStart")
84
+ for event in events:
85
+ entries = hooks.setdefault(event, [])
86
+ # Remove only our recorder entries, identified by their exact invocation.
87
+ def ours(entry):
88
+ commands = [entry.get("command", "")] + [h.get("command", "") for h in entry.get("hooks", [])]
89
+ return any("-m superwait --db " in c and c.endswith(" hook " + provider) for c in commands)
90
+ entries[:] = [entry for entry in entries if not ours(entry)]
91
+ if provider == "cursor":
92
+ entries.append({"command": hook_command, "timeout": 5})
93
+ else:
94
+ entry = {"hooks": [{"type": "command", "command": hook_command, "timeout": 5}]}
95
+ if event == "PostToolUse":
96
+ entry["matcher"] = "SubagentHandback"
97
+ entries.append(entry)
98
+ put(hookpath, json.dumps(config, indent=2) + "\n")
99
+ skilldir = ".agents" if provider == "codex" else hostdir
100
+ skillpath = project / skilldir / "skills/superwait/SKILL.md"
101
+ put(skillpath, files("superwait").joinpath("SKILL.md").read_text()
102
+ + "\nCLI for this installation (includes the shared event database):\n\n```sh\n"
103
+ + shlex.join(prefix) + " wait --request wait.json\n```\n")
104
+ return {"written": written, "database": str(db), "max_wait": max_wait,
105
+ "next": "Restart the host and review its MCP/hooks trust prompts. Cursor: use the CLI for waits beyond its tool timeout."}
superwait/store.py ADDED
@@ -0,0 +1,113 @@
1
+ """Small local event store, shared by hooks and waiting processes."""
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import time
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+
11
+ class AmbiguousAgent(ValueError):
12
+ pass
13
+
14
+
15
+ def default_db() -> Path:
16
+ if value := os.environ.get("SUPERWAIT_DB"):
17
+ return Path(value).expanduser().absolute()
18
+ root = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
19
+ return root / "superwait/events.sqlite3"
20
+
21
+
22
+ class Store:
23
+ def __init__(self, path: Path | str | None = None):
24
+ self.path = Path(path) if path is not None else default_db()
25
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
26
+ fd = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600)
27
+ os.close(fd)
28
+ with self.connect() as db:
29
+ db.executescript("""
30
+ CREATE TABLE IF NOT EXISTS events (
31
+ seq INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL,
32
+ provider TEXT NOT NULL, session TEXT NOT NULL,
33
+ key TEXT NOT NULL, state TEXT NOT NULL,
34
+ at REAL NOT NULL, data TEXT NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS event_lookup
37
+ ON events(kind, provider, session, key, seq);
38
+ """)
39
+
40
+ @contextmanager
41
+ def connect(self):
42
+ db = sqlite3.connect(self.path, timeout=2)
43
+ db.row_factory = sqlite3.Row
44
+ try:
45
+ with db:
46
+ yield db
47
+ finally:
48
+ db.close()
49
+
50
+ def emit(self, kind, key, state, *, provider="", session="", data=None):
51
+ payload = json.dumps(data or {}, ensure_ascii=False)
52
+ if len(payload.encode()) > 65536:
53
+ raise ValueError("event data must fit in 64 KiB")
54
+ with self.connect() as db:
55
+ cursor = db.execute(
56
+ "INSERT INTO events(kind,provider,session,key,state,at,data) VALUES(?,?,?,?,?,?,?)",
57
+ (kind, provider, session, key, state, time.time(), payload),
58
+ )
59
+ return cursor.lastrowid
60
+
61
+ @staticmethod
62
+ def decode(row):
63
+ return {**dict(row), "data": json.loads(row["data"])}
64
+
65
+ def latest(self, kind, key, *, provider="", session=None):
66
+ query = "SELECT * FROM events WHERE kind=? AND provider=? AND key=?"
67
+ args = [kind, provider, key]
68
+ if session is not None:
69
+ query += " AND session=?"
70
+ args.append(session)
71
+ query = "SELECT * FROM (" + query + " ORDER BY seq DESC) GROUP BY session HAVING seq=MAX(seq)"
72
+ with self.connect() as db:
73
+ rows = db.execute(query, args).fetchall()
74
+ if len(rows) > 1:
75
+ raise ValueError("agent ID exists in multiple sessions; specify session")
76
+ return self.decode(rows[0]) if rows else None
77
+
78
+ def signal(self, key, state=None, after=0):
79
+ query = "SELECT * FROM events WHERE kind='signal' AND key=? AND seq>?"
80
+ args = [key, after]
81
+ if state is not None:
82
+ query += " AND state=?"
83
+ args.append(state)
84
+ with self.connect() as db:
85
+ row = db.execute(query + " ORDER BY seq DESC LIMIT 1", args).fetchone()
86
+ return self.decode(row) if row else None
87
+
88
+ def agent(self, handle, provider, session=None):
89
+ if provider == "codex" and handle.startswith("/") and session is None:
90
+ raise AmbiguousAgent("Codex task paths are session-local; include session from the Superwait SessionStart context.")
91
+ matches = [a for a in self.agents(provider, session, None)
92
+ if a["key"] == handle or handle in a["data"].get("aliases", [])]
93
+ if len(matches) > 1:
94
+ raise AmbiguousAgent(f"agent handle {handle!r} is ambiguous; specify its parent session")
95
+ return matches[0] if matches else None
96
+
97
+ def agents(self, provider=None, session=None, limit=50):
98
+ query = "SELECT * FROM events WHERE kind='agent'"
99
+ args = []
100
+ for name, value in (("provider", provider), ("session", session)):
101
+ if value is not None:
102
+ query += f" AND {name}=?"
103
+ args.append(value)
104
+ query += " AND seq IN (SELECT MAX(seq) FROM events WHERE kind='agent' GROUP BY provider,session,key) ORDER BY seq DESC"
105
+ if limit is not None:
106
+ query += " LIMIT ?"
107
+ args.append(limit)
108
+ with self.connect() as db:
109
+ return [self.decode(r) for r in db.execute(query, args)]
110
+
111
+ def prune(self, days=30):
112
+ with self.connect() as db:
113
+ return db.execute("DELETE FROM events WHERE at < ?", (time.time() - days * 86400,)).rowcount
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.5
2
+ Name: superwait
3
+ Version: 0.2.0
4
+ Summary: Conditional waiting for coding agents
5
+ Project-URL: Repository, https://github.com/ctxrs/superwait
6
+ Project-URL: Issues, https://github.com/ctxrs/superwait/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: httpx2<3,>=2.13
11
+ Requires-Dist: mcp<3,>=2.2
12
+ Requires-Dist: pydantic<3,>=2.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Superwait
16
+
17
+ Conditional waiting for coding agents. Wait for any, all, or a count of workers
18
+ and other conditions, wake early on a blocker, and keep one deadline across
19
+ interruptions.
20
+
21
+ Superwait provides an MCP tool and CLI, with setup for Codex, Claude Code, and
22
+ Cursor. The agent chooses the workflow; local code checks the conditions.
23
+ There are no model calls inside the wait.
24
+
25
+ ## Install
26
+
27
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
28
+
29
+ ```sh
30
+ uv tool install superwait
31
+ superwait setup codex --project /path/to/project
32
+ # Other hosts:
33
+ superwait setup claude --project /path/to/project
34
+ superwait setup cursor --project /path/to/project
35
+ ```
36
+
37
+ Setup installs project-local MCP settings, lifecycle hooks, and agent
38
+ instructions. It preserves unrelated settings and backs up changed files as
39
+ `.superwait-backup`. Restart the host and accept its normal trust prompts.
40
+ Install hooks before spawning workers, and keep the Python tool environment
41
+ installed. See [host integration](https://github.com/ctxrs/superwait/blob/main/docs/hosts.md) for details.
42
+
43
+ To install the current source instead, use
44
+ `uv tool install 'git+https://github.com/ctxrs/superwait'`.
45
+
46
+ Linux is tested. Codex has live integration coverage; Claude Code and Cursor
47
+ have adapter tests and still need full live workflow qualification. Other
48
+ operating systems and multi-hour host waits remain unqualified.
49
+
50
+ ## Express the wait
51
+
52
+ Call the MCP tool `wait_for` with a `request`. For example, wait for two of three
53
+ reviewers, or return early when an explicit blocker signal arrives:
54
+
55
+ ```json
56
+ {
57
+ "request": {
58
+ "agents": ["reviewer-a-id", "reviewer-b-id", "reviewer-c-id"],
59
+ "mode": "quorum",
60
+ "quorum": 2,
61
+ "wake_on": [{"kind": "signal", "key": "review-42/blocker", "state": "blocked"}],
62
+ "timeout": "2h"
63
+ }
64
+ }
65
+ ```
66
+
67
+ Use the native worker IDs from your host. The provider defaults to the host
68
+ selected during setup. Codex task paths such as `/root/reviewer_a` also work
69
+ when accompanied by the parent `session` supplied in its startup context.
70
+ `list_agents` is available for discovery and troubleshooting.
71
+
72
+ `mode` is `all` by default, or `any`, or `quorum` with a count. Add `targets` for
73
+ other conditions; they count toward the same threshold as `agents`.
74
+
75
+ | Condition | Matches when |
76
+ | --- | --- |
77
+ | `agent` | A lifecycle hook observes a requested worker state |
78
+ | `signal` | A task-specific event is published |
79
+ | `file` | A path exists, is missing, changes, or contains literal text |
80
+ | `http` | A GET returns the requested status code |
81
+ | `command` | An observational command returns the requested exit code |
82
+
83
+ Checks run concurrently. Command probes take an argument array and run without
84
+ a shell; use an observational check because it repeats. `interval` controls
85
+ polling, with a default of one second. Use absolute paths when the MCP server's
86
+ working directory may differ from yours.
87
+
88
+ Publish an explicit checkpoint or blocker through the `signal` MCP tool or CLI:
89
+
90
+ ```sh
91
+ superwait signal review-42/blocker --state blocked --data '{"reason":"missing fixture"}'
92
+ ```
93
+
94
+ ## Act on the result
95
+
96
+ The result includes `status`, `reason`, completed reports in `ready`, remaining
97
+ work in `pending`, and early wake conditions in `triggered`.
98
+
99
+ Pass the returned `continue_wait` object back as the next request to continue.
100
+ It preserves the deadline, remaining count, and file-change baselines, and
101
+ advances past delivered event signals. Persistent conditions such as an existing
102
+ blocker file must clear or be removed before continuing. After a quorum has
103
+ already been reached, continuation waits for all remaining work.
104
+
105
+ A stopped response does not prove an assignment succeeded. Read its report;
106
+ another host hook may continue that worker. For a resumed worker, use an `agent`
107
+ target with `after` set to its last returned `cursor`. Unknown workers stay
108
+ pending. `details: true` adds raw observations for troubleshooting.
109
+
110
+ ## Long waits and the CLI
111
+
112
+ Setup configures a per-server MCP timeout of 24 hours plus 30 seconds for Codex
113
+ and Claude Code. `setup --max-wait 3d` changes that ceiling. Each request still
114
+ has its own timeout or timezone-qualified `deadline`. An absolute deadline
115
+ survives continuation; a timed-out continuation remains expired.
116
+
117
+ For long Cursor waits, or waits beyond the host's configured MCP timeout, run
118
+ the same engine through the host's background terminal:
119
+
120
+ ```sh
121
+ superwait wait --request wait.json --output result.json
122
+ ```
123
+
124
+ `wait.json` contains the request itself, without the MCP `request` wrapper.
125
+ The CLI prints one final JSON result and atomically saves the optional output.
126
+ It exits 0 for matched/early wake, 124 for timeout, 130 for Ctrl-C, and 2 for an
127
+ invalid request or target ambiguity. Native completion notifications or terminal
128
+ collection retrieve the result; the CLI does not background itself.
129
+
130
+ Cancellation stops the wait and any probes it launched. It never cancels the
131
+ workers being observed. The machine and process must remain alive.
132
+
133
+ ## Local state
134
+
135
+ Hooks and waiters share a local SQLite database, defaulting to
136
+ `$XDG_STATE_HOME/superwait/events.sqlite3` or
137
+ `~/.local/state/superwait/events.sqlite3`. Set `SUPERWAIT_DB` or `--db` to use
138
+ another store. Stored observations include worker IDs, report excerpts, and
139
+ available transcript paths. `superwait prune --days 30` removes old observations.
140
+
141
+ The Codex task-name adapter reads only the metadata header of the exact worker
142
+ transcript supplied by its hook. It does not scan conversation history.
143
+ Network requests occur only for requested HTTP or command checks.
144
+
145
+ See [contributing](https://github.com/ctxrs/superwait/blob/main/CONTRIBUTING.md)
146
+ for local development. Licensed under [MIT](https://github.com/ctxrs/superwait/blob/main/LICENSE).
@@ -0,0 +1,15 @@
1
+ superwait/SKILL.md,sha256=qIRiVg40JeGvW3FzDSP0yRCtnYldaoTKDxTySYTI918,4666
2
+ superwait/__init__.py,sha256=Eb5iQDfMQ6BltLvKXgYz6itjD0baUJdySt-7acaRSM8,66
3
+ superwait/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
4
+ superwait/cli.py,sha256=rrvrHfqiJVosNdRoif-NwwRb9r68zg_Z_q-WVp7uE1E,5760
5
+ superwait/engine.py,sha256=OBUePlOKbTxZbKGL5F2-WM8hKUw1zInV_NsH0qUlIKo,11974
6
+ superwait/hooks.py,sha256=R8MYib_QMuXIfv02nJJmKDWGAa4J6tg5e6jKTwbBMYw,5129
7
+ superwait/models.py,sha256=mxk-oUARwa74POBX0ogitVrUDgEwOGQTqMcYH34yH38,5247
8
+ superwait/server.py,sha256=diwhioIg9iiHcxnGJE7eu_AWY1Y3zJgkb_4KezMagXo,3396
9
+ superwait/setup.py,sha256=YZo6bE2xZpktlXXU0tMYBdGTT_gx7AMS7aVjmJql5IQ,4766
10
+ superwait/store.py,sha256=keR5rqEmzBsN1mkkPdHoUn1HGLQN1KQjyvxdNoYZoUI,4660
11
+ superwait-0.2.0.dist-info/METADATA,sha256=W16JdwV-j0XL1hUPSYKmZRyGy3EJ4U5TaJ2jBoBzmIo,6147
12
+ superwait-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
+ superwait-0.2.0.dist-info/entry_points.txt,sha256=2k_uHiST-TZ6HmFop70vtEcaHqFQwuC5F1oGFMTYdCE,49
14
+ superwait-0.2.0.dist-info/licenses/LICENSE,sha256=Pda-CKlQ8mHwydWfTeX6d9eh6TgYUvymMFlDx0twgXE,1079
15
+ superwait-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ superwait = superwait.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Superwait contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.