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 +95 -0
- superwait/__init__.py +1 -0
- superwait/__main__.py +3 -0
- superwait/cli.py +109 -0
- superwait/engine.py +244 -0
- superwait/hooks.py +102 -0
- superwait/models.py +128 -0
- superwait/server.py +64 -0
- superwait/setup.py +105 -0
- superwait/store.py +113 -0
- superwait-0.2.0.dist-info/METADATA +146 -0
- superwait-0.2.0.dist-info/RECORD +15 -0
- superwait-0.2.0.dist-info/WHEEL +4 -0
- superwait-0.2.0.dist-info/entry_points.txt +2 -0
- superwait-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|